From 95989a6c9befa319d6af6908bdc71b805f42be8b Mon Sep 17 00:00:00 2001 From: Syed Bariman Jan Date: Mon, 24 Feb 2025 21:08:05 +0500 Subject: [PATCH 001/380] Add new category flow (#1857) * resolve git issue * Add new category flow * Improve contrast checker * make error message small * update ui to match figma design * realign color picker * changes * rename color picker controller to new category controller * cleanup code * cleanup code * resize and realign icon avatar * Fix js lint errors Signed-off-by: Syed Bariman Jan --------- Signed-off-by: Syed Bariman Jan --- app/assets/stylesheets/simonweb_pickr.css | 2 + app/assets/tailwind/application.css | 29 +++ .../controllers/category_controller.js | 206 ++++++++++++++++++ .../controllers/color_avatar_controller.js | 8 +- app/models/category.rb | 4 +- app/models/demo/generator.rb | 6 +- app/views/categories/_color_avatar.html.erb | 8 + app/views/categories/_form.html.erb | 74 +++++-- app/views/shared/_color_avatar.html.erb | 2 +- app/views/shared/_modal.html.erb | 4 +- config/importmap.rb | 1 + ...5_add_default_lucide_icon_to_categories.rb | 23 ++ db/schema.rb | 4 +- vendor/javascript/@simonwep--pickr.js | 4 + 14 files changed, 335 insertions(+), 40 deletions(-) create mode 100644 app/assets/stylesheets/simonweb_pickr.css create mode 100644 app/javascript/controllers/category_controller.js create mode 100644 app/views/categories/_color_avatar.html.erb create mode 100644 db/migrate/20250220200735_add_default_lucide_icon_to_categories.rb create mode 100644 vendor/javascript/@simonwep--pickr.js diff --git a/app/assets/stylesheets/simonweb_pickr.css b/app/assets/stylesheets/simonweb_pickr.css new file mode 100644 index 00000000..c99ab696 --- /dev/null +++ b/app/assets/stylesheets/simonweb_pickr.css @@ -0,0 +1,2 @@ +/*! Pickr 1.9.1 MIT | https://github.com/Simonwep/pickr */ +.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, ") 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, ");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, ");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, ");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, ");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, ");background-size:100%,.25em} \ No newline at end of file diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css index ecb797a1..7159c950 100644 --- a/app/assets/tailwind/application.css +++ b/app/assets/tailwind/application.css @@ -8,6 +8,35 @@ @plugin "@tailwindcss/typography"; @plugin "@tailwindcss/forms"; +@import "../stylesheets/simonweb_pickr.css"; + +@layer components { + .pcr-app{ + position: static !important; + background: none !important; + box-shadow: none !important; + padding: 0 !important; + width: 100% !important; + } + .pcr-color-palette{ + height: 12em !important; + width: 21.5rem !important; + } + .pcr-palette{ + border-radius: 10px !important; + } + .pcr-palette:before{ + border-radius: 10px !important; + } + .pcr-color-chooser{ + height: 1.5em !important; + } + .pcr-picker{ + height: 20px !important; + width: 20px !important; + } +} + .combobox { .hw-combobox__main__wrapper, .hw-combobox__input { diff --git a/app/javascript/controllers/category_controller.js b/app/javascript/controllers/category_controller.js new file mode 100644 index 00000000..dfbcd029 --- /dev/null +++ b/app/javascript/controllers/category_controller.js @@ -0,0 +1,206 @@ +import { Controller } from "@hotwired/stimulus" +import Pickr from '@simonwep/pickr' + +export default class extends Controller { + static targets = ["pickerBtn", "colorInput", "colorsSection", "paletteSection", "pickerSection", "colorPreview", "avatar", "details", "icon","validationMessage","selection","colorPickerRadioBtn"]; + static values = { + presetColors: Array, + }; + + initialize() { + this.pickerBtnTarget.addEventListener('click', () => { + this.showPaletteSection(); + }); + + this.colorInputTarget.addEventListener('input', (e) => { + this.picker.setColor(e.target.value); + }); + + this.detailsTarget.addEventListener('toggle', (e) => { + if (!this.colorInputTarget.checkValidity()) { + e.preventDefault(); + this.colorInputTarget.reportValidity(); + e.target.open = true; + } + }); + + this.selectedIcon = null; + + if (!this.presetColorsValue.includes(this.colorInputTarget.value)) { + this.colorPickerRadioBtnTarget.checked = true; + } + } + + initPicker() { + const pickerContainer = document.createElement("div"); + pickerContainer.classList.add("pickerContainer"); + this.pickerSectionTarget.append(pickerContainer); + + this.picker = Pickr.create({ + el: this.pickerBtnTarget, + theme: 'monolith', + container: ".pickerContainer", + useAsButton: true, + showAlways: true, + default: this.colorInputTarget.value, + components: { + hue: true, + }, + }); + + this.picker.on('change', (color) => { + const hexColor = color.toHEXA().toString(); + const rgbacolor = color.toRGBA(); + + this.updateAvatarColors(hexColor); + this.updateSelectedIconColor(hexColor); + + const backgroundColor = this.backgroundColor(rgbacolor, 10); + const contrastRatio = this.contrast(rgbacolor, backgroundColor); + + this.colorInputTarget.value = hexColor; + this.colorInputTarget.dataset.colorPickerColorValue = hexColor; + this.colorPreviewTarget.style.backgroundColor = hexColor; + + this.handleContrastValidation(contrastRatio); + }); + } + + updateAvatarColors(color) { + this.avatarTarget.style.backgroundColor = `${this.#backgroundColor(color)}`; + this.avatarTarget.style.color = color; + } + + handleIconColorChange(e) { + const selectedIcon = e.target; + this.selectedIcon = selectedIcon; + + const currentColor = this.colorInputTarget.value; + + this.iconTargets.forEach(icon => { + const iconWrapper = icon.nextElementSibling; + iconWrapper.style.removeProperty("background-color") + iconWrapper.style.color = "black"; + }); + + this.updateSelectedIconColor(currentColor); + } + + handleIconChange(e) { + const iconSVG = e.currentTarget.closest('label').querySelector('svg').cloneNode(true); + this.avatarTarget.innerHTML = ''; + iconSVG.style.padding = "0px" + iconSVG.classList.add("w-8","h-8") + this.avatarTarget.appendChild(iconSVG); + } + + updateSelectedIconColor(color) { + if (this.selectedIcon) { + const iconWrapper = this.selectedIcon.nextElementSibling; + iconWrapper.style.backgroundColor = `${this.#backgroundColor(color)}`; + iconWrapper.style.color = color; + } + } + + handleColorChange(e) { + const color = e.currentTarget.value; + this.colorInputTarget.value = color; + this.colorPreviewTarget.style.backgroundColor = color; + this.updateAvatarColors(color); + this.updateSelectedIconColor(color); + } + + handleContrastValidation(contrastRatio) { + if (contrastRatio < 4.5) { + this.colorInputTarget.setCustomValidity("Poor contrast, choose darker color or auto-adjust."); + + this.validationMessageTarget.classList.remove("hidden"); + } else { + this.colorInputTarget.setCustomValidity(""); + this.validationMessageTarget.classList.add("hidden"); + } + } + + autoAdjust(e){ + const currentRGBA = this.picker.getColor(); + const adjustedRGBA = this.darkenColor(currentRGBA).toString(); + this.picker.setColor(adjustedRGBA); + } + + handleParentChange(e) { + const parent = e.currentTarget.value; + const display = typeof parent === "string" && parent !== "" ? "none" : "flex"; + this.selectionTarget.style.display = display; + } + + backgroundColor([r,g,b,a], percentage) { + const mixedR = Math.round((r * (percentage / 100)) + (255 * (1 - percentage / 100))); + const mixedG = Math.round((g * (percentage / 100)) + (255 * (1 - percentage / 100))); + const mixedB = Math.round((b * (percentage / 100)) + (255 * (1 - percentage / 100))); + return [mixedR, mixedG, mixedB]; + } + + luminance([r,g,b]) { + const toLinear = c => { + const scaled = c / 255; + return scaled <= 0.04045 + ? scaled / 12.92 + : ((scaled + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b); + } + + contrast(foregroundColor, backgroundColor) { + const fgLum = this.luminance(foregroundColor); + const bgLum = this.luminance(backgroundColor); + const [l1, l2] = [Math.max(fgLum, bgLum), Math.min(fgLum, bgLum)]; + return (l1 + 0.05) / (l2 + 0.05); + } + + darkenColor(color) { + let darkened = color.toRGBA(); + const backgroundColor = this.backgroundColor(darkened, 10); + let contrastRatio = this.contrast(darkened, backgroundColor); + + while (contrastRatio < 4.5 && (darkened[0] > 0 || darkened[1] > 0 || darkened[2] > 0)) { + darkened = [ + Math.max(0, darkened[0] - 10), + Math.max(0, darkened[1] - 10), + Math.max(0, darkened[2] - 10), + darkened[3] + ]; + contrastRatio = this.contrast(darkened, backgroundColor); + } + + return `rgba(${darkened.join(", ")})`; + } + + showPaletteSection() { + this.initPicker(); + this.colorsSectionTarget.classList.add('hidden'); + this.paletteSectionTarget.classList.remove('hidden'); + this.pickerSectionTarget.classList.remove('hidden'); + this.picker.show(); + } + + showColorsSection() { + this.colorsSectionTarget.classList.remove('hidden'); + this.paletteSectionTarget.classList.add('hidden'); + this.pickerSectionTarget.classList.add('hidden'); + if (this.picker) { + this.picker.destroyAndRemove(); + } + } + + toggleSections() { + if (this.colorsSectionTarget.classList.contains('hidden')) { + this.showColorsSection(); + } else { + this.showPaletteSection(); + } + } + + #backgroundColor(color) { + return `color-mix(in oklab, ${color} 10%, transparent)`; + } +} diff --git a/app/javascript/controllers/color_avatar_controller.js b/app/javascript/controllers/color_avatar_controller.js index 41b7568b..49d9caeb 100644 --- a/app/javascript/controllers/color_avatar_controller.js +++ b/app/javascript/controllers/color_avatar_controller.js @@ -21,14 +21,8 @@ export default class extends Controller { handleColorChange(e) { const color = e.currentTarget.value; - this.avatarTarget.style.backgroundColor = `color-mix(in srgb, ${color} 5%, white)`; + this.avatarTarget.style.backgroundColor = `color-mix(in srgb, ${color} 10%, white)`; this.avatarTarget.style.borderColor = `color-mix(in srgb, ${color} 10%, white)`; this.avatarTarget.style.color = color; } - - handleParentChange(e) { - const parent = e.currentTarget.value; - const visibility = typeof parent === "string" && parent !== "" ? "hidden" : "visible" - this.selectionTarget.style.visibility = visibility - } } diff --git a/app/models/category.rb b/app/models/category.rb index 15e8e0bc..2adc7788 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -8,13 +8,13 @@ class Category < ApplicationRecord has_many :subcategories, class_name: "Category", foreign_key: :parent_id, dependent: :nullify belongs_to :parent, class_name: "Category", optional: true - validates :name, :color, :family, presence: true + validates :name, :color, :lucide_icon, :family, presence: true validates :name, uniqueness: { scope: :family_id } validate :category_level_limit validate :nested_category_matches_parent_classification - before_create :inherit_color_from_parent + before_save :inherit_color_from_parent scope :alphabetically, -> { order(:name) } scope :roots, -> { where(parent_id: nil) } diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb index bc5036eb..d60be41e 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -142,9 +142,9 @@ class Demo::Generator family.categories.bootstrap_defaults food = family.categories.find_by(name: "Food & Drink") - family.categories.create!(name: "Restaurants", parent: food, color: COLORS.sample, classification: "expense") - family.categories.create!(name: "Groceries", parent: food, color: COLORS.sample, classification: "expense") - family.categories.create!(name: "Alcohol & Bars", parent: food, color: COLORS.sample, classification: "expense") + family.categories.create!(name: "Restaurants", parent: food, color: COLORS.sample, lucide_icon: "utensils", classification: "expense") + family.categories.create!(name: "Groceries", parent: food, color: COLORS.sample, lucide_icon: "shopping-cart", classification: "expense") + family.categories.create!(name: "Alcohol & Bars", parent: food, color: COLORS.sample, lucide_icon: "beer", classification: "expense") end def create_merchants!(family) diff --git a/app/views/categories/_color_avatar.html.erb b/app/views/categories/_color_avatar.html.erb new file mode 100644 index 00000000..1c00f365 --- /dev/null +++ b/app/views/categories/_color_avatar.html.erb @@ -0,0 +1,8 @@ +<%# locals: (category:) %> + + + <%= lucide_icon(category.lucide_icon, class: "w-8 h-8") %> + diff --git a/app/views/categories/_form.html.erb b/app/views/categories/_form.html.erb index fd9a8513..81afcab4 100644 --- a/app/views/categories/_form.html.erb +++ b/app/views/categories/_form.html.erb @@ -1,42 +1,70 @@ <%# locals: (category:, categories:) %> -
+
<%= styled_form_with model: category, class: "space-y-4" do |f| %>
- <%= render partial: "shared/color_avatar", locals: { name: category.name, color: category.color } %> + <%= render partial: "color_avatar", locals: { category: category } %>
+
+ + <%= icon("pen", size: "sm") %> + -
- <% Category::COLORS.each do |color| %> - - <% end %> -
+
+
"> +
+

Color

+
+ <% Category::COLORS.each do |color| %> + + <% end %> + +
+ +
+ +
+

Icon

+
+ <% Category.icon_codes.each do |icon| %> + + <% end %> +
+
+
+
<% if category.errors.any? %> <%= render "shared/form_errors", model: category %> <% end %> -
- - <% Category.icon_codes.each do |icon| %> - - <% end %> -
-
<%= f.select :classification, [["Income", "income"], ["Expense", "expense"]], { label: "Classification" }, required: true %> <%= f.text_field :name, placeholder: t(".placeholder"), required: true, autofocus: true, label: "Name", data: { color_avatar_target: "name" } %> <% unless category.parent? %> - <%= f.select :parent_id, categories.pluck(:name, :id), { include_blank: "(unassigned)", label: "Parent category (optional)" }, disabled: category.parent?, data: { action: "change->color-avatar#handleParentChange" } %> + <%= f.select :parent_id, categories.pluck(:name, :id), { include_blank: "(unassigned)", label: "Parent category (optional)" }, disabled: category.parent?, data: { action: "change->category#handleParentChange" } %> <% end %>
diff --git a/app/views/shared/_color_avatar.html.erb b/app/views/shared/_color_avatar.html.erb index be59ad7d..1ce60932 100644 --- a/app/views/shared/_color_avatar.html.erb +++ b/app/views/shared/_color_avatar.html.erb @@ -8,4 +8,4 @@ class="w-8 h-8 flex items-center justify-center rounded-full" style="background-color: <%= background_color %>; border-color: <%= border_color %>; color: <%= color %>"> <%= letter.upcase %> - + \ No newline at end of file diff --git a/app/views/shared/_modal.html.erb b/app/views/shared/_modal.html.erb index 067e4ca0..3468bca7 100644 --- a/app/views/shared/_modal.html.erb +++ b/app/views/shared/_modal.html.erb @@ -1,8 +1,8 @@ <%# locals: (content:, classes:) -%> <%= turbo_frame_tag "modal" do %> - +
<%= content %>
-<% end %> +<% end %> \ No newline at end of file diff --git a/config/importmap.rb b/config/importmap.rb index 65539f9d..f79ddb7f 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -7,6 +7,7 @@ pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" pin_all_from "app/javascript/controllers", under: "controllers" pin_all_from "app/javascript/services", under: "services", to: "services" pin "@github/hotkey", to: "@github--hotkey.js" # @3.1.0 +pin "@simonwep/pickr", to: "@simonwep--pickr.js" # @1.9.1 # D3 packages pin "d3" # @7.8.5 diff --git a/db/migrate/20250220200735_add_default_lucide_icon_to_categories.rb b/db/migrate/20250220200735_add_default_lucide_icon_to_categories.rb new file mode 100644 index 00000000..d4be6eea --- /dev/null +++ b/db/migrate/20250220200735_add_default_lucide_icon_to_categories.rb @@ -0,0 +1,23 @@ +class AddDefaultLucideIconToCategories < ActiveRecord::Migration[7.2] + def up + execute <<-SQL + UPDATE categories + SET lucide_icon = 'shapes' + WHERE lucide_icon IS NULL + SQL + + change_column_null :categories, :lucide_icon, false + change_column_default :categories, :lucide_icon, 'shapes' + end + + def down + change_column_default :categories, :lucide_icon, nil + change_column_null :categories, :lucide_icon, true + + execute <<-SQL + UPDATE categories + SET lucide_icon = NULL + WHERE lucide_icon = 'shapes' + SQL + end +end diff --git a/db/schema.rb b/db/schema.rb index ccbe329a..d78271ac 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_02_20_153958) do +ActiveRecord::Schema[7.2].define(version: 2025_02_20_200735) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -192,7 +192,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_02_20_153958) do t.datetime "updated_at", null: false t.uuid "parent_id" t.string "classification", default: "expense", null: false - t.string "lucide_icon" + t.string "lucide_icon", default: "shapes", null: false t.index ["family_id"], name: "index_categories_on_family_id" end diff --git a/vendor/javascript/@simonwep--pickr.js b/vendor/javascript/@simonwep--pickr.js new file mode 100644 index 00000000..f0f6e0d2 --- /dev/null +++ b/vendor/javascript/@simonwep--pickr.js @@ -0,0 +1,4 @@ +// @simonwep/pickr@1.9.1 downloaded from https://ga.jspm.io/npm:@simonwep/pickr@1.9.1/dist/pickr.min.js + +var 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),ht)){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,PB.left+B.width&&(P=B.left+B.width),xB.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(!(dR))for(const R of z){let H=T[(h?"v":"h")+R];if(!(HD)){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
\n\n ${h?"":''}\n\n
\n
\n
\n \n
\n
\n\n
\n
\n
\n
\n\n
\n
\n
\n
\n\n
\n
\n
\n
\n
\n\n
\n\n
\n \n\n \n \n \n \n \n\n \n \n \n
\n
\n
\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{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(` + + <% breadcrumbs.each_with_index do |(name, path), index| %> + <% if index > 0 %> + <%= icon("chevron-right", color: "gray", size: "sm") %> + <% end %> + + <% if path.present? && index < breadcrumbs.size - 1 %> + <%= link_to name, path, class: "text-sm text-gray-500 font-medium" %> + <% elsif index == breadcrumbs.size - 1 %> + <%= name %> + <% else %> + <%= name %> + <% end %> + <% end %> + \ No newline at end of file diff --git a/app/views/layouts/shared/_page_header.html.erb b/app/views/layouts/shared/_page_header.html.erb new file mode 100644 index 00000000..9c2e2726 --- /dev/null +++ b/app/views/layouts/shared/_page_header.html.erb @@ -0,0 +1,11 @@ +<%# This partial renders the page header with title and optional subtitle %> +
+ <% if local_assigns[:title].present? %> +
+

<%= title %>

+ <% if local_assigns[:subtitle].present? %> +

<%= subtitle %>

+ <% end %> +
+ <% end %> +
\ No newline at end of file diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index b9055b70..cc6ef589 100644 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -1,23 +1,11 @@ +<% content_for :page_header do %> +
+

Welcome back, <%= Current.user.first_name %>

+

Here's what's happening with your money this week

+
+<% end %> +
-
- - -
-

Welcome back, <%= Current.user.first_name %>

-

Here's what's happening with your money this week

-
-
-
<%= render partial: "pages/dashboard/net_worth_chart", locals: { series: @balance_sheet.net_worth_series(period: @period), period: @period } %>
diff --git a/test/system/accounts_test.rb b/test/system/accounts_test.rb index 64c5a8bf..ff8d4500 100644 --- a/test/system/accounts_test.rb +++ b/test/system/accounts_test.rb @@ -103,7 +103,7 @@ class AccountsTest < ApplicationSystemTestCase visit account_url(created_account) - within "header" do + within "header:has(button[data-menu-target='button'])" do find('button[data-menu-target="button"]').click click_on "Edit" end From c610b0ba4b2168717f23d68ff973fa8f68e92377 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Tue, 25 Feb 2025 17:28:40 -0500 Subject: [PATCH 008/380] Dashboard design fixes (#1898) * Dashboard design fixes * Update dashboard greeting * Remove sidebar toggle from settings breadcrumbs * Autofocus and outlines for category dropdowns * Lint fixes --- app/helpers/forms_helper.rb | 2 +- .../controllers/list_filter_controller.js | 4 ++ .../controllers/sidebar_controller.js | 2 +- .../time_series_chart_controller.js | 2 +- .../accountable_sparklines/show.html.erb | 2 +- .../accounts/_account_sidebar_tabs.html.erb | 10 ++--- .../accounts/_accountable_group.html.erb | 8 ++-- app/views/accounts/_logo.html.erb | 4 +- app/views/accounts/chart.html.erb | 2 +- app/views/accounts/new.html.erb | 29 +++++++++----- app/views/accounts/sparkline.html.erb | 2 +- app/views/categories/_color_avatar.html.erb | 2 +- app/views/categories/_form.html.erb | 4 +- app/views/category/dropdowns/_row.html.erb | 7 +++- app/views/layouts/application.html.erb | 23 +++++------ app/views/layouts/settings.html.erb | 6 +-- .../layouts/shared/_breadcrumbs.html.erb | 40 ++++++++++--------- .../layouts/shared/_page_header.html.erb | 2 +- app/views/pages/dashboard.html.erb | 2 +- .../pages/dashboard/_balance_sheet.html.erb | 2 +- .../pages/dashboard/_net_worth_chart.html.erb | 4 +- app/views/settings/_settings_nav.html.erb | 2 +- app/views/shared/_color_avatar.html.erb | 2 +- app/views/shared/_modal.html.erb | 2 +- app/views/shared/_trend_change.html.erb | 4 +- 25 files changed, 95 insertions(+), 74 deletions(-) diff --git a/app/helpers/forms_helper.rb b/app/helpers/forms_helper.rb index 22861eb0..55c8c74c 100644 --- a/app/helpers/forms_helper.rb +++ b/app/helpers/forms_helper.rb @@ -17,7 +17,7 @@ module FormsHelper end end - def period_select(form:, selected:, classes: "border border-tertiary shadow-xs rounded-lg text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0") + def period_select(form:, selected:, classes: "border border-secondary rounded-lg text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0") periods_for_select = Period.all.map { |period| [ period.label_short, period.key ] } form.select(:period, periods_for_select, { selected: selected.key }, class: classes, data: { "auto-submit-form-target": "auto" }) diff --git a/app/javascript/controllers/list_filter_controller.js b/app/javascript/controllers/list_filter_controller.js index dafb214a..279d8b47 100644 --- a/app/javascript/controllers/list_filter_controller.js +++ b/app/javascript/controllers/list_filter_controller.js @@ -4,6 +4,10 @@ import { Controller } from "@hotwired/stimulus"; export default class extends Controller { static targets = ["input", "list", "emptyMessage"]; + connect() { + this.inputTarget.focus(); + } + filter() { const filterValue = this.inputTarget.value.toLowerCase(); const items = this.listTarget.querySelectorAll(".filterable-item"); diff --git a/app/javascript/controllers/sidebar_controller.js b/app/javascript/controllers/sidebar_controller.js index 7ed40418..e0577edf 100644 --- a/app/javascript/controllers/sidebar_controller.js +++ b/app/javascript/controllers/sidebar_controller.js @@ -8,7 +8,7 @@ export default class extends Controller { toggle() { this.panelTarget.classList.toggle("w-0"); this.panelTarget.classList.toggle("opacity-0"); - this.panelTarget.classList.toggle("w-[260px]"); + this.panelTarget.classList.toggle("w-80"); this.panelTarget.classList.toggle("opacity-100"); this.contentTarget.classList.toggle("max-w-4xl"); this.contentTarget.classList.toggle("max-w-5xl"); diff --git a/app/javascript/controllers/time_series_chart_controller.js b/app/javascript/controllers/time_series_chart_controller.js index d3cc05cc..7fc86100 100644 --- a/app/javascript/controllers/time_series_chart_controller.js +++ b/app/javascript/controllers/time_series_chart_controller.js @@ -446,7 +446,7 @@ export default class extends Controller { get _margin() { if (this.useLabelsValue) { - return { top: 20, right: 0, bottom: 30, left: 0 }; + return { top: 20, right: 0, bottom: 10, left: 0 }; } return { top: 0, right: 0, bottom: 0, left: 0 }; } diff --git a/app/views/accountable_sparklines/show.html.erb b/app/views/accountable_sparklines/show.html.erb index b5a479a8..ba915d57 100644 --- a/app/views/accountable_sparklines/show.html.erb +++ b/app/views/accountable_sparklines/show.html.erb @@ -6,6 +6,6 @@ <%= tag.p @series.trend.percent_formatted, style: "color: #{@series.trend.color}", - class: "text-right text-xs font-medium text-primary" %> + class: "font-mono text-right text-xs font-medium text-primary" %>
<% end %> diff --git a/app/views/accounts/_account_sidebar_tabs.html.erb b/app/views/accounts/_account_sidebar_tabs.html.erb index 3bf4d64e..d42fab86 100644 --- a/app/views/accounts/_account_sidebar_tabs.html.erb +++ b/app/views/accounts/_account_sidebar_tabs.html.erb @@ -8,21 +8,21 @@ data-tabs-inactive-class="text-secondary" data-tabs-default-tab-value="assets-tab">
- - -
- <%= link_to new_account_path(step: "method_select"), + <%= link_to new_account_path(step: "method_select", classification: "asset"), class: "flex items-center gap-3 btn btn--ghost text-secondary mb-1", data: { turbo_frame: "modal" } do %> <%= icon("plus") %> @@ -37,7 +37,7 @@
-<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/shared/_trend_change.html.erb b/app/views/shared/_trend_change.html.erb index a3f3ef74..3235bc0e 100644 --- a/app/views/shared/_trend_change.html.erb +++ b/app/views/shared/_trend_change.html.erb @@ -4,11 +4,11 @@ <% if trend.direction.flat? %> No change <% else %> - + <%= trend.value.is_a?(Money) ? format_money(trend.value) : trend.value.round(2) %> <% unless trend.percent.infinite? %> - (<%= lucide_icon(trend.icon, class: "w-4 h-4 align-text-bottom inline") %><%= trend.percent_formatted %>) + (<%= lucide_icon(trend.icon, class: "w-4 h-4 align-text-bottom inline") %><%= trend.percent_formatted %>) <% end %> <% end %>

From f7064fd4ddc3defcd4404d7b88bbb05420506091 Mon Sep 17 00:00:00 2001 From: Harshit Chaudhary <55315065+Harry-kp@users.noreply.github.com> Date: Thu, 27 Feb 2025 01:43:51 +0530 Subject: [PATCH 009/380] fixed example account balance (#1910) --- app/views/onboardings/preferences.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/onboardings/preferences.html.erb b/app/views/onboardings/preferences.html.erb index 633b9145..a0e2330b 100644 --- a/app/views/onboardings/preferences.html.erb +++ b/app/views/onboardings/preferences.html.erb @@ -12,7 +12,7 @@
<%= tag.p t(".example"), class: "text-secondary text-sm" %> - <%= tag.p "$2,323.25", class: "text-primary font-medium text-2xl" %> + <%= tag.p format_money(Money.new(2325.25, params[:currency] || @user.family.currency)), class: "text-primary font-medium text-2xl" %>

+<%= format_money(Money.new(78.90, params[:currency] || @user.family.currency)) %> (+<%= format_money(Money.new(6.39, params[:currency] || @user.family.currency)) %>) From 8208722247e4d3c8c9f9130e088a6aa69835b75c Mon Sep 17 00:00:00 2001 From: Tony Vincent Date: Fri, 28 Feb 2025 13:49:12 +0100 Subject: [PATCH 010/380] Feat: Data "reset" button (#1913) * feat: Allow admins to delete family data * feat: Allow self-hosting users to delete cached data * Remove system tests --- .../settings/hostings_controller.rb | 10 ++++ app/controllers/users_controller.rb | 10 ++++ app/jobs/data_cache_clear_job.rb | 16 +++++++ app/jobs/family_reset_job.rb | 19 ++++++++ .../hostings/_danger_zone_settings.html.erb | 20 ++++++++ app/views/settings/hostings/show.html.erb | 4 ++ app/views/settings/profiles/show.html.erb | 48 +++++++++++++------ config/locales/views/settings/en.yml | 5 ++ config/locales/views/settings/hostings/en.yml | 9 ++++ config/locales/views/users/en.yml | 3 ++ config/routes.rb | 8 +++- .../settings/hostings_controller_test.rb | 35 ++++++++++++++ test/controllers/users_controller_test.rb | 35 ++++++++++++++ 13 files changed, 206 insertions(+), 16 deletions(-) create mode 100644 app/jobs/data_cache_clear_job.rb create mode 100644 app/jobs/family_reset_job.rb create mode 100644 app/views/settings/hostings/_danger_zone_settings.html.erb diff --git a/app/controllers/settings/hostings_controller.rb b/app/controllers/settings/hostings_controller.rb index fa88d23c..0740b0bc 100644 --- a/app/controllers/settings/hostings_controller.rb +++ b/app/controllers/settings/hostings_controller.rb @@ -2,6 +2,7 @@ class Settings::HostingsController < ApplicationController layout "settings" before_action :raise_if_not_self_hosted + before_action :ensure_admin, only: :clear_cache def show @synth_usage = Current.family.synth_usage @@ -38,6 +39,11 @@ class Settings::HostingsController < ApplicationController render :show, status: :unprocessable_entity end + def clear_cache + DataCacheClearJob.perform_later(Current.family) + redirect_to settings_hosting_path, notice: t(".cache_cleared") + end + private def hosting_params params.require(:setting).permit(:render_deploy_hook, :upgrades_setting, :require_invite_for_signup, :require_email_confirmation, :synth_api_key) @@ -46,4 +52,8 @@ class Settings::HostingsController < ApplicationController def raise_if_not_self_hosted raise "Settings not available on non-self-hosted instance" unless self_hosted? end + + def ensure_admin + redirect_to settings_hosting_path, alert: t(".not_authorized") unless Current.user.admin? + end end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index d8d0de8a..9b82509d 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1,5 +1,6 @@ class UsersController < ApplicationController before_action :set_user + before_action :ensure_admin, only: :reset def update @user = Current.user @@ -26,6 +27,11 @@ class UsersController < ApplicationController end end + def reset + FamilyResetJob.perform_later(Current.family) + redirect_to settings_profile_path, notice: t(".success") + end + def destroy if @user.deactivate Current.session.destroy @@ -68,4 +74,8 @@ class UsersController < ApplicationController def set_user @user = Current.user end + + def ensure_admin + redirect_to settings_profile_path, alert: I18n.t("users.reset.unauthorized") unless Current.user.admin? + end end diff --git a/app/jobs/data_cache_clear_job.rb b/app/jobs/data_cache_clear_job.rb new file mode 100644 index 00000000..49e18880 --- /dev/null +++ b/app/jobs/data_cache_clear_job.rb @@ -0,0 +1,16 @@ +class DataCacheClearJob < ApplicationJob + queue_as :default + + def perform(family) + ActiveRecord::Base.transaction do + ExchangeRate.delete_all + Security::Price.delete_all + family.accounts.each do |account| + account.balances.delete_all + account.holdings.delete_all + end + + family.sync_later + end + end +end diff --git a/app/jobs/family_reset_job.rb b/app/jobs/family_reset_job.rb new file mode 100644 index 00000000..20dc2499 --- /dev/null +++ b/app/jobs/family_reset_job.rb @@ -0,0 +1,19 @@ +class FamilyResetJob < ApplicationJob + queue_as :default + + def perform(family) + # Delete all family data except users + ActiveRecord::Base.transaction do + # Delete accounts and related data + family.accounts.destroy_all + family.categories.destroy_all + family.tags.destroy_all + family.merchants.destroy_all + family.plaid_items.destroy_all + family.imports.destroy_all + family.budgets.destroy_all + + family.sync_later + end + end +end diff --git a/app/views/settings/hostings/_danger_zone_settings.html.erb b/app/views/settings/hostings/_danger_zone_settings.html.erb new file mode 100644 index 00000000..7f04c4e2 --- /dev/null +++ b/app/views/settings/hostings/_danger_zone_settings.html.erb @@ -0,0 +1,20 @@ +<% if Current.user.admin? %> +

+
+
+

<%= t("settings.hostings.show.clear_cache") %>

+

<%= t("settings.hostings.show.clear_cache_warning") %>

+
+ <%= + button_to t("settings.hostings.show.clear_cache"), clear_cache_settings_hosting_path, method: :delete, + class: "bg-orange-500 text-white text-sm font-medium rounded-lg px-4 py-2", + data: { turbo_confirm: { + title: t("settings.hostings.show.confirm_clear_cache.title"), + body: t("settings.hostings.show.confirm_clear_cache.body"), + accept: t("settings.hostings.show.clear_cache"), + acceptClass: "w-full bg-orange-500 text-white rounded-xl text-center p-[10px] border mb-2" + }} + %> +
+
+<% end %> diff --git a/app/views/settings/hostings/show.html.erb b/app/views/settings/hostings/show.html.erb index ca74914a..bd1916f3 100644 --- a/app/views/settings/hostings/show.html.erb +++ b/app/views/settings/hostings/show.html.erb @@ -11,3 +11,7 @@ <%= settings_section title: t(".invites") do %> <%= render "settings/hostings/invite_code_settings" %> <% end %> + +<%= settings_section title: t(".danger_zone") do %> + <%= render "settings/hostings/danger_zone_settings" %> +<% end %> diff --git a/app/views/settings/profiles/show.html.erb b/app/views/settings/profiles/show.html.erb index 165f9b1c..168f1472 100644 --- a/app/views/settings/profiles/show.html.erb +++ b/app/views/settings/profiles/show.html.erb @@ -127,20 +127,40 @@ <% end %> <%= settings_section title: t(".danger_zone_title") do %> -
-
-

<%= t(".delete_account") %>

-

<%= t(".delete_account_warning") %>

+
+ <% if Current.user.admin? %> +
+
+

<%= t(".reset_account") %>

+

<%= t(".reset_account_warning") %>

+
+ <%= + button_to t(".reset_account"), reset_user_path(@user), method: :delete, + class: "bg-orange-500 text-white text-sm font-medium rounded-lg px-4 py-2", + data: { turbo_confirm: { + title: t(".confirm_reset.title"), + body: t(".confirm_reset.body"), + accept: t(".reset_account"), + acceptClass: "w-full bg-orange-500 text-white rounded-xl text-center p-[10px] border mb-2" + }} + %> +
+ <% end %> +
+
+

<%= t(".delete_account") %>

+

<%= t(".delete_account_warning") %>

+
+ <%= + button_to t(".delete_account"), user_path(@user), method: :delete, + class: "bg-red-500 text-white text-sm font-medium rounded-lg px-3 py-2", + data: { turbo_confirm: { + title: t(".confirm_delete.title"), + body: t(".confirm_delete.body"), + accept: t(".delete_account"), + acceptClass: "w-full bg-red-500 text-white rounded-xl text-center p-[10px] border mb-2" + }} + %>
- <%= - button_to t(".delete_account"), user_path(@user), method: :delete, - class: "bg-red-500 text-white text-sm font-medium rounded-lg px-3 py-2", - data: { turbo_confirm: { - title: t(".confirm_delete.title"), - body: t(".confirm_delete.body"), - accept: t(".delete_account"), - acceptClass: "w-full bg-red-500 text-white rounded-xl text-center p-[10px] border mb-2" - }} - %>
<% end %> diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index e7e7d627..8596904e 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -39,6 +39,9 @@ en: body: Are you sure you want to permanently delete your account? This action is irreversible. title: Delete account? + confirm_reset: + 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. + title: Reset account? confirm_remove_invitation: body: Are you sure you want to remove the invitation for %{email}? title: Remove Invitation @@ -49,6 +52,8 @@ en: delete_account: Delete account delete_account_warning: Deleting your account will permanently remove all your data and cannot be undone. + reset_account: Reset account + reset_account_warning: Resetting your account will delete all your accounts, categories, merchants, tags, and other data, but keep your user account intact. email: Email first_name: First Name household_form_input_placeholder: Enter household name diff --git a/config/locales/views/settings/hostings/en.yml b/config/locales/views/settings/hostings/en.yml index 8cb7f6c4..bbcb8ee9 100644 --- a/config/locales/views/settings/hostings/en.yml +++ b/config/locales/views/settings/hostings/en.yml @@ -20,6 +20,12 @@ en: general: General Settings invites: Invite Codes title: Self-Hosting + danger_zone: Danger Zone + clear_cache: Clear data cache + 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. + confirm_clear_cache: + title: Clear data cache? + 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. synth_settings: api_calls_used: "%{used} / %{limit} API calls used (%{percentage})" description: Input the API key provided by Synth @@ -30,6 +36,8 @@ en: update: failure: Invalid setting value success: Settings updated + clear_cache: + cache_cleared: Data cache has been cleared. This may take a few moments to complete. upgrade_settings: description: Configure how your application receives updates latest_commit_description: Automatically update to the latest commit (unstable) @@ -40,3 +48,4 @@ en: manual_description: You control when to download and install updates manual_title: Manual title: Auto Upgrade + not_authorized: You are not authorized to perform this action diff --git a/config/locales/views/users/en.yml b/config/locales/views/users/en.yml index e9aae045..e04ebc8c 100644 --- a/config/locales/views/users/en.yml +++ b/config/locales/views/users/en.yml @@ -8,3 +8,6 @@ en: email_change_initiated: Please check your new email address for confirmation instructions. success: Your profile has been updated. + reset: + success: Your account has been reset. Data will be deleted in the background in some time. + unauthorized: You are not authorized to perform this action diff --git a/config/routes.rb b/config/routes.rb index 13f9f61f..30d3303e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -18,7 +18,9 @@ Rails.application.routes.draw do resource :password, only: %i[edit update] resource :email_confirmation, only: :new - resources :users, only: %i[update destroy] + resources :users, only: %i[update destroy] do + delete :reset, on: :member + end resource :onboarding, only: :show do collection do @@ -30,7 +32,9 @@ Rails.application.routes.draw do namespace :settings do resource :profile, only: [ :show, :destroy ] resource :preferences, only: :show - resource :hosting, only: %i[show update] + resource :hosting, only: %i[show update] do + delete :clear_cache, on: :collection + end resource :billing, only: :show resource :security, only: :show end diff --git a/test/controllers/settings/hostings_controller_test.rb b/test/controllers/settings/hostings_controller_test.rb index 283b70bc..3ee8a226 100644 --- a/test/controllers/settings/hostings_controller_test.rb +++ b/test/controllers/settings/hostings_controller_test.rb @@ -45,4 +45,39 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest assert_equal NEW_RENDER_DEPLOY_HOOK, Setting.render_deploy_hook end end + + test "can clear data cache when self hosting is enabled" do + account = accounts(:investment) + holding = account.holdings.first + exchange_rate = exchange_rates(:one) + security_price = holding.security.prices.first + account_balance = account.balances.create!(date: Date.current, balance: 1000, currency: "USD") + + with_self_hosting do + perform_enqueued_jobs(only: DataCacheClearJob) do + delete clear_cache_settings_hosting_url + end + end + + assert_redirected_to settings_hosting_url + assert_equal I18n.t("settings.hostings.clear_cache.cache_cleared"), flash[:notice] + + assert_not ExchangeRate.exists?(exchange_rate.id) + assert_not Security::Price.exists?(security_price.id) + assert_not Account::Holding.exists?(holding.id) + assert_not Account::Balance.exists?(account_balance.id) + end + + test "can clear data only when admin" do + with_self_hosting do + sign_in users(:family_member) + + assert_no_enqueued_jobs do + delete clear_cache_settings_hosting_url + end + + assert_redirected_to settings_hosting_url + assert_equal I18n.t("settings.hostings.not_authorized"), flash[:alert] + end + end end diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb index 74e217f2..bd68fe77 100644 --- a/test/controllers/users_controller_test.rb +++ b/test/controllers/users_controller_test.rb @@ -31,6 +31,41 @@ class UsersControllerTest < ActionDispatch::IntegrationTest assert_equal "Your profile has been updated.", flash[:notice] end + test "admin can reset family data" do + account = accounts(:investment) + category = categories(:income) + tag = tags(:one) + merchant = merchants(:netflix) + import = imports(:transaction) + budget = budgets(:one) + plaid_item = plaid_items(:one) + + perform_enqueued_jobs(only: FamilyResetJob) do + delete reset_user_url(@user) + end + + assert_redirected_to settings_profile_url + assert_equal I18n.t("users.reset.success"), flash[:notice] + + assert_not Account.exists?(account.id) + assert_not Category.exists?(category.id) + assert_not Tag.exists?(tag.id) + assert_not Merchant.exists?(merchant.id) + assert_not Import.exists?(import.id) + assert_not Budget.exists?(budget.id) + assert_not PlaidItem.exists?(plaid_item.id) + end + + test "non-admin cannot reset family data" do + sign_in @member = users(:family_member) + + delete reset_user_url(@member) + + assert_redirected_to settings_profile_url + assert_equal I18n.t("users.reset.unauthorized"), flash[:alert] + assert_no_enqueued_jobs only: FamilyResetJob + end + test "member can deactivate their account" do sign_in @member = users(:family_member) delete user_url(@member) From fae781e1be5b96b484d4b41a027abbbaa11e4d7a Mon Sep 17 00:00:00 2001 From: Tony Vincent Date: Fri, 28 Feb 2025 13:53:05 +0100 Subject: [PATCH 011/380] Make tags scrollable again (#1921) --- app/views/account/transactions/show.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/account/transactions/show.html.erb b/app/views/account/transactions/show.html.erb index e101e8cf..17ff31cf 100644 --- a/app/views/account/transactions/show.html.erb +++ b/app/views/account/transactions/show.html.erb @@ -81,7 +81,7 @@ label: t(".tags_label"), container_class: "h-40" }, - { "data-auto-submit-form-target": "auto" } %> + { "data-auto-submit-form-target": "auto", class: "overflow-y-auto border-none" } %> <% end %> <% end %> From 98c842d3b87c8bc46f503be487c4a4ceabf02474 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 28 Feb 2025 08:23:46 -0500 Subject: [PATCH 012/380] Add note about self hosted versions prior to opening bugs --- .github/ISSUE_TEMPLATE/bug_report.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 389a4271..a2671474 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -10,6 +10,7 @@ assignees: '' **Where did this bug occur? (required)** - [ ] I am a self-hosted user reporting a bug from my self hosted app + - [ ] I have verified that I am running the **latest** version of the Maybe app (your app should be running [this version](https://github.com/maybe-finance/maybe/pkgs/container/maybe/364919621?tag=latest) before opening a bug) - [ ] I am a user of Maybe's paid app _Please note, if you are reporting a bug with sensitive data, please open an Intercom chat from within the app for help_ From 58cc09f5ae0592e6fc9aa4b34c50641241803891 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 28 Feb 2025 08:25:20 -0500 Subject: [PATCH 013/380] Fix bad link in bug template Signed-off-by: Zach Gollwitzer --- .github/ISSUE_TEMPLATE/bug_report.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index a2671474..203ba646 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -10,7 +10,7 @@ assignees: '' **Where did this bug occur? (required)** - [ ] I am a self-hosted user reporting a bug from my self hosted app - - [ ] I have verified that I am running the **latest** version of the Maybe app (your app should be running [this version](https://github.com/maybe-finance/maybe/pkgs/container/maybe/364919621?tag=latest) before opening a bug) + - [ ] I have verified that I am running the **latest** version of the Maybe app (your app should be running [this version](https://github.com/maybe-finance/maybe/pkgs/container/maybe) before opening a bug) - [ ] I am a user of Maybe's paid app _Please note, if you are reporting a bug with sensitive data, please open an Intercom chat from within the app for help_ From e771c8c1df0a1db322193377c10446138cb72422 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 28 Feb 2025 08:35:14 -0500 Subject: [PATCH 014/380] Fix value wrapping on account balance in sidebar (#1922) --- app/views/accounts/_accountable_group.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/accounts/_accountable_group.html.erb b/app/views/accounts/_accountable_group.html.erb index 008cc4b1..b240a557 100644 --- a/app/views/accounts/_accountable_group.html.erb +++ b/app/views/accounts/_accountable_group.html.erb @@ -28,7 +28,7 @@
- <%= tag.p format_money(account.balance_money), class: "text-sm font-medium text-primary" %> + <%= tag.p format_money(account.balance_money), class: "text-sm font-medium text-primary whitespace-nowrap" %> <%= turbo_frame_tag dom_id(account, :sparkline), src: sparkline_account_path(account), loading: "lazy" do %>
From d6793dec0507ea50dc6b26be59a65983de5d63b6 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 28 Feb 2025 08:36:57 -0500 Subject: [PATCH 015/380] Fix import configuration form so number format is applied (#1923) * Fix number format form error when loading import * Add test to verify import configuration was properly applied --- .../import/configurations_controller.rb | 1 + .../import/configurations_controller_test.rb | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/app/controllers/import/configurations_controller.rb b/app/controllers/import/configurations_controller.rb index 9a060f8f..3b9ca5b5 100644 --- a/app/controllers/import/configurations_controller.rb +++ b/app/controllers/import/configurations_controller.rb @@ -35,6 +35,7 @@ class Import::ConfigurationsController < ApplicationController :notes_col_label, :currency_col_label, :date_format, + :number_format, :signage_convention ) end diff --git a/test/controllers/import/configurations_controller_test.rb b/test/controllers/import/configurations_controller_test.rb index 95edf0c3..078c17fe 100644 --- a/test/controllers/import/configurations_controller_test.rb +++ b/test/controllers/import/configurations_controller_test.rb @@ -23,11 +23,24 @@ class Import::ConfigurationsControllerTest < ActionDispatch::IntegrationTest tags_col_label: "Tags", amount_col_label: "Amount", signage_convention: "inflows_positive", - account_col_label: "Account" + account_col_label: "Account", + number_format: "1.234,56" } } assert_redirected_to import_clean_url(@import) assert_equal "Import configured successfully.", flash[:notice] + + # Verify configurations were saved + @import.reload + assert_equal "Date", @import.date_col_label + assert_equal "%Y-%m-%d", @import.date_format + assert_equal "Name", @import.name_col_label + assert_equal "Category", @import.category_col_label + assert_equal "Tags", @import.tags_col_label + assert_equal "Amount", @import.amount_col_label + assert_equal "inflows_positive", @import.signage_convention + assert_equal "Account", @import.account_col_label + assert_equal "1.234,56", @import.number_format end end From 882857fcf0a6f5373de8e8ee4545fe703b62af4d Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 28 Feb 2025 09:29:07 -0500 Subject: [PATCH 016/380] Add transitions to buttons and other common design system elements (#1924) --- app/assets/tailwind/maybe-design-system.css | 43 +++++++++++-------- .../account/transactions/bulk_edit.html.erb | 4 +- app/views/account/transactions/show.html.erb | 2 +- app/views/budgets/_picker.html.erb | 2 +- app/views/mfa/new.html.erb | 2 +- app/views/settings/profiles/show.html.erb | 6 +-- app/views/transactions/_header.html.erb | 2 +- 7 files changed, 34 insertions(+), 27 deletions(-) diff --git a/app/assets/tailwind/maybe-design-system.css b/app/assets/tailwind/maybe-design-system.css index 3f252db6..c37eb286 100644 --- a/app/assets/tailwind/maybe-design-system.css +++ b/app/assets/tailwind/maybe-design-system.css @@ -331,29 +331,13 @@ details>summary { @apply list-none; } - - select[multiple="multiple"] { - @apply py-2 pr-2 space-y-0.5 overflow-y-auto; - } - - select[multiple="multiple"] option { - @apply py-2 rounded-md; - } - - select[multiple="multiple"] option:checked { - @apply after:content-['\2713'] bg-white after:text-gray-500 after:ml-2; - } - - select[multiple="multiple"] option:active, - select[multiple="multiple"] option:focus { - @apply bg-white; - } } @layer components { /* Buttons */ .btn { @apply px-3 py-2 rounded-lg text-sm font-medium cursor-pointer disabled:cursor-not-allowed focus:outline-gray-500; + @apply transition-all duration-300; } .btn--primary { @@ -380,6 +364,25 @@ .form-field { @apply flex flex-col gap-1 relative px-3 py-2 rounded-md border bg-white border-alpha-black-100 shadow-xs w-full; @apply focus-within:border-gray-900 focus-within:shadow-none focus-within:ring-4 focus-within:ring-gray-100; + @apply transition-all duration-300; + + /* Add styles for multiple select within form fields */ + select[multiple] { + @apply py-2 pr-2 space-y-0.5 overflow-y-auto; + + option { + @apply py-2 rounded-md; + } + + option:checked { + @apply after:content-['\2713'] bg-white after:text-gray-500 after:ml-2; + } + + option:active, + option:focus { + @apply bg-white; + } + } } .form-field__label { @@ -392,6 +395,7 @@ @apply placeholder-shown:opacity-50; @apply disabled:text-gray-400; @apply text-ellipsis overflow-hidden whitespace-nowrap; + @apply transition-opacity duration-300; &select { @apply pr-8; @@ -410,6 +414,7 @@ .checkbox { &[type='checkbox'] { @apply rounded-sm; + @apply transition-colors duration-300; } } @@ -440,8 +445,10 @@ /* Switches */ .switch { @apply block bg-gray-100 w-9 h-5 rounded-full cursor-pointer; - @apply after:content-[''] after:block after:absolute after:top-0.5 after:left-0.5 after:bg-white after:w-4 after:h-4 after:rounded-full after:transition-transform after:duration-300 after:ease-in-out; + @apply after:content-[''] after:block after:absolute after:top-0.5 after:left-0.5 after:bg-white after:w-4 after:h-4 after:rounded-full; + @apply after:transition-transform after:duration-300 after:ease-in-out; @apply peer-checked:bg-green-600 peer-checked:after:translate-x-4; + @apply transition-colors duration-300; } /* Tooltips */ diff --git a/app/views/account/transactions/bulk_edit.html.erb b/app/views/account/transactions/bulk_edit.html.erb index 02afa497..b8317084 100644 --- a/app/views/account/transactions/bulk_edit.html.erb +++ b/app/views/account/transactions/bulk_edit.html.erb @@ -49,12 +49,12 @@
- <%= link_to t(".cancel"), transactions_path, class: "text-sm font-medium text-primary px-3 py-2" %> + <%= link_to t(".cancel"), transactions_path, class: "btn btn--ghost" %> <%= tag.button t(".save"), type: "button", data: { "bulk-select-scope-param": "bulk_update", action: "bulk-select#submitBulkRequest" }, - class: "px-3 py-2 bg-gray-900 text-white text-sm font-medium rounded-lg" %> + class: "btn btn--primary" %>
<% end %> diff --git a/app/views/account/transactions/show.html.erb b/app/views/account/transactions/show.html.erb index 17ff31cf..e101e8cf 100644 --- a/app/views/account/transactions/show.html.erb +++ b/app/views/account/transactions/show.html.erb @@ -81,7 +81,7 @@ label: t(".tags_label"), container_class: "h-40" }, - { "data-auto-submit-form-target": "auto", class: "overflow-y-auto border-none" } %> + { "data-auto-submit-form-target": "auto" } %> <% end %> <% end %> diff --git a/app/views/budgets/_picker.html.erb b/app/views/budgets/_picker.html.erb index 4aefbf62..8fb4bd54 100644 --- a/app/views/budgets/_picker.html.erb +++ b/app/views/budgets/_picker.html.erb @@ -38,7 +38,7 @@ <% param_key = Budget.date_to_param(date) %> <% if Budget.budget_date_valid?(date, family: family) %> - <%= link_to month_name, budget_path(param_key), data: { turbo_frame: "_top" }, class: "block px-3 py-2 text-sm text-primary hover:bg-gray-100 rounded-md" %> + <%= link_to month_name, budget_path(param_key), data: { turbo_frame: "_top" }, class: "btn btn--ghost" %> <% else %> <%= month_name %> <% end %> diff --git a/app/views/mfa/new.html.erb b/app/views/mfa/new.html.erb index 68bbcf54..fdf753c0 100644 --- a/app/views/mfa/new.html.erb +++ b/app/views/mfa/new.html.erb @@ -57,7 +57,7 @@ placeholder: t(".code_placeholder") %>
- <%= f.submit t(".verify_button"), class: "bg-gray-900 hover:bg-gray-700 cursor-pointer text-white rounded-lg px-3 py-2" %> + <%= f.submit t(".verify_button"), class: "btn btn--primary" %>
<% end %> diff --git a/app/views/settings/profiles/show.html.erb b/app/views/settings/profiles/show.html.erb index 168f1472..29a2f239 100644 --- a/app/views/settings/profiles/show.html.erb +++ b/app/views/settings/profiles/show.html.erb @@ -16,7 +16,7 @@ <%= form.text_field :last_name, placeholder: t(".last_name"), label: t(".last_name") %>
- <%= form.submit t(".save"), class: "bg-gray-900 hover:bg-gray-700 cursor-pointer text-white rounded-lg px-3 py-2" %> + <%= form.submit t(".save"), class: "btn btn--primary" %>
<% end %> @@ -136,7 +136,7 @@
<%= button_to t(".reset_account"), reset_user_path(@user), method: :delete, - class: "bg-orange-500 text-white text-sm font-medium rounded-lg px-4 py-2", + class: "btn btn--destructive", data: { turbo_confirm: { title: t(".confirm_reset.title"), body: t(".confirm_reset.body"), @@ -153,7 +153,7 @@
<%= button_to t(".delete_account"), user_path(@user), method: :delete, - class: "bg-red-500 text-white text-sm font-medium rounded-lg px-3 py-2", + class: "btn btn--destructive", data: { turbo_confirm: { title: t(".confirm_delete.title"), body: t(".confirm_delete.body"), diff --git a/app/views/transactions/_header.html.erb b/app/views/transactions/_header.html.erb index 1a53bf80..f2487458 100644 --- a/app/views/transactions/_header.html.erb +++ b/app/views/transactions/_header.html.erb @@ -16,7 +16,7 @@

<%= t(".import") %>

<% end %> - <%= link_to new_account_transaction_path, class: "rounded-lg bg-gray-900 text-white flex items-center gap-1 justify-center hover:bg-gray-700 px-3 py-2", data: { turbo_frame: :modal } do %> + <%= link_to new_account_transaction_path, class: "btn btn--primary flex items-center gap-2", data: { turbo_frame: :modal } do %> <%= lucide_icon("plus", class: "w-5 h-5") %>

New transaction

<% end %> From 9138bd2b76a61b9f1bfda2003aba98acd59ab88c Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 28 Feb 2025 09:34:14 -0500 Subject: [PATCH 017/380] Allow offline trade tickers (#1925) --- app/controllers/account/trades_controller.rb | 2 +- app/models/account/trade_builder.rb | 5 +++-- app/models/security.rb | 5 +++++ app/views/account/trades/_form.html.erb | 15 ++++++++++++--- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/app/controllers/account/trades_controller.rb b/app/controllers/account/trades_controller.rb index 6ace6538..fd9b7d48 100644 --- a/app/controllers/account/trades_controller.rb +++ b/app/controllers/account/trades_controller.rb @@ -10,7 +10,7 @@ class Account::TradesController < ApplicationController def create_entry_params params.require(:account_entry).permit( - :account_id, :date, :amount, :currency, :qty, :price, :ticker, :type, :transfer_account_id + :account_id, :date, :amount, :currency, :qty, :price, :ticker, :manual_ticker, :type, :transfer_account_id ).tap do |params| account_id = params.delete(:account_id) params[:account] = Current.family.accounts.find(account_id) diff --git a/app/models/account/trade_builder.rb b/app/models/account/trade_builder.rb index 704d851f..c632f272 100644 --- a/app/models/account/trade_builder.rb +++ b/app/models/account/trade_builder.rb @@ -2,7 +2,7 @@ class Account::TradeBuilder include ActiveModel::Model attr_accessor :account, :date, :amount, :currency, :qty, - :price, :ticker, :type, :transfer_account_id + :price, :ticker, :manual_ticker, :type, :transfer_account_id attr_reader :buildable @@ -110,8 +110,9 @@ class Account::TradeBuilder account.family end + # Users can either look up a ticker from our provider (Synth) or enter a manual, "offline" ticker (that we won't fetch prices for) def security - ticker_symbol, exchange_operating_mic = ticker.split("|") + ticker_symbol, exchange_operating_mic = ticker.present? ? ticker.split("|") : [ manual_ticker, nil ] Security.find_or_create_by(ticker: ticker_symbol, exchange_operating_mic: exchange_operating_mic) do |s| FetchSecurityInfoJob.perform_later(s.id) diff --git a/app/models/security.rb b/app/models/security.rb index 46672fb5..991ac202 100644 --- a/app/models/security.rb +++ b/app/models/security.rb @@ -1,5 +1,6 @@ class Security < ApplicationRecord include Providable + before_save :upcase_ticker has_many :trades, dependent: :nullify, class_name: "Account::Trade" @@ -9,6 +10,10 @@ class Security < ApplicationRecord validates :ticker, uniqueness: { scope: :exchange_operating_mic, case_sensitive: false } class << self + def provider + security_prices_provider + end + def search(query) security_prices_provider.search_securities( query: query[:search], diff --git a/app/views/account/trades/_form.html.erb b/app/views/account/trades/_form.html.erb index c8dae4d3..a74d401f 100644 --- a/app/views/account/trades/_form.html.erb +++ b/app/views/account/trades/_form.html.erb @@ -27,9 +27,18 @@ }} %> <% if %w[buy sell].include?(type) %> -
- <%= form.combobox :ticker, securities_path(country_code: Current.family.country), label: t(".holding"), placeholder: t(".ticker_placeholder"), required: true %> -
+ <% if Security.provider.present? %> +
+ <%= form.combobox :ticker, + securities_path(country_code: Current.family.country), + name_when_new: "account_entry[manual_ticker]", + label: t(".holding"), + placeholder: t(".ticker_placeholder"), + required: true %> +
+ <% else %> + <%= form.text_field :manual_ticker, label: "Ticker", placeholder: "AAPL", required: true %> + <% end %> <% end %> <%= form.date_field :date, label: true, value: Date.current, required: true %> From 624faa10d0fef27d1cb04fa40ed3d48f97766c48 Mon Sep 17 00:00:00 2001 From: Tony Vincent Date: Fri, 28 Feb 2025 15:35:00 +0100 Subject: [PATCH 018/380] fix: Don't show Billings on settings navbar when self-hosted (#1912) * Do not show billing settings navbar item when self hosted * Do not show billing settings navbar item when self hosted * Add condition to settings helper * Let Stripe::AuthenticationError bubble up --- app/controllers/subscriptions_controller.rb | 6 ++++++ app/helpers/settings_helper.rb | 7 ++++++- app/views/settings/_settings_nav.html.erb | 9 +++++---- config/locales/views/subscriptions/en.yml | 3 +++ test/controllers/subscriptions_controller_test.rb | 13 ++++++++++--- test/system/settings_test.rb | 6 ++++++ 6 files changed, 36 insertions(+), 8 deletions(-) create mode 100644 config/locales/views/subscriptions/en.yml diff --git a/app/controllers/subscriptions_controller.rb b/app/controllers/subscriptions_controller.rb index e2c19535..64552508 100644 --- a/app/controllers/subscriptions_controller.rb +++ b/app/controllers/subscriptions_controller.rb @@ -1,4 +1,6 @@ class SubscriptionsController < ApplicationController + before_action :redirect_to_root_if_self_hosted + def new if Current.family.stripe_customer_id.blank? customer = stripe_client.v1.customers.create( @@ -44,4 +46,8 @@ class SubscriptionsController < ApplicationController def stripe_client @stripe_client ||= Stripe::StripeClient.new(ENV["STRIPE_SECRET_KEY"]) end + + def redirect_to_root_if_self_hosted + redirect_to root_path, alert: I18n.t("subscriptions.self_hosted_alert") if self_hosted? + end end diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index 0eaecd85..e15414a5 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -4,7 +4,7 @@ module SettingsHelper { name: I18n.t("settings.settings_nav.preferences_label"), path: :settings_preferences_path }, { name: I18n.t("settings.settings_nav.security_label"), path: :settings_security_path }, { name: I18n.t("settings.settings_nav.self_hosting_label"), path: :settings_hosting_path, condition: :self_hosted? }, - { name: I18n.t("settings.settings_nav.billing_label"), path: :settings_billing_path }, + { name: I18n.t("settings.settings_nav.billing_label"), path: :settings_billing_path, condition: :not_self_hosted? }, { name: I18n.t("settings.settings_nav.accounts_label"), path: :accounts_path }, { name: I18n.t("settings.settings_nav.imports_label"), path: :imports_path }, { name: I18n.t("settings.settings_nav.tags_label"), path: :tags_path }, @@ -45,4 +45,9 @@ module SettingsHelper concat(next_setting) end end + + private + def not_self_hosted? + !self_hosted? + end end diff --git a/app/views/settings/_settings_nav.html.erb b/app/views/settings/_settings_nav.html.erb index 1934d921..632e774c 100644 --- a/app/views/settings/_settings_nav.html.erb +++ b/app/views/settings/_settings_nav.html.erb @@ -32,10 +32,11 @@ <%= render "settings/settings_nav_item", name: t(".self_hosting_label"), path: settings_hosting_path, icon: "database" %> <% end %> - -
  • - <%= render "settings/settings_nav_item", name: t(".billing_label"), path: settings_billing_path, icon: "circle-dollar-sign" %> -
  • + <% unless self_hosted? %> +
  • + <%= render "settings/settings_nav_item", name: t(".billing_label"), path: settings_billing_path, icon: "circle-dollar-sign" %> +
  • + <% end %>
  • <%= render "settings/settings_nav_item", name: t(".accounts_label"), path: accounts_path, icon: "layers" %> diff --git a/config/locales/views/subscriptions/en.yml b/config/locales/views/subscriptions/en.yml new file mode 100644 index 00000000..72ab67a9 --- /dev/null +++ b/config/locales/views/subscriptions/en.yml @@ -0,0 +1,3 @@ +en: + subscriptions: + self_hosted_alert: "Maybe+ is not available in self-hosted mode." diff --git a/test/controllers/subscriptions_controller_test.rb b/test/controllers/subscriptions_controller_test.rb index 3fda28d4..fe1b38d7 100644 --- a/test/controllers/subscriptions_controller_test.rb +++ b/test/controllers/subscriptions_controller_test.rb @@ -1,7 +1,14 @@ require "test_helper" class SubscriptionsControllerTest < ActionDispatch::IntegrationTest - # test "the truth" do - # assert true - # end + setup do + sign_in @user = users(:family_admin) + end + + test "redirects to settings if self hosting" do + Rails.application.config.app_mode.stubs(:self_hosted?).returns(true) + get subscription_path + assert_redirected_to root_path + assert_equal I18n.t("subscriptions.self_hosted_alert"), flash[:alert] + end end diff --git a/test/system/settings_test.rb b/test/system/settings_test.rb index 32f2b8c8..9d0338b0 100644 --- a/test/system/settings_test.rb +++ b/test/system/settings_test.rb @@ -46,6 +46,12 @@ class SettingsTest < ApplicationSystemTestCase assert_selector 'span[data-clipboard-target="iconSuccess"]', visible: true, count: 1 # text copied and icon changed to checkmark end + test "does not show billing link if self hosting" do + Rails.application.config.app_mode.stubs(:self_hosted?).returns(true) + open_settings_from_sidebar + assert_no_selector "li", text: I18n.t("settings.settings_nav.billing_label") + end + private def open_settings_from_sidebar From fa0248056dabb29693e190c6faedbdedc64b9d82 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 28 Feb 2025 11:35:10 -0500 Subject: [PATCH 019/380] Show UI warning to user when they need provider data but have not setup Synth yet (#1926) * Simplify provider concerns * Update tests * Add UI warning for missing Synth key if family requires external data --- app/controllers/securities_controller.rb | 2 +- app/jobs/fetch_security_info_job.rb | 4 +- app/models/account/data_enricher.rb | 4 +- app/models/account/entry.rb | 2 +- app/models/account/entry/provided.rb | 11 ++++++ app/models/concerns/providable.rb | 35 ------------------ app/models/concerns/synthable.rb | 37 +++++++++++++++++++ app/models/exchange_rate/provided.rb | 15 ++++---- app/models/family.rb | 29 ++++++++------- app/models/security.rb | 17 +-------- app/models/security/price/provided.rb | 15 +++++--- app/models/security/provided.rb | 26 +++++++++++++ app/models/trade_import.rb | 17 ++------- app/models/upgrader/provided.rb | 5 ++- .../accounts/_account_sidebar_tabs.html.erb | 20 ++++++++++ .../configurations/_trade_import.html.erb | 5 +-- .../hostings/_synth_settings.html.erb | 9 ++++- test/models/exchange_rate_test.rb | 16 +++++--- test/models/security/price_test.rb | 17 ++++++--- test/models/trade_import_test.rb | 30 +++++---------- test/system/imports_test.rb | 2 + test/system/trades_test.rb | 2 +- 22 files changed, 184 insertions(+), 136 deletions(-) create mode 100644 app/models/account/entry/provided.rb delete mode 100644 app/models/concerns/providable.rb create mode 100644 app/models/concerns/synthable.rb create mode 100644 app/models/security/provided.rb diff --git a/app/controllers/securities_controller.rb b/app/controllers/securities_controller.rb index 5be3cbd9..6cfe9fd0 100644 --- a/app/controllers/securities_controller.rb +++ b/app/controllers/securities_controller.rb @@ -3,7 +3,7 @@ class SecuritiesController < ApplicationController query = params[:q] return render json: [] if query.blank? || query.length < 2 || query.length > 100 - @securities = Security.search({ + @securities = Security.search_provider({ search: query, country: params[:country_code] == "US" ? "US" : nil }) diff --git a/app/jobs/fetch_security_info_job.rb b/app/jobs/fetch_security_info_job.rb index aa64c169..484a47e1 100644 --- a/app/jobs/fetch_security_info_job.rb +++ b/app/jobs/fetch_security_info_job.rb @@ -2,7 +2,7 @@ class FetchSecurityInfoJob < ApplicationJob queue_as :latency_low def perform(security_id) - return unless Security.security_info_provider.present? + return unless Security.provider.present? security = Security.find(security_id) @@ -12,7 +12,7 @@ class FetchSecurityInfoJob < ApplicationJob params[:mic_code] = security.exchange_mic if security.exchange_mic.present? params[:operating_mic] = security.exchange_operating_mic if security.exchange_operating_mic.present? - security_info_response = Security.security_info_provider.fetch_security_info(**params) + security_info_response = Security.provider.fetch_security_info(**params) security.update( name: security_info_response.info.dig("name") diff --git a/app/models/account/data_enricher.rb b/app/models/account/data_enricher.rb index 59979df0..8d07eff8 100644 --- a/app/models/account/data_enricher.rb +++ b/app/models/account/data_enricher.rb @@ -1,6 +1,4 @@ class Account::DataEnricher - include Providable - attr_reader :account def initialize(account) @@ -37,7 +35,7 @@ class Account::DataEnricher candidates.each do |entry| begin - info = self.class.synth_provider.enrich_transaction(entry.name).info + info = entry.fetch_enrichment_info next unless info.present? diff --git a/app/models/account/entry.rb b/app/models/account/entry.rb index b53db19b..25065efd 100644 --- a/app/models/account/entry.rb +++ b/app/models/account/entry.rb @@ -1,5 +1,5 @@ class Account::Entry < ApplicationRecord - include Monetizable + include Monetizable, Provided monetize :amount diff --git a/app/models/account/entry/provided.rb b/app/models/account/entry/provided.rb new file mode 100644 index 00000000..c18654c9 --- /dev/null +++ b/app/models/account/entry/provided.rb @@ -0,0 +1,11 @@ +module Account::Entry::Provided + extend ActiveSupport::Concern + + include Synthable + + def fetch_enrichment_info + return nil unless synth_client.present? + + synth_client.enrich_transaction(name).info + end +end diff --git a/app/models/concerns/providable.rb b/app/models/concerns/providable.rb deleted file mode 100644 index 996efff8..00000000 --- a/app/models/concerns/providable.rb +++ /dev/null @@ -1,35 +0,0 @@ -# `Providable` serves as an extension point for integrating multiple providers. -# For an example of a multi-provider, multi-concept implementation, -# see: https://github.com/maybe-finance/maybe/pull/561 - -module Providable - extend ActiveSupport::Concern - - class_methods do - def security_prices_provider - synth_provider - end - - def security_info_provider - synth_provider - end - - def exchange_rates_provider - synth_provider - end - - def git_repository_provider - Provider::Github.new - end - - def synth_provider - api_key = self_hosted? ? Setting.synth_api_key : ENV["SYNTH_API_KEY"] - api_key.present? ? Provider::Synth.new(api_key) : nil - end - - private - def self_hosted? - Rails.application.config.app_mode.self_hosted? - end - end -end diff --git a/app/models/concerns/synthable.rb b/app/models/concerns/synthable.rb new file mode 100644 index 00000000..51adcade --- /dev/null +++ b/app/models/concerns/synthable.rb @@ -0,0 +1,37 @@ +module Synthable + extend ActiveSupport::Concern + + class_methods do + def synth_usage + synth_client&.usage + end + + def synth_overage? + synth_usage&.usage&.utilization.to_i >= 100 + end + + def synth_healthy? + synth_client&.healthy? + end + + def synth_client + api_key = ENV.fetch("SYNTH_API_KEY", Setting.synth_api_key) + + return nil unless api_key.present? + + Provider::Synth.new(api_key) + end + end + + def synth_client + self.class.synth_client + end + + def synth_usage + self.class.synth_usage + end + + def synth_overage? + self.class.synth_overage? + end +end diff --git a/app/models/exchange_rate/provided.rb b/app/models/exchange_rate/provided.rb index d1e2aea2..d010ff98 100644 --- a/app/models/exchange_rate/provided.rb +++ b/app/models/exchange_rate/provided.rb @@ -1,19 +1,18 @@ module ExchangeRate::Provided extend ActiveSupport::Concern - include Providable + include Synthable class_methods do - def provider_healthy? - exchange_rates_provider.present? && exchange_rates_provider.healthy? + def provider + synth_client end private - def fetch_rates_from_provider(from:, to:, start_date:, end_date: Date.current, cache: false) - return [] unless exchange_rates_provider.present? + return [] unless provider.present? - response = exchange_rates_provider.fetch_exchange_rates \ + response = provider.fetch_exchange_rates \ from: from, to: to, start_date: start_date, @@ -38,9 +37,9 @@ module ExchangeRate::Provided end def fetch_rate_from_provider(from:, to:, date:, cache: false) - return nil unless exchange_rates_provider.present? + return nil unless provider.present? - response = exchange_rates_provider.fetch_exchange_rate \ + response = provider.fetch_exchange_rate \ from: from, to: to, date: date diff --git a/app/models/family.rb b/app/models/family.rb index ff2c8b07..ffb14a7d 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -1,5 +1,5 @@ class Family < ApplicationRecord - include Providable, Plaidable, Syncable, AutoTransferMatchable + include Synthable, Plaidable, Syncable, AutoTransferMatchable DATE_FORMATS = [ [ "MM-DD-YYYY", "%m-%d-%Y" ], @@ -92,22 +92,25 @@ class Family < ApplicationRecord ).link_token end - def synth_usage - self.class.synth_provider&.usage - end - - def synth_overage? - self.class.synth_provider&.usage&.utilization.to_i >= 100 - end - - def synth_valid? - self.class.synth_provider&.healthy? - end - def subscribed? stripe_subscription_status == "active" end + def requires_data_provider? + # If family has any trades, they need a provider for historical prices + return true if trades.any? + + # If family has any accounts not denominated in the family's currency, they need a provider for historical exchange rates + return true if accounts.where.not(currency: self.currency).any? + + # If family has any entries in different currencies, they need a provider for historical exchange rates + uniq_currencies = entries.pluck(:currency).uniq + return true if uniq_currencies.count > 1 + return true if uniq_currencies.first != self.currency + + false + end + def primary_user users.order(:created_at).first end diff --git a/app/models/security.rb b/app/models/security.rb index 991ac202..6d94c798 100644 --- a/app/models/security.rb +++ b/app/models/security.rb @@ -1,5 +1,5 @@ class Security < ApplicationRecord - include Providable + include Provided before_save :upcase_ticker @@ -9,21 +9,6 @@ class Security < ApplicationRecord validates :ticker, presence: true validates :ticker, uniqueness: { scope: :exchange_operating_mic, case_sensitive: false } - class << self - def provider - security_prices_provider - end - - def search(query) - security_prices_provider.search_securities( - query: query[:search], - dataset: "limited", - country_code: query[:country], - exchange_operating_mic: query[:exchange_operating_mic] - ).securities.map { |attrs| new(**attrs) } - end - end - def current_price @current_price ||= Security::Price.find_price(security: self, date: Date.current) return nil if @current_price.nil? diff --git a/app/models/security/price/provided.rb b/app/models/security/price/provided.rb index e2a99774..aed56702 100644 --- a/app/models/security/price/provided.rb +++ b/app/models/security/price/provided.rb @@ -1,16 +1,19 @@ module Security::Price::Provided extend ActiveSupport::Concern - include Providable + include Synthable class_methods do - private + def provider + synth_client + end + private def fetch_price_from_provider(security:, date:, cache: false) - return nil unless security_prices_provider.present? + return nil unless provider.present? return nil unless security.has_prices? - response = security_prices_provider.fetch_security_prices \ + response = provider.fetch_security_prices \ ticker: security.ticker, mic_code: security.exchange_mic, start_date: date, @@ -31,11 +34,11 @@ module Security::Price::Provided end def fetch_prices_from_provider(security:, start_date:, end_date:, cache: false) - return [] unless security_prices_provider.present? + return [] unless provider.present? return [] unless security return [] unless security.has_prices? - response = security_prices_provider.fetch_security_prices \ + response = provider.fetch_security_prices \ ticker: security.ticker, mic_code: security.exchange_mic, start_date: start_date, diff --git a/app/models/security/provided.rb b/app/models/security/provided.rb new file mode 100644 index 00000000..4a4fd6a5 --- /dev/null +++ b/app/models/security/provided.rb @@ -0,0 +1,26 @@ +module Security::Provided + extend ActiveSupport::Concern + + include Synthable + + class_methods do + def provider + synth_client + end + + def search_provider(query) + response = provider.search_securities( + query: query[:search], + dataset: "limited", + country_code: query[:country], + exchange_operating_mic: query[:exchange_operating_mic] + ) + + if response.success? + response.securities.map { |attrs| new(**attrs) } + else + [] + end + end + end +end diff --git a/app/models/trade_import.rb b/app/models/trade_import.rb index ddaad904..549c9093 100644 --- a/app/models/trade_import.rb +++ b/app/models/trade_import.rb @@ -75,10 +75,7 @@ class TradeImport < Import return internal_security if internal_security.present? # If security prices provider isn't properly configured or available, create with nil exchange_operating_mic - provider = Security.security_prices_provider - unless provider.present? && provider.respond_to?(:search_securities) - return Security.find_or_create_by!(ticker: ticker, exchange_operating_mic: nil) - end + return Security.find_or_create_by!(ticker: ticker, exchange_operating_mic: nil) unless Security.provider.present? # Cache provider responses so that when we're looping through rows and importing, # we only hit our provider for the unique combinations of ticker / exchange_operating_mic @@ -86,18 +83,10 @@ class TradeImport < Import @provider_securities_cache ||= {} provider_security = @provider_securities_cache[cache_key] ||= begin - response = provider.search_securities( + Security.search_provider( query: ticker, exchange_operating_mic: exchange_operating_mic - ) - - if !response || !response.success? || !response.securities || response.securities.empty? - nil - else - response.securities.first - end - rescue => e - nil + ).first end return Security.find_or_create_by!(ticker: ticker, exchange_operating_mic: nil) if provider_security.nil? diff --git a/app/models/upgrader/provided.rb b/app/models/upgrader/provided.rb index 4b518e51..fc1e65b7 100644 --- a/app/models/upgrader/provided.rb +++ b/app/models/upgrader/provided.rb @@ -1,11 +1,14 @@ module Upgrader::Provided extend ActiveSupport::Concern - include Providable class_methods do private def fetch_latest_upgrade_candidates_from_provider git_repository_provider.fetch_latest_upgrade_candidates end + + def git_repository_provider + Provider::Github.new + end end end diff --git a/app/views/accounts/_account_sidebar_tabs.html.erb b/app/views/accounts/_account_sidebar_tabs.html.erb index d42fab86..bb409492 100644 --- a/app/views/accounts/_account_sidebar_tabs.html.erb +++ b/app/views/accounts/_account_sidebar_tabs.html.erb @@ -1,5 +1,25 @@ <%# locals: (family:) %> +<% if family.requires_data_provider? && family.synth_client.nil? %> +
    + +
    + <%= icon "triangle-alert", size: "sm" %> +

    Missing historical data

    +
    + + <%= lucide_icon "chevron-down", class: "text-yellow-600 group-open:transform group-open:rotate-180 w-5" %> +
    +
    +

    Maybe uses Synth API to fetch historical exchange rates, security prices, and more. This data is required to calculate accurate historical account balances.

    + +

    + <%= link_to "Add your Synth API key here.", settings_hosting_path, class: "text-yellow-600 underline" %> +

    +
    +
    +<% end %> +
    <%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name (optional)" } %> - <% if Security.security_prices_provider.nil? %> + <% unless Security.provider %>

    - Note: The Synth provider is not configured. Exchange validation is disabled. - Securities will be created without exchange validation, and price history will not be available. + Note: 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.

    <% end %> diff --git a/app/views/settings/hostings/_synth_settings.html.erb b/app/views/settings/hostings/_synth_settings.html.erb index 9a4e9dd4..4ee9c182 100644 --- a/app/views/settings/hostings/_synth_settings.html.erb +++ b/app/views/settings/hostings/_synth_settings.html.erb @@ -1,7 +1,11 @@

    <%= t(".title") %>

    -

    <%= t(".description") %>

    + <% if ENV["SYNTH_API_KEY"].present? %> +

    You have successfully configured your Synth API key through the SYNTH_API_KEY environment variable.

    + <% else %> +

    <%= t(".description") %>

    + <% end %>
    <%= styled_form_with model: Setting.new, @@ -15,7 +19,8 @@ label: t(".label"), type: "password", placeholder: t(".placeholder"), - value: Setting.synth_api_key, + value: ENV.fetch("SYNTH_API_KEY", Setting.synth_api_key), + disabled: ENV["SYNTH_API_KEY"].present?, container_class: @synth_usage.present? && !@synth_usage.success? ? "border-red-500" : "", data: { "auto-submit-form-target": "auto" } %> <% end %> diff --git a/test/models/exchange_rate_test.rb b/test/models/exchange_rate_test.rb index 1a705a5b..7bc7ba61 100644 --- a/test/models/exchange_rate_test.rb +++ b/test/models/exchange_rate_test.rb @@ -5,14 +5,16 @@ class ExchangeRateTest < ActiveSupport::TestCase setup do @provider = mock - ExchangeRate.stubs(:exchange_rates_provider).returns(@provider) + ExchangeRate.stubs(:provider).returns(@provider) end test "exchange rate provider nil if no api key configured" do - ExchangeRate.unstub(:exchange_rates_provider) + ExchangeRate.unstub(:provider) + + Setting.stubs(:synth_api_key).returns(nil) with_env_overrides SYNTH_API_KEY: nil do - assert_not ExchangeRate.exchange_rates_provider + assert_not ExchangeRate.provider end end @@ -42,7 +44,9 @@ class ExchangeRateTest < ActiveSupport::TestCase end test "nil if rate is not found in DB and provider is disabled" do - ExchangeRate.unstub(:exchange_rates_provider) + ExchangeRate.unstub(:provider) + + Setting.stubs(:synth_api_key).returns(nil) with_env_overrides SYNTH_API_KEY: nil do assert_not ExchangeRate.find_rate(from: "USD", to: "EUR", date: Date.current) @@ -102,7 +106,9 @@ class ExchangeRateTest < ActiveSupport::TestCase end test "returns empty array if no rates found in DB or provider" do - ExchangeRate.unstub(:exchange_rates_provider) + ExchangeRate.unstub(:provider) + + Setting.stubs(:synth_api_key).returns(nil) with_env_overrides SYNTH_API_KEY: nil do assert_equal [], ExchangeRate.find_rates(from: "USD", to: "JPY", start_date: 10.days.ago.to_date) diff --git a/test/models/security/price_test.rb b/test/models/security/price_test.rb index d5021242..66b60469 100644 --- a/test/models/security/price_test.rb +++ b/test/models/security/price_test.rb @@ -5,14 +5,16 @@ class Security::PriceTest < ActiveSupport::TestCase setup do @provider = mock - Security::Price.stubs(:security_prices_provider).returns(@provider) + Security::Price.stubs(:provider).returns(@provider) end test "security price provider nil if no api key provided" do - Security::Price.unstub(:security_prices_provider) + Security::Price.unstub(:provider) + + Setting.stubs(:synth_api_key).returns(nil) with_env_overrides SYNTH_API_KEY: nil do - assert_not Security::Price.security_prices_provider + assert_not Security::Price.provider end end @@ -60,7 +62,10 @@ class Security::PriceTest < ActiveSupport::TestCase end test "returns nil if price not found in DB and provider disabled" do - Security::Price.unstub(:security_prices_provider) + Security::Price.unstub(:provider) + + Setting.stubs(:synth_api_key).returns(nil) + security = Security.new(ticker: "NVDA") with_env_overrides SYNTH_API_KEY: nil do @@ -105,7 +110,9 @@ class Security::PriceTest < ActiveSupport::TestCase end test "returns empty array if no prices found in DB or from provider" do - Security::Price.unstub(:security_prices_provider) + Security::Price.unstub(:provider) + + Setting.stubs(:synth_api_key).returns(nil) with_env_overrides SYNTH_API_KEY: nil do assert_equal [], Security::Price.find_prices(security: Security.new(ticker: "NVDA"), start_date: 10.days.ago.to_date, end_date: Date.current) diff --git a/test/models/trade_import_test.rb b/test/models/trade_import_test.rb index 2900034e..f0ee3e68 100644 --- a/test/models/trade_import_test.rb +++ b/test/models/trade_import_test.rb @@ -12,30 +12,20 @@ class TradeImportTest < ActiveSupport::TestCase # Create an existing AAPL security with no exchange_operating_mic aapl = Security.create!(ticker: "AAPL", exchange_operating_mic: nil) - provider = mock - # We should only hit the provider for GOOGL since AAPL already exists - provider.expects(:search_securities).with( + Security.expects(:search_provider).with( query: "GOOGL", exchange_operating_mic: "XNAS" - ).returns( - OpenStruct.new( - securities: [ - { - ticker: "GOOGL", - name: "Google Inc.", - country_code: "US", - exchange_mic: "XNGS", - exchange_operating_mic: "XNAS", - exchange_acronym: "NGS" - } - ], - success?: true, - raw_response: nil + ).returns([ + Security.new( + ticker: "GOOGL", + name: "Google Inc.", + country_code: "US", + exchange_mic: "XNGS", + exchange_operating_mic: "XNAS", + exchange_acronym: "NGS" ) - ).once - - Security.stubs(:security_prices_provider).returns(provider) + ]).once import = <<~CSV date,ticker,qty,price,currency,account,name,exchange_operating_mic diff --git a/test/system/imports_test.rb b/test/system/imports_test.rb index 5c936f93..85c4707c 100644 --- a/test/system/imports_test.rb +++ b/test/system/imports_test.rb @@ -52,6 +52,8 @@ class ImportsTest < ApplicationSystemTestCase end test "trade import" do + Security.stubs(:search_provider).returns([]) + visit new_import_path click_on "Import investments" diff --git a/test/system/trades_test.rb b/test/system/trades_test.rb index ab0c6109..a1f19139 100644 --- a/test/system/trades_test.rb +++ b/test/system/trades_test.rb @@ -10,7 +10,7 @@ class TradesTest < ApplicationSystemTestCase visit_account_portfolio - Security.stubs(:search).returns([ + Security.stubs(:search_provider).returns([ Security.new( ticker: "AAPL", name: "Apple Inc.", From 7c66f167500f478ea9a092b588c6444f93c7d19b Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 28 Feb 2025 12:17:25 -0500 Subject: [PATCH 020/380] Invert liability graphs to have correct signage (#1928) --- app/models/account/chartable.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/models/account/chartable.rb b/app/models/account/chartable.rb index aba8415c..2770da3b 100644 --- a/app/models/account/chartable.rb +++ b/app/models/account/chartable.rb @@ -14,6 +14,7 @@ module Account::Chartable ]) balances = gapfill_balances(balances) + balances = invert_balances(balances) if favorable_direction == "down" values = [ nil, *balances ].each_cons(2).map do |prev, curr| Series::Value.new( @@ -69,6 +70,13 @@ module Account::Chartable SQL end + def invert_balances(balances) + balances.map do |balance| + balance.balance = -balance.balance + balance + end + end + def gapfill_balances(balances) gapfilled = [] From 4d0df9b950e214fa615695fb1b9e4328babeb18c Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 28 Feb 2025 12:21:07 -0500 Subject: [PATCH 021/380] Escape quotations in CSV imports properly (#1929) * Parse quotes in imports * Update invalid CSV for test --- app/controllers/import/uploads_controller.rb | 4 +--- app/models/import.rb | 19 +++++++++++++------ test/fixtures/files/imports/invalid.csv | 4 +--- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/app/controllers/import/uploads_controller.rb b/app/controllers/import/uploads_controller.rb index f3c65d6e..8efc7c75 100644 --- a/app/controllers/import/uploads_controller.rb +++ b/app/controllers/import/uploads_controller.rb @@ -29,10 +29,8 @@ class Import::UploadsController < ApplicationController end def csv_valid?(str) - require "csv" - begin - csv = CSV.parse(str || "", headers: true, col_sep: upload_params[:col_sep]) + csv = Import.parse_csv_str(str, col_sep: upload_params[:col_sep]) return false if csv.headers.empty? return false if csv.count == 0 true diff --git a/app/models/import.rb b/app/models/import.rb index 9542e187..9a21d6bd 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -34,6 +34,18 @@ class Import < ApplicationRecord has_many :accounts, dependent: :destroy has_many :entries, dependent: :destroy, class_name: "Account::Entry" + class << self + def parse_csv_str(csv_str, col_sep: ",") + CSV.parse( + (csv_str || "").strip, + headers: true, + col_sep: col_sep, + converters: [ ->(str) { str&.strip } ], + liberal_parsing: true + ) + end + end + def publish_later raise "Import is not publishable" unless publishable? @@ -178,12 +190,7 @@ class Import < ApplicationRecord end def parsed_csv - @parsed_csv ||= CSV.parse( - (raw_file_str || "").strip, - headers: true, - col_sep: col_sep, - converters: [ ->(str) { str&.strip } ] - ) + @parsed_csv ||= self.class.parse_csv_str(raw_file_str, col_sep: col_sep) end def sanitize_number(value) diff --git a/test/fixtures/files/imports/invalid.csv b/test/fixtures/files/imports/invalid.csv index cae4503c..b8552222 100644 --- a/test/fixtures/files/imports/invalid.csv +++ b/test/fixtures/files/imports/invalid.csv @@ -1,3 +1 @@ -name,age -"John Doe,23 -"Jane Doe",25 \ No newline at end of file +name,description,amount,currency \ No newline at end of file From c95bb082a98be370c90fc87a912763950eb59714 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 28 Feb 2025 15:11:41 -0500 Subject: [PATCH 022/380] Bump to v0.4.3 Signed-off-by: Zach Gollwitzer --- config/initializers/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/initializers/version.rb b/config/initializers/version.rb index 57f1e0e6..385ca36d 100644 --- a/config/initializers/version.rb +++ b/config/initializers/version.rb @@ -10,7 +10,7 @@ module Maybe private def semver - "0.4.1" + "0.4.3" end end end From 4c4a4026c4ac728802f8a3581ed9919291778662 Mon Sep 17 00:00:00 2001 From: Tony Vincent Date: Mon, 3 Mar 2025 17:34:03 +0100 Subject: [PATCH 023/380] fix: Bug - Transcation Matching Dialog isn't Opening (#1942) --- app/controllers/account/transfer_matches_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/account/transfer_matches_controller.rb b/app/controllers/account/transfer_matches_controller.rb index b686c6c8..851f3ac3 100644 --- a/app/controllers/account/transfer_matches_controller.rb +++ b/app/controllers/account/transfer_matches_controller.rb @@ -3,7 +3,7 @@ class Account::TransferMatchesController < ApplicationController def new @accounts = Current.family.accounts.alphabetically.where.not(id: @entry.account_id) - @transfer_match_candidates = @entry.transfer_match_candidates + @transfer_match_candidates = @entry.account_transaction.transfer_match_candidates end def create From e907b073ed7b78434674983fbecb9c6ca917ac52 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 3 Mar 2025 12:47:20 -0500 Subject: [PATCH 024/380] Fix time period key conflicts (#1944) --- app/controllers/pages_controller.rb | 2 +- app/models/budget.rb | 2 +- app/models/period.rb | 55 ++++++++++++------------- app/views/accounts/chart.html.erb | 2 +- app/views/accounts/show/_chart.html.erb | 2 +- test/models/period_test.rb | 13 +++--- 6 files changed, 37 insertions(+), 39 deletions(-) diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 0b2b26b4..34d6cca3 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -2,7 +2,7 @@ class PagesController < ApplicationController skip_before_action :authenticate_user!, only: %i[early_access] def dashboard - @period = Period.from_key(params[:period], fallback: true) + @period = params[:period] ? Period.from_key(params[:period]) : Period.last_30_days @balance_sheet = Current.family.balance_sheet @accounts = Current.family.accounts.active.with_attached_logo diff --git a/app/models/budget.rb b/app/models/budget.rb index 66a4acb7..c2393da4 100644 --- a/app/models/budget.rb +++ b/app/models/budget.rb @@ -54,7 +54,7 @@ class Budget < ApplicationRecord end def period - Period.new(start_date: start_date, end_date: end_date) + Period.custom(start_date: start_date, end_date: end_date) end def to_param diff --git a/app/models/period.rb b/app/models/period.rb index 65825058..7e1a8008 100644 --- a/app/models/period.rb +++ b/app/models/period.rb @@ -1,9 +1,12 @@ class Period include ActiveModel::Validations, Comparable - attr_reader :start_date, :end_date + class InvalidKeyError < StandardError; end - validates :start_date, :end_date, presence: true + attr_reader :key, :start_date, :end_date + + validates :start_date, :end_date, presence: true, if: -> { PERIODS[key].nil? } + validates :key, presence: true, if: -> { start_date.nil? || end_date.nil? } validate :must_be_valid_date_range PERIODS = { @@ -64,18 +67,18 @@ class Period } class << self - def default - from_key("last_30_days") + def from_key(key) + unless PERIODS.key?(key) + raise InvalidKeyError, "Invalid period key: #{key}" + end + + start_date, end_date = PERIODS[key].fetch(:date_range) + + new(key: key, start_date: start_date, end_date: end_date) end - def from_key(key, fallback: false) - if PERIODS[key].present? - start_date, end_date = PERIODS[key].fetch(:date_range) - new(start_date: start_date, end_date: end_date) - else - return default if fallback - raise ArgumentError, "Invalid period key: #{key}" - end + def custom(start_date:, end_date:) + new(start_date: start_date, end_date: end_date) end def all @@ -85,12 +88,12 @@ class Period PERIODS.each do |key, period| define_singleton_method(key) do - start_date, end_date = period.fetch(:date_range) - new(start_date: start_date, end_date: end_date) + from_key(key) end end - def initialize(start_date:, end_date:, date_format: "%b %d, %Y") + def initialize(start_date: nil, end_date: nil, key: nil, date_format: "%b %d, %Y") + @key = key @start_date = start_date @end_date = end_date @date_format = date_format @@ -121,37 +124,33 @@ class Period end end - def key - PERIODS.find { |_, period| period.fetch(:date_range) == [ start_date, end_date ] }&.first - end - def label - if known? - PERIODS[key].fetch(:label) + if key_metadata + key_metadata.fetch(:label) else "Custom Period" end end def label_short - if known? - PERIODS[key].fetch(:label_short) + if key_metadata + key_metadata.fetch(:label_short) else - "CP" + "Custom" end end def comparison_label - if known? - PERIODS[key].fetch(:comparison_label) + if key_metadata + key_metadata.fetch(:comparison_label) else "#{start_date.strftime(@date_format)} to #{end_date.strftime(@date_format)}" end end private - def known? - key.present? + def key_metadata + @key_metadata ||= PERIODS[key] end def must_be_valid_date_range diff --git a/app/views/accounts/chart.html.erb b/app/views/accounts/chart.html.erb index 467fd35f..c5cc51eb 100644 --- a/app/views/accounts/chart.html.erb +++ b/app/views/accounts/chart.html.erb @@ -1,4 +1,4 @@ -<% period = Period.from_key(params[:period], fallback: true) %> +<% period = params[:period] ? Period.from_key(params[:period]) : Period.last_30_days %> <% series = @account.balance_series(period: period) %> <% trend = series.trend %> diff --git a/app/views/accounts/show/_chart.html.erb b/app/views/accounts/show/_chart.html.erb index 2b0ee941..d7427762 100644 --- a/app/views/accounts/show/_chart.html.erb +++ b/app/views/accounts/show/_chart.html.erb @@ -1,6 +1,6 @@ <%# locals: (account:, title: nil, tooltip: nil, **args) %> -<% period = Period.from_key(params[:period], fallback: true) %> +<% period = params[:period] ? Period.from_key(params[:period]) : Period.last_30_days %> <% default_value_title = account.asset? ? t(".balance") : t(".owed") %>
    diff --git a/test/models/period_test.rb b/test/models/period_test.rb index 1dfbf91d..eaea02c5 100644 --- a/test/models/period_test.rb +++ b/test/models/period_test.rb @@ -18,20 +18,19 @@ class PeriodTest < ActiveSupport::TestCase assert_includes error.message, "Start date must be before end date" end + test "can create custom period" do + period = Period.new(start_date: Date.current - 15.days, end_date: Date.current) + assert_equal "Custom Period", period.label + end + test "from_key returns period for valid key" do period = Period.from_key("last_30_days") assert_equal 30.days.ago.to_date, period.start_date assert_equal Date.current, period.end_date end - test "from_key with invalid key and fallback returns default period" do - period = Period.from_key("invalid_key", fallback: true) - assert_equal 30.days.ago.to_date, period.start_date - assert_equal Date.current, period.end_date - end - test "from_key with invalid key and no fallback raises error" do - assert_raises ArgumentError do + error = assert_raises(Period::InvalidKeyError) do Period.from_key("invalid_key") end end From c5da8ea5502f2732d01fcafebe43c0502fc22614 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 3 Mar 2025 12:47:30 -0500 Subject: [PATCH 025/380] Allow CSV imports to be configured with single or multi-account mode (#1943) * Allow CSV imports to be configured to a single account or multiple accounts * Initialize import directly from accounts page * Fix brakeman warnings * Fix schema * Fix Synth check --- app/controllers/import/confirms_controller.rb | 4 ++ app/controllers/import/uploads_controller.rb | 1 + app/controllers/imports_controller.rb | 3 +- app/models/family.rb | 2 +- app/models/import.rb | 13 ++++-- app/models/import/account_mapping.rb | 7 +++- app/models/trade_import.rb | 33 ++++++++++----- app/models/transaction_import.rb | 21 +++++++--- app/views/account/trades/_form.html.erb | 8 ++-- app/views/accounts/show/_menu.html.erb | 4 +- .../configurations/_mint_import.html.erb | 7 +++- .../configurations/_trade_import.html.erb | 6 ++- .../_transaction_import.html.erb | 5 ++- app/views/import/confirms/_mappings.html.erb | 2 +- app/views/import/uploads/show.html.erb | 42 +++++++++---------- app/views/imports/_import.html.erb | 4 ++ app/views/imports/_nav.html.erb | 2 +- ...3141007_add_optional_account_for_import.rb | 5 +++ db/schema.rb | 4 +- .../import/rows_controller_test.rb | 2 +- 20 files changed, 118 insertions(+), 57 deletions(-) create mode 100644 db/migrate/20250303141007_add_optional_account_for_import.rb diff --git a/app/controllers/import/confirms_controller.rb b/app/controllers/import/confirms_controller.rb index 0c1a8872..1a687d4b 100644 --- a/app/controllers/import/confirms_controller.rb +++ b/app/controllers/import/confirms_controller.rb @@ -4,6 +4,10 @@ class Import::ConfirmsController < ApplicationController before_action :set_import def show + if @import.mapping_steps.empty? + return redirect_to import_path(@import) + end + redirect_to import_clean_path(@import), alert: "You have invalid data, please edit until all errors are resolved" unless @import.cleaned? end diff --git a/app/controllers/import/uploads_controller.rb b/app/controllers/import/uploads_controller.rb index 8efc7c75..42e6c975 100644 --- a/app/controllers/import/uploads_controller.rb +++ b/app/controllers/import/uploads_controller.rb @@ -8,6 +8,7 @@ class Import::UploadsController < ApplicationController def update if csv_valid?(csv_str) + @import.account = Current.family.accounts.find_by(id: params.dig(:import, :account_id)) @import.assign_attributes(raw_file_str: csv_str, col_sep: upload_params[:col_sep]) @import.save!(validate: false) diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb index 3fb704fe..e95e6e11 100644 --- a/app/controllers/imports_controller.rb +++ b/app/controllers/imports_controller.rb @@ -18,7 +18,8 @@ class ImportsController < ApplicationController end def create - import = Current.family.imports.create! import_params + account = Current.family.accounts.find_by(id: params.dig(:import, :account_id)) + import = Current.family.imports.create!(type: import_params[:type], account: account) redirect_to import_upload_path(import) end diff --git a/app/models/family.rb b/app/models/family.rb index ffb14a7d..0f71731f 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -106,7 +106,7 @@ class Family < ApplicationRecord # If family has any entries in different currencies, they need a provider for historical exchange rates uniq_currencies = entries.pluck(:currency).uniq return true if uniq_currencies.count > 1 - return true if uniq_currencies.first != self.currency + return true if uniq_currencies.count > 0 && uniq_currencies.first != self.currency false end diff --git a/app/models/import.rb b/app/models/import.rb index 9a21d6bd..600f30a7 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -1,6 +1,7 @@ class Import < ApplicationRecord TYPES = %w[TransactionImport TradeImport AccountImport MintImport].freeze SIGNAGE_CONVENTIONS = %w[inflows_positive inflows_negative] + SEPARATORS = [ [ "Comma (,)", "," ], [ "Semicolon (;)", ";" ] ].freeze NUMBER_FORMATS = { "1,234.56" => { separator: ".", delimiter: "," }, # US/UK/Asia @@ -10,6 +11,7 @@ class Import < ApplicationRecord }.freeze belongs_to :family + belongs_to :account, optional: true before_validation :set_default_number_format @@ -25,7 +27,7 @@ class Import < ApplicationRecord }, validate: true, default: "pending" validates :type, inclusion: { in: TYPES } - validates :col_sep, inclusion: { in: [ ",", ";" ] } + validates :col_sep, inclusion: { in: SEPARATORS.map(&:last) } validates :signage_convention, inclusion: { in: SIGNAGE_CONVENTIONS } validates :number_format, presence: true, inclusion: { in: NUMBER_FORMATS.keys } @@ -98,12 +100,17 @@ class Import < ApplicationRecord end def dry_run - { + mappings = { transactions: rows.count, - accounts: Import::AccountMapping.for_import(self).creational.count, categories: Import::CategoryMapping.for_import(self).creational.count, tags: Import::TagMapping.for_import(self).creational.count } + + mappings.merge( + accounts: Import::AccountMapping.for_import(self).creational.count, + ) if account.nil? + + mappings end def required_column_keys diff --git a/app/models/import/account_mapping.rb b/app/models/import/account_mapping.rb index c4c00414..9cf43f7a 100644 --- a/app/models/import/account_mapping.rb +++ b/app/models/import/account_mapping.rb @@ -1,5 +1,5 @@ class Import::AccountMapping < Import::Mapping - validates :mappable, presence: true, if: -> { key.blank? || !create_when_empty } + validates :mappable, presence: true, if: :requires_mapping? class << self def mapping_values(import) @@ -42,4 +42,9 @@ class Import::AccountMapping < Import::Mapping self.mappable = account save! end + + private + def requires_mapping? + (key.blank? || !create_when_empty) && import.account.nil? + end end diff --git a/app/models/trade_import.rb b/app/models/trade_import.rb index 549c9093..4bbffdaa 100644 --- a/app/models/trade_import.rb +++ b/app/models/trade_import.rb @@ -4,7 +4,11 @@ class TradeImport < Import mappings.each(&:create_mappable!) rows.each do |row| - account = mappings.accounts.mappable_for(row.account) + mapped_account = if account + account + else + mappings.accounts.mappable_for(row.account) + end # Try to find or create security with ticker only security = find_or_create_security( @@ -12,15 +16,15 @@ class TradeImport < Import exchange_operating_mic: row.exchange_operating_mic ) - entry = account.entries.build \ + entry = mapped_account.entries.build \ date: row.date_iso, amount: row.signed_amount, name: row.name, - currency: row.currency.presence || account.currency, + currency: row.currency.presence || mapped_account.currency, entryable: Account::Trade.new( security: security, qty: row.qty, - currency: row.currency.presence || account.currency, + currency: row.currency.presence || mapped_account.currency, price: row.price ), import: self @@ -31,7 +35,9 @@ class TradeImport < Import end def mapping_steps - [ Import::AccountMapping ] + base = [] + base << Import::AccountMapping if account.nil? + base end def required_column_keys @@ -39,14 +45,19 @@ class TradeImport < Import end def column_keys - %i[date ticker exchange_operating_mic currency qty price account name] + base = %i[date ticker exchange_operating_mic currency qty price name] + base.unshift(:account) if account.nil? + base end def dry_run - { - transactions: rows.count, + mappings = { transactions: rows.count } + + mappings.merge( accounts: Import::AccountMapping.for_import(self).creational.count - } + ) if account.nil? + + mappings end def csv_template @@ -57,7 +68,9 @@ class TradeImport < Import 05/17/2024,TSLA,XNAS,USD,2,700.50,Retirement Account,Tesla Inc. Purchase CSV - CSV.parse(template, headers: true) + csv = CSV.parse(template, headers: true) + csv.delete("account") if account.present? + csv end private diff --git a/app/models/transaction_import.rb b/app/models/transaction_import.rb index e2b1c3d6..2bb9d4d5 100644 --- a/app/models/transaction_import.rb +++ b/app/models/transaction_import.rb @@ -4,11 +4,16 @@ class TransactionImport < Import mappings.each(&:create_mappable!) rows.each do |row| - account = mappings.accounts.mappable_for(row.account) + mapped_account = if account + account + else + mappings.accounts.mappable_for(row.account) + end + category = mappings.categories.mappable_for(row.category) tags = row.tags_list.map { |tag| mappings.tags.mappable_for(tag) }.compact - entry = account.entries.build \ + entry = mapped_account.entries.build \ date: row.date_iso, amount: row.signed_amount, name: row.name, @@ -27,11 +32,15 @@ class TransactionImport < Import end def column_keys - %i[date amount name currency category tags account notes] + base = %i[date amount name currency category tags notes] + base.unshift(:account) if account.nil? + base end def mapping_steps - [ Import::CategoryMapping, Import::TagMapping, Import::AccountMapping ] + base = [ Import::CategoryMapping, Import::TagMapping ] + base << Import::AccountMapping if account.nil? + base end def csv_template @@ -42,6 +51,8 @@ class TransactionImport < Import 05/17/2024,-12.50,Coffee Shop,,,coffee,, CSV - CSV.parse(template, headers: true) + csv = CSV.parse(template, headers: true) + csv.delete("account") if account.present? + csv end end diff --git a/app/views/account/trades/_form.html.erb b/app/views/account/trades/_form.html.erb index a74d401f..89c8c151 100644 --- a/app/views/account/trades/_form.html.erb +++ b/app/views/account/trades/_form.html.erb @@ -29,11 +29,11 @@ <% if %w[buy sell].include?(type) %> <% if Security.provider.present? %>
    - <%= form.combobox :ticker, - securities_path(country_code: Current.family.country), + <%= form.combobox :ticker, + securities_path(country_code: Current.family.country), name_when_new: "account_entry[manual_ticker]", - label: t(".holding"), - placeholder: t(".ticker_placeholder"), + label: t(".holding"), + placeholder: t(".ticker_placeholder"), required: true %>
    <% else %> diff --git a/app/views/accounts/show/_menu.html.erb b/app/views/accounts/show/_menu.html.erb index f41723a6..57241528 100644 --- a/app/views/accounts/show/_menu.html.erb +++ b/app/views/accounts/show/_menu.html.erb @@ -20,8 +20,8 @@ <% end %> <% unless account.crypto? %> - <%= link_to new_import_path, - data: { turbo_frame: :modal }, + <%= button_to imports_path({ import: { type: account.investment? ? "TradeImport" : "TransactionImport", account_id: account.id } }), + data: { turbo_frame: :_top }, class: "block w-full py-2 px-3 space-x-2 text-primary hover:bg-gray-50 flex items-center rounded-lg" do %> <%= lucide_icon "download", class: "w-5 h-5 text-secondary" %> diff --git a/app/views/import/configurations/_mint_import.html.erb b/app/views/import/configurations/_mint_import.html.erb index 1aabce75..987f2fed 100644 --- a/app/views/import/configurations/_mint_import.html.erb +++ b/app/views/import/configurations/_mint_import.html.erb @@ -1,6 +1,6 @@ <%# locals: (import:) %> -
    +
    <%= lucide_icon("check-circle", class: "w-5 h-5 shrink-0 text-green-500") %>

    We have pre-configured your Mint import for you. Please proceed to the next step.

    @@ -21,7 +21,10 @@ <%= form.select :number_format, Import::NUMBER_FORMATS.keys, { label: "Format", prompt: "Select format" }, required: true %>
    - <%= form.select :account_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Account (optional)" }, disabled: import.complete? %> + <% unless import.account.present? %> + <%= form.select :account_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Account (optional)" }, disabled: import.complete? %> + <% end %> + <%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name (optional)" }, disabled: import.complete? %> <%= form.select :category_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Category (optional)" }, disabled: import.complete? %> <%= form.select :tags_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Tags (optional)" }, disabled: import.complete? %> diff --git a/app/views/import/configurations/_trade_import.html.erb b/app/views/import/configurations/_trade_import.html.erb index f923940c..7db04ba9 100644 --- a/app/views/import/configurations/_trade_import.html.erb +++ b/app/views/import/configurations/_trade_import.html.erb @@ -20,7 +20,11 @@ <%= form.select :ticker_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Ticker" } %> <%= form.select :exchange_operating_mic_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Exchange Operating MIC" } %> <%= form.select :price_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Price" } %> - <%= form.select :account_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Account (optional)" } %> + + <% unless import.account.present? %> + <%= form.select :account_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Account (optional)" } %> + <% end %> + <%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name (optional)" } %> <% unless Security.provider %> diff --git a/app/views/import/configurations/_transaction_import.html.erb b/app/views/import/configurations/_transaction_import.html.erb index 65c9f5f4..a75b1681 100644 --- a/app/views/import/configurations/_transaction_import.html.erb +++ b/app/views/import/configurations/_transaction_import.html.erb @@ -16,7 +16,10 @@ <%= form.select :number_format, Import::NUMBER_FORMATS.keys, { label: "Format", prompt: "Select format" }, required: true %>
    - <%= form.select :account_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Account (optional)" } %> + <% unless import.account.present? %> + <%= form.select :account_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Account (optional)" } %> + <% end %> + <%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name (optional)" } %> <%= form.select :category_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Category (optional)" } %> <%= form.select :tags_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Tags (optional)" } %> diff --git a/app/views/import/confirms/_mappings.html.erb b/app/views/import/confirms/_mappings.html.erb index 2880ec6d..0f07eb2f 100644 --- a/app/views/import/confirms/_mappings.html.erb +++ b/app/views/import/confirms/_mappings.html.erb @@ -3,7 +3,7 @@ <% mappings = mapping_class.for_import(import) %> <% is_last_step = step_idx == import.mapping_steps.count - 1 %> -<% if mapping_class == Import::AccountMapping %> +<% if mapping_class == Import::AccountMapping && import.account.nil? %> <% if import.requires_account? %>
    <%= tag.p t(".no_accounts"), class: "text-sm" %> diff --git a/app/views/import/uploads/show.html.erb b/app/views/import/uploads/show.html.erb index 2f126a1c..ab7e4bb2 100644 --- a/app/views/import/uploads/show.html.erb +++ b/app/views/import/uploads/show.html.erb @@ -19,33 +19,33 @@
    -
    - <%= styled_form_with model: @import, scope: :import, url: import_upload_path(@import), multipart: true, class: "space-y-2" do |form| %> - <%= form.select :col_sep, [["Comma (,)", ","], ["Semicolon (;)", ";"]], label: true %> - <%= form.text_area :raw_file_str, + <% ["csv-paste-tab", "csv-upload-tab"].each do |tab| %> + <%= tag.div id: tab, data: { tabs_target: "tab" }, class: tab == "csv-upload-tab" ? "hidden" : "" do %> + <%= styled_form_with model: @import, scope: :import, url: import_upload_path(@import), multipart: true, class: "space-y-2" do |form| %> + <%= form.select :col_sep, Import::SEPARATORS, label: true %> + + <% unless @import.type == "MintImport" %> + <%= form.select :account_id, @import.family.accounts.pluck(:name, :id), { label: "Account (optional)", include_blank: "Multi-account import", selected: @import.account_id } %> + <% end %> + + <% if tab == "csv-paste-tab" %> + <%= form.text_area :raw_file_str, rows: 10, required: true, placeholder: "Paste your CSV file contents here", "data-auto-submit-form-target": "auto" %> + <% else %> + + <% end %> - <%= form.submit "Upload CSV", disabled: @import.complete? %> + <%= form.submit "Upload CSV", disabled: @import.complete? %> + <% end %> <% end %> - -
    - - + <% end %>
    diff --git a/app/views/imports/_import.html.erb b/app/views/imports/_import.html.erb index 3ee7d68c..26c77ee9 100644 --- a/app/views/imports/_import.html.erb +++ b/app/views/imports/_import.html.erb @@ -2,6 +2,10 @@
    <%= link_to import_path(import), class: "text-sm text-primary hover:underline" do %> + <% if import.account.present? %> + <%= import.account.name + " " %> + <% end %> + <%= t(".label", type: import.type.titleize, datetime: import.updated_at.strftime("%b %-d, %Y at %l:%M %p")) %> <% end %> diff --git a/app/views/imports/_nav.html.erb b/app/views/imports/_nav.html.erb index b68b6ce1..0b9bd817 100644 --- a/app/views/imports/_nav.html.erb +++ b/app/views/imports/_nav.html.erb @@ -6,7 +6,7 @@ { name: "Clean", path: import_clean_path(import), is_complete: import.cleaned?, step_number: 3 }, { name: "Map", path: import_confirm_path(import), is_complete: import.publishable?, step_number: 4 }, { name: "Confirm", path: import_path(import), is_complete: import.complete?, step_number: 5 } -] %> +].reject { |step| step[:name] == "Map" && import.mapping_steps.empty? } %>
      <% steps.each_with_index do |step, idx| %> diff --git a/db/migrate/20250303141007_add_optional_account_for_import.rb b/db/migrate/20250303141007_add_optional_account_for_import.rb new file mode 100644 index 00000000..52da03d9 --- /dev/null +++ b/db/migrate/20250303141007_add_optional_account_for_import.rb @@ -0,0 +1,5 @@ +class AddOptionalAccountForImport < ActiveRecord::Migration[7.2] + def change + rename_column :imports, :original_account_id, :account_id + end +end diff --git a/db/schema.rb b/db/schema.rb index d78271ac..492edb1e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_02_20_200735) do +ActiveRecord::Schema[7.2].define(version: 2025_03_03_141007) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -398,7 +398,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_02_20_200735) do t.datetime "updated_at", null: false t.string "col_sep", default: "," t.uuid "family_id", null: false - t.uuid "original_account_id" + t.uuid "account_id" t.string "type", null: false t.string "date_col_label", default: "date" t.string "amount_col_label", default: "amount" diff --git a/test/controllers/import/rows_controller_test.rb b/test/controllers/import/rows_controller_test.rb index d16fd143..e6394d94 100644 --- a/test/controllers/import/rows_controller_test.rb +++ b/test/controllers/import/rows_controller_test.rb @@ -22,7 +22,7 @@ class Import::RowsControllerTest < ActionDispatch::IntegrationTest get import_row_path(import, row) - assert_row_fields(row, [ :date, :ticker, :qty, :price, :currency, :account, :name ]) + assert_row_fields(row, [ :date, :ticker, :qty, :price, :currency, :account, :name, :account ]) assert_response :success end From 4e96ca83762b0c69a220ccb34f1d0a4331cde5dc Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 3 Mar 2025 14:34:56 -0500 Subject: [PATCH 026/380] Add manual Docker publishing trigger in GH action workflow --- .github/workflows/publish.yml | 9 +++++++++ Dockerfile | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 86ba83cf..ef6dbe94 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,6 +1,13 @@ name: Publish Docker image on: + workflow_dispatch: + inputs: + ref: + description: 'Git ref (tag or commit SHA) to build' + required: true + type: string + default: 'main' push: tags: - 'v*' @@ -33,6 +40,8 @@ jobs: steps: - name: Check out the repo uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.ref || github.ref }} - name: Set up QEMU uses: docker/setup-qemu-action@v3 diff --git a/Dockerfile b/Dockerfile index aeb8aaa0..ff2d8d30 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,7 @@ ENV RAILS_ENV="production" \ FROM base AS build # Install packages needed to build gems -RUN apt-get install --no-install-recommends -y build-essential libpq-dev pkg-config +RUN apt-get install --no-install-recommends -y build-essential libpq-dev git pkg-config # Install application gems COPY .ruby-version Gemfile Gemfile.lock ./ From cf0e57353382d2315e959d811f77989400fdb08e Mon Sep 17 00:00:00 2001 From: Bryan McKnight Date: Mon, 3 Mar 2025 15:37:12 -0600 Subject: [PATCH 027/380] Fix modal closing on color picker drag #1869 (#1931) * Replaced data-action click event with data-action mousedown to prevent the modal from hiding on mouse up whenever mouse down starts within the modal * Changed click events to mousedown within dialog elements to trigger the closing of the element --- app/views/account/transactions/bulk_edit.html.erb | 4 ++-- app/views/shared/_drawer.html.erb | 4 ++-- app/views/shared/_modal.html.erb | 2 +- app/views/shared/_modal_form.html.erb | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/views/account/transactions/bulk_edit.html.erb b/app/views/account/transactions/bulk_edit.html.erb index b8317084..ed9a6194 100644 --- a/app/views/account/transactions/bulk_edit.html.erb +++ b/app/views/account/transactions/bulk_edit.html.erb @@ -1,12 +1,12 @@ <%= turbo_frame_tag "bulk_transaction_edit_drawer" do %> <%= styled_form_with url: bulk_update_account_transactions_path, scope: "bulk_update", class: "h-full", data: { turbo_frame: "_top" } do |form| %>
      -
      +
      <%= lucide_icon("x", class: "w-5 h-5 shrink-0") %>
      diff --git a/app/views/shared/_drawer.html.erb b/app/views/shared/_drawer.html.erb index 35e225c2..4da7ccb0 100644 --- a/app/views/shared/_drawer.html.erb +++ b/app/views/shared/_drawer.html.erb @@ -3,11 +3,11 @@ <%= turbo_frame_tag "drawer" do %>
      -
      +
      <%= lucide_icon("x", class: "w-5 h-5 shrink-0") %>
      diff --git a/app/views/shared/_modal.html.erb b/app/views/shared/_modal.html.erb index dc0c5e90..2eb1b693 100644 --- a/app/views/shared/_modal.html.erb +++ b/app/views/shared/_modal.html.erb @@ -1,6 +1,6 @@ <%# locals: (content:, classes:) -%> <%= turbo_frame_tag "modal" do %> - +
      <%= content %>
      diff --git a/app/views/shared/_modal_form.html.erb b/app/views/shared/_modal_form.html.erb index a3d5456b..98d9b598 100644 --- a/app/views/shared/_modal_form.html.erb +++ b/app/views/shared/_modal_form.html.erb @@ -5,7 +5,7 @@

      <%= title %>

      - <%= lucide_icon("x", class: "cursor-pointer w-5 h-5 text-secondary", data: { action: "click->modal#close" }) %> + <%= lucide_icon("x", class: "cursor-pointer w-5 h-5 text-secondary", data: { action: "mousedown->modal#close" }) %>
      <% if subtitle.present? %> From 5b2fa3d707d790caf338f20b003350efc3145780 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Tue, 4 Mar 2025 07:50:21 -0500 Subject: [PATCH 028/380] Fix commit resolution for Docker builds --- .github/workflows/publish.yml | 1 + Dockerfile | 8 +++++--- config/initializers/version.rb | 6 +++++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ef6dbe94..062d11b9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -82,3 +82,4 @@ jobs: provenance: false # https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#adding-a-description-to-multi-arch-images outputs: type=image,name=target,annotation-index.org.opencontainers.image.description=A multi-arch Docker image for the Maybe Rails app + build-args: BUILD_COMMIT_SHA=${{ github.sha }} diff --git a/Dockerfile b/Dockerfile index ff2d8d30..d98833b3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,14 +9,16 @@ WORKDIR /rails # Install base packages RUN apt-get update -qq && \ - apt-get install --no-install-recommends -y curl libvips postgresql-client git + apt-get install --no-install-recommends -y curl libvips postgresql-client # Set production environment +ARG BUILD_COMMIT_SHA ENV RAILS_ENV="production" \ BUNDLE_DEPLOYMENT="1" \ BUNDLE_PATH="/usr/local/bundle" \ - BUNDLE_WITHOUT="development" - + BUNDLE_WITHOUT="development" \ + BUILD_COMMIT_SHA=${BUILD_COMMIT_SHA} + # Throw-away build stage to reduce size of final image FROM base AS build diff --git a/config/initializers/version.rb b/config/initializers/version.rb index 385ca36d..40a27790 100644 --- a/config/initializers/version.rb +++ b/config/initializers/version.rb @@ -5,7 +5,11 @@ module Maybe end def commit_sha - `git rev-parse HEAD`.chomp + if Rails.env.production? + ENV["BUILD_COMMIT_SHA"] + else + `git rev-parse HEAD`.chomp + end end private From 0544089710d941da058bfdd4a062f2f42b318941 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Tue, 4 Mar 2025 13:10:01 -0500 Subject: [PATCH 029/380] Account-level import configuration templates (#1946) * Account-level import configuration templates * Default import to family's preferred date format --- app/controllers/import/rows_controller.rb | 4 +- app/controllers/import/uploads_controller.rb | 2 +- app/controllers/imports_controller.rb | 17 ++++++++- app/models/import.rb | 38 ++++++++++++++++++- app/models/import/account_mapping.rb | 7 +++- app/models/import/account_type_mapping.rb | 4 +- app/models/import/category_mapping.rb | 7 +++- app/models/import/mapping.rb | 15 +------- app/models/import/row.rb | 9 ++--- app/models/import/tag_mapping.rb | 8 +++- app/views/import/configurations/show.html.erb | 25 ++++++++++-- app/views/import/uploads/show.html.erb | 2 +- app/views/shared/_notification.html.erb | 2 +- config/routes.rb | 7 +++- .../import/uploads_controller_test.rb | 4 +- 15 files changed, 108 insertions(+), 43 deletions(-) diff --git a/app/controllers/import/rows_controller.rb b/app/controllers/import/rows_controller.rb index b5b9092c..a3905b14 100644 --- a/app/controllers/import/rows_controller.rb +++ b/app/controllers/import/rows_controller.rb @@ -2,9 +2,7 @@ class Import::RowsController < ApplicationController before_action :set_import_row def update - @row.assign_attributes(row_params) - @row.save!(validate: false) - @row.sync_mappings + @row.update_and_sync(row_params) redirect_to import_row_path(@row.import, @row) end diff --git a/app/controllers/import/uploads_controller.rb b/app/controllers/import/uploads_controller.rb index 42e6c975..d30e0082 100644 --- a/app/controllers/import/uploads_controller.rb +++ b/app/controllers/import/uploads_controller.rb @@ -12,7 +12,7 @@ class Import::UploadsController < ApplicationController @import.assign_attributes(raw_file_str: csv_str, col_sep: upload_params[:col_sep]) @import.save!(validate: false) - redirect_to import_configuration_path(@import), notice: "CSV uploaded successfully." + redirect_to import_configuration_path(@import, template_hint: true), notice: "CSV uploaded successfully." else flash.now[:alert] = "Must be valid CSV with headers and at least one row of data" diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb index e95e6e11..c1b51c23 100644 --- a/app/controllers/imports_controller.rb +++ b/app/controllers/imports_controller.rb @@ -1,5 +1,5 @@ class ImportsController < ApplicationController - before_action :set_import, only: %i[show publish destroy revert] + before_action :set_import, only: %i[show publish destroy revert apply_template] def publish @import.publish_later @@ -19,7 +19,11 @@ class ImportsController < ApplicationController def create account = Current.family.accounts.find_by(id: params.dig(:import, :account_id)) - import = Current.family.imports.create!(type: import_params[:type], account: account) + import = Current.family.imports.create!( + type: import_params[:type], + account: account, + date_format: Current.family.date_format, + ) redirect_to import_upload_path(import) end @@ -37,6 +41,15 @@ class ImportsController < ApplicationController redirect_to imports_path, notice: "Import is reverting in the background." end + def apply_template + if @import.suggested_template + @import.apply_template!(@import.suggested_template) + redirect_to import_configuration_path(@import), notice: "Template applied." + else + redirect_to import_configuration_path(@import), alert: "No template found, please manually configure your import." + end + end + def destroy @import.destroy diff --git a/app/models/import.rb b/app/models/import.rb index 600f30a7..662b4cee 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -146,8 +146,20 @@ class Import < ApplicationRecord end def sync_mappings - mapping_steps.each do |mapping| - mapping.sync(self) + transaction do + mapping_steps.each do |mapping_class| + mappables_by_key = mapping_class.mappables_by_key(self) + + updated_mappings = mappables_by_key.map do |key, mappable| + mapping = mappings.find_or_initialize_by(key: key, import: self, type: mapping_class.name) + mapping.mappable = mappable + mapping.create_when_empty = key.present? && mappable.nil? + mapping + end + + updated_mappings.each { |m| m.save(validate: false) } + mapping_class.where.not(id: updated_mappings.map(&:id)).destroy_all + end end end @@ -183,6 +195,28 @@ class Import < ApplicationRecord family.accounts.empty? && has_unassigned_account? end + # Used to optionally pre-fill the configuration for the current import + def suggested_template + family.imports + .complete + .where(account: account, type: type) + .order(created_at: :desc) + .first + end + + def apply_template!(import_template) + update!( + import_template.attributes.slice( + "date_col_label", "amount_col_label", "name_col_label", + "category_col_label", "tags_col_label", "account_col_label", + "qty_col_label", "ticker_col_label", "price_col_label", + "entity_type_col_label", "notes_col_label", "currency_col_label", + "date_format", "signage_convention", "number_format", + "exchange_operating_mic_col_label" + ) + ) + end + private def import! # no-op, subclasses can implement for customization of algorithm diff --git a/app/models/import/account_mapping.rb b/app/models/import/account_mapping.rb index 9cf43f7a..67280da4 100644 --- a/app/models/import/account_mapping.rb +++ b/app/models/import/account_mapping.rb @@ -2,8 +2,11 @@ class Import::AccountMapping < Import::Mapping validates :mappable, presence: true, if: :requires_mapping? class << self - def mapping_values(import) - import.rows.map(&:account).uniq + def mappables_by_key(import) + unique_values = import.rows.map(&:account).uniq + accounts = import.family.accounts.where(name: unique_values).index_by(&:name) + + unique_values.index_with { |value| accounts[value] } end end diff --git a/app/models/import/account_type_mapping.rb b/app/models/import/account_type_mapping.rb index 8b60edcd..2d4b5431 100644 --- a/app/models/import/account_type_mapping.rb +++ b/app/models/import/account_type_mapping.rb @@ -2,8 +2,8 @@ class Import::AccountTypeMapping < Import::Mapping validates :value, presence: true class << self - def mapping_values(import) - import.rows.map(&:entity_type).uniq + def mappables_by_key(import) + import.rows.map(&:entity_type).uniq.index_with { nil } end end diff --git a/app/models/import/category_mapping.rb b/app/models/import/category_mapping.rb index 12302603..4b633ea4 100644 --- a/app/models/import/category_mapping.rb +++ b/app/models/import/category_mapping.rb @@ -1,7 +1,10 @@ class Import::CategoryMapping < Import::Mapping class << self - def mapping_values(import) - import.rows.map(&:category).uniq + def mappables_by_key(import) + unique_values = import.rows.map(&:category).uniq + categories = import.family.categories.where(name: unique_values).index_by(&:name) + + unique_values.index_with { |value| categories[value] } end end diff --git a/app/models/import/mapping.rb b/app/models/import/mapping.rb index a0a4bc8b..b783ef95 100644 --- a/app/models/import/mapping.rb +++ b/app/models/import/mapping.rb @@ -18,19 +18,8 @@ class Import::Mapping < ApplicationRecord find_by(key: key)&.mappable end - def sync(import) - unique_values = mapping_values(import).uniq - - unique_values.each do |value| - mapping = find_or_initialize_by(key: value, import: import, create_when_empty: value.present?) - mapping.save(validate: false) if mapping.new_record? - end - - where(import: import).where.not(key: unique_values).destroy_all - end - - def mapping_values(import) - raise NotImplementedError, "Subclass must implement mapping_values" + def mappables_by_key(import) + raise NotImplementedError, "Subclass must implement mappables_by_key" end end diff --git a/app/models/import/row.rb b/app/models/import/row.rb index d4316a60..622a9d0a 100644 --- a/app/models/import/row.rb +++ b/app/models/import/row.rb @@ -30,11 +30,10 @@ class Import::Row < ApplicationRecord end end - def sync_mappings - Import::CategoryMapping.sync(import) if import.column_keys.include?(:category) - Import::TagMapping.sync(import) if import.column_keys.include?(:tags) - Import::AccountMapping.sync(import) if import.column_keys.include?(:account) - Import::AccountTypeMapping.sync(import) if import.column_keys.include?(:entity_type) + def update_and_sync(params) + assign_attributes(params) + save!(validate: false) + import.sync_mappings end private diff --git a/app/models/import/tag_mapping.rb b/app/models/import/tag_mapping.rb index 899b4dc5..4b9a1be0 100644 --- a/app/models/import/tag_mapping.rb +++ b/app/models/import/tag_mapping.rb @@ -1,7 +1,11 @@ class Import::TagMapping < Import::Mapping class << self - def mapping_values(import) - import.rows.map(&:tags_list).flatten.uniq + def mappables_by_key(import) + unique_values = import.rows.map(&:tags_list).flatten.uniq + + tags = import.family.tags.where(name: unique_values).index_by(&:name) + + unique_values.index_with { |value| tags[value] } end end diff --git a/app/views/import/configurations/show.html.erb b/app/views/import/configurations/show.html.erb index a807c97e..aaefd4e6 100644 --- a/app/views/import/configurations/show.html.erb +++ b/app/views/import/configurations/show.html.erb @@ -4,14 +4,33 @@ <%= content_for :previous_path, import_upload_path(@import) %> -
      +<% if @import.suggested_template.present? && params[:template_hint] == "true" %> +
      +
      +

      + + <%= icon "sparkles" %> + + + Template configuration found +

      + +

      We found a configuration from a previous import for this account. Would you like to apply it to this import?

      + +
      + <%= link_to "Manually configure", import_configuration_path(@import), class: "btn btn--outline" %> + <%= button_to "Apply template", apply_template_import_path(@import), class: "btn btn--primary", method: :put, data: { turbo_frame: :_top } %> +
      +
      +
      +<% else %>

      <%= t(".title") %>

      <%= t(".description") %>

      -
      +
      <%= render partial: permitted_import_configuration_path(@import), locals: { import: @import } %>
      @@ -19,4 +38,4 @@
      <%= render "imports/table", headers: @import.csv_headers, rows: @import.csv_sample, caption: "Sample data from your uploaded CSV" %>
      -
      +<% end %> diff --git a/app/views/import/uploads/show.html.erb b/app/views/import/uploads/show.html.erb index ab7e4bb2..fdcf787a 100644 --- a/app/views/import/uploads/show.html.erb +++ b/app/views/import/uploads/show.html.erb @@ -24,7 +24,7 @@ <%= styled_form_with model: @import, scope: :import, url: import_upload_path(@import), multipart: true, class: "space-y-2" do |form| %> <%= form.select :col_sep, Import::SEPARATORS, label: true %> - <% unless @import.type == "MintImport" %> + <% if @import.type == "TransactionImport" || @import.type == "TradeImport" %> <%= form.select :account_id, @import.family.accounts.pluck(:name, :id), { label: "Account (optional)", include_blank: "Multi-account import", selected: @import.account_id } %> <% end %> diff --git a/app/views/shared/_notification.html.erb b/app/views/shared/_notification.html.erb index 3e01536d..fbf4e2c9 100644 --- a/app/views/shared/_notification.html.erb +++ b/app/views/shared/_notification.html.erb @@ -16,7 +16,7 @@ <%= lucide_icon "check", class: "w-3 h-3" %>
      <% when :alert %> -
      +
      <%= lucide_icon "x", class: "w-3 h-3" %>
      <% end %> diff --git a/config/routes.rb b/config/routes.rb index 30d3303e..c714a3ef 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -68,8 +68,11 @@ Rails.application.routes.draw do resources :transfers, only: %i[new create destroy show update] resources :imports, only: %i[index new show create destroy] do - post :publish, on: :member - put :revert, on: :member + member do + post :publish + put :revert + put :apply_template + end resource :upload, only: %i[show update], module: :import resource :configuration, only: %i[show update], module: :import diff --git a/test/controllers/import/uploads_controller_test.rb b/test/controllers/import/uploads_controller_test.rb index eb7f418b..647815d4 100644 --- a/test/controllers/import/uploads_controller_test.rb +++ b/test/controllers/import/uploads_controller_test.rb @@ -19,7 +19,7 @@ class Import::UploadsControllerTest < ActionDispatch::IntegrationTest } } - assert_redirected_to import_configuration_url(@import) + assert_redirected_to import_configuration_url(@import, template_hint: true) assert_equal "CSV uploaded successfully.", flash[:notice] end @@ -31,7 +31,7 @@ class Import::UploadsControllerTest < ActionDispatch::IntegrationTest } } - assert_redirected_to import_configuration_url(@import) + assert_redirected_to import_configuration_url(@import, template_hint: true) assert_equal "CSV uploaded successfully.", flash[:notice] end From cf59fe45e7798cacacd3ffe913a5dfca921a0c9b Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Wed, 5 Mar 2025 09:30:47 -0500 Subject: [PATCH 030/380] Fix ticker filling when Synth is connected (#1950) --- app/controllers/securities_controller.rb | 5 +---- app/models/security/provided.rb | 2 ++ 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/controllers/securities_controller.rb b/app/controllers/securities_controller.rb index 6cfe9fd0..2c4124cf 100644 --- a/app/controllers/securities_controller.rb +++ b/app/controllers/securities_controller.rb @@ -1,10 +1,7 @@ class SecuritiesController < ApplicationController def index - query = params[:q] - return render json: [] if query.blank? || query.length < 2 || query.length > 100 - @securities = Security.search_provider({ - search: query, + search: params[:q], country: params[:country_code] == "US" ? "US" : nil }) end diff --git a/app/models/security/provided.rb b/app/models/security/provided.rb index 4a4fd6a5..c7e38fb5 100644 --- a/app/models/security/provided.rb +++ b/app/models/security/provided.rb @@ -9,6 +9,8 @@ module Security::Provided end def search_provider(query) + return [] if query[:search].blank? || query[:search].length < 2 + response = provider.search_securities( query: query[:search], dataset: "limited", From d66c37939acf6056c0d1eb6a57377874da10d158 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Wed, 5 Mar 2025 10:07:29 -0500 Subject: [PATCH 031/380] Update bug_report.md Signed-off-by: Zach Gollwitzer --- .github/ISSUE_TEMPLATE/bug_report.md | 51 +++++++++++++++++++++------- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 203ba646..d7f8526c 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,32 +1,59 @@ --- name: Bug report -about: Create a report to help us improve +about: Open a bug report when you experience broken functionality within the latest version of the Maybe app title: 'Bug: [Add descriptive title here]' labels: '' assignees: '' --- -**Where did this bug occur? (required)** +## Before you start (required) -- [ ] I am a self-hosted user reporting a bug from my self hosted app - - [ ] I have verified that I am running the **latest** version of the Maybe app (your app should be running [this version](https://github.com/maybe-finance/maybe/pkgs/container/maybe) before opening a bug) -- [ ] I am a user of Maybe's paid app +### General checklist -_Please note, if you are reporting a bug with sensitive data, please open an Intercom chat from within the app for help_ +- [ ] I have removed personal / sensitive data from screenshots and logs +- [ ] 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 + +### How are you using Maybe? + +- [ ] I am a paying Maybe customer (hosted version) +- [ ] I am a self-hosted user + +### Self hoster checklist + +_Paying, hosted users should delete this entire section._ + +If 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. + +- Self hosted app commit SHA (find in user menu): [enter commit sha here] + - [ ] I have confirmed that my app's commit is the latest version of Maybe +- Where are you hosting? + - [ ] Render + - [ ] Docker Compose + - [ ] Umbrel + - [ ] Other (please specify) + +--- + +## Bug description -**Describe the bug** A clear and concise description of what the bug is. -**To Reproduce** +### To Reproduce + +Be as specific as possible so Maybe maintainers can quickly reproduce the bug you're experiencing. + Steps to reproduce the behavior: + 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error -**Expected behavior** -A clear and concise description of what you expected to happen. +### Expected behavior -**Screenshots / Recordings** -If applicable, add screenshots or short video recordings to help show the bug in more detail. +What is the intended behavior that you would expect? + +### Screenshots and/or recordings + +We 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. From 8d0509fda03d5fb9f4ee2b7b01979c8779f38ffb Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Wed, 5 Mar 2025 10:15:12 -0500 Subject: [PATCH 032/380] Conditionally show commit sha Fixes #1951 --- app/views/users/_user_menu.html.erb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/views/users/_user_menu.html.erb b/app/views/users/_user_menu.html.erb index c8997be2..38e078a9 100644 --- a/app/views/users/_user_menu.html.erb +++ b/app/views/users/_user_menu.html.erb @@ -26,7 +26,10 @@

      Version: <%= 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" %> - (<%= link_to Maybe.commit_sha.first(7), "https://github.com/maybe-finance/maybe/commit/#{Maybe.commit_sha}", target: "_blank", class: "hover:underline" %>) + + <% if Maybe.commit_sha.present? %> + (<%= link_to Maybe.commit_sha.first(7), "https://github.com/maybe-finance/maybe/commit/#{Maybe.commit_sha}", target: "_blank", class: "hover:underline" %>) + <% end %>

      <% end %> From e384369cfbb42e6f9f6cfe8cdb68245965b10683 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Wed, 5 Mar 2025 10:20:02 -0500 Subject: [PATCH 033/380] Adjust graph intervals to show more data Fixes #1948 --- app/models/period.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/period.rb b/app/models/period.rb index 7e1a8008..85ad2947 100644 --- a/app/models/period.rb +++ b/app/models/period.rb @@ -117,8 +117,8 @@ class Period end def interval - if days > 90 - "1 month" + if days > 366 + "1 week" else "1 day" end From eaa1b6abe02c97e8a25aae9393d349b07ee1dfcc Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Wed, 5 Mar 2025 11:01:07 -0500 Subject: [PATCH 034/380] Update issue templates --- .github/ISSUE_TEMPLATE/bug_report.md | 4 +++- .github/ISSUE_TEMPLATE/other.md | 35 ++++++++++++++++++++++------ 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index d7f8526c..4f1db63d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,6 +1,7 @@ --- name: Bug report -about: Open a bug report when you experience broken functionality within the latest version of the Maybe app +about: Open a bug report when you experience broken functionality within the latest + version of the Maybe app title: 'Bug: [Add descriptive title here]' labels: '' assignees: '' @@ -17,6 +18,7 @@ assignees: '' ### How are you using Maybe? - [ ] I am a paying Maybe customer (hosted version) + - Paying Maybe users can also open requests in Intercom (if there is sensitive info involved) - [ ] I am a self-hosted user ### Self hoster checklist diff --git a/.github/ISSUE_TEMPLATE/other.md b/.github/ISSUE_TEMPLATE/other.md index 6a230e6e..9e0c9148 100644 --- a/.github/ISSUE_TEMPLATE/other.md +++ b/.github/ISSUE_TEMPLATE/other.md @@ -7,15 +7,36 @@ assignees: '' --- -**PLEASE READ before opening an issue:** +## Before you start (required) -- Is this a feature request? Please [open a feature request discussion](https://github.com/maybe-finance/maybe/discussions/new?category=feature-requests). -- Do you need help or have a question? Please [open a discussion](https://github.com/maybe-finance/maybe/discussions/new/choose) or [join our Discord](https://link.maybe.co/discord) and post to the "help" channel. +### Is this a bug? ----------------------- +A 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. -**Is this issue related to a problem? Please describe.** +### Is this a bug with _sensitive info_? -**Describe the work that needs to be done to address this issue** +If you are a _paying_ Maybe user, you can open a support request in Intercom. -**Additional context** +### Is this a feature request? + +A feature request is functionality that you would like that is not already on our [Roadmap](https://github.com/maybe-finance/maybe/wiki/Roadmap). + +All feature requests should be opened as Discussions here: + +https://github.com/maybe-finance/maybe/discussions/categories/feature-requests + +Be sure to search existing discussions prior to opening a new feature request. + +### Is this related to Docker and/or hosting for self hosting? + +If 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: + +- **First**: Read our [self hosting guides](https://github.com/maybe-finance/maybe/tree/main/docs/hosting) and follow them step-by-step +- Open a [General Discussion](https://github.com/maybe-finance/maybe/discussions/categories/general) +- Make a post in the "Self hosted" channel in our [Discord](https://link.maybe.co/discord) + +--- + +## Issue description + +If your issue does not fall into the categories above, please provide a **descriptive and complete** overview of your issue. From 381e39bea8b4ebb768a8cda6c9d63e486904c959 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Wed, 5 Mar 2025 12:21:17 -0500 Subject: [PATCH 035/380] Fix: Purge stale holdings from accounts during sync (#1954) * Fix: Purge stale holdings from accounts during sync * Fix typo * Prevent Plaid holding deletions --- .../account/holdings_controller.rb | 9 +++-- app/controllers/plaid_items_controller.rb | 2 +- app/models/account/syncer.rb | 35 +++++++++++++------ app/views/account/holdings/index.html.erb | 8 ++--- app/views/account/holdings/show.html.erb | 30 ++++++++-------- test/models/account/syncer_test.rb | 2 ++ 6 files changed, 54 insertions(+), 32 deletions(-) diff --git a/app/controllers/account/holdings_controller.rb b/app/controllers/account/holdings_controller.rb index bfda09fb..9ded4165 100644 --- a/app/controllers/account/holdings_controller.rb +++ b/app/controllers/account/holdings_controller.rb @@ -9,9 +9,12 @@ class Account::HoldingsController < ApplicationController end def destroy - @holding.destroy_holding_and_entries! - - flash[:notice] = t(".success") + if @holding.account.plaid_account_id.present? + flash[:alert] = "You cannot delete this holding" + else + @holding.destroy_holding_and_entries! + flash[:notice] = t(".success") + end respond_to do |format| format.html { redirect_back_or_to account_path(@holding.account) } diff --git a/app/controllers/plaid_items_controller.rb b/app/controllers/plaid_items_controller.rb index 406da016..37efd5e3 100644 --- a/app/controllers/plaid_items_controller.rb +++ b/app/controllers/plaid_items_controller.rb @@ -22,7 +22,7 @@ class PlaidItemsController < ApplicationController end respond_to do |format| - format.html { redirect_to accounts_path } + format.html { redirect_back_or_to accounts_path } format.json { head :ok } end end diff --git a/app/models/account/syncer.rb b/app/models/account/syncer.rb index 8aeb0ba0..cd664e66 100644 --- a/app/models/account/syncer.rb +++ b/app/models/account/syncer.rb @@ -10,11 +10,11 @@ class Account::Syncer holdings = sync_holdings balances = sync_balances(holdings) account.reload - update_account_info(balances, holdings) unless account.plaid_account_id.present? + update_account_info(balances, holdings) unless plaid_sync? convert_records_to_family_currency(balances, holdings) unless account.currency == account.family.currency # Enrich if user opted in or if we're syncing transactions from a Plaid account on the hosted app - if account.family.data_enrichment_enabled? || (account.plaid_account_id.present? && Rails.application.config.app_mode.hosted?) + if account.family.data_enrichment_enabled? || (plaid_sync? && Rails.application.config.app_mode.hosted?) account.enrich_data else Rails.logger.info("Data enrichment is disabled, skipping enrichment for account #{account.id}") @@ -41,15 +41,13 @@ class Account::Syncer def sync_holdings calculator = Account::HoldingCalculator.new(account) - calculated_holdings = calculator.calculate(reverse: account.plaid_account_id.present?) + calculated_holdings = calculator.calculate(reverse: plaid_sync?) current_time = Time.now Account.transaction do load_holdings(calculated_holdings) - - # Purge outdated holdings - account.holdings.delete_by("date < ? OR security_id NOT IN (?)", account_start_date, calculated_holdings.map(&:security_id)) + purge_outdated_holdings unless plaid_sync? end calculated_holdings @@ -57,13 +55,11 @@ class Account::Syncer def sync_balances(holdings) calculator = Account::BalanceCalculator.new(account, holdings: holdings) - calculated_balances = calculator.calculate(reverse: account.plaid_account_id.present?, start_date: start_date) + calculated_balances = calculator.calculate(reverse: plaid_sync?, start_date: start_date) Account.transaction do load_balances(calculated_balances) - - # Purge outdated balances - account.balances.delete_by("date < ?", account_start_date) + purge_outdated_balances end calculated_balances @@ -131,4 +127,23 @@ class Account::Syncer unique_by: %i[account_id security_id date currency] ) end + + def purge_outdated_balances + account.balances.delete_by("date < ?", account_start_date) + end + + def plaid_sync? + account.plaid_account_id.present? + end + + def purge_outdated_holdings + portfolio_security_ids = account.entries.account_trades.map { |entry| entry.entryable.security_id }.uniq + + # If there are no securities in the portfolio, delete all holdings + if portfolio_security_ids.empty? + account.holdings.delete_all + else + account.holdings.delete_by("date < ? OR security_id NOT IN (?)", account_start_date, portfolio_security_ids) + end + end end diff --git a/app/views/account/holdings/index.html.erb b/app/views/account/holdings/index.html.erb index a4dfeb4d..6b80ab59 100644 --- a/app/views/account/holdings/index.html.erb +++ b/app/views/account/holdings/index.html.erb @@ -21,12 +21,12 @@
      + <%= render "account/holdings/cash", account: @account %> + + <%= render "account/holdings/ruler" %> + <% if @account.current_holdings.any? %> - <%= render "account/holdings/cash", account: @account %> - <%= render "account/holdings/ruler" %> <%= render partial: "account/holdings/holding", collection: @account.current_holdings, spacer_template: "ruler" %> - <% else %> -

      <%= t(".no_holdings") %>

      <% end %>
      diff --git a/app/views/account/holdings/show.html.erb b/app/views/account/holdings/show.html.erb index 1dd09244..b783ac5b 100644 --- a/app/views/account/holdings/show.html.erb +++ b/app/views/account/holdings/show.html.erb @@ -87,26 +87,28 @@
      -
      - -

      <%= t(".settings") %>

      - <%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-secondary w-5" %> -
      + <% unless @holding.account.plaid_account_id.present? %> +
      + +

      <%= t(".settings") %>

      + <%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-secondary w-5" %> +
      -
      -
      -
      -

      <%= t(".delete_title") %>

      -

      <%= t(".delete_subtitle") %>

      -
      +
      +
      +
      +

      <%= t(".delete_title") %>

      +

      <%= t(".delete_subtitle") %>

      +
      - <%= button_to t(".delete"), + <%= button_to t(".delete"), account_holding_path(@holding), method: :delete, class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-secondary", data: { turbo_confirm: true } %> +
      -
      -
      +
      + <% end %>
      <% end %> diff --git a/test/models/account/syncer_test.rb b/test/models/account/syncer_test.rb index 261ab28c..5cc85ee9 100644 --- a/test/models/account/syncer_test.rb +++ b/test/models/account/syncer_test.rb @@ -17,6 +17,8 @@ class Account::SyncerTest < ActiveSupport::TestCase @account.family.update! currency: "USD" @account.update! currency: "EUR" + @account.entries.create!(date: 1.day.ago.to_date, currency: "EUR", amount: 500, name: "Buy AAPL", entryable: Account::Trade.new(security: securities(:aapl), qty: 10, price: 50, currency: "EUR")) + ExchangeRate.create!(date: 1.day.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: 1.2) ExchangeRate.create!(date: Date.current, from_currency: "EUR", to_currency: "USD", rate: 2) From 071ad52c7fb2a7e4d22640991bd39ec863c0bd50 Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Wed, 5 Mar 2025 13:04:45 -0600 Subject: [PATCH 036/380] Potential fix for MFA login issues --- app/controllers/mfa_controller.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/controllers/mfa_controller.rb b/app/controllers/mfa_controller.rb index ec3071e5..da161476 100644 --- a/app/controllers/mfa_controller.rb +++ b/app/controllers/mfa_controller.rb @@ -29,7 +29,8 @@ class MfaController < ApplicationController if @user&.verify_otp?(params[:code]) session.delete(:mfa_user_id) @session = create_session_for(@user) - redirect_to root_path + Rails.logger.info "MFA verification successful for user #{@user.id}. Session created: #{@session.id}" + redirect_to root_path, turbo: false else flash.now[:alert] = t(".invalid_code") render :verify, status: :unprocessable_entity From e49bda4a2e2edbf7a65c5eb5f17beed8f4292b08 Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Wed, 5 Mar 2025 13:10:53 -0600 Subject: [PATCH 037/380] Another attempt at fixing MFA issues --- app/controllers/concerns/authentication.rb | 14 ++++++++++++-- app/controllers/mfa_controller.rb | 9 +++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 9dc23942..69cc666d 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -28,12 +28,22 @@ module Authentication end def find_session_by_cookie - Session.find_by(id: cookies.signed[:session_token]) + cookie_value = cookies.signed[:session_token] + Rails.logger.info "Looking for session with cookie value: #{cookie_value.present? ? 'present' : 'missing'}" + session = Session.find_by(id: cookie_value) + Rails.logger.info "Session found: #{session.present? ? 'yes' : 'no'}" + session end def create_session_for(user) session = user.sessions.create! - cookies.signed.permanent[:session_token] = { value: session.id, httponly: true } + Rails.logger.info "Setting session cookie with value: #{session.id}" + # Explicitly set SameSite attribute and ensure cookie is set properly + cookies.signed.permanent[:session_token] = { + value: session.id, + httponly: true, + same_site: :lax + } session end diff --git a/app/controllers/mfa_controller.rb b/app/controllers/mfa_controller.rb index da161476..ea8d388c 100644 --- a/app/controllers/mfa_controller.rb +++ b/app/controllers/mfa_controller.rb @@ -30,6 +30,15 @@ class MfaController < ApplicationController session.delete(:mfa_user_id) @session = create_session_for(@user) Rails.logger.info "MFA verification successful for user #{@user.id}. Session created: #{@session.id}" + + # Explicitly set the cookie again to ensure it's properly set + cookies.signed.permanent[:session_token] = { + value: @session.id, + httponly: true, + same_site: :lax + } + + # Use turbo: false to ensure a full page reload redirect_to root_path, turbo: false else flash.now[:alert] = t(".invalid_code") From 28bfcda50af28667eb296703e43905133ab57878 Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Wed, 5 Mar 2025 13:20:36 -0600 Subject: [PATCH 038/380] Temporary additional logging to continue debugging MFA issues --- app/controllers/concerns/authentication.rb | 43 ++++++++++++----- app/controllers/mfa_controller.rb | 54 ++++++++++++++++------ app/controllers/sessions_controller.rb | 10 ++++ 3 files changed, 81 insertions(+), 26 deletions(-) diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 69cc666d..fcba1483 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -16,12 +16,19 @@ module Authentication private def authenticate_user! + Rails.logger.info "Authentication#authenticate_user! - Checking for session cookie" + if session_record = find_session_by_cookie + Rails.logger.info "Authentication#authenticate_user! - Found valid session: #{session_record.id} for user: #{session_record.user_id}" Current.session = session_record else + Rails.logger.info "Authentication#authenticate_user! - No valid session found" + if self_hosted_first_login? + Rails.logger.info "Authentication#authenticate_user! - Self-hosted first login detected, redirecting to registration" redirect_to new_registration_url else + Rails.logger.info "Authentication#authenticate_user! - Redirecting to login page" redirect_to new_session_url end end @@ -29,21 +36,35 @@ module Authentication def find_session_by_cookie cookie_value = cookies.signed[:session_token] - Rails.logger.info "Looking for session with cookie value: #{cookie_value.present? ? 'present' : 'missing'}" - session = Session.find_by(id: cookie_value) - Rails.logger.info "Session found: #{session.present? ? 'yes' : 'no'}" - session + Rails.logger.info "Authentication#find_session_by_cookie - Looking for session with cookie value: #{cookie_value.present? ? 'present' : 'missing'}" + + if cookie_value.present? + session = Session.find_by(id: cookie_value) + Rails.logger.info "Authentication#find_session_by_cookie - Session found: #{session.present? ? 'yes' : 'no'}" + + if session.present? + Rails.logger.info "Authentication#find_session_by_cookie - Session belongs to user: #{session.user_id}" + end + + session + else + Rails.logger.info "Authentication#find_session_by_cookie - No session cookie found" + nil + end end def create_session_for(user) + Rails.logger.info "Authentication#create_session_for - Creating session for user: #{user.id}" session = user.sessions.create! - Rails.logger.info "Setting session cookie with value: #{session.id}" - # Explicitly set SameSite attribute and ensure cookie is set properly - cookies.signed.permanent[:session_token] = { - value: session.id, - httponly: true, - same_site: :lax - } + Rails.logger.info "Authentication#create_session_for - Session created with ID: #{session.id}" + + Rails.logger.info "Authentication#create_session_for - Setting session cookie" + cookies.signed.permanent[:session_token] = { value: session.id, httponly: true } + + Rails.logger.info "Authentication#create_session_for - Cookie set, verifying..." + cookie_value = cookies.signed[:session_token] + Rails.logger.info "Authentication#create_session_for - Cookie verification: #{cookie_value == session.id ? 'successful' : 'failed'}" + session end diff --git a/app/controllers/mfa_controller.rb b/app/controllers/mfa_controller.rb index ea8d388c..925a03ef 100644 --- a/app/controllers/mfa_controller.rb +++ b/app/controllers/mfa_controller.rb @@ -3,50 +3,74 @@ class MfaController < ApplicationController skip_authentication only: [ :verify, :verify_code ] def new + Rails.logger.info "MfaController#new - User: #{Current.user.id} accessing MFA setup" redirect_to root_path if Current.user.otp_required? Current.user.setup_mfa! unless Current.user.otp_secret.present? end def create + Rails.logger.info "MfaController#create - User: #{Current.user.id} attempting to enable MFA" if Current.user.verify_otp?(params[:code]) + Rails.logger.info "MfaController#create - MFA verification successful for user: #{Current.user.id}" Current.user.enable_mfa! @backup_codes = Current.user.otp_backup_codes + Rails.logger.info "MfaController#create - Generated backup codes for user: #{Current.user.id}" render :backup_codes else + Rails.logger.info "MfaController#create - MFA verification failed for user: #{Current.user.id}" Current.user.disable_mfa! redirect_to new_mfa_path, alert: t(".invalid_code") end end def verify + Rails.logger.info "MfaController#verify - Attempting to verify MFA for user_id from session: #{session[:mfa_user_id]}" @user = User.find_by(id: session[:mfa_user_id]) - redirect_to new_session_path unless @user + + if @user + Rails.logger.info "MfaController#verify - Found user: #{@user.id} for MFA verification" + else + Rails.logger.info "MfaController#verify - No user found for MFA verification, redirecting to login" + redirect_to new_session_path + end end def verify_code + Rails.logger.info "MfaController#verify_code - Attempting to verify MFA code for user_id from session: #{session[:mfa_user_id]}" @user = User.find_by(id: session[:mfa_user_id]) - if @user&.verify_otp?(params[:code]) - session.delete(:mfa_user_id) - @session = create_session_for(@user) - Rails.logger.info "MFA verification successful for user #{@user.id}. Session created: #{@session.id}" - - # Explicitly set the cookie again to ensure it's properly set - cookies.signed.permanent[:session_token] = { - value: @session.id, - httponly: true, - same_site: :lax - } - - # Use turbo: false to ensure a full page reload - redirect_to root_path, turbo: false + if @user + Rails.logger.info "MfaController#verify_code - Found user: #{@user.id} for MFA verification" else + Rails.logger.info "MfaController#verify_code - No user found for MFA verification" + end + + if @user&.verify_otp?(params[:code]) + Rails.logger.info "MfaController#verify_code - MFA code verification successful for user: #{@user.id}" + session.delete(:mfa_user_id) + Rails.logger.info "MfaController#verify_code - Deleted mfa_user_id from session" + + @session = create_session_for(@user) + Rails.logger.info "MfaController#verify_code - Created session: #{@session.id} for user: #{@user.id}" + + # Log cookie information + Rails.logger.info "MfaController#verify_code - Cookie details:" + Rails.logger.info " - session_token present: #{cookies.signed[:session_token].present?}" + Rails.logger.info " - session_token value: #{cookies.signed[:session_token]}" + Rails.logger.info " - all cookies: #{cookies.to_h.keys.join(', ')}" + + # Simply redirect to root path with data-turbo="false" + Rails.logger.info "MfaController#verify_code - Redirecting to root_path with data-turbo=false" + redirect_to root_path, data: { turbo: false } + else + Rails.logger.info "MfaController#verify_code - MFA code verification failed for user: #{@user&.id}" flash.now[:alert] = t(".invalid_code") render :verify, status: :unprocessable_entity end end def disable + Rails.logger.info "MfaController#disable - User: #{Current.user.id} disabling MFA" Current.user.disable_mfa! redirect_to settings_security_path, notice: t(".success") end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 3b7357f8..88c788b9 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -5,24 +5,34 @@ class SessionsController < ApplicationController layout "auth" def new + Rails.logger.info "SessionsController#new - Rendering login form" end def create + Rails.logger.info "SessionsController#create - Attempting to authenticate user with email: #{params[:email]}" + if user = User.authenticate_by(email: params[:email], password: params[:password]) + Rails.logger.info "SessionsController#create - Authentication successful for user: #{user.id}" + if user.otp_required? + Rails.logger.info "SessionsController#create - MFA required for user: #{user.id}, redirecting to MFA verification" session[:mfa_user_id] = user.id redirect_to verify_mfa_path else + Rails.logger.info "SessionsController#create - MFA not required for user: #{user.id}, creating session" @session = create_session_for(user) + Rails.logger.info "SessionsController#create - Session created: #{@session.id}, redirecting to root_path" redirect_to root_path end else + Rails.logger.info "SessionsController#create - Authentication failed for email: #{params[:email]}" flash.now[:alert] = t(".invalid_credentials") render :new, status: :unprocessable_entity end end def destroy + Rails.logger.info "SessionsController#destroy - Destroying session: #{@session.id} for user: #{Current.user.id}" @session.destroy redirect_to new_session_path, notice: t(".logout_successful") end From f7fa8fa08510210fe8df92d6e8d6e95dbc500d0a Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Wed, 5 Mar 2025 13:32:53 -0600 Subject: [PATCH 039/380] Disable turbo on login forms --- app/views/mfa/verify.html.erb | 2 +- app/views/sessions/new.html.erb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/mfa/verify.html.erb b/app/views/mfa/verify.html.erb index 67476e5a..05fb8b9e 100644 --- a/app/views/mfa/verify.html.erb +++ b/app/views/mfa/verify.html.erb @@ -3,7 +3,7 @@ header_description t(".description") %> -<%= styled_form_with url: verify_mfa_path, method: :post, class: "space-y-4" do |form| %> +<%= styled_form_with url: verify_mfa_path, method: :post, class: "space-y-4", data: { turbo: false } do |form| %> <%= form.text_field :code, required: true, autofocus: true, diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index 174a76d3..2f5228e4 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -2,7 +2,7 @@ header_title t(".title") %> -<%= styled_form_with url: sessions_path, class: "space-y-4" do |form| %> +<%= styled_form_with url: sessions_path, class: "space-y-4", data: { turbo: false } do |form| %> <%= form.email_field :email, label: t(".email"), autofocus: false, autocomplete: "email", required: "required", placeholder: t(".email_placeholder") %> <%= form.password_field :password, label: t(".password"), required: "required" %> From cffafd23f0cfefb1d0929b13ac75be5d94661027 Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Wed, 5 Mar 2025 13:44:56 -0600 Subject: [PATCH 040/380] Logger cleanup --- app/controllers/concerns/authentication.rb | 27 +---------------- app/controllers/mfa_controller.rb | 35 ++-------------------- app/controllers/sessions_controller.rb | 10 ------- 3 files changed, 3 insertions(+), 69 deletions(-) diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index fcba1483..28758d9d 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -16,19 +16,12 @@ module Authentication private def authenticate_user! - Rails.logger.info "Authentication#authenticate_user! - Checking for session cookie" - if session_record = find_session_by_cookie - Rails.logger.info "Authentication#authenticate_user! - Found valid session: #{session_record.id} for user: #{session_record.user_id}" Current.session = session_record else - Rails.logger.info "Authentication#authenticate_user! - No valid session found" - if self_hosted_first_login? - Rails.logger.info "Authentication#authenticate_user! - Self-hosted first login detected, redirecting to registration" redirect_to new_registration_url else - Rails.logger.info "Authentication#authenticate_user! - Redirecting to login page" redirect_to new_session_url end end @@ -36,35 +29,17 @@ module Authentication def find_session_by_cookie cookie_value = cookies.signed[:session_token] - Rails.logger.info "Authentication#find_session_by_cookie - Looking for session with cookie value: #{cookie_value.present? ? 'present' : 'missing'}" if cookie_value.present? - session = Session.find_by(id: cookie_value) - Rails.logger.info "Authentication#find_session_by_cookie - Session found: #{session.present? ? 'yes' : 'no'}" - - if session.present? - Rails.logger.info "Authentication#find_session_by_cookie - Session belongs to user: #{session.user_id}" - end - - session + Session.find_by(id: cookie_value) else - Rails.logger.info "Authentication#find_session_by_cookie - No session cookie found" nil end end def create_session_for(user) - Rails.logger.info "Authentication#create_session_for - Creating session for user: #{user.id}" session = user.sessions.create! - Rails.logger.info "Authentication#create_session_for - Session created with ID: #{session.id}" - - Rails.logger.info "Authentication#create_session_for - Setting session cookie" cookies.signed.permanent[:session_token] = { value: session.id, httponly: true } - - Rails.logger.info "Authentication#create_session_for - Cookie set, verifying..." - cookie_value = cookies.signed[:session_token] - Rails.logger.info "Authentication#create_session_for - Cookie verification: #{cookie_value == session.id ? 'successful' : 'failed'}" - session end diff --git a/app/controllers/mfa_controller.rb b/app/controllers/mfa_controller.rb index 925a03ef..5d1fef0b 100644 --- a/app/controllers/mfa_controller.rb +++ b/app/controllers/mfa_controller.rb @@ -3,74 +3,43 @@ class MfaController < ApplicationController skip_authentication only: [ :verify, :verify_code ] def new - Rails.logger.info "MfaController#new - User: #{Current.user.id} accessing MFA setup" redirect_to root_path if Current.user.otp_required? Current.user.setup_mfa! unless Current.user.otp_secret.present? end def create - Rails.logger.info "MfaController#create - User: #{Current.user.id} attempting to enable MFA" if Current.user.verify_otp?(params[:code]) - Rails.logger.info "MfaController#create - MFA verification successful for user: #{Current.user.id}" Current.user.enable_mfa! @backup_codes = Current.user.otp_backup_codes - Rails.logger.info "MfaController#create - Generated backup codes for user: #{Current.user.id}" render :backup_codes else - Rails.logger.info "MfaController#create - MFA verification failed for user: #{Current.user.id}" Current.user.disable_mfa! redirect_to new_mfa_path, alert: t(".invalid_code") end end def verify - Rails.logger.info "MfaController#verify - Attempting to verify MFA for user_id from session: #{session[:mfa_user_id]}" @user = User.find_by(id: session[:mfa_user_id]) - if @user - Rails.logger.info "MfaController#verify - Found user: #{@user.id} for MFA verification" - else - Rails.logger.info "MfaController#verify - No user found for MFA verification, redirecting to login" + if @user.nil? redirect_to new_session_path end end def verify_code - Rails.logger.info "MfaController#verify_code - Attempting to verify MFA code for user_id from session: #{session[:mfa_user_id]}" @user = User.find_by(id: session[:mfa_user_id]) - if @user - Rails.logger.info "MfaController#verify_code - Found user: #{@user.id} for MFA verification" - else - Rails.logger.info "MfaController#verify_code - No user found for MFA verification" - end - if @user&.verify_otp?(params[:code]) - Rails.logger.info "MfaController#verify_code - MFA code verification successful for user: #{@user.id}" session.delete(:mfa_user_id) - Rails.logger.info "MfaController#verify_code - Deleted mfa_user_id from session" - @session = create_session_for(@user) - Rails.logger.info "MfaController#verify_code - Created session: #{@session.id} for user: #{@user.id}" - - # Log cookie information - Rails.logger.info "MfaController#verify_code - Cookie details:" - Rails.logger.info " - session_token present: #{cookies.signed[:session_token].present?}" - Rails.logger.info " - session_token value: #{cookies.signed[:session_token]}" - Rails.logger.info " - all cookies: #{cookies.to_h.keys.join(', ')}" - - # Simply redirect to root path with data-turbo="false" - Rails.logger.info "MfaController#verify_code - Redirecting to root_path with data-turbo=false" - redirect_to root_path, data: { turbo: false } + redirect_to root_path else - Rails.logger.info "MfaController#verify_code - MFA code verification failed for user: #{@user&.id}" flash.now[:alert] = t(".invalid_code") render :verify, status: :unprocessable_entity end end def disable - Rails.logger.info "MfaController#disable - User: #{Current.user.id} disabling MFA" Current.user.disable_mfa! redirect_to settings_security_path, notice: t(".success") end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 88c788b9..3b7357f8 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -5,34 +5,24 @@ class SessionsController < ApplicationController layout "auth" def new - Rails.logger.info "SessionsController#new - Rendering login form" end def create - Rails.logger.info "SessionsController#create - Attempting to authenticate user with email: #{params[:email]}" - if user = User.authenticate_by(email: params[:email], password: params[:password]) - Rails.logger.info "SessionsController#create - Authentication successful for user: #{user.id}" - if user.otp_required? - Rails.logger.info "SessionsController#create - MFA required for user: #{user.id}, redirecting to MFA verification" session[:mfa_user_id] = user.id redirect_to verify_mfa_path else - Rails.logger.info "SessionsController#create - MFA not required for user: #{user.id}, creating session" @session = create_session_for(user) - Rails.logger.info "SessionsController#create - Session created: #{@session.id}, redirecting to root_path" redirect_to root_path end else - Rails.logger.info "SessionsController#create - Authentication failed for email: #{params[:email]}" flash.now[:alert] = t(".invalid_credentials") render :new, status: :unprocessable_entity end end def destroy - Rails.logger.info "SessionsController#destroy - Destroying session: #{@session.id} for user: #{Current.user.id}" @session.destroy redirect_to new_session_path, notice: t(".logout_successful") end From 9627a6bf6fb69e5f54df5ab24c6b67596f6666ed Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Wed, 5 Mar 2025 15:38:31 -0500 Subject: [PATCH 041/380] Add tagged logging to sync process (#1956) * Add tagged logging to sync process * Reduce logging in syncer * Typo --- app/models/account/balance_calculator.rb | 15 +++++---- app/models/account/holding_calculator.rb | 11 +++++-- app/models/account/syncer.rb | 39 ++++++++++++++++-------- app/models/plaid_item.rb | 20 ++++++++++-- app/models/sync.rb | 33 ++++++++++++-------- config/environments/production.rb | 7 +++-- 6 files changed, 86 insertions(+), 39 deletions(-) diff --git a/app/models/account/balance_calculator.rb b/app/models/account/balance_calculator.rb index d5b3b3d1..ca292dc5 100644 --- a/app/models/account/balance_calculator.rb +++ b/app/models/account/balance_calculator.rb @@ -5,13 +5,16 @@ class Account::BalanceCalculator end def calculate(reverse: false, start_date: nil) - cash_balances = reverse ? reverse_cash_balances : forward_cash_balances + Rails.logger.tagged("Account::BalanceCalculator") do + Rails.logger.info("Calculating cash balances with strategy: #{reverse ? "reverse sync" : "forward sync"}") + cash_balances = reverse ? reverse_cash_balances : forward_cash_balances - cash_balances.map do |balance| - holdings_value = converted_holdings.select { |h| h.date == balance.date }.sum(&:amount) - balance.balance = balance.balance + holdings_value - balance - end.compact + cash_balances.map do |balance| + holdings_value = converted_holdings.select { |h| h.date == balance.date }.sum(&:amount) + balance.balance = balance.balance + holdings_value + balance + end.compact + end end private diff --git a/app/models/account/holding_calculator.rb b/app/models/account/holding_calculator.rb index c9327daa..edb55acf 100644 --- a/app/models/account/holding_calculator.rb +++ b/app/models/account/holding_calculator.rb @@ -5,9 +5,14 @@ class Account::HoldingCalculator end def calculate(reverse: false) - preload_securities - calculated_holdings = reverse ? reverse_holdings : forward_holdings - gapfill_holdings(calculated_holdings) + Rails.logger.tagged("Account::HoldingCalculator") do + preload_securities + + Rails.logger.info("Calculating holdings with strategy: #{reverse ? "reverse sync" : "forward sync"}") + calculated_holdings = reverse ? reverse_holdings : forward_holdings + + gapfill_holdings(calculated_holdings) + end end private diff --git a/app/models/account/syncer.rb b/app/models/account/syncer.rb index cd664e66..d664b8f1 100644 --- a/app/models/account/syncer.rb +++ b/app/models/account/syncer.rb @@ -5,19 +5,34 @@ class Account::Syncer end def run - account.family.auto_match_transfers! + Rails.logger.tagged("Account::Syncer") do + Rails.logger.info("Finding potential transfers to auto-match") + account.family.auto_match_transfers! - holdings = sync_holdings - balances = sync_balances(holdings) - account.reload - update_account_info(balances, holdings) unless plaid_sync? - convert_records_to_family_currency(balances, holdings) unless account.currency == account.family.currency + holdings = sync_holdings + Rails.logger.info("Calculated #{holdings.size} holdings") - # Enrich if user opted in or if we're syncing transactions from a Plaid account on the hosted app - if account.family.data_enrichment_enabled? || (plaid_sync? && Rails.application.config.app_mode.hosted?) - account.enrich_data - else - Rails.logger.info("Data enrichment is disabled, skipping enrichment for account #{account.id}") + balances = sync_balances(holdings) + Rails.logger.info("Calculated #{balances.size} balances") + + account.reload + + unless plaid_sync? + update_account_info(balances, holdings) + end + + unless account.currency == account.family.currency + Rails.logger.info("Converting #{balances.size} balances and #{holdings.size} holdings from #{account.currency} to #{account.family.currency}") + convert_records_to_family_currency(balances, holdings) + end + + # Enrich if user opted in or if we're syncing transactions from a Plaid account on the hosted app + if account.family.data_enrichment_enabled? || (plaid_sync? && Rails.application.config.app_mode.hosted?) + Rails.logger.info("Enriching transaction data for account #{account.name}") + account.enrich_data + else + Rails.logger.info("Data enrichment disabled for account #{account.name}") + end end end @@ -43,8 +58,6 @@ class Account::Syncer calculator = Account::HoldingCalculator.new(account) calculated_holdings = calculator.calculate(reverse: plaid_sync?) - current_time = Time.now - Account.transaction do load_holdings(calculated_holdings) purge_outdated_holdings unless plaid_sync? diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index 1a49675b..c76af8aa 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -41,8 +41,11 @@ class PlaidItem < ApplicationRecord update!(last_synced_at: Time.current) begin + Rails.logger.info("Fetching and loading Plaid data") plaid_data = fetch_and_load_plaid_data update!(status: :good) if requires_update? + + Rails.logger.info("Plaid data fetched and loaded") plaid_data rescue Plaid::ApiError => e handle_plaid_error(e) @@ -83,12 +86,17 @@ class PlaidItem < ApplicationRecord private def fetch_and_load_plaid_data data = {} + + # Log what we're about to fetch + Rails.logger.info "Starting Plaid data fetch (accounts, transactions, investments, liabilities)" + item = plaid_provider.get_item(access_token).item update!(available_products: item.available_products, billed_products: item.billed_products) - # Fetch and store institution details + # Institution details if item.institution_id.present? begin + Rails.logger.info "Fetching Plaid institution details for #{item.institution_id}" institution = plaid_provider.get_institution(item.institution_id) update!( institution_id: item.institution_id, @@ -96,12 +104,14 @@ class PlaidItem < ApplicationRecord institution_color: institution.institution.primary_color ) rescue Plaid::ApiError => e - Rails.logger.warn("Error fetching institution details for item #{id}: #{e.message}") + Rails.logger.warn "Failed to fetch Plaid institution details: #{e.message}" end end + # Accounts fetched_accounts = plaid_provider.get_item_accounts(self).accounts data[:accounts] = fetched_accounts || [] + Rails.logger.info "Processing Plaid accounts (count: #{fetched_accounts.size})" internal_plaid_accounts = fetched_accounts.map do |account| internal_plaid_account = plaid_accounts.find_or_create_from_plaid_data!(account, family) @@ -109,10 +119,12 @@ class PlaidItem < ApplicationRecord internal_plaid_account end + # Transactions fetched_transactions = safe_fetch_plaid_data(:get_item_transactions) data[:transactions] = fetched_transactions || [] if fetched_transactions + Rails.logger.info "Processing Plaid transactions (added: #{fetched_transactions.added.size}, modified: #{fetched_transactions.modified.size}, removed: #{fetched_transactions.removed.size})" transaction do internal_plaid_accounts.each do |internal_plaid_account| added = fetched_transactions.added.select { |t| t.account_id == internal_plaid_account.plaid_id } @@ -126,10 +138,12 @@ class PlaidItem < ApplicationRecord end end + # Investments fetched_investments = safe_fetch_plaid_data(:get_item_investments) data[:investments] = fetched_investments || [] if fetched_investments + Rails.logger.info "Processing Plaid investments (transactions: #{fetched_investments.transactions.size}, holdings: #{fetched_investments.holdings.size}, securities: #{fetched_investments.securities.size})" transaction do internal_plaid_accounts.each do |internal_plaid_account| transactions = fetched_investments.transactions.select { |t| t.account_id == internal_plaid_account.plaid_id } @@ -141,10 +155,12 @@ class PlaidItem < ApplicationRecord end end + # Liabilities fetched_liabilities = safe_fetch_plaid_data(:get_item_liabilities) data[:liabilities] = fetched_liabilities || [] if fetched_liabilities + Rails.logger.info "Processing Plaid liabilities (credit: #{fetched_liabilities.credit&.size || 0}, mortgage: #{fetched_liabilities.mortgage&.size || 0}, student: #{fetched_liabilities.student&.size || 0})" transaction do internal_plaid_accounts.each do |internal_plaid_account| credit = fetched_liabilities.credit&.find { |l| l.account_id == internal_plaid_account.plaid_id } diff --git a/app/models/sync.rb b/app/models/sync.rb index 65e474d7..64446cdd 100644 --- a/app/models/sync.rb +++ b/app/models/sync.rb @@ -6,38 +6,47 @@ class Sync < ApplicationRecord scope :ordered, -> { order(created_at: :desc) } def perform - start! + Rails.logger.tagged("Sync", id, syncable_type, syncable_id) do + start! - begin - data = syncable.sync_data(start_date: start_date) - update!(data: data) if data - complete! - rescue StandardError => error - fail! error - raise error if Rails.env.development? - ensure - syncable.post_sync + begin + data = syncable.sync_data(start_date: start_date) + update!(data: data) if data + complete! + rescue StandardError => error + fail! error + raise error if Rails.env.development? + ensure + Rails.logger.info("Sync completed, starting post-sync") + + syncable.post_sync + + Rails.logger.info("Post-sync completed") + end end end private def start! + Rails.logger.info("Starting sync") update! status: :syncing end def complete! + Rails.logger.info("Sync completed") update! status: :completed, last_ran_at: Time.current end def fail!(error) + Rails.logger.error("Sync failed: #{error.message}") + Sentry.capture_exception(error) do |scope| - scope.set_context("sync", { id: id }) + scope.set_context("sync", { id: id, syncable_type: syncable_type, syncable_id: syncable_id }) end update!( status: :failed, error: error.message, - error_backtrace: error.backtrace&.first(10), last_ran_at: Time.current ) end diff --git a/config/environments/production.rb b/config/environments/production.rb index fba29091..4670492d 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -49,17 +49,18 @@ Rails.application.configure do config.assume_ssl = ActiveModel::Type::Boolean.new.cast(ENV.fetch("RAILS_ASSUME_SSL", true)) # Log to Logtail if API key is present, otherwise log to STDOUT - config.logger = if ENV["LOGTAIL_API_KEY"].present? + base_logger = if ENV["LOGTAIL_API_KEY"].present? Logtail::Logger.create_default_logger( ENV["LOGTAIL_API_KEY"], telemetry_host: "in.logs.betterstack.com" ) else ActiveSupport::Logger.new(STDOUT) - .tap { |logger| logger.formatter = ::Logger::Formatter.new } - .then { |logger| ActiveSupport::TaggedLogging.new(logger) } + .tap { |logger| logger.formatter = ::Logger::Formatter.new } end + config.logger = ActiveSupport::TaggedLogging.new(base_logger) + # Prepend all log lines with the following tags. config.log_tags = [ :request_id ] From 372b64ffea651bfdede2a990876d102b53e5ba38 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Wed, 5 Mar 2025 16:02:07 -0500 Subject: [PATCH 042/380] Fix Plaid sync error when current balance is null --- app/models/plaid_account.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/plaid_account.rb b/app/models/plaid_account.rb index 186105ce..77a4288d 100644 --- a/app/models/plaid_account.rb +++ b/app/models/plaid_account.rb @@ -20,7 +20,7 @@ class PlaidAccount < ApplicationRecord find_or_create_by!(plaid_id: plaid_data.account_id) do |a| a.account = family.accounts.new( name: plaid_data.name, - balance: plaid_data.balances.current, + balance: plaid_data.balances.current || plaid_data.balances.available, currency: plaid_data.balances.iso_currency_code, accountable: TYPE_MAPPING[plaid_data.type].new ) From 26762477a323b63c46f4c7064e1ed361d781e320 Mon Sep 17 00:00:00 2001 From: Nikhil Badyal <59223300+nikhilbadyal@users.noreply.github.com> Date: Fri, 7 Mar 2025 20:35:54 +0530 Subject: [PATCH 043/380] Preference to set default_period (#1941) --- app/controllers/accounts_controller.rb | 1 + app/controllers/concerns/accountable_resource.rb | 2 +- app/controllers/concerns/periodable.rb | 14 ++++++++++++++ app/controllers/pages_controller.rb | 2 +- app/controllers/users_controller.rb | 2 +- app/models/user.rb | 1 + app/views/accounts/chart.html.erb | 5 ++--- app/views/accounts/show/_chart.html.erb | 2 +- app/views/settings/preferences/show.html.erb | 5 +++++ config/locales/views/settings/en.yml | 1 + .../20250304140435_add_default_period_to_users.rb | 5 +++++ db/schema.rb | 3 ++- 12 files changed, 35 insertions(+), 8 deletions(-) create mode 100644 app/controllers/concerns/periodable.rb create mode 100644 db/migrate/20250304140435_add_default_period_to_users.rb diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index df2b4d3c..5be606d2 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -1,5 +1,6 @@ class AccountsController < ApplicationController before_action :set_account, only: %i[sync chart sparkline] + include Periodable def index @manual_accounts = family.accounts.manual.alphabetically diff --git a/app/controllers/concerns/accountable_resource.rb b/app/controllers/concerns/accountable_resource.rb index 16467e36..524be0fc 100644 --- a/app/controllers/concerns/accountable_resource.rb +++ b/app/controllers/concerns/accountable_resource.rb @@ -2,7 +2,7 @@ module AccountableResource extend ActiveSupport::Concern included do - include ScrollFocusable + include ScrollFocusable, Periodable before_action :set_account, only: [ :show, :edit, :update, :destroy ] before_action :set_link_token, only: :new diff --git a/app/controllers/concerns/periodable.rb b/app/controllers/concerns/periodable.rb new file mode 100644 index 00000000..8cf02395 --- /dev/null +++ b/app/controllers/concerns/periodable.rb @@ -0,0 +1,14 @@ +module Periodable + extend ActiveSupport::Concern + + included do + before_action :set_period + end + + private + def set_period + @period = Period.from_key(params[:period] || Current.user&.default_period) + rescue Period::InvalidKeyError + @period = Period.last_30_days + end +end diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 34d6cca3..f2a91f62 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -1,8 +1,8 @@ class PagesController < ApplicationController skip_before_action :authenticate_user!, only: %i[early_access] + include Periodable def dashboard - @period = params[:period] ? Period.from_key(params[:period]) : Period.last_30_days @balance_sheet = Current.family.balance_sheet @accounts = Current.family.accounts.active.with_attached_logo diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 9b82509d..4300477d 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -66,7 +66,7 @@ class UsersController < ApplicationController def user_params params.require(:user).permit( - :first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, :show_sidebar, + :first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, :show_sidebar, :default_period, family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id, :data_enrichment_enabled ] ) end diff --git a/app/models/user.rb b/app/models/user.rb index 24932861..479ce225 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -9,6 +9,7 @@ class User < ApplicationRecord validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } validate :ensure_valid_profile_image + validates :default_period, inclusion: { in: Period::PERIODS.keys } normalizes :email, with: ->(email) { email.strip.downcase } normalizes :unconfirmed_email, with: ->(email) { email&.strip&.downcase } diff --git a/app/views/accounts/chart.html.erb b/app/views/accounts/chart.html.erb index c5cc51eb..22e2528e 100644 --- a/app/views/accounts/chart.html.erb +++ b/app/views/accounts/chart.html.erb @@ -1,5 +1,4 @@ -<% period = params[:period] ? Period.from_key(params[:period]) : Period.last_30_days %> -<% series = @account.balance_series(period: period) %> +<% series = @account.balance_series(period: @period) %> <% trend = series.trend %> <%= turbo_frame_tag dom_id(@account, :chart_details) do %> @@ -13,7 +12,7 @@ <% end %> <% end %> - <%= tag.span period.comparison_label, class: "text-secondary" %> + <%= tag.span @period.comparison_label, class: "text-secondary" %>
      diff --git a/app/views/accounts/show/_chart.html.erb b/app/views/accounts/show/_chart.html.erb index d7427762..c78a3676 100644 --- a/app/views/accounts/show/_chart.html.erb +++ b/app/views/accounts/show/_chart.html.erb @@ -1,6 +1,6 @@ <%# locals: (account:, title: nil, tooltip: nil, **args) %> -<% period = params[:period] ? Period.from_key(params[:period]) : Period.last_30_days %> +<% period = @period || Period.last_30_days %> <% default_value_title = account.asset? ? t(".balance") : t(".owed") %>
      diff --git a/app/views/settings/preferences/show.html.erb b/app/views/settings/preferences/show.html.erb index c51444dc..e053d969 100644 --- a/app/views/settings/preferences/show.html.erb +++ b/app/views/settings/preferences/show.html.erb @@ -25,6 +25,11 @@ { label: t(".date_format") }, { data: { auto_submit_form_target: "auto" } } %> + <%= form.select :default_period, + Period.all.map { |period| [ period.label, period.key ] }, + { label: t(".default_period") }, + { data: { auto_submit_form_target: "auto" } } %> + <%= family_form.select :country, country_options, { label: t(".country") }, diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index 8596904e..8bc0dbf0 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -20,6 +20,7 @@ en: date_format: Date format general_subtitle: Configure your preferences general_title: General + default_period: Default Period language: Language page_title: Preferences theme_dark: Dark diff --git a/db/migrate/20250304140435_add_default_period_to_users.rb b/db/migrate/20250304140435_add_default_period_to_users.rb new file mode 100644 index 00000000..ee2f5e2f --- /dev/null +++ b/db/migrate/20250304140435_add_default_period_to_users.rb @@ -0,0 +1,5 @@ +class AddDefaultPeriodToUsers < ActiveRecord::Migration[7.2] + def change + add_column :users, :default_period, :string, default: "last_30_days", null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 492edb1e..59eabedf 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_03_03_141007) do +ActiveRecord::Schema[7.2].define(version: 2025_03_04_140435) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -675,6 +675,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_03_141007) do t.boolean "otp_required", default: false, null: false t.string "otp_backup_codes", default: [], array: true t.boolean "show_sidebar", default: true + t.string "default_period", default: "last_30_days", null: false t.index ["email"], name: "index_users_on_email", unique: true t.index ["family_id"], name: "index_users_on_family_id" t.index ["otp_secret"], name: "index_users_on_otp_secret", unique: true, where: "(otp_secret IS NOT NULL)" From eac5d5e6630db73ef97e9a0bbaa994a1ea79bfca Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 7 Mar 2025 17:35:55 -0500 Subject: [PATCH 044/380] Populate holdings for "offline" securities properly (#1958) * Placeholder logic for missing prices * Generate holdings properly for "offline" securities * Separate forward and reverse calculators for holdings and balances * Remove unnecessary currency conversion during sync * Clearer sync process * Move price caching logic to dedicated model * Base holding calculator * Base calculator for balances * Finish balance calculators * Better naming * Logs cleanup * Remove stale data type * Remove stale test * Fix price lookup logic for holdings sync * Fix Plaid item sync regression * Remove temp logging * Calculate cash and holdings series * Add holdings, cash, and balance series dropdown for investments --- app/controllers/accounts_controller.rb | 1 + .../concerns/accountable_resource.rb | 1 + app/controllers/concerns/auto_sync.rb | 1 + app/models/account.rb | 33 +-- app/models/account/balance/base_calculator.rb | 35 ++++ .../account/balance/forward_calculator.rb | 28 +++ .../account/balance/reverse_calculator.rb | 32 +++ app/models/account/balance/sync_cache.rb | 46 +++++ app/models/account/balance/syncer.rb | 69 +++++++ app/models/account/balance_calculator.rb | 124 ------------ app/models/account/chartable.rb | 59 ++++-- app/models/account/enrichable.rb | 12 ++ app/models/account/holding.rb | 2 +- app/models/account/holding/base_calculator.rb | 63 ++++++ .../account/holding/forward_calculator.rb | 21 ++ app/models/account/holding/gapfillable.rb | 38 ++++ app/models/account/holding/portfolio_cache.rb | 132 ++++++++++++ .../account/holding/reverse_calculator.rb | 38 ++++ app/models/account/holding/syncer.rb | 58 ++++++ app/models/account/holding_calculator.rb | 188 ------------------ app/models/account/linkable.rb | 18 ++ app/models/account/syncer.rb | 162 --------------- app/models/plaid_item.rb | 5 + app/views/accounts/chart.html.erb | 2 +- app/views/accounts/show/_chart.html.erb | 19 +- app/views/investments/show.html.erb | 1 + .../balance/forward_calculator_test.rb | 74 +++++++ .../balance/reverse_calculator_test.rb | 59 ++++++ test/models/account/balance/syncer_test.rb | 51 +++++ .../models/account/balance_calculator_test.rb | 156 --------------- .../holding/forward_calculator_test.rb | 146 ++++++++++++++ .../account/holding/portfolio_cache_test.rb | 63 ++++++ .../reverse_calculator_test.rb} | 86 +------- test/models/account/holding/syncer_test.rb | 29 +++ test/models/account/syncer_test.rb | 65 ------ 35 files changed, 1109 insertions(+), 808 deletions(-) create mode 100644 app/models/account/balance/base_calculator.rb create mode 100644 app/models/account/balance/forward_calculator.rb create mode 100644 app/models/account/balance/reverse_calculator.rb create mode 100644 app/models/account/balance/sync_cache.rb create mode 100644 app/models/account/balance/syncer.rb delete mode 100644 app/models/account/balance_calculator.rb create mode 100644 app/models/account/enrichable.rb create mode 100644 app/models/account/holding/base_calculator.rb create mode 100644 app/models/account/holding/forward_calculator.rb create mode 100644 app/models/account/holding/gapfillable.rb create mode 100644 app/models/account/holding/portfolio_cache.rb create mode 100644 app/models/account/holding/reverse_calculator.rb create mode 100644 app/models/account/holding/syncer.rb delete mode 100644 app/models/account/holding_calculator.rb create mode 100644 app/models/account/linkable.rb delete mode 100644 app/models/account/syncer.rb create mode 100644 test/models/account/balance/forward_calculator_test.rb create mode 100644 test/models/account/balance/reverse_calculator_test.rb create mode 100644 test/models/account/balance/syncer_test.rb delete mode 100644 test/models/account/balance_calculator_test.rb create mode 100644 test/models/account/holding/forward_calculator_test.rb create mode 100644 test/models/account/holding/portfolio_cache_test.rb rename test/models/account/{holding_calculator_test.rb => holding/reverse_calculator_test.rb} (61%) create mode 100644 test/models/account/holding/syncer_test.rb delete mode 100644 test/models/account/syncer_test.rb diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 5be606d2..33b3980a 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -18,6 +18,7 @@ class AccountsController < ApplicationController end def chart + @chart_view = params[:chart_view] || "balance" render layout: "application" end diff --git a/app/controllers/concerns/accountable_resource.rb b/app/controllers/concerns/accountable_resource.rb index 524be0fc..d7d3b169 100644 --- a/app/controllers/concerns/accountable_resource.rb +++ b/app/controllers/concerns/accountable_resource.rb @@ -23,6 +23,7 @@ module AccountableResource end def show + @chart_view = params[:chart_view] || "balance" @q = params.fetch(:q, {}).permit(:search) entries = @account.entries.search(@q).reverse_chronological diff --git a/app/controllers/concerns/auto_sync.rb b/app/controllers/concerns/auto_sync.rb index d9e616e4..970eec0a 100644 --- a/app/controllers/concerns/auto_sync.rb +++ b/app/controllers/concerns/auto_sync.rb @@ -13,6 +13,7 @@ module AutoSync def family_needs_auto_sync? return false unless Current.family.present? + return false unless Current.family.accounts.active.any? (Current.family.last_synced_at&.to_date || 1.day.ago) < Date.current end diff --git a/app/models/account.rb b/app/models/account.rb index 75752077..0c037609 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -1,11 +1,10 @@ class Account < ApplicationRecord - include Syncable, Monetizable, Issuable, Chartable + include Syncable, Monetizable, Issuable, Chartable, Enrichable, Linkable validates :name, :balance, :currency, presence: true belongs_to :family belongs_to :import, optional: true - belongs_to :plaid_account, optional: true has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping" has_many :entries, dependent: :destroy, class_name: "Account::Entry" @@ -75,7 +74,16 @@ class Account < ApplicationRecord def sync_data(start_date: nil) update!(last_synced_at: Time.current) - Syncer.new(self, start_date: start_date).run + Rails.logger.info("Auto-matching transfers") + family.auto_match_transfers! + + Rails.logger.info("Processing balances (#{linked? ? 'reverse' : 'forward'})") + sync_balances + + if enrichable? + Rails.logger.info("Enriching transaction data") + enrich_data + end end def post_sync @@ -93,10 +101,6 @@ class Account < ApplicationRecord holdings.where(currency: currency, date: holdings.maximum(:date)).order(amount: :desc) end - def enrich_data - DataEnricher.new(self).run - end - def update_with_sync!(attributes) should_update_balance = attributes[:balance] && attributes[:balance].to_d != balance @@ -123,11 +127,14 @@ class Account < ApplicationRecord end end - def sparkline_series - cache_key = family.build_cache_key("#{id}_sparkline") - - Rails.cache.fetch(cache_key) do - balance_series - end + def start_date + first_entry_date = entries.minimum(:date) || Date.current + first_entry_date - 1.day end + + private + def sync_balances + strategy = linked? ? :reverse : :forward + Balance::Syncer.new(self, strategy: strategy).sync_balances + end end diff --git a/app/models/account/balance/base_calculator.rb b/app/models/account/balance/base_calculator.rb new file mode 100644 index 00000000..7acb51e8 --- /dev/null +++ b/app/models/account/balance/base_calculator.rb @@ -0,0 +1,35 @@ +class Account::Balance::BaseCalculator + attr_reader :account + + def initialize(account) + @account = account + end + + def calculate + Rails.logger.tagged(self.class.name) do + calculate_balances + end + end + + private + def sync_cache + @sync_cache ||= Account::Balance::SyncCache.new(account) + end + + def build_balance(date, cash_balance, holdings_value) + Account::Balance.new( + account_id: account.id, + date: date, + balance: holdings_value + cash_balance, + cash_balance: cash_balance, + currency: account.currency + ) + end + + def calculate_next_balance(prior_balance, transactions, direction: :forward) + flows = transactions.sum(&:amount) + negated = direction == :forward ? account.asset? : account.liability? + flows *= -1 if negated + prior_balance + flows + end +end diff --git a/app/models/account/balance/forward_calculator.rb b/app/models/account/balance/forward_calculator.rb new file mode 100644 index 00000000..503e5b79 --- /dev/null +++ b/app/models/account/balance/forward_calculator.rb @@ -0,0 +1,28 @@ +class Account::Balance::ForwardCalculator < Account::Balance::BaseCalculator + private + def calculate_balances + current_cash_balance = 0 + next_cash_balance = nil + + @balances = [] + + account.start_date.upto(Date.current).each do |date| + entries = sync_cache.get_entries(date) + holdings = sync_cache.get_holdings(date) + holdings_value = holdings.sum(&:amount) + valuation = sync_cache.get_valuation(date) + + next_cash_balance = if valuation + valuation.amount - holdings_value + else + calculate_next_balance(current_cash_balance, entries, direction: :forward) + end + + @balances << build_balance(date, next_cash_balance, holdings_value) + + current_cash_balance = next_cash_balance + end + + @balances + end +end diff --git a/app/models/account/balance/reverse_calculator.rb b/app/models/account/balance/reverse_calculator.rb new file mode 100644 index 00000000..151f4036 --- /dev/null +++ b/app/models/account/balance/reverse_calculator.rb @@ -0,0 +1,32 @@ +class Account::Balance::ReverseCalculator < Account::Balance::BaseCalculator + private + def calculate_balances + current_cash_balance = account.cash_balance + previous_cash_balance = nil + + @balances = [] + + Date.current.downto(account.start_date).map do |date| + entries = sync_cache.get_entries(date) + holdings = sync_cache.get_holdings(date) + holdings_value = holdings.sum(&:amount) + valuation = sync_cache.get_valuation(date) + + previous_cash_balance = if valuation + valuation.amount - holdings_value + else + calculate_next_balance(current_cash_balance, entries, direction: :reverse) + end + + if valuation.present? + @balances << build_balance(date, previous_cash_balance, holdings_value) + else + @balances << build_balance(date, current_cash_balance, holdings_value) + end + + current_cash_balance = previous_cash_balance + end + + @balances + end +end diff --git a/app/models/account/balance/sync_cache.rb b/app/models/account/balance/sync_cache.rb new file mode 100644 index 00000000..1fb7ea7f --- /dev/null +++ b/app/models/account/balance/sync_cache.rb @@ -0,0 +1,46 @@ +class Account::Balance::SyncCache + def initialize(account) + @account = account + end + + def get_valuation(date) + converted_entries.find { |e| e.date == date && e.account_valuation? } + end + + def get_holdings(date) + converted_holdings.select { |h| h.date == date } + end + + def get_entries(date) + converted_entries.select { |e| e.date == date && (e.account_transaction? || e.account_trade?) } + end + + private + attr_reader :account + + def converted_entries + @converted_entries ||= account.entries.order(:date).to_a.map do |e| + converted_entry = e.dup + converted_entry.amount = converted_entry.amount_money.exchange_to( + account.currency, + date: e.date, + fallback_rate: 1 + ).amount + converted_entry.currency = account.currency + converted_entry + end + end + + def converted_holdings + @converted_holdings ||= account.holdings.map do |h| + converted_holding = h.dup + converted_holding.amount = converted_holding.amount_money.exchange_to( + account.currency, + date: h.date, + fallback_rate: 1 + ).amount + converted_holding.currency = account.currency + converted_holding + end + end +end diff --git a/app/models/account/balance/syncer.rb b/app/models/account/balance/syncer.rb new file mode 100644 index 00000000..cc8ca68b --- /dev/null +++ b/app/models/account/balance/syncer.rb @@ -0,0 +1,69 @@ +class Account::Balance::Syncer + attr_reader :account, :strategy + + def initialize(account, strategy:) + @account = account + @strategy = strategy + end + + def sync_balances + Account::Balance.transaction do + sync_holdings + calculate_balances + + Rails.logger.info("Persisting #{@balances.size} balances") + persist_balances + + purge_stale_balances + + if strategy == :forward + update_account_info + end + end + end + + private + def sync_holdings + @holdings = Account::Holding::Syncer.new(account, strategy: strategy).sync_holdings + end + + def update_account_info + calculated_balance = @balances.sort_by(&:date).last&.balance || 0 + calculated_holdings_value = @holdings.select { |h| h.date == Date.current }.sum(&:amount) || 0 + calculated_cash_balance = calculated_balance - calculated_holdings_value + + Rails.logger.info("Balance update: cash=#{calculated_cash_balance}, total=#{calculated_balance}") + + account.update!( + balance: calculated_balance, + cash_balance: calculated_cash_balance + ) + end + + def calculate_balances + @balances = calculator.calculate + end + + def persist_balances + current_time = Time.now + account.balances.upsert_all( + @balances.map { |b| b.attributes + .slice("date", "balance", "cash_balance", "currency") + .merge("updated_at" => current_time) }, + unique_by: %i[account_id date currency] + ) + end + + def purge_stale_balances + deleted_count = account.balances.delete_by("date < ?", account.start_date) + Rails.logger.info("Purged #{deleted_count} stale balances") if deleted_count > 0 + end + + def calculator + if strategy == :reverse + Account::Balance::ReverseCalculator.new(account) + else + Account::Balance::ForwardCalculator.new(account) + end + end +end diff --git a/app/models/account/balance_calculator.rb b/app/models/account/balance_calculator.rb deleted file mode 100644 index ca292dc5..00000000 --- a/app/models/account/balance_calculator.rb +++ /dev/null @@ -1,124 +0,0 @@ -class Account::BalanceCalculator - def initialize(account, holdings: nil) - @account = account - @holdings = holdings || [] - end - - def calculate(reverse: false, start_date: nil) - Rails.logger.tagged("Account::BalanceCalculator") do - Rails.logger.info("Calculating cash balances with strategy: #{reverse ? "reverse sync" : "forward sync"}") - cash_balances = reverse ? reverse_cash_balances : forward_cash_balances - - cash_balances.map do |balance| - holdings_value = converted_holdings.select { |h| h.date == balance.date }.sum(&:amount) - balance.balance = balance.balance + holdings_value - balance - end.compact - end - end - - private - attr_reader :account, :holdings - - def oldest_date - converted_entries.first ? converted_entries.first.date - 1.day : Date.current - end - - def reverse_cash_balances - prior_balance = account.cash_balance - - Date.current.downto(oldest_date).map do |date| - entries_for_date = converted_entries.select { |e| e.date == date } - holdings_for_date = converted_holdings.select { |h| h.date == date } - - valuation = entries_for_date.find { |e| e.account_valuation? } - - current_balance = if valuation - # To get this to a cash valuation, we back out holdings value on day - valuation.amount - holdings_for_date.sum(&:amount) - else - transactions = entries_for_date.select { |e| e.account_transaction? || e.account_trade? } - - calculate_balance(prior_balance, transactions) - end - - balance_record = Account::Balance.new( - account: account, - date: date, - balance: valuation ? current_balance : prior_balance, - cash_balance: valuation ? current_balance : prior_balance, - currency: account.currency - ) - - prior_balance = current_balance - - balance_record - end - end - - def forward_cash_balances - prior_balance = 0 - current_balance = nil - - oldest_date.upto(Date.current).map do |date| - entries_for_date = converted_entries.select { |e| e.date == date } - holdings_for_date = converted_holdings.select { |h| h.date == date } - - valuation = entries_for_date.find { |e| e.account_valuation? } - - current_balance = if valuation - # To get this to a cash valuation, we back out holdings value on day - valuation.amount - holdings_for_date.sum(&:amount) - else - transactions = entries_for_date.select { |e| e.account_transaction? || e.account_trade? } - - calculate_balance(prior_balance, transactions, inverse: true) - end - - balance_record = Account::Balance.new( - account: account, - date: date, - balance: current_balance, - cash_balance: current_balance, - currency: account.currency - ) - - prior_balance = current_balance - - balance_record - end - end - - def converted_entries - @converted_entries ||= @account.entries.order(:date).to_a.map do |e| - converted_entry = e.dup - converted_entry.amount = converted_entry.amount_money.exchange_to( - account.currency, - date: e.date, - fallback_rate: 1 - ).amount - converted_entry.currency = account.currency - converted_entry - end - end - - def converted_holdings - @converted_holdings ||= holdings.map do |h| - converted_holding = h.dup - converted_holding.amount = converted_holding.amount_money.exchange_to( - account.currency, - date: h.date, - fallback_rate: 1 - ).amount - converted_holding.currency = account.currency - converted_holding - end - end - - def calculate_balance(prior_balance, transactions, inverse: false) - flows = transactions.sum(&:amount) - negated = inverse ? account.asset? : account.liability? - flows *= -1 if negated - prior_balance + flows - end -end diff --git a/app/models/account/chartable.rb b/app/models/account/chartable.rb index 2770da3b..f251e7f1 100644 --- a/app/models/account/chartable.rb +++ b/app/models/account/chartable.rb @@ -2,7 +2,9 @@ module Account::Chartable extend ActiveSupport::Concern class_methods do - def balance_series(currency:, period: Period.last_30_days, favorable_direction: "up") + def balance_series(currency:, period: Period.last_30_days, favorable_direction: "up", view: :balance) + raise ArgumentError, "Invalid view type" unless [ :balance, :cash_balance, :holdings_balance ].include?(view.to_sym) + balances = Account::Balance.find_by_sql([ balance_series_query, { @@ -21,8 +23,8 @@ module Account::Chartable date: curr.date, date_formatted: I18n.l(curr.date, format: :long), trend: Trend.new( - current: Money.new(curr.balance, currency), - previous: prev.nil? ? nil : Money.new(prev.balance, currency), + current: Money.new(balance_value_for(curr, view), currency), + previous: prev.nil? ? nil : Money.new(balance_value_for(prev, view), currency), favorable_direction: favorable_direction ) ) @@ -33,8 +35,8 @@ module Account::Chartable end_date: period.end_date, interval: period.interval, trend: Trend.new( - current: Money.new(balances.last&.balance || 0, currency), - previous: Money.new(balances.first&.balance || 0, currency), + current: Money.new(balance_value_for(balances.last, view) || 0, currency), + previous: Money.new(balance_value_for(balances.first, view) || 0, currency), favorable_direction: favorable_direction ), values: values @@ -52,6 +54,8 @@ module Account::Chartable SELECT d.date, SUM(CASE WHEN accounts.classification = 'asset' THEN ab.balance ELSE -ab.balance END * COALESCE(er.rate, 1)) as balance, + SUM(CASE WHEN accounts.classification = 'asset' THEN ab.cash_balance ELSE -ab.cash_balance END * COALESCE(er.rate, 1)) as cash_balance, + SUM(CASE WHEN accounts.classification = 'asset' THEN ab.balance - ab.cash_balance ELSE 0 END * COALESCE(er.rate, 1)) as holdings_balance, COUNT(CASE WHEN accounts.currency <> :target_currency AND er.rate IS NULL THEN 1 END) as missing_rates FROM dates d LEFT JOIN accounts ON accounts.id IN (#{all.select(:id).to_sql}) @@ -70,26 +74,46 @@ module Account::Chartable SQL end + def balance_value_for(balance_record, view) + return 0 if balance_record.nil? + + case view.to_sym + when :balance then balance_record.balance + when :cash_balance then balance_record.cash_balance + when :holdings_balance then balance_record.holdings_balance + else + raise ArgumentError, "Invalid view type: #{view}" + end + end + def invert_balances(balances) balances.map do |balance| balance.balance = -balance.balance + balance.cash_balance = -balance.cash_balance + balance.holdings_balance = -balance.holdings_balance balance end end def gapfill_balances(balances) gapfilled = [] + prev = nil - prev_balance = nil - - [ nil, *balances ].each_cons(2).each_with_index do |(prev, curr), index| - if index == 0 && curr.balance.nil? - curr.balance = 0 # Ensure all series start with a non-nil balance - elsif curr.balance.nil? - curr.balance = prev.balance + balances.each do |curr| + if prev.nil? + # Initialize first record with zeros if nil + curr.balance ||= 0 + curr.cash_balance ||= 0 + curr.holdings_balance ||= 0 + else + # Copy previous values for nil fields + curr.balance ||= prev.balance + curr.cash_balance ||= prev.cash_balance + curr.holdings_balance ||= prev.holdings_balance end gapfilled << curr + prev = curr end gapfilled @@ -100,11 +124,20 @@ module Account::Chartable classification == "asset" ? "up" : "down" end - def balance_series(period: Period.last_30_days) + def balance_series(period: Period.last_30_days, view: :balance) self.class.where(id: self.id).balance_series( currency: currency, period: period, + view: view, favorable_direction: favorable_direction ) end + + def sparkline_series + cache_key = family.build_cache_key("#{id}_sparkline") + + Rails.cache.fetch(cache_key) do + balance_series + end + end end diff --git a/app/models/account/enrichable.rb b/app/models/account/enrichable.rb new file mode 100644 index 00000000..236cce58 --- /dev/null +++ b/app/models/account/enrichable.rb @@ -0,0 +1,12 @@ +module Account::Enrichable + extend ActiveSupport::Concern + + def enrich_data + DataEnricher.new(self).run + end + + private + def enrichable? + family.data_enrichment_enabled? || (linked? && Rails.application.config.app_mode.hosted?) + end +end diff --git a/app/models/account/holding.rb b/app/models/account/holding.rb index eb6e35ef..ba7a7e2d 100644 --- a/app/models/account/holding.rb +++ b/app/models/account/holding.rb @@ -1,5 +1,5 @@ class Account::Holding < ApplicationRecord - include Monetizable + include Monetizable, Gapfillable monetize :amount diff --git a/app/models/account/holding/base_calculator.rb b/app/models/account/holding/base_calculator.rb new file mode 100644 index 00000000..4359e9ab --- /dev/null +++ b/app/models/account/holding/base_calculator.rb @@ -0,0 +1,63 @@ +class Account::Holding::BaseCalculator + attr_reader :account + + def initialize(account) + @account = account + end + + def calculate + Rails.logger.tagged(self.class.name) do + holdings = calculate_holdings + Account::Holding.gapfill(holdings) + end + end + + private + def portfolio_cache + @portfolio_cache ||= Account::Holding::PortfolioCache.new(account) + end + + def empty_portfolio + securities = portfolio_cache.get_securities + securities.each_with_object({}) { |security, hash| hash[security.id] = 0 } + end + + def generate_starting_portfolio + empty_portfolio + end + + def transform_portfolio(previous_portfolio, trade_entries, direction: :forward) + new_quantities = previous_portfolio.dup + + trade_entries.each do |trade_entry| + trade = trade_entry.entryable + security_id = trade.security_id + qty_change = trade.qty + qty_change = qty_change * -1 if direction == :reverse + new_quantities[security_id] = (new_quantities[security_id] || 0) + qty_change + end + + new_quantities + end + + def build_holdings(portfolio, date) + portfolio.map do |security_id, qty| + price = portfolio_cache.get_price(security_id, date) + + if price.nil? + Rails.logger.warn "No price found for security #{security_id} on #{date}" + next + end + + Account::Holding.new( + account_id: account.id, + security_id: security_id, + date: date, + qty: qty, + price: price.price, + currency: price.currency, + amount: qty * price.price + ) + end.compact + end +end diff --git a/app/models/account/holding/forward_calculator.rb b/app/models/account/holding/forward_calculator.rb new file mode 100644 index 00000000..afb6b71f --- /dev/null +++ b/app/models/account/holding/forward_calculator.rb @@ -0,0 +1,21 @@ +class Account::Holding::ForwardCalculator < Account::Holding::BaseCalculator + private + def portfolio_cache + @portfolio_cache ||= Account::Holding::PortfolioCache.new(account) + end + + def calculate_holdings + current_portfolio = generate_starting_portfolio + next_portfolio = {} + holdings = [] + + account.start_date.upto(Date.current).each do |date| + trades = portfolio_cache.get_trades(date: date) + next_portfolio = transform_portfolio(current_portfolio, trades, direction: :forward) + holdings += build_holdings(next_portfolio, date) + current_portfolio = next_portfolio + end + + holdings + end +end diff --git a/app/models/account/holding/gapfillable.rb b/app/models/account/holding/gapfillable.rb new file mode 100644 index 00000000..e2462a6f --- /dev/null +++ b/app/models/account/holding/gapfillable.rb @@ -0,0 +1,38 @@ +module Account::Holding::Gapfillable + extend ActiveSupport::Concern + + class_methods do + def gapfill(holdings) + filled_holdings = [] + + holdings.group_by { |h| h.security_id }.each do |security_id, security_holdings| + next if security_holdings.empty? + + sorted = security_holdings.sort_by(&:date) + previous_holding = sorted.first + + sorted.first.date.upto(Date.current) do |date| + holding = security_holdings.find { |h| h.date == date } + + if holding + filled_holdings << holding + previous_holding = holding + else + # Create a new holding based on the previous day's data + filled_holdings << Account::Holding.new( + account: previous_holding.account, + security: previous_holding.security, + date: date, + qty: previous_holding.qty, + price: previous_holding.price, + currency: previous_holding.currency, + amount: previous_holding.amount + ) + end + end + end + + filled_holdings + end + end +end diff --git a/app/models/account/holding/portfolio_cache.rb b/app/models/account/holding/portfolio_cache.rb new file mode 100644 index 00000000..6a839382 --- /dev/null +++ b/app/models/account/holding/portfolio_cache.rb @@ -0,0 +1,132 @@ +class Account::Holding::PortfolioCache + attr_reader :account, :use_holdings + + class SecurityNotFound < StandardError + def initialize(security_id, account_id) + super("Security id=#{security_id} not found in portfolio cache for account #{account_id}. This should not happen unless securities were preloaded incorrectly.") + end + end + + def initialize(account, use_holdings: false) + @account = account + @use_holdings = use_holdings + load_prices + end + + def get_trades(date: nil) + if date.blank? + trades + else + trades.select { |t| t.date == date } + end + end + + def get_price(security_id, date) + security = @security_cache[security_id] + raise SecurityNotFound.new(security_id, account.id) unless security + + price = security[:prices].select { |p| p.price.date == date }.min_by(&:priority)&.price + + return nil unless price + + price_money = Money.new(price.price, price.currency) + + converted_amount = price_money.exchange_to(account.currency, fallback_rate: 1).amount + + Security::Price.new( + security_id: security_id, + date: price.date, + price: converted_amount, + currency: price.currency + ) + end + + def get_securities + @security_cache.map { |_, v| v[:security] } + end + + private + PriceWithPriority = Data.define(:price, :priority) + + def trades + @trades ||= account.entries.includes(entryable: :security).account_trades.chronological.to_a + end + + def holdings + @holdings ||= account.holdings.chronological.to_a + end + + def collect_unique_securities + unique_securities_from_trades = trades.map(&:entryable).map(&:security).uniq + + return unique_securities_from_trades unless use_holdings + + unique_securities_from_holdings = holdings.map(&:security).uniq + + (unique_securities_from_trades + unique_securities_from_holdings).uniq + end + + # Loads all known prices for all securities in the account with priority based on source: + # 1 - DB or provider prices + # 2 - Trade prices + # 3 - Holding prices + def load_prices + @security_cache = {} + securities = collect_unique_securities + + Rails.logger.info "Preloading #{securities.size} securities for account #{account.id}" + + securities.each do |security| + Rails.logger.info "Loading security: ID=#{security.id} Ticker=#{security.ticker}" + + # Highest priority prices + db_or_provider_prices = Security::Price.find_prices( + security: security, + start_date: account.start_date, + end_date: Date.current + ).map do |price| + PriceWithPriority.new( + price: price, + priority: 1 + ) + end + + # Medium priority prices from trades + trade_prices = trades + .select { |t| t.entryable.security_id == security.id } + .map do |trade| + PriceWithPriority.new( + price: Security::Price.new( + security: security, + price: trade.entryable.price, + currency: trade.entryable.currency, + date: trade.date + ), + priority: 2 + ) + end + + # Low priority prices from holdings (if applicable) + holding_prices = if use_holdings + holdings.select { |h| h.security_id == security.id }.map do |holding| + PriceWithPriority.new( + price: Security::Price.new( + security: security, + price: holding.price, + currency: holding.currency, + date: holding.date + ), + priority: 3 + ) + end + else + [] + end + + @security_cache[security.id] = { + security: security, + prices: db_or_provider_prices + trade_prices + holding_prices + } + end + end +end diff --git a/app/models/account/holding/reverse_calculator.rb b/app/models/account/holding/reverse_calculator.rb new file mode 100644 index 00000000..d3677c88 --- /dev/null +++ b/app/models/account/holding/reverse_calculator.rb @@ -0,0 +1,38 @@ +class Account::Holding::ReverseCalculator < Account::Holding::BaseCalculator + private + # Reverse calculators will use the existing holdings as a source of security ids and prices + # since it is common for a provider to supply "current day" holdings but not all the historical + # trades that make up those holdings. + def portfolio_cache + @portfolio_cache ||= Account::Holding::PortfolioCache.new(account, use_holdings: true) + end + + def calculate_holdings + current_portfolio = generate_starting_portfolio + previous_portfolio = {} + + holdings = [] + + Date.current.downto(account.start_date).each do |date| + today_trades = portfolio_cache.get_trades(date: date) + previous_portfolio = transform_portfolio(current_portfolio, today_trades, direction: :reverse) + holdings += build_holdings(current_portfolio, date) + current_portfolio = previous_portfolio + end + + holdings + end + + # Since this is a reverse sync, we start with today's holdings + def generate_starting_portfolio + holding_quantities = empty_portfolio + + todays_holdings = account.holdings.where(date: Date.current) + + todays_holdings.each do |holding| + holding_quantities[holding.security_id] = holding.qty + end + + holding_quantities + end +end diff --git a/app/models/account/holding/syncer.rb b/app/models/account/holding/syncer.rb new file mode 100644 index 00000000..bfccd6f0 --- /dev/null +++ b/app/models/account/holding/syncer.rb @@ -0,0 +1,58 @@ +class Account::Holding::Syncer + def initialize(account, strategy:) + @account = account + @strategy = strategy + end + + def sync_holdings + calculate_holdings + + Rails.logger.info("Persisting #{@holdings.size} holdings") + persist_holdings + + if strategy == :forward + purge_stale_holdings + end + + @holdings + end + + private + attr_reader :account, :strategy + + def calculate_holdings + @holdings = calculator.calculate + end + + def persist_holdings + current_time = Time.now + + account.holdings.upsert_all( + @holdings.map { |h| h.attributes + .slice("date", "currency", "qty", "price", "amount", "security_id") + .merge("account_id" => account.id, "updated_at" => current_time) }, + unique_by: %i[account_id security_id date currency] + ) + end + + def purge_stale_holdings + portfolio_security_ids = account.entries.account_trades.map { |entry| entry.entryable.security_id }.uniq + + # If there are no securities in the portfolio, delete all holdings + if portfolio_security_ids.empty? + Rails.logger.info("Clearing all holdings (no securities)") + account.holdings.delete_all + else + deleted_count = account.holdings.delete_by("date < ? OR security_id NOT IN (?)", account.start_date, portfolio_security_ids) + Rails.logger.info("Purged #{deleted_count} stale holdings") if deleted_count > 0 + end + end + + def calculator + if strategy == :reverse + Account::Holding::ReverseCalculator.new(account) + else + Account::Holding::ForwardCalculator.new(account) + end + end +end diff --git a/app/models/account/holding_calculator.rb b/app/models/account/holding_calculator.rb deleted file mode 100644 index edb55acf..00000000 --- a/app/models/account/holding_calculator.rb +++ /dev/null @@ -1,188 +0,0 @@ -class Account::HoldingCalculator - def initialize(account) - @account = account - @securities_cache = {} - end - - def calculate(reverse: false) - Rails.logger.tagged("Account::HoldingCalculator") do - preload_securities - - Rails.logger.info("Calculating holdings with strategy: #{reverse ? "reverse sync" : "forward sync"}") - calculated_holdings = reverse ? reverse_holdings : forward_holdings - - gapfill_holdings(calculated_holdings) - end - end - - private - attr_reader :account, :securities_cache - - def reverse_holdings - current_holding_quantities = load_current_holding_quantities - prior_holding_quantities = {} - - holdings = [] - - Date.current.downto(portfolio_start_date).map do |date| - today_trades = trades.select { |t| t.date == date } - prior_holding_quantities = calculate_portfolio(current_holding_quantities, today_trades) - holdings += generate_holding_records(current_holding_quantities, date) - current_holding_quantities = prior_holding_quantities - end - - holdings - end - - def forward_holdings - prior_holding_quantities = load_empty_holding_quantities - current_holding_quantities = {} - - holdings = [] - - portfolio_start_date.upto(Date.current).map do |date| - today_trades = trades.select { |t| t.date == date } - current_holding_quantities = calculate_portfolio(prior_holding_quantities, today_trades, inverse: true) - holdings += generate_holding_records(current_holding_quantities, date) - prior_holding_quantities = current_holding_quantities - end - - holdings - end - - def generate_holding_records(portfolio, date) - Rails.logger.info "[HoldingCalculator] Generating holdings for #{portfolio.size} securities on #{date}" - - portfolio.map do |security_id, qty| - security = securities_cache[security_id] - - if security.blank? - Rails.logger.error "[HoldingCalculator] Security #{security_id} not found in cache for account #{account.id}" - next - end - - price = security.dig(:prices)&.find { |p| p.date == date } - - if price.blank? - Rails.logger.info "[HoldingCalculator] No price found for security #{security_id} on #{date}" - next - end - - converted_price = Money.new(price.price, price.currency).exchange_to(account.currency, fallback_rate: 1).amount - - account.holdings.build( - security: security.dig(:security), - date: date, - qty: qty, - price: converted_price, - currency: account.currency, - amount: qty * converted_price - ) - end.compact - end - - def gapfill_holdings(holdings) - filled_holdings = [] - - holdings.group_by { |h| h.security_id }.each do |security_id, security_holdings| - next if security_holdings.empty? - - sorted = security_holdings.sort_by(&:date) - previous_holding = sorted.first - - sorted.first.date.upto(Date.current) do |date| - holding = security_holdings.find { |h| h.date == date } - - if holding - filled_holdings << holding - previous_holding = holding - else - # Create a new holding based on the previous day's data - filled_holdings << account.holdings.build( - security: previous_holding.security, - date: date, - qty: previous_holding.qty, - price: previous_holding.price, - currency: previous_holding.currency, - amount: previous_holding.amount - ) - end - end - end - - filled_holdings - end - - def trades - @trades ||= account.entries.includes(entryable: :security).account_trades.chronological.to_a - end - - def portfolio_start_date - trades.first ? trades.first.date - 1.day : Date.current - end - - def preload_securities - # Get securities from trades and current holdings - securities = trades.map(&:entryable).map(&:security).uniq - securities += account.holdings.where(date: Date.current).map(&:security) - securities.uniq! - - Rails.logger.info "[HoldingCalculator] Preloading #{securities.size} securities for account #{account.id}" - - securities.each do |security| - begin - Rails.logger.info "[HoldingCalculator] Loading security: ID=#{security.id} Ticker=#{security.ticker}" - - prices = Security::Price.find_prices( - security: security, - start_date: portfolio_start_date, - end_date: Date.current - ) - - Rails.logger.info "[HoldingCalculator] Found #{prices.size} prices for security #{security.id}" - - @securities_cache[security.id] = { - security: security, - prices: prices - } - rescue => e - Rails.logger.error "[HoldingCalculator] Error processing security #{security.id}: #{e.message}" - Rails.logger.error "[HoldingCalculator] Security details: #{security.attributes}" - Rails.logger.error e.backtrace.join("\n") - next # Skip this security and continue with others - end - end - end - - def calculate_portfolio(holding_quantities, today_trades, inverse: false) - new_quantities = holding_quantities.dup - - today_trades.each do |trade| - security_id = trade.entryable.security_id - qty_change = inverse ? trade.entryable.qty : -trade.entryable.qty - new_quantities[security_id] = (new_quantities[security_id] || 0) + qty_change - end - - new_quantities - end - - def load_empty_holding_quantities - holding_quantities = {} - - trades.map { |t| t.entryable.security_id }.uniq.each do |security_id| - holding_quantities[security_id] = 0 - end - - holding_quantities - end - - def load_current_holding_quantities - holding_quantities = load_empty_holding_quantities - - account.holdings.where(date: Date.current, currency: account.currency).map do |holding| - holding_quantities[holding.security_id] = holding.qty - end - - holding_quantities - end -end diff --git a/app/models/account/linkable.rb b/app/models/account/linkable.rb new file mode 100644 index 00000000..ee0871bd --- /dev/null +++ b/app/models/account/linkable.rb @@ -0,0 +1,18 @@ +module Account::Linkable + extend ActiveSupport::Concern + + included do + belongs_to :plaid_account, optional: true + end + + # A "linked" account gets transaction and balance data from a third party like Plaid + def linked? + plaid_account_id.present? + end + + # An "offline" or "unlinked" account is one where the user tracks values and + # adds transactions manually, without the help of a data provider + def unlinked? + !linked? + end +end diff --git a/app/models/account/syncer.rb b/app/models/account/syncer.rb deleted file mode 100644 index d664b8f1..00000000 --- a/app/models/account/syncer.rb +++ /dev/null @@ -1,162 +0,0 @@ -class Account::Syncer - def initialize(account, start_date: nil) - @account = account - @start_date = start_date - end - - def run - Rails.logger.tagged("Account::Syncer") do - Rails.logger.info("Finding potential transfers to auto-match") - account.family.auto_match_transfers! - - holdings = sync_holdings - Rails.logger.info("Calculated #{holdings.size} holdings") - - balances = sync_balances(holdings) - Rails.logger.info("Calculated #{balances.size} balances") - - account.reload - - unless plaid_sync? - update_account_info(balances, holdings) - end - - unless account.currency == account.family.currency - Rails.logger.info("Converting #{balances.size} balances and #{holdings.size} holdings from #{account.currency} to #{account.family.currency}") - convert_records_to_family_currency(balances, holdings) - end - - # Enrich if user opted in or if we're syncing transactions from a Plaid account on the hosted app - if account.family.data_enrichment_enabled? || (plaid_sync? && Rails.application.config.app_mode.hosted?) - Rails.logger.info("Enriching transaction data for account #{account.name}") - account.enrich_data - else - Rails.logger.info("Data enrichment disabled for account #{account.name}") - end - end - end - - private - attr_reader :account, :start_date - - def account_start_date - @account_start_date ||= (account.entries.chronological.first&.date || Date.current) - 1.day - end - - def update_account_info(balances, holdings) - new_balance = balances.sort_by(&:date).last.balance - new_holdings_value = holdings.select { |h| h.date == Date.current }.sum(&:amount) - new_cash_balance = new_balance - new_holdings_value - - account.update!( - balance: new_balance, - cash_balance: new_cash_balance - ) - end - - def sync_holdings - calculator = Account::HoldingCalculator.new(account) - calculated_holdings = calculator.calculate(reverse: plaid_sync?) - - Account.transaction do - load_holdings(calculated_holdings) - purge_outdated_holdings unless plaid_sync? - end - - calculated_holdings - end - - def sync_balances(holdings) - calculator = Account::BalanceCalculator.new(account, holdings: holdings) - calculated_balances = calculator.calculate(reverse: plaid_sync?, start_date: start_date) - - Account.transaction do - load_balances(calculated_balances) - purge_outdated_balances - end - - calculated_balances - end - - def convert_records_to_family_currency(balances, holdings) - from_currency = account.currency - to_currency = account.family.currency - - exchange_rates = ExchangeRate.find_rates( - from: from_currency, - to: to_currency, - start_date: balances.min_by(&:date).date - ) - - converted_balances = balances.map do |balance| - exchange_rate = exchange_rates.find { |er| er.date == balance.date } - - next unless exchange_rate.present? - - account.balances.build( - date: balance.date, - balance: exchange_rate.rate * balance.balance, - currency: to_currency - ) - end.compact - - converted_holdings = holdings.map do |holding| - exchange_rate = exchange_rates.find { |er| er.date == holding.date } - - next unless exchange_rate.present? - - account.holdings.build( - security: holding.security, - date: holding.date, - qty: holding.qty, - price: exchange_rate.rate * holding.price, - amount: exchange_rate.rate * holding.amount, - currency: to_currency - ) - end.compact - - Account.transaction do - load_balances(converted_balances) - load_holdings(converted_holdings) - end - end - - def load_balances(balances = []) - current_time = Time.now - account.balances.upsert_all( - balances.map { |b| b.attributes - .slice("date", "balance", "cash_balance", "currency") - .merge("updated_at" => current_time) }, - unique_by: %i[account_id date currency] - ) - end - - def load_holdings(holdings = []) - current_time = Time.now - account.holdings.upsert_all( - holdings.map { |h| h.attributes - .slice("date", "currency", "qty", "price", "amount", "security_id") - .merge("updated_at" => current_time) }, - unique_by: %i[account_id security_id date currency] - ) - end - - def purge_outdated_balances - account.balances.delete_by("date < ?", account_start_date) - end - - def plaid_sync? - account.plaid_account_id.present? - end - - def purge_outdated_holdings - portfolio_security_ids = account.entries.account_trades.map { |entry| entry.entryable.security_id }.uniq - - # If there are no securities in the portfolio, delete all holdings - if portfolio_security_ids.empty? - account.holdings.delete_all - else - account.holdings.delete_by("date < ? OR security_id NOT IN (?)", account_start_date, portfolio_security_ids) - end - end -end diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index c76af8aa..9ffdadf1 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -45,6 +45,11 @@ class PlaidItem < ApplicationRecord plaid_data = fetch_and_load_plaid_data update!(status: :good) if requires_update? + # Schedule account syncs + accounts.each do |account| + account.sync_later(start_date: start_date) + end + Rails.logger.info("Plaid data fetched and loaded") plaid_data rescue Plaid::ApiError => e diff --git a/app/views/accounts/chart.html.erb b/app/views/accounts/chart.html.erb index 22e2528e..6be29472 100644 --- a/app/views/accounts/chart.html.erb +++ b/app/views/accounts/chart.html.erb @@ -1,4 +1,4 @@ -<% series = @account.balance_series(period: @period) %> +<% series = @account.balance_series(period: @period, view: @chart_view) %> <% trend = series.trend %> <%= turbo_frame_tag dom_id(@account, :chart_details) do %> diff --git a/app/views/accounts/show/_chart.html.erb b/app/views/accounts/show/_chart.html.erb index c78a3676..e6dadae8 100644 --- a/app/views/accounts/show/_chart.html.erb +++ b/app/views/accounts/show/_chart.html.erb @@ -1,6 +1,6 @@ -<%# locals: (account:, title: nil, tooltip: nil, **args) %> +<%# locals: (account:, title: nil, tooltip: nil, chart_view: nil, **args) %> -<% period = @period || Period.last_30_days %> +<% period = @period || Period.last_30_days %> <% default_value_title = account.asset? ? t(".balance") : t(".owed") %>
      @@ -15,11 +15,22 @@
      <%= form_with url: request.path, method: :get, data: { controller: "auto-submit-form" } do |form| %> - <%= period_select form: form, selected: period %> +
      + <% if chart_view.present? %> + <%= form.select :chart_view, + [["Total value", "balance"], ["Holdings", "holdings_balance"], ["Cash", "cash_balance"]], + { selected: chart_view }, + class: "border border-secondary rounded-lg text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0", + data: { "auto-submit-form-target": "auto" } + %> + <% end %> + + <%= period_select form: form, selected: period %> +
      <% end %>
      - <%= turbo_frame_tag dom_id(account, :chart_details), src: chart_account_path(account, period: period.key) do %> + <%= turbo_frame_tag dom_id(account, :chart_details), src: chart_account_path(account, period: period.key, chart_view: chart_view) do %> <%= render "accounts/chart_loader" %> <% end %>
      diff --git a/app/views/investments/show.html.erb b/app/views/investments/show.html.erb index a1e34e49..7bd7da3b 100644 --- a/app/views/investments/show.html.erb +++ b/app/views/investments/show.html.erb @@ -7,6 +7,7 @@ <%= render "accounts/show/chart", account: @account, title: t(".chart_title"), + chart_view: @chart_view, tooltip: render( "investments/value_tooltip", balance: @account.balance_money, diff --git a/test/models/account/balance/forward_calculator_test.rb b/test/models/account/balance/forward_calculator_test.rb new file mode 100644 index 00000000..cb96572f --- /dev/null +++ b/test/models/account/balance/forward_calculator_test.rb @@ -0,0 +1,74 @@ +require "test_helper" + +class Account::Balance::ForwardCalculatorTest < ActiveSupport::TestCase + include Account::EntriesTestHelper + + setup do + @account = families(:empty).accounts.create!( + name: "Test", + balance: 20000, + cash_balance: 20000, + currency: "USD", + accountable: Investment.new + ) + end + + # When syncing forwards, we don't care about the account balance. We generate everything based on entries, starting from 0. + test "no entries sync" do + assert_equal 0, @account.balances.count + + expected = [ 0, 0 ] + calculated = Account::Balance::ForwardCalculator.new(@account).calculate + + assert_equal expected, calculated.map(&:balance) + end + + test "valuations sync" do + create_valuation(account: @account, date: 4.days.ago.to_date, amount: 17000) + create_valuation(account: @account, date: 2.days.ago.to_date, amount: 19000) + + expected = [ 0, 17000, 17000, 19000, 19000, 19000 ] + calculated = Account::Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance) + + assert_equal expected, calculated + end + + test "transactions sync" do + create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500) # income + create_transaction(account: @account, date: 2.days.ago.to_date, amount: 100) # expense + + expected = [ 0, 500, 500, 400, 400, 400 ] + calculated = Account::Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance) + + assert_equal expected, calculated + end + + test "multi-entry sync" do + create_transaction(account: @account, date: 8.days.ago.to_date, amount: -5000) + create_valuation(account: @account, date: 6.days.ago.to_date, amount: 17000) + create_transaction(account: @account, date: 6.days.ago.to_date, amount: -500) + create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500) + create_valuation(account: @account, date: 3.days.ago.to_date, amount: 17000) + create_transaction(account: @account, date: 1.day.ago.to_date, amount: 100) + + expected = [ 0, 5000, 5000, 17000, 17000, 17500, 17000, 17000, 16900, 16900 ] + calculated = Account::Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance) + + assert_equal expected, calculated + end + + test "multi-currency sync" do + ExchangeRate.create! date: 1.day.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: 1.2 + + create_transaction(account: @account, date: 3.days.ago.to_date, amount: -100, currency: "USD") + create_transaction(account: @account, date: 2.days.ago.to_date, amount: -300, currency: "USD") + + # Transaction in different currency than the account's main currency + create_transaction(account: @account, date: 1.day.ago.to_date, amount: -500, currency: "EUR") # €500 * 1.2 = $600 + + expected = [ 0, 100, 400, 1000, 1000 ] + calculated = Account::Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance) + + assert_equal expected, calculated + end +end diff --git a/test/models/account/balance/reverse_calculator_test.rb b/test/models/account/balance/reverse_calculator_test.rb new file mode 100644 index 00000000..e81c9eb5 --- /dev/null +++ b/test/models/account/balance/reverse_calculator_test.rb @@ -0,0 +1,59 @@ +require "test_helper" + +class Account::Balance::ReverseCalculatorTest < ActiveSupport::TestCase + include Account::EntriesTestHelper + + setup do + @account = families(:empty).accounts.create!( + name: "Test", + balance: 20000, + cash_balance: 20000, + currency: "USD", + accountable: Investment.new + ) + end + + # When syncing backwards, we start with the account balance and generate everything from there. + test "no entries sync" do + assert_equal 0, @account.balances.count + + expected = [ @account.balance, @account.balance ] + calculated = Account::Balance::ReverseCalculator.new(@account).calculate + + assert_equal expected, calculated.map(&:balance) + end + + test "valuations sync" do + create_valuation(account: @account, date: 4.days.ago.to_date, amount: 17000) + create_valuation(account: @account, date: 2.days.ago.to_date, amount: 19000) + + expected = [ 17000, 17000, 19000, 19000, 20000, 20000 ] + calculated = Account::Balance::ReverseCalculator.new(@account).calculate.sort_by(&:date).map(&:balance) + + assert_equal expected, calculated + end + + test "transactions sync" do + create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500) # income + create_transaction(account: @account, date: 2.days.ago.to_date, amount: 100) # expense + + expected = [ 19600, 20100, 20100, 20000, 20000, 20000 ] + calculated = Account::Balance::ReverseCalculator.new(@account).calculate.sort_by(&:date).map(&:balance) + + assert_equal expected, calculated + end + + test "multi-entry sync" do + create_transaction(account: @account, date: 8.days.ago.to_date, amount: -5000) + create_valuation(account: @account, date: 6.days.ago.to_date, amount: 17000) + create_transaction(account: @account, date: 6.days.ago.to_date, amount: -500) + create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500) + create_valuation(account: @account, date: 3.days.ago.to_date, amount: 17000) + create_transaction(account: @account, date: 1.day.ago.to_date, amount: 100) + + expected = [ 12000, 17000, 17000, 17000, 16500, 17000, 17000, 20100, 20000, 20000 ] + calculated = Account::Balance::ReverseCalculator.new(@account).calculate.sort_by(&:date).map(&:balance) + + assert_equal expected, calculated + end +end diff --git a/test/models/account/balance/syncer_test.rb b/test/models/account/balance/syncer_test.rb new file mode 100644 index 00000000..72dfc568 --- /dev/null +++ b/test/models/account/balance/syncer_test.rb @@ -0,0 +1,51 @@ +require "test_helper" + +class Account::Balance::SyncerTest < ActiveSupport::TestCase + include Account::EntriesTestHelper + + setup do + @account = families(:empty).accounts.create!( + name: "Test", + balance: 20000, + cash_balance: 20000, + currency: "USD", + accountable: Investment.new + ) + end + + test "syncs balances" do + Account::Holding::Syncer.any_instance.expects(:sync_holdings).returns([]).once + + @account.expects(:start_date).returns(2.days.ago.to_date) + + Account::Balance::ForwardCalculator.any_instance.expects(:calculate).returns( + [ + Account::Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"), + Account::Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD") + ] + ) + + assert_difference "@account.balances.count", 2 do + Account::Balance::Syncer.new(@account, strategy: :forward).sync_balances + end + end + + test "purges stale balances and holdings" do + # Balance before start date is stale + @account.expects(:start_date).returns(2.days.ago.to_date).twice + stale_balance = Account::Balance.new(date: 3.days.ago.to_date, balance: 10000, cash_balance: 10000, currency: "USD") + + Account::Balance::ForwardCalculator.any_instance.expects(:calculate).returns( + [ + stale_balance, + Account::Balance.new(date: 2.days.ago.to_date, balance: 10000, cash_balance: 10000, currency: "USD"), + Account::Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"), + Account::Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD") + ] + ) + + assert_difference "@account.balances.count", 3 do + Account::Balance::Syncer.new(@account, strategy: :forward).sync_balances + end + end +end diff --git a/test/models/account/balance_calculator_test.rb b/test/models/account/balance_calculator_test.rb deleted file mode 100644 index 2f3879a8..00000000 --- a/test/models/account/balance_calculator_test.rb +++ /dev/null @@ -1,156 +0,0 @@ -require "test_helper" - -class Account::BalanceCalculatorTest < ActiveSupport::TestCase - include Account::EntriesTestHelper - - setup do - @account = families(:empty).accounts.create!( - name: "Test", - balance: 20000, - cash_balance: 20000, - currency: "USD", - accountable: Investment.new - ) - end - - # When syncing backwards, we start with the account balance and generate everything from there. - test "reverse no entries sync" do - assert_equal 0, @account.balances.count - - expected = [ @account.balance ] - calculated = Account::BalanceCalculator.new(@account).calculate(reverse: true) - - assert_equal expected, calculated.map(&:balance) - end - - # When syncing forwards, we don't care about the account balance. We generate everything based on entries, starting from 0. - test "forward no entries sync" do - assert_equal 0, @account.balances.count - - expected = [ 0 ] - calculated = Account::BalanceCalculator.new(@account).calculate - - assert_equal expected, calculated.map(&:balance) - end - - test "forward valuations sync" do - create_valuation(account: @account, date: 4.days.ago.to_date, amount: 17000) - create_valuation(account: @account, date: 2.days.ago.to_date, amount: 19000) - - expected = [ 0, 17000, 17000, 19000, 19000, 19000 ] - calculated = Account::BalanceCalculator.new(@account).calculate.sort_by(&:date).map(&:balance) - - assert_equal expected, calculated - end - - test "reverse valuations sync" do - create_valuation(account: @account, date: 4.days.ago.to_date, amount: 17000) - create_valuation(account: @account, date: 2.days.ago.to_date, amount: 19000) - - expected = [ 17000, 17000, 19000, 19000, 20000, 20000 ] - calculated = Account::BalanceCalculator.new(@account).calculate(reverse: true).sort_by(&:date).map(&:balance) - - assert_equal expected, calculated - end - - test "forward transactions sync" do - create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500) # income - create_transaction(account: @account, date: 2.days.ago.to_date, amount: 100) # expense - - expected = [ 0, 500, 500, 400, 400, 400 ] - calculated = Account::BalanceCalculator.new(@account).calculate.sort_by(&:date).map(&:balance) - - assert_equal expected, calculated - end - - test "reverse transactions sync" do - create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500) # income - create_transaction(account: @account, date: 2.days.ago.to_date, amount: 100) # expense - - expected = [ 19600, 20100, 20100, 20000, 20000, 20000 ] - calculated = Account::BalanceCalculator.new(@account).calculate(reverse: true).sort_by(&:date).map(&:balance) - - assert_equal expected, calculated - end - - test "reverse multi-entry sync" do - create_transaction(account: @account, date: 8.days.ago.to_date, amount: -5000) - create_valuation(account: @account, date: 6.days.ago.to_date, amount: 17000) - create_transaction(account: @account, date: 6.days.ago.to_date, amount: -500) - create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500) - create_valuation(account: @account, date: 3.days.ago.to_date, amount: 17000) - create_transaction(account: @account, date: 1.day.ago.to_date, amount: 100) - - expected = [ 12000, 17000, 17000, 17000, 16500, 17000, 17000, 20100, 20000, 20000 ] - calculated = Account::BalanceCalculator.new(@account).calculate(reverse: true) .sort_by(&:date).map(&:balance) - - assert_equal expected, calculated - end - - test "forward multi-entry sync" do - create_transaction(account: @account, date: 8.days.ago.to_date, amount: -5000) - create_valuation(account: @account, date: 6.days.ago.to_date, amount: 17000) - create_transaction(account: @account, date: 6.days.ago.to_date, amount: -500) - create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500) - create_valuation(account: @account, date: 3.days.ago.to_date, amount: 17000) - create_transaction(account: @account, date: 1.day.ago.to_date, amount: 100) - - expected = [ 0, 5000, 5000, 17000, 17000, 17500, 17000, 17000, 16900, 16900 ] - calculated = Account::BalanceCalculator.new(@account).calculate.sort_by(&:date).map(&:balance) - - assert_equal expected, calculated - end - - test "investment balance sync" do - @account.update!(cash_balance: 18000) - - # Transactions represent deposits / withdrawals from the brokerage account - # Ex: We deposit $20,000 into the brokerage account - create_transaction(account: @account, date: 2.days.ago.to_date, amount: -20000) - - # Trades either consume cash (buy) or generate cash (sell). They do NOT change total balance, but do affect composition of cash/holdings. - # Ex: We buy 20 shares of MSFT at $100 for a total of $2000 - create_trade(securities(:msft), account: @account, date: 1.day.ago.to_date, qty: 20, price: 100) - - holdings = [ - Account::Holding.new(date: Date.current, security: securities(:msft), amount: 2000, currency: "USD"), - Account::Holding.new(date: 1.day.ago.to_date, security: securities(:msft), amount: 2000, currency: "USD"), - Account::Holding.new(date: 2.days.ago.to_date, security: securities(:msft), amount: 0, currency: "USD") - ] - - expected = [ 0, 20000, 20000, 20000 ] - calculated_backwards = Account::BalanceCalculator.new(@account, holdings: holdings).calculate(reverse: true).sort_by(&:date).map(&:balance) - calculated_forwards = Account::BalanceCalculator.new(@account, holdings: holdings).calculate.sort_by(&:date).map(&:balance) - - assert_equal calculated_forwards, calculated_backwards - assert_equal expected, calculated_forwards - end - - test "multi-currency sync" do - ExchangeRate.create! date: 1.day.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: 1.2 - - create_transaction(account: @account, date: 3.days.ago.to_date, amount: -100, currency: "USD") - create_transaction(account: @account, date: 2.days.ago.to_date, amount: -300, currency: "USD") - - # Transaction in different currency than the account's main currency - create_transaction(account: @account, date: 1.day.ago.to_date, amount: -500, currency: "EUR") # €500 * 1.2 = $600 - - expected = [ 0, 100, 400, 1000, 1000 ] - calculated = Account::BalanceCalculator.new(@account).calculate.sort_by(&:date).map(&:balance) - - assert_equal expected, calculated - end - - private - def create_holding(date:, security:, amount:) - Account::Holding.create!( - account: @account, - security: security, - date: date, - qty: 0, # not used - price: 0, # not used - amount: amount, - currency: @account.currency - ) - end -end diff --git a/test/models/account/holding/forward_calculator_test.rb b/test/models/account/holding/forward_calculator_test.rb new file mode 100644 index 00000000..70fd0e2a --- /dev/null +++ b/test/models/account/holding/forward_calculator_test.rb @@ -0,0 +1,146 @@ +require "test_helper" + +class Account::Holding::ForwardCalculatorTest < ActiveSupport::TestCase + include Account::EntriesTestHelper + + setup do + @account = families(:empty).accounts.create!( + name: "Test", + balance: 20000, + cash_balance: 20000, + currency: "USD", + accountable: Investment.new + ) + end + + test "no holdings" do + calculated = Account::Holding::ForwardCalculator.new(@account).calculate + assert_equal [], calculated + end + + test "forward portfolio calculation" do + load_prices + + # Build up to 10 shares of VOO (current value $5000) + create_trade(@voo, qty: 20, date: 3.days.ago.to_date, price: 470, account: @account) + create_trade(@voo, qty: -15, date: 2.days.ago.to_date, price: 480, account: @account) + create_trade(@voo, qty: 5, date: 1.day.ago.to_date, price: 490, account: @account) + + # Amazon won't exist in current holdings because qty is zero, but should show up in historical portfolio + create_trade(@amzn, qty: 1, date: 2.days.ago.to_date, price: 200, account: @account) + create_trade(@amzn, qty: -1, date: 1.day.ago.to_date, price: 200, account: @account) + + # Build up to 100 shares of WMT (current value $10000) + create_trade(@wmt, qty: 100, date: 1.day.ago.to_date, price: 100, account: @account) + + expected = [ + # 4 days ago + Account::Holding.new(security: @voo, date: 4.days.ago.to_date, qty: 0, price: 460, amount: 0), + Account::Holding.new(security: @wmt, date: 4.days.ago.to_date, qty: 0, price: 100, amount: 0), + Account::Holding.new(security: @amzn, date: 4.days.ago.to_date, qty: 0, price: 200, amount: 0), + + # 3 days ago + Account::Holding.new(security: @voo, date: 3.days.ago.to_date, qty: 20, price: 470, amount: 9400), + Account::Holding.new(security: @wmt, date: 3.days.ago.to_date, qty: 0, price: 100, amount: 0), + Account::Holding.new(security: @amzn, date: 3.days.ago.to_date, qty: 0, price: 200, amount: 0), + + # 2 days ago + Account::Holding.new(security: @voo, date: 2.days.ago.to_date, qty: 5, price: 480, amount: 2400), + Account::Holding.new(security: @wmt, date: 2.days.ago.to_date, qty: 0, price: 100, amount: 0), + Account::Holding.new(security: @amzn, date: 2.days.ago.to_date, qty: 1, price: 200, amount: 200), + + # 1 day ago + Account::Holding.new(security: @voo, date: 1.day.ago.to_date, qty: 10, price: 490, amount: 4900), + Account::Holding.new(security: @wmt, date: 1.day.ago.to_date, qty: 100, price: 100, amount: 10000), + Account::Holding.new(security: @amzn, date: 1.day.ago.to_date, qty: 0, price: 200, amount: 0), + + # Today + Account::Holding.new(security: @voo, date: Date.current, qty: 10, price: 500, amount: 5000), + Account::Holding.new(security: @wmt, date: Date.current, qty: 100, price: 100, amount: 10000), + Account::Holding.new(security: @amzn, date: Date.current, qty: 0, price: 200, amount: 0) + ] + + calculated = Account::Holding::ForwardCalculator.new(@account).calculate + + assert_equal expected.length, calculated.length + assert_holdings(expected, calculated) + end + + # Carries the previous record forward if no holding exists for a date + # to ensure that net worth historical rollups have a value for every date + test "uses locf to fill missing holdings" do + load_prices + + create_trade(@wmt, qty: 100, date: 1.day.ago.to_date, price: 100, account: @account) + + expected = [ + Account::Holding.new(security: @wmt, date: 2.days.ago.to_date, qty: 0, price: 100, amount: 0), + Account::Holding.new(security: @wmt, date: 1.day.ago.to_date, qty: 100, price: 100, amount: 10000), + Account::Holding.new(security: @wmt, date: Date.current, qty: 100, price: 100, amount: 10000) + ] + + # Price missing today, so we should carry forward the holding from 1 day ago + Security.stubs(:find).returns(@wmt) + Security::Price.stubs(:find_price).with(security: @wmt, date: 2.days.ago.to_date).returns(Security::Price.new(price: 100)) + Security::Price.stubs(:find_price).with(security: @wmt, date: 1.day.ago.to_date).returns(Security::Price.new(price: 100)) + Security::Price.stubs(:find_price).with(security: @wmt, date: Date.current).returns(nil) + + calculated = Account::Holding::ForwardCalculator.new(@account).calculate + + assert_equal expected.length, calculated.length + assert_holdings(expected, calculated) + end + + test "offline tickers sync holdings based on most recent trade price" do + offline_security = Security.create!(ticker: "OFFLINE", name: "Offline Ticker") + + create_trade(offline_security, qty: 1, date: 3.days.ago.to_date, price: 90, account: @account) + create_trade(offline_security, qty: 1, date: 1.day.ago.to_date, price: 100, account: @account) + + expected = [ + Account::Holding.new(security: offline_security, date: 3.days.ago.to_date, qty: 1, price: 90, amount: 90), + Account::Holding.new(security: offline_security, date: 2.days.ago.to_date, qty: 1, price: 90, amount: 90), + Account::Holding.new(security: offline_security, date: 1.day.ago.to_date, qty: 2, price: 100, amount: 200), + Account::Holding.new(security: offline_security, date: Date.current, qty: 2, price: 100, amount: 200) + ] + + calculated = Account::Holding::ForwardCalculator.new(@account).calculate + + assert_equal expected.length, calculated.length + assert_holdings(expected, calculated) + end + + private + def assert_holdings(expected, calculated) + expected.each do |expected_entry| + calculated_entry = calculated.find { |c| c.security == expected_entry.security && c.date == expected_entry.date } + + assert_equal expected_entry.qty, calculated_entry.qty, "Qty mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}" + assert_equal expected_entry.price, calculated_entry.price, "Price mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}" + assert_equal expected_entry.amount, calculated_entry.amount, "Amount mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}" + end + end + + def load_prices + @voo = Security.create!(ticker: "VOO", name: "Vanguard S&P 500 ETF") + Security::Price.create!(security: @voo, date: 4.days.ago.to_date, price: 460) + Security::Price.create!(security: @voo, date: 3.days.ago.to_date, price: 470) + Security::Price.create!(security: @voo, date: 2.days.ago.to_date, price: 480) + Security::Price.create!(security: @voo, date: 1.day.ago.to_date, price: 490) + Security::Price.create!(security: @voo, date: Date.current, price: 500) + + @wmt = Security.create!(ticker: "WMT", name: "Walmart Inc.") + Security::Price.create!(security: @wmt, date: 4.days.ago.to_date, price: 100) + Security::Price.create!(security: @wmt, date: 3.days.ago.to_date, price: 100) + Security::Price.create!(security: @wmt, date: 2.days.ago.to_date, price: 100) + Security::Price.create!(security: @wmt, date: 1.day.ago.to_date, price: 100) + Security::Price.create!(security: @wmt, date: Date.current, price: 100) + + @amzn = Security.create!(ticker: "AMZN", name: "Amazon.com Inc.") + Security::Price.create!(security: @amzn, date: 4.days.ago.to_date, price: 200) + Security::Price.create!(security: @amzn, date: 3.days.ago.to_date, price: 200) + Security::Price.create!(security: @amzn, date: 2.days.ago.to_date, price: 200) + Security::Price.create!(security: @amzn, date: 1.day.ago.to_date, price: 200) + Security::Price.create!(security: @amzn, date: Date.current, price: 200) + end +end diff --git a/test/models/account/holding/portfolio_cache_test.rb b/test/models/account/holding/portfolio_cache_test.rb new file mode 100644 index 00000000..b973fa00 --- /dev/null +++ b/test/models/account/holding/portfolio_cache_test.rb @@ -0,0 +1,63 @@ +require "test_helper" + +class Account::Holding::PortfolioCacheTest < ActiveSupport::TestCase + include Account::EntriesTestHelper + + setup do + # Prices, highest to lowest priority + @db_price = 210 + @provider_price = 220 + @trade_price = 200 + @holding_price = 250 + + @account = families(:empty).accounts.create!(name: "Test Brokerage", balance: 10000, currency: "USD", accountable: Investment.new) + @test_security = Security.create!(name: "Test Security", ticker: "TEST") + + @trade = create_trade(@test_security, account: @account, qty: 1, date: Date.current, price: @trade_price) + @holding = Account::Holding.create!(security: @test_security, account: @account, date: Date.current, qty: 1, price: @holding_price, amount: @holding_price, currency: "USD") + Security::Price.create!(security: @test_security, date: Date.current, price: @db_price) + end + + test "gets price from DB if available" do + cache = Account::Holding::PortfolioCache.new(@account) + + assert_equal @db_price, cache.get_price(@test_security.id, Date.current).price + end + + test "if no price in DB, try fetching from provider" do + Security::Price.destroy_all + Security::Price.expects(:find_prices) + .with(security: @test_security, start_date: @account.start_date, end_date: Date.current) + .returns([ + Security::Price.new(security: @test_security, date: Date.current, price: @provider_price, currency: "USD") + ]) + + cache = Account::Holding::PortfolioCache.new(@account) + + assert_equal @provider_price, cache.get_price(@test_security.id, Date.current).price + end + + test "if no price from db or provider, try getting the price from trades" do + Security::Price.destroy_all # No DB prices + Security::Price.expects(:find_prices) + .with(security: @test_security, start_date: @account.start_date, end_date: Date.current) + .returns([]) # No provider prices + + cache = Account::Holding::PortfolioCache.new(@account) + + assert_equal @trade_price, cache.get_price(@test_security.id, Date.current).price + end + + test "if no price from db, provider, or trades, search holdings" do + Security::Price.destroy_all # No DB prices + Security::Price.expects(:find_prices) + .with(security: @test_security, start_date: @account.start_date, end_date: Date.current) + .returns([]) # No provider prices + + @account.entries.destroy_all # No prices from trades + + cache = Account::Holding::PortfolioCache.new(@account, use_holdings: true) + + assert_equal @holding_price, cache.get_price(@test_security.id, Date.current).price + end +end diff --git a/test/models/account/holding_calculator_test.rb b/test/models/account/holding/reverse_calculator_test.rb similarity index 61% rename from test/models/account/holding_calculator_test.rb rename to test/models/account/holding/reverse_calculator_test.rb index 154c8afe..6e9535e5 100644 --- a/test/models/account/holding_calculator_test.rb +++ b/test/models/account/holding/reverse_calculator_test.rb @@ -1,6 +1,6 @@ require "test_helper" -class Account::HoldingCalculatorTest < ActiveSupport::TestCase +class Account::Holding::ReverseCalculatorTest < ActiveSupport::TestCase include Account::EntriesTestHelper setup do @@ -14,10 +14,8 @@ class Account::HoldingCalculatorTest < ActiveSupport::TestCase end test "no holdings" do - forward = Account::HoldingCalculator.new(@account).calculate - reverse = Account::HoldingCalculator.new(@account).calculate(reverse: true) - assert_equal forward, reverse - assert_equal [], forward + calculated = Account::Holding::ReverseCalculator.new(@account).calculate + assert_equal [], calculated end # Should be able to handle this case, although we should not be reverse-syncing an account without provided current day holdings @@ -28,7 +26,7 @@ class Account::HoldingCalculatorTest < ActiveSupport::TestCase create_trade(voo, qty: -10, date: Date.current, price: 470, account: @account) - calculated = Account::HoldingCalculator.new(@account).calculate(reverse: true) + calculated = Account::Holding::ReverseCalculator.new(@account).calculate assert_equal 2, calculated.length end @@ -74,7 +72,7 @@ class Account::HoldingCalculatorTest < ActiveSupport::TestCase Account::Holding.new(security: @amzn, date: Date.current, qty: 0, price: 200, amount: 0) ] - calculated = Account::HoldingCalculator.new(@account).calculate(reverse: true) + calculated = Account::Holding::ReverseCalculator.new(@account).calculate assert_equal expected.length, calculated.length @@ -87,80 +85,6 @@ class Account::HoldingCalculatorTest < ActiveSupport::TestCase end end - test "forward portfolio calculation" do - load_prices - - # Build up to 10 shares of VOO (current value $5000) - create_trade(@voo, qty: 20, date: 3.days.ago.to_date, price: 470, account: @account) - create_trade(@voo, qty: -15, date: 2.days.ago.to_date, price: 480, account: @account) - create_trade(@voo, qty: 5, date: 1.day.ago.to_date, price: 490, account: @account) - - # Amazon won't exist in current holdings because qty is zero, but should show up in historical portfolio - create_trade(@amzn, qty: 1, date: 2.days.ago.to_date, price: 200, account: @account) - create_trade(@amzn, qty: -1, date: 1.day.ago.to_date, price: 200, account: @account) - - # Build up to 100 shares of WMT (current value $10000) - create_trade(@wmt, qty: 100, date: 1.day.ago.to_date, price: 100, account: @account) - - expected = [ - # 4 days ago - Account::Holding.new(security: @voo, date: 4.days.ago.to_date, qty: 0, price: 460, amount: 0), - Account::Holding.new(security: @wmt, date: 4.days.ago.to_date, qty: 0, price: 100, amount: 0), - Account::Holding.new(security: @amzn, date: 4.days.ago.to_date, qty: 0, price: 200, amount: 0), - - # 3 days ago - Account::Holding.new(security: @voo, date: 3.days.ago.to_date, qty: 20, price: 470, amount: 9400), - Account::Holding.new(security: @wmt, date: 3.days.ago.to_date, qty: 0, price: 100, amount: 0), - Account::Holding.new(security: @amzn, date: 3.days.ago.to_date, qty: 0, price: 200, amount: 0), - - # 2 days ago - Account::Holding.new(security: @voo, date: 2.days.ago.to_date, qty: 5, price: 480, amount: 2400), - Account::Holding.new(security: @wmt, date: 2.days.ago.to_date, qty: 0, price: 100, amount: 0), - Account::Holding.new(security: @amzn, date: 2.days.ago.to_date, qty: 1, price: 200, amount: 200), - - # 1 day ago - Account::Holding.new(security: @voo, date: 1.day.ago.to_date, qty: 10, price: 490, amount: 4900), - Account::Holding.new(security: @wmt, date: 1.day.ago.to_date, qty: 100, price: 100, amount: 10000), - Account::Holding.new(security: @amzn, date: 1.day.ago.to_date, qty: 0, price: 200, amount: 0), - - # Today - Account::Holding.new(security: @voo, date: Date.current, qty: 10, price: 500, amount: 5000), - Account::Holding.new(security: @wmt, date: Date.current, qty: 100, price: 100, amount: 10000), - Account::Holding.new(security: @amzn, date: Date.current, qty: 0, price: 200, amount: 0) - ] - - calculated = Account::HoldingCalculator.new(@account).calculate - - assert_equal expected.length, calculated.length - assert_holdings(expected, calculated) - end - - # Carries the previous record forward if no holding exists for a date - # to ensure that net worth historical rollups have a value for every date - test "uses locf to fill missing holdings" do - load_prices - - create_trade(@wmt, qty: 100, date: 1.day.ago.to_date, price: 100, account: @account) - - expected = [ - Account::Holding.new(security: @wmt, date: 2.days.ago.to_date, qty: 0, price: 100, amount: 0), - Account::Holding.new(security: @wmt, date: 1.day.ago.to_date, qty: 100, price: 100, amount: 10000), - Account::Holding.new(security: @wmt, date: Date.current, qty: 100, price: 100, amount: 10000) - ] - - # Price missing today, so we should carry forward the holding from 1 day ago - Security.stubs(:find).returns(@wmt) - Security::Price.stubs(:find_price).with(security: @wmt, date: 2.days.ago.to_date).returns(Security::Price.new(price: 100)) - Security::Price.stubs(:find_price).with(security: @wmt, date: 1.day.ago.to_date).returns(Security::Price.new(price: 100)) - Security::Price.stubs(:find_price).with(security: @wmt, date: Date.current).returns(nil) - - calculated = Account::HoldingCalculator.new(@account).calculate - - assert_equal expected.length, calculated.length - - assert_holdings(expected, calculated) - end - private def assert_holdings(expected, calculated) expected.each do |expected_entry| diff --git a/test/models/account/holding/syncer_test.rb b/test/models/account/holding/syncer_test.rb new file mode 100644 index 00000000..43ca5dcb --- /dev/null +++ b/test/models/account/holding/syncer_test.rb @@ -0,0 +1,29 @@ +require "test_helper" + +class Account::Holding::SyncerTest < ActiveSupport::TestCase + include Account::EntriesTestHelper + + setup do + @family = families(:empty) + @account = @family.accounts.create!(name: "Test", balance: 20000, cash_balance: 20000, currency: "USD", accountable: Investment.new) + @aapl = securities(:aapl) + end + + test "syncs holdings" do + create_trade(@aapl, account: @account, qty: 1, price: 200, date: Date.current) + + # Should have yesterday's and today's holdings + assert_difference "@account.holdings.count", 2 do + Account::Holding::Syncer.new(@account, strategy: :forward).sync_holdings + end + end + + test "purges stale holdings for unlinked accounts" do + # Since the account has no entries, there should be no holdings + Account::Holding.create!(account: @account, security: @aapl, qty: 1, price: 100, amount: 100, currency: "USD", date: Date.current) + + assert_difference "Account::Holding.count", -1 do + Account::Holding::Syncer.new(@account, strategy: :forward).sync_holdings + end + end +end diff --git a/test/models/account/syncer_test.rb b/test/models/account/syncer_test.rb deleted file mode 100644 index 5cc85ee9..00000000 --- a/test/models/account/syncer_test.rb +++ /dev/null @@ -1,65 +0,0 @@ -require "test_helper" - -class Account::SyncerTest < ActiveSupport::TestCase - include Account::EntriesTestHelper - - setup do - @account = families(:empty).accounts.create!( - name: "Test", - balance: 20000, - cash_balance: 20000, - currency: "USD", - accountable: Investment.new - ) - end - - test "converts foreign account balances and holdings to family currency" do - @account.family.update! currency: "USD" - @account.update! currency: "EUR" - - @account.entries.create!(date: 1.day.ago.to_date, currency: "EUR", amount: 500, name: "Buy AAPL", entryable: Account::Trade.new(security: securities(:aapl), qty: 10, price: 50, currency: "EUR")) - - ExchangeRate.create!(date: 1.day.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: 1.2) - ExchangeRate.create!(date: Date.current, from_currency: "EUR", to_currency: "USD", rate: 2) - - Account::BalanceCalculator.any_instance.expects(:calculate).returns( - [ - Account::Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "EUR"), - Account::Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "EUR") - ] - ) - - Account::HoldingCalculator.any_instance.expects(:calculate).returns( - [ - Account::Holding.new(security: securities(:aapl), date: 1.day.ago.to_date, qty: 10, price: 50, amount: 500, currency: "EUR"), - Account::Holding.new(security: securities(:aapl), date: Date.current, qty: 10, price: 50, amount: 500, currency: "EUR") - ] - ) - - Account::Syncer.new(@account).run - - assert_equal [ 1000, 1000 ], @account.balances.where(currency: "EUR").chronological.map(&:balance) - assert_equal [ 1200, 2000 ], @account.balances.where(currency: "USD").chronological.map(&:balance) - assert_equal [ 500, 500 ], @account.holdings.where(currency: "EUR").chronological.map(&:amount) - assert_equal [ 600, 1000 ], @account.holdings.where(currency: "USD").chronological.map(&:amount) - end - - test "purges stale balances and holdings" do - # Old, out of range holdings and balances - @account.holdings.create!(security: securities(:aapl), date: 10.years.ago.to_date, currency: "USD", qty: 100, price: 100, amount: 10000) - @account.balances.create!(date: 10.years.ago.to_date, currency: "USD", balance: 10000, cash_balance: 10000) - - assert_equal 1, @account.holdings.count - assert_equal 1, @account.balances.count - - Account::Syncer.new(@account).run - - @account.reload - - assert_equal 0, @account.holdings.count - - # Balance sync always creates 1 balance if no entries present. - assert_equal 1, @account.balances.count - assert_equal 0, @account.balances.first.balance - end -end From 5f8a3c9f508cfbc24f265b9d8f6a6e79dae84ece Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 7 Mar 2025 17:48:26 -0500 Subject: [PATCH 045/380] Search securities with correct exchange mic --- app/models/security/price/provided.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/security/price/provided.rb b/app/models/security/price/provided.rb index aed56702..c429e0a6 100644 --- a/app/models/security/price/provided.rb +++ b/app/models/security/price/provided.rb @@ -15,7 +15,7 @@ module Security::Price::Provided response = provider.fetch_security_prices \ ticker: security.ticker, - mic_code: security.exchange_mic, + mic_code: security.exchange_operating_mic, start_date: date, end_date: date @@ -40,7 +40,7 @@ module Security::Price::Provided response = provider.fetch_security_prices \ ticker: security.ticker, - mic_code: security.exchange_mic, + mic_code: security.exchange_operating_mic, start_date: start_date, end_date: end_date From 86bf47a32ead80b33161d5bc5afaf78ee20f432f Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 7 Mar 2025 18:02:08 -0500 Subject: [PATCH 046/380] Ensure holdings are normalized to account currency --- app/models/account/holding/portfolio_cache.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/account/holding/portfolio_cache.rb b/app/models/account/holding/portfolio_cache.rb index 6a839382..bb6035cf 100644 --- a/app/models/account/holding/portfolio_cache.rb +++ b/app/models/account/holding/portfolio_cache.rb @@ -37,7 +37,7 @@ class Account::Holding::PortfolioCache security_id: security_id, date: price.date, price: converted_amount, - currency: price.currency + currency: account.currency ) end From a3cd5f4f1d589b933d8a400eaf63744b4427da96 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 7 Mar 2025 19:09:54 -0500 Subject: [PATCH 047/380] Format money for trade history in holdings drawer (#1961) * Format money for trade history in holdings drawer * Fix broken tests * Lint fix --- app/views/account/holdings/show.html.erb | 2 +- app/views/accounts/show/_chart.html.erb | 3 +-- lib/money/formatting.rb | 10 +++++++++- test/models/security/price_test.rb | 6 +++--- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/app/views/account/holdings/show.html.erb b/app/views/account/holdings/show.html.erb index b783ac5b..12199512 100644 --- a/app/views/account/holdings/show.html.erb +++ b/app/views/account/holdings/show.html.erb @@ -73,7 +73,7 @@ ".trade_history_entry", qty: trade_entry.account_trade.qty, security: trade_entry.account_trade.security.ticker, - price: format_money(trade_entry.account_trade.price) + price: trade_entry.account_trade.price_money.format ) %>

      diff --git a/app/views/accounts/show/_chart.html.erb b/app/views/accounts/show/_chart.html.erb index e6dadae8..47c91fb0 100644 --- a/app/views/accounts/show/_chart.html.erb +++ b/app/views/accounts/show/_chart.html.erb @@ -21,8 +21,7 @@ [["Total value", "balance"], ["Holdings", "holdings_balance"], ["Cash", "cash_balance"]], { selected: chart_view }, class: "border border-secondary rounded-lg text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0", - data: { "auto-submit-form-target": "auto" } - %> + data: { "auto-submit-form-target": "auto" } %> <% end %> <%= period_select form: form, selected: period %> diff --git a/lib/money/formatting.rb b/lib/money/formatting.rb index 25185b1a..cd160bef 100644 --- a/lib/money/formatting.rb +++ b/lib/money/formatting.rb @@ -13,7 +13,7 @@ module Money::Formatting local_option_overrides = locale_options(locale) { - unit: currency.symbol, + unit: get_symbol, precision: currency.default_precision, delimiter: currency.delimiter, separator: currency.separator, @@ -22,6 +22,14 @@ module Money::Formatting end private + def get_symbol + if currency.symbol == "$" && currency.iso_code != "USD" + [ currency.iso_code.first(2), currency.symbol ].join + else + currency.symbol + end + end + def locale_options(locale) case [ currency.iso_code, locale.to_sym ] when [ "EUR", :nl ], [ "EUR", :pt ] diff --git a/test/models/security/price_test.rb b/test/models/security/price_test.rb index 66b60469..32dd00f3 100644 --- a/test/models/security/price_test.rb +++ b/test/models/security/price_test.rb @@ -33,7 +33,7 @@ class Security::PriceTest < ActiveSupport::TestCase tomorrow = Date.current + 1.day @provider.expects(:fetch_security_prices) - .with(ticker: security.ticker, mic_code: security.exchange_mic, start_date: tomorrow, end_date: tomorrow) + .with(ticker: security.ticker, mic_code: security.exchange_operating_mic, start_date: tomorrow, end_date: tomorrow) .once .returns( OpenStruct.new( @@ -54,7 +54,7 @@ class Security::PriceTest < ActiveSupport::TestCase Security::Price.delete_all # Clear any existing prices @provider.expects(:fetch_security_prices) - .with(ticker: security.ticker, mic_code: security.exchange_mic, start_date: Date.current, end_date: Date.current) + .with(ticker: security.ticker, mic_code: security.exchange_operating_mic, start_date: Date.current, end_date: Date.current) .once .returns(OpenStruct.new(success?: false)) @@ -91,7 +91,7 @@ class Security::PriceTest < ActiveSupport::TestCase @provider.expects(:fetch_security_prices) .with(ticker: security.ticker, - mic_code: security.exchange_mic, + mic_code: security.exchange_operating_mic, start_date: 2.days.ago.to_date, end_date: 2.days.ago.to_date) .returns(OpenStruct.new(success?: true, prices: [ { date: 2.days.ago.to_date, price: missing_price, currency: "USD" } ])) From 4b19ca50ebc901096937e74cbd20a42c2efbaf9c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Mar 2025 09:13:05 -0400 Subject: [PATCH 048/380] Bump i18n-tasks from 1.0.14 to 1.0.15 (#1974) Bumps [i18n-tasks](https://github.com/glebm/i18n-tasks) from 1.0.14 to 1.0.15. - [Release notes](https://github.com/glebm/i18n-tasks/releases) - [Changelog](https://github.com/glebm/i18n-tasks/blob/main/CHANGES.md) - [Commits](https://github.com/glebm/i18n-tasks/compare/v1.0.14...v1.0.15) --- updated-dependencies: - dependency-name: i18n-tasks dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 5ff46a2a..a18204df 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -208,7 +208,7 @@ GEM railties (>= 7.0.0) i18n (1.14.7) concurrent-ruby (~> 1.0) - i18n-tasks (1.0.14) + i18n-tasks (1.0.15) activesupport (>= 4.0.2) ast (>= 2.1.0) erubi @@ -217,6 +217,7 @@ GEM parser (>= 3.2.2.1) rails-i18n rainbow (>= 2.2.2, < 4.0) + ruby-progressbar (~> 1.8, >= 1.8.1) terminal-table (>= 1.5.1) image_processing (1.14.0) mini_magick (>= 4.9.5, < 6) @@ -293,28 +294,28 @@ GEM net-smtp (0.5.0) net-protocol nio4r (2.7.4) - nokogiri (1.18.2-aarch64-linux-gnu) + nokogiri (1.18.3-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.2-aarch64-linux-musl) + nokogiri (1.18.3-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.18.2-arm-linux-gnu) + nokogiri (1.18.3-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.18.2-arm-linux-musl) + nokogiri (1.18.3-arm-linux-musl) racc (~> 1.4) - nokogiri (1.18.2-arm64-darwin) + nokogiri (1.18.3-arm64-darwin) racc (~> 1.4) - nokogiri (1.18.2-x86_64-darwin) + nokogiri (1.18.3-x86_64-darwin) racc (~> 1.4) - nokogiri (1.18.2-x86_64-linux-gnu) + nokogiri (1.18.3-x86_64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.2-x86_64-linux-musl) + nokogiri (1.18.3-x86_64-linux-musl) racc (~> 1.4) octokit (9.2.0) faraday (>= 1, < 3) sawyer (~> 0.9) pagy (9.3.3) parallel (1.26.3) - parser (3.3.7.0) + parser (3.3.7.1) ast (~> 2.4.1) racc pg (1.5.9) @@ -341,7 +342,7 @@ GEM nio4r (~> 2.0) raabro (1.4.0) racc (1.8.1) - rack (3.1.10) + rack (3.1.11) rack-mini-profiler (3.3.1) rack (>= 1.2.0) rack-session (2.1.0) @@ -472,7 +473,7 @@ GEM sorbet-runtime (0.5.11813) stimulus-rails (1.3.4) railties (>= 6.0.0) - stringio (3.1.3) + stringio (3.1.5) stripe (13.4.1) tailwindcss-rails (4.0.0) railties (>= 7.0.0) @@ -517,7 +518,7 @@ GEM websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.7.1) + zeitwerk (2.7.2) PLATFORMS aarch64-linux From 2e4180fbf0e301b7167618d432d38548ed769352 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Mar 2025 09:13:15 -0400 Subject: [PATCH 049/380] Bump turbo-rails from 2.0.11 to 2.0.13 (#1973) Bumps [turbo-rails](https://github.com/hotwired/turbo-rails) from 2.0.11 to 2.0.13. - [Release notes](https://github.com/hotwired/turbo-rails/releases) - [Commits](https://github.com/hotwired/turbo-rails/compare/v2.0.11...v2.0.13) --- updated-dependencies: - dependency-name: turbo-rails dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index a18204df..797cd3cc 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -489,9 +489,9 @@ GEM unicode-display_width (>= 1.1.1, < 4) thor (1.3.2) timeout (0.4.3) - turbo-rails (2.0.11) - actionpack (>= 6.0.0) - railties (>= 6.0.0) + turbo-rails (2.0.13) + actionpack (>= 7.1.0) + railties (>= 7.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (3.1.4) From dc44da6c00bb463c291ab24496191e45423eafa3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Mar 2025 09:13:25 -0400 Subject: [PATCH 050/380] Bump redcarpet from 3.6.0 to 3.6.1 (#1972) Bumps [redcarpet](https://github.com/vmg/redcarpet) from 3.6.0 to 3.6.1. - [Release notes](https://github.com/vmg/redcarpet/releases) - [Changelog](https://github.com/vmg/redcarpet/blob/master/CHANGELOG.md) - [Commits](https://github.com/vmg/redcarpet/compare/v3.6.0...v3.6.1) --- updated-dependencies: - dependency-name: redcarpet dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 797cd3cc..ea60d695 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -396,7 +396,7 @@ GEM logger rdoc (6.12.0) psych (>= 4.0.0) - redcarpet (3.6.0) + redcarpet (3.6.1) regexp_parser (2.10.0) reline (0.6.0) io-console (~> 0.5) From 3f8351abfe3824754f05480ac6599ee4ab3c130a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Mar 2025 09:13:38 -0400 Subject: [PATCH 051/380] Bump rubocop-rails-omakase from 1.0.0 to 1.1.0 (#1971) Bumps [rubocop-rails-omakase](https://github.com/rails/rubocop-rails-omakase) from 1.0.0 to 1.1.0. - [Release notes](https://github.com/rails/rubocop-rails-omakase/releases) - [Commits](https://github.com/rails/rubocop-rails-omakase/compare/v1.0.0...v1.1.0) --- updated-dependencies: - dependency-name: rubocop-rails-omakase dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index ea60d695..4f68d95c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -248,6 +248,7 @@ GEM logger (~> 1.6) letter_opener (1.10.0) launchy (>= 2.2, < 4) + lint_roller (1.1.0) listen (3.9.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) @@ -406,34 +407,33 @@ GEM chunky_png (~> 1.0) rqrcode_core (~> 1.0) rqrcode_core (1.2.0) - rubocop (1.71.0) + rubocop (1.73.2) json (~> 2.3) - language_server-protocol (>= 3.17.0) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) parallel (~> 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.36.2, < 2.0) + rubocop-ast (>= 1.38.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.38.0) + rubocop-ast (1.38.1) parser (>= 3.3.1.0) - rubocop-minitest (0.36.0) - rubocop (>= 1.61, < 2.0) - rubocop-ast (>= 1.31.1, < 2.0) - rubocop-performance (1.23.1) - rubocop (>= 1.48.1, < 2.0) - rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rails (2.29.1) + rubocop-performance (1.24.0) + lint_roller (~> 1.1) + rubocop (>= 1.72.1, < 2.0) + rubocop-ast (>= 1.38.0, < 2.0) + rubocop-rails (2.30.3) activesupport (>= 4.2.0) + lint_roller (~> 1.1) rack (>= 1.1) - rubocop (>= 1.52.0, < 2.0) - rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rails-omakase (1.0.0) - rubocop - rubocop-minitest - rubocop-performance - rubocop-rails + rubocop (>= 1.72.1, < 2.0) + rubocop-ast (>= 1.38.0, < 2.0) + rubocop-rails-omakase (1.1.0) + rubocop (>= 1.72) + rubocop-performance (>= 1.24) + rubocop-rails (>= 2.30) ruby-lsp (0.23.9) language_server-protocol (~> 3.17.0) prism (>= 1.2, < 2.0) From 045fa1931c08d38ad5ff29bd3d2ba3a06112687d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Mar 2025 09:15:51 -0400 Subject: [PATCH 052/380] Bump good_job from 4.9.0 to 4.9.3 (#1969) Bumps [good_job](https://github.com/bensheldon/good_job) from 4.9.0 to 4.9.3. - [Release notes](https://github.com/bensheldon/good_job/releases) - [Changelog](https://github.com/bensheldon/good_job/blob/main/CHANGELOG.md) - [Commits](https://github.com/bensheldon/good_job/compare/v4.9.0...v4.9.3) --- updated-dependencies: - dependency-name: good_job dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 4f68d95c..f5db9a44 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -192,7 +192,7 @@ GEM raabro (~> 1.4) globalid (1.2.1) activesupport (>= 6.1) - good_job (4.9.0) + good_job (4.9.3) activejob (>= 6.1.0) activerecord (>= 6.1.0) concurrent-ruby (>= 1.3.1) From 9dcb9e8ed2eebf7f9acea229b18c2ae9fd291b02 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Mar 2025 09:16:01 -0400 Subject: [PATCH 053/380] Bump webmock from 3.25.0 to 3.25.1 (#1968) Bumps [webmock](https://github.com/bblimke/webmock) from 3.25.0 to 3.25.1. - [Changelog](https://github.com/bblimke/webmock/blob/master/CHANGELOG.md) - [Commits](https://github.com/bblimke/webmock/compare/v3.25.0...v3.25.1) --- updated-dependencies: - dependency-name: webmock dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index f5db9a44..86eb1249 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -507,7 +507,7 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) - webmock (3.25.0) + webmock (3.25.1) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) From c66401dc0f58d9300f37cd0eb050f54abf68712b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Mar 2025 09:34:15 -0400 Subject: [PATCH 054/380] Bump stripe from 13.4.1 to 13.5.0 (#1970) Bumps [stripe](https://github.com/stripe/stripe-ruby) from 13.4.1 to 13.5.0. - [Release notes](https://github.com/stripe/stripe-ruby/releases) - [Changelog](https://github.com/stripe/stripe-ruby/blob/master/CHANGELOG.md) - [Commits](https://github.com/stripe/stripe-ruby/compare/v13.4.1...v13.5.0) --- updated-dependencies: - dependency-name: stripe dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 86eb1249..6fe445f8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -474,7 +474,7 @@ GEM stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.1.5) - stripe (13.4.1) + stripe (13.5.0) tailwindcss-rails (4.0.0) railties (>= 7.0.0) tailwindcss-ruby (~> 4.0) From 15d59959cfb4d55ff51b91d7172c68a46234a6e4 Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Mon, 10 Mar 2025 11:20:58 -0500 Subject: [PATCH 055/380] Fix issue of syncing notice covering up user menu --- app/views/layouts/shared/_htmldoc.html.erb | 4 ++-- app/views/shared/_syncing_notice.html.erb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/views/layouts/shared/_htmldoc.html.erb b/app/views/layouts/shared/_htmldoc.html.erb index b53b5880..6123b318 100644 --- a/app/views/layouts/shared/_htmldoc.html.erb +++ b/app/views/layouts/shared/_htmldoc.html.erb @@ -6,8 +6,8 @@ -
      -
      +
      +
      <%= render_flash_notifications %> <% if Current.family&.syncing? %> diff --git a/app/views/shared/_syncing_notice.html.erb b/app/views/shared/_syncing_notice.html.erb index 791a265b..8458a423 100644 --- a/app/views/shared/_syncing_notice.html.erb +++ b/app/views/shared/_syncing_notice.html.erb @@ -1,4 +1,4 @@ -<%= tag.div id: "syncing-notice", class: "flex gap-3 rounded-lg border bg-white p-4 group max-w-80 shadow-xs border-alpha-black-25" do %> +<%= tag.div id: "syncing-notice", class: "flex gap-3 rounded-lg border bg-white p-4 group w-full shadow-xs border-alpha-black-25" do %>
      <%= lucide_icon "loader", class: "w-5 h-5 text-secondary animate-pulse" %>
      From 7b751ac7cae4ca1f44aa14b453a088636886490c Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Tue, 11 Mar 2025 09:11:40 -0400 Subject: [PATCH 056/380] Do not prompt upgrades until user is logged in Fixes #1982 --- app/helpers/upgrades_helper.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/helpers/upgrades_helper.rb b/app/helpers/upgrades_helper.rb index c8f579c6..6e1584d9 100644 --- a/app/helpers/upgrades_helper.rb +++ b/app/helpers/upgrades_helper.rb @@ -1,6 +1,7 @@ module UpgradesHelper def get_upgrade_for_notification(user, upgrades_mode) return nil unless ENV["UPGRADES_ENABLED"] == "true" + return nil unless user.present? completed_upgrade = Upgrader.completed_upgrade return completed_upgrade if completed_upgrade && user.last_alerted_upgrade_commit_sha != completed_upgrade.commit_sha From b8a3ca7732a07cc86a9cd4526dff5b70a6c48d01 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Tue, 11 Mar 2025 10:10:28 -0400 Subject: [PATCH 057/380] Fetch exchange rates for accounts that require conversion for net worth rollups (#1983) * Sync required exchange rates for accounts * Refactor into concern --- app/models/account.rb | 2 +- app/models/account/balance/syncer.rb | 2 + app/models/account/convertible.rb | 28 ++++++++++++ test/models/account/chartable_test.rb | 23 ++++++++++ test/models/account/convertible_test.rb | 59 +++++++++++++++++++++++++ 5 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 app/models/account/convertible.rb create mode 100644 test/models/account/convertible_test.rb diff --git a/app/models/account.rb b/app/models/account.rb index 0c037609..a50ef13a 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -1,5 +1,5 @@ class Account < ApplicationRecord - include Syncable, Monetizable, Issuable, Chartable, Enrichable, Linkable + include Syncable, Monetizable, Issuable, Chartable, Enrichable, Linkable, Convertible validates :name, :balance, :currency, presence: true diff --git a/app/models/account/balance/syncer.rb b/app/models/account/balance/syncer.rb index cc8ca68b..7aeaebda 100644 --- a/app/models/account/balance/syncer.rb +++ b/app/models/account/balance/syncer.rb @@ -19,6 +19,8 @@ class Account::Balance::Syncer if strategy == :forward update_account_info end + + account.sync_required_exchange_rates end end diff --git a/app/models/account/convertible.rb b/app/models/account/convertible.rb new file mode 100644 index 00000000..8f5a1199 --- /dev/null +++ b/app/models/account/convertible.rb @@ -0,0 +1,28 @@ +module Account::Convertible + extend ActiveSupport::Concern + + def sync_required_exchange_rates + unless requires_exchange_rates? + Rails.logger.info("No exchange rate sync needed for account #{id}") + return + end + + rates = ExchangeRate.find_rates( + from: currency, + to: target_currency, + start_date: start_date, + cache: true # caches from provider to DB + ) + + Rails.logger.info("Synced #{rates.count} exchange rates for account #{id}") + end + + private + def target_currency + family.currency + end + + def requires_exchange_rates? + currency != target_currency + end +end diff --git a/test/models/account/chartable_test.rb b/test/models/account/chartable_test.rb index 196feca6..a7d3acc5 100644 --- a/test/models/account/chartable_test.rb +++ b/test/models/account/chartable_test.rb @@ -35,4 +35,27 @@ class Account::ChartableTest < ActiveSupport::TestCase assert_equal 3000, series.values.find { |v| v.date == 20.days.ago.to_date }.trend.current.amount assert_equal 3500, series.values.last.trend.current.amount end + + test "generates correct totals for multi currency families" do + family = families(:empty) + family.update!(currency: "USD") + + usd_account = family.accounts.create!(name: "Asset", currency: "USD", balance: 5000, accountable: Depository.new) + eur_account = family.accounts.create!(name: "Asset", currency: "EUR", balance: 1000, accountable: Depository.new) + + usd_account.balances.create!(date: 3.days.ago.to_date, balance: 5000, currency: "USD") + eur_account.balances.create!(date: 3.days.ago.to_date, balance: 1000, currency: "EUR") + + # 1 EUR = 1.1 USD, so 1000 EUR = 1100 USD + ExchangeRate.create!(from_currency: "EUR", to_currency: "USD", date: 3.days.ago.to_date, rate: 1.1) + ExchangeRate.create!(from_currency: "EUR", to_currency: "USD", date: 2.days.ago.to_date, rate: 1.1) + ExchangeRate.create!(from_currency: "EUR", to_currency: "USD", date: 1.days.ago.to_date, rate: 1.1) + ExchangeRate.create!(from_currency: "EUR", to_currency: "USD", date: Date.current, rate: 1.1) + + series = family.accounts.balance_series(currency: "USD", period: Period.last_7_days) + + assert_equal 0, series.values.first.trend.current.amount + assert_equal 6100, series.values.find { |v| v.date == 3.days.ago.to_date }.trend.current.amount + assert_equal 6100, series.values.last.trend.current.amount + end end diff --git a/test/models/account/convertible_test.rb b/test/models/account/convertible_test.rb new file mode 100644 index 00000000..a142f881 --- /dev/null +++ b/test/models/account/convertible_test.rb @@ -0,0 +1,59 @@ +require "test_helper" +require "ostruct" + +class Account::ConvertibleTest < ActiveSupport::TestCase + include Account::EntriesTestHelper + + setup do + @family = families(:empty) + @family.update!(currency: "USD") + + # Foreign account (currency is not in the family's primary currency, so it will require exchange rates for net worth rollups) + @account = @family.accounts.create!(name: "Test Account", currency: "EUR", balance: 10000, accountable: Depository.new) + + @provider = mock + ExchangeRate.stubs(:provider).returns(@provider) + end + + test "syncs required exchange rates for an account" do + create_valuation(account: @account, date: 5.days.ago.to_date, amount: 9500, currency: "EUR") + + # Since we had a valuation 5 days ago, this account starts 6 days ago and needs daily exchange rates looking forward + assert_equal 6.days.ago.to_date, @account.start_date + + @provider.expects(:fetch_exchange_rates) + .with( + from: "EUR", + to: "USD", + start_date: 6.days.ago.to_date, + end_date: Date.current + ).returns( + OpenStruct.new( + success?: true, + rates: [ + OpenStruct.new(date: 6.days.ago.to_date, rate: 1.1), + OpenStruct.new(date: 5.days.ago.to_date, rate: 1.2), + OpenStruct.new(date: 4.days.ago.to_date, rate: 1.3), + OpenStruct.new(date: 3.days.ago.to_date, rate: 1.4), + OpenStruct.new(date: 2.days.ago.to_date, rate: 1.5), + OpenStruct.new(date: 1.day.ago.to_date, rate: 1.6), + OpenStruct.new(date: Date.current, rate: 1.7) + ] + ) + ) + + assert_difference "ExchangeRate.count", 7 do + @account.sync_required_exchange_rates + end + end + + test "does not sync rates for a domestic account" do + @account.update!(currency: "USD") + + @provider.expects(:fetch_exchange_rates).never + + assert_no_difference "ExchangeRate.count" do + @account.sync_required_exchange_rates + end + end +end From f363fd4a4e6dfb7f872d80db8fa357aa317a804e Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Tue, 11 Mar 2025 12:37:57 -0400 Subject: [PATCH 058/380] Fix incorrect totals calculation when family has loan payments (#1984) * Fix income totals calculation error when loan payments exist * Include transaction totals in totals query --- app/models/income_statement.rb | 2 +- app/models/income_statement/base_query.rb | 3 ++- app/models/income_statement/totals.rb | 6 ++++-- app/views/transactions/_summary.html.erb | 2 +- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/models/income_statement.rb b/app/models/income_statement.rb index cacc3f33..fba114e4 100644 --- a/app/models/income_statement.rb +++ b/app/models/income_statement.rb @@ -18,7 +18,7 @@ class IncomeStatement total_expense = result.select { |t| t.classification == "expense" }.sum(&:total) ScopeTotals.new( - transactions_count: transactions_scope.count, + transactions_count: result.sum(&:transactions_count), income_money: Money.new(total_income, family.currency), expense_money: Money.new(total_expense, family.currency), missing_exchange_rates?: result.any?(&:missing_exchange_rates?) diff --git a/app/models/income_statement/base_query.rb b/app/models/income_statement/base_query.rb index baa09659..d2b17b81 100644 --- a/app/models/income_statement/base_query.rb +++ b/app/models/income_statement/base_query.rb @@ -8,6 +8,7 @@ module IncomeStatement::BaseQuery date_trunc(:interval, ae.date) as date, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, SUM(ae.amount * COALESCE(er.rate, 1)) as total, + COUNT(ae.id) as transactions_count, BOOL_OR(ae.currency <> :target_currency AND er.rate IS NULL) as missing_exchange_rates FROM (#{transactions_scope.to_sql}) at JOIN account_entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Account::Transaction' @@ -29,7 +30,7 @@ module IncomeStatement::BaseQuery ) WHERE ( transfer_info.transfer_id IS NULL OR - (ae.amount < 0 AND transfer_info.accountable_type = 'Loan') + (ae.amount > 0 AND transfer_info.accountable_type = 'Loan') ) GROUP BY 1, 2, 3, 4 SQL diff --git a/app/models/income_statement/totals.rb b/app/models/income_statement/totals.rb index acf2017b..88161e41 100644 --- a/app/models/income_statement/totals.rb +++ b/app/models/income_statement/totals.rb @@ -13,13 +13,14 @@ class IncomeStatement::Totals category_id: row["category_id"], classification: row["classification"], total: row["total"], + transactions_count: row["transactions_count"], missing_exchange_rates?: row["missing_exchange_rates"] ) end end private - TotalsRow = Data.define(:parent_category_id, :category_id, :classification, :total, :missing_exchange_rates?) + TotalsRow = Data.define(:parent_category_id, :category_id, :classification, :total, :transactions_count, :missing_exchange_rates?) def query_sql base_sql = base_query_sql(family: @family, interval: "day", transactions_scope: @transactions_scope) @@ -33,7 +34,8 @@ class IncomeStatement::Totals category_id, classification, ABS(SUM(total)) as total, - BOOL_OR(missing_exchange_rates) as missing_exchange_rates + BOOL_OR(missing_exchange_rates) as missing_exchange_rates, + SUM(transactions_count) as transactions_count FROM base_totals GROUP BY 1, 2, 3; SQL diff --git a/app/views/transactions/_summary.html.erb b/app/views/transactions/_summary.html.erb index 19c27346..428e425d 100644 --- a/app/views/transactions/_summary.html.erb +++ b/app/views/transactions/_summary.html.erb @@ -2,7 +2,7 @@

      Total transactions

      -

      <%= totals.transactions_count %>

      +

      <%= totals.transactions_count.round(0) %>

      Income

      From ed55ef624bcc60de58469b587df6911c5fdcd60b Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Tue, 11 Mar 2025 13:00:34 -0500 Subject: [PATCH 059/380] Update billing settings view and locales --- app/views/settings/billings/show.html.erb | 7 +++---- config/locales/views/settings/en.yml | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/app/views/settings/billings/show.html.erb b/app/views/settings/billings/show.html.erb index ddf53db0..cd284f83 100644 --- a/app/views/settings/billings/show.html.erb +++ b/app/views/settings/billings/show.html.erb @@ -11,7 +11,6 @@
      <% if @user.family.subscribed? || subscription_pending? %>

      You are currently subscribed to Maybe+

      -

      Manage your billing settings here.

      <% else %>

      You are currently not subscribed

      Once you subscribe to Maybe+, you'll see your billing settings here.

      @@ -20,12 +19,12 @@
      <% if @user.family.subscribed? || subscription_pending? %> - <%= link_to subscription_path, class: "btn btn--secondary flex items-center gap-1" do %> + <%= link_to subscription_path, class: "btn btn--secondary flex items-center gap-1", target: "_blank", rel: "noopener" do %> Manage <%= lucide_icon "external-link", class: "w-5 h-5 shrink-0 text-secondary" %> <% end %> <% else %> - <%= link_to new_subscription_path, class: "btn btn--secondary flex items-center gap-1" do %> + <%= link_to new_subscription_path, class: "btn btn--secondary flex items-center gap-1", target: "_blank", rel: "noopener" do %> Subscribe <%= lucide_icon "external-link", class: "w-5 h-5 shrink-0 text-secondary" %> <% end %> @@ -34,7 +33,7 @@
      <%= image_tag "stripe-logo.svg", class: "w-5 h-5 shrink-0" %> -

      Managed via Stripe

      +

      Billing via Stripe

      <% end %> diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index 8bc0dbf0..35c10f9b 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -4,7 +4,7 @@ en: billings: show: page_title: Billing - subscription_subtitle: Manage your subscription and billing details + subscription_subtitle: Update your subscription and billing details subscription_title: Manage subscription preferences: data_enrichment_settings: From dd75cadebcafe395eb241a43b9a4a0b06232924d Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Tue, 11 Mar 2025 15:38:45 -0400 Subject: [PATCH 060/380] Fix transaction filters when transfers are present (#1986) * Proper filtering of transfers in search * Fix transaction search --- app/helpers/account/entries_helper.rb | 20 ++++- app/models/account/entry_search.rb | 15 ---- app/models/account/transaction_search.rb | 99 ++++++++++++++++++------ app/views/transactions/index.html.erb | 7 +- 4 files changed, 96 insertions(+), 45 deletions(-) diff --git a/app/helpers/account/entries_helper.rb b/app/helpers/account/entries_helper.rb index 3350a328..af2f16f7 100644 --- a/app/helpers/account/entries_helper.rb +++ b/app/helpers/account/entries_helper.rb @@ -1,6 +1,24 @@ module Account::EntriesHelper def entries_by_date(entries, totals: false) - entries.group_by(&:date).map do |date, grouped_entries| + transfer_groups = entries.group_by do |entry| + # Only check for transfer if it's a transaction + next nil unless entry.entryable_type == "Account::Transaction" + entry.entryable.transfer&.id + end + + # For a more intuitive UX, we do not want to show the same transfer twice in the list + deduped_entries = transfer_groups.flat_map do |transfer_id, grouped_entries| + if transfer_id.nil? || grouped_entries.size == 1 + grouped_entries + else + grouped_entries.reject do |e| + e.entryable_type == "Account::Transaction" && + e.entryable.transfer_as_inflow.present? + end + end + end + + deduped_entries.group_by(&:date).map do |date, grouped_entries| content = capture do yield grouped_entries end diff --git a/app/models/account/entry_search.rb b/app/models/account/entry_search.rb index b6617037..b08c338f 100644 --- a/app/models/account/entry_search.rb +++ b/app/models/account/entry_search.rb @@ -31,20 +31,6 @@ class Account::EntrySearch query end - def apply_type_filter(scope, types) - return scope if types.blank? - - query = scope - - if types.include?("income") && !types.include?("expense") - query = query.where("account_entries.amount < 0") - elsif types.include?("expense") && !types.include?("income") - query = query.where("account_entries.amount >= 0") - end - - query - end - def apply_amount_filter(scope, amount, amount_operator) return scope if amount.blank? || amount_operator.blank? @@ -76,7 +62,6 @@ class Account::EntrySearch query = scope.joins(:account) query = self.class.apply_search_filter(query, search) query = self.class.apply_date_filters(query, start_date, end_date) - query = self.class.apply_type_filter(query, types) query = self.class.apply_amount_filter(query, amount, amount_operator) query = self.class.apply_accounts_filter(query, accounts, account_ids) query diff --git a/app/models/account/transaction_search.rb b/app/models/account/transaction_search.rb index 3cf927e8..215c6a98 100644 --- a/app/models/account/transaction_search.rb +++ b/app/models/account/transaction_search.rb @@ -14,39 +14,88 @@ class Account::TransactionSearch attribute :merchants, array: true attribute :tags, array: true - # Returns array of Account::Entry objects to stay consistent with partials, which only deal with Account::Entry def build_query(scope) query = scope.joins(entry: :account) + .joins(transfer_join) - if types.present? && types.exclude?("transfer") - query = query.joins("LEFT JOIN transfers ON transfers.inflow_transaction_id = account_entries.id OR transfers.outflow_transaction_id = account_entries.id") - .where("transfers.id IS NULL") - end - - if categories.present? - if categories.exclude?("Uncategorized") - query = query - .joins(:category) - .where(categories: { name: categories }) - else - query = query - .left_joins(:category) - .where(categories: { name: categories }) - .or(query.where(category_id: nil)) - end - end - - query = query.joins(:merchant).where(merchants: { name: merchants }) if merchants.present? - - query = query.joins(:tags).where(tags: { name: tags }) if tags.present? - - # Apply common entry search filters + query = apply_category_filter(query, categories) + query = apply_type_filter(query, types) + query = apply_merchant_filter(query, merchants) + query = apply_tag_filter(query, tags) query = Account::EntrySearch.apply_search_filter(query, search) query = Account::EntrySearch.apply_date_filters(query, start_date, end_date) - query = Account::EntrySearch.apply_type_filter(query, types) query = Account::EntrySearch.apply_amount_filter(query, amount, amount_operator) query = Account::EntrySearch.apply_accounts_filter(query, accounts, account_ids) query end + + private + def transfer_join + <<~SQL + LEFT JOIN ( + SELECT t.*, t.id as transfer_id, a.accountable_type + FROM transfers t + JOIN account_entries ae ON ae.entryable_id = t.inflow_transaction_id + AND ae.entryable_type = 'Account::Transaction' + JOIN accounts a ON a.id = ae.account_id + ) transfer_info ON ( + transfer_info.inflow_transaction_id = account_transactions.id OR + transfer_info.outflow_transaction_id = account_transactions.id + ) + SQL + end + + def apply_category_filter(query, categories) + return query unless categories.present? + + query = query.left_joins(:category).where( + "categories.name IN (?) OR ( + categories.id IS NULL AND (transfer_info.transfer_id IS NULL OR transfer_info.accountable_type = 'Loan') + )", + categories + ) + + if categories.exclude?("Uncategorized") + query = query.where.not(category_id: nil) + end + + query + end + + def apply_type_filter(query, types) + return query unless types.present? + return query if types.sort == [ "expense", "income", "transfer" ] + + transfer_condition = "transfer_info.transfer_id IS NOT NULL" + expense_condition = "account_entries.amount >= 0" + income_condition = "account_entries.amount <= 0" + + condition = case types.sort + when [ "transfer" ] + transfer_condition + when [ "expense" ] + Arel.sql("#{expense_condition} AND NOT (#{transfer_condition})") + when [ "income" ] + Arel.sql("#{income_condition} AND NOT (#{transfer_condition})") + when [ "expense", "transfer" ] + Arel.sql("#{expense_condition} OR #{transfer_condition}") + when [ "income", "transfer" ] + Arel.sql("#{income_condition} OR #{transfer_condition}") + when [ "expense", "income" ] + Arel.sql("NOT (#{transfer_condition})") + end + + query.where(condition) + end + + def apply_merchant_filter(query, merchants) + return query unless merchants.present? + query.joins(:merchant).where(merchants: { name: merchants }) + end + + def apply_tag_filter(query, tags) + return query unless tags.present? + query.joins(:tags).where(tags: { name: tags }) + end end diff --git a/app/views/transactions/index.html.erb b/app/views/transactions/index.html.erb index 35c99e4a..e6772607 100644 --- a/app/views/transactions/index.html.erb +++ b/app/views/transactions/index.html.erb @@ -1,4 +1,4 @@ -
      +
      <%= render "header" %> <%= render "summary", totals: @totals %> @@ -7,7 +7,7 @@ data-controller="bulk-select" data-bulk-select-singular-label-value="<%= t(".transaction") %>" data-bulk-select-plural-label-value="<%= t(".transactions") %>" - class="overflow-y-auto flex flex-col bg-white rounded-xl shadow-border-xs p-4"> + class="flex flex-col bg-white rounded-xl shadow-border-xs p-4"> <%= render "transactions/searches/search" %>
      <%= entries_by_date(@transactions.map(&:entry), totals: true) do |entries| %> - <%# Only render the outflow side of transfers to avoid duplicate entries %> - <%= render partial: "account/entries/entry", collection: entries.reject { |e| e.entryable.transfer_as_inflow.present? } %> + <%= render entries %> <% end %>
      From f65b93a3520fa91504e278b9301e925459292f6b Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 17 Mar 2025 11:54:53 -0400 Subject: [PATCH 061/380] Data provider simplification, tests, and documentation (#1997) * Ignore env.test from source control * Simplification of providers interface * Synth tests * Update money to use new find rates method * Remove unused issues code * Additional issue feature removals * Update price data fetching and tests * Update documentation for providers * Security test fixes * Fix self host test * Update synth usage data access * Remove AI pr schema changes --- .cursor/rules/project-design.mdc | 174 ++++++++ .env.test | 8 - .env.test.example | 2 + .gitignore | 1 - ...hange_rate_provider_missings_controller.rb | 20 - app/controllers/issues_controller.rb | 13 - app/controllers/securities_controller.rb | 8 +- .../settings/hostings_controller.rb | 2 +- app/jobs/enrich_transaction_batch_job.rb | 3 +- app/models/account.rb | 4 +- app/models/account/convertible.rb | 5 +- app/models/account/data_enricher.rb | 66 --- app/models/account/enrichable.rb | 61 ++- app/models/account/entry.rb | 2 +- app/models/account/entry/provided.rb | 11 - app/models/account/holding/portfolio_cache.rb | 13 +- app/models/account/transaction.rb | 2 +- app/models/account/transaction/provided.rb | 15 + app/models/concerns/issuable.rb | 52 --- app/models/concerns/synthable.rb | 37 -- app/models/exchange_rate.rb | 24 +- app/models/exchange_rate/provideable.rb | 15 + app/models/exchange_rate/provided.rb | 75 ++-- app/models/family.rb | 7 +- app/models/financial_assistant.rb | 11 + app/models/financial_assistant/provided.rb | 13 + app/models/issue.rb | 35 -- .../issue/exchange_rate_provider_missing.rb | 9 - app/models/issue/exchange_rates_missing.rb | 15 - app/models/issue/unknown.rb | 11 - app/models/plaid_account.rb | 2 - app/models/plaid_item.rb | 2 +- .../plaidable.rb => plaid_item/provided.rb} | 6 +- app/models/provider.rb | 35 ++ app/models/provider/base.rb | 18 - app/models/provider/synth.rb | 376 +++++++++--------- app/models/providers.rb | 35 ++ app/models/security.rb | 2 +- app/models/security/price.rb | 31 +- app/models/security/price/provided.rb | 65 --- app/models/security/provideable.rb | 31 ++ app/models/security/provided.rb | 61 ++- app/models/trade_import.rb | 2 +- app/models/upgrader/provided.rb | 6 +- app/views/account/trades/_form.html.erb | 2 +- app/views/accounts/_account.html.erb | 7 - .../accounts/_account_sidebar_tabs.html.erb | 2 +- app/views/accounts/show/_header.html.erb | 4 - .../issue/_request_synth_data_action.html.erb | 12 - .../show.html.erb | 28 -- .../exchange_rates_missings/show.html.erb | 11 - app/views/issue/prices_missings/show.html.erb | 11 - app/views/issue/unknowns/show.html.erb | 23 -- app/views/issues/_issue.html.erb | 15 - app/views/layouts/issues.html.erb | 15 - .../hostings/_synth_settings.html.erb | 10 +- config/brakeman.ignore | 33 +- config/locales/models/issue/en.yml | 8 - config/locales/views/accounts/en.yml | 1 - config/routes.rb | 6 - ...1233_remove_ticker_from_security_prices.rb | 5 + db/migrate/20250316103753_remove_issues.rb | 11 + ...50316122019_security_price_unique_index.rb | 31 ++ db/schema.rb | 27 +- lib/money.rb | 2 +- ..._rate_provider_missings_controller_test.rb | 19 - test/controllers/issues_controller_test.rb | 18 - .../settings/hostings_controller_test.rb | 16 + test/fixtures/issues.yml | 5 - .../exchange_rate_provider_interface_test.rb | 38 +- .../security_price_provider_interface_test.rb | 26 -- .../security_provider_interface_test.rb | 62 +++ test/lib/money_test.rb | 6 +- test/models/account/convertible_test.rb | 43 +- .../account/holding/portfolio_cache_test.rb | 98 +++-- test/models/exchange_rate_test.rb | 161 ++++---- test/models/provider/synth_test.rb | 61 ++- test/models/provider_test.rb | 61 +++ test/models/providers_test.rb | 27 ++ test/models/security/price_test.rb | 138 +++---- test/models/trade_import_test.rb | 4 +- test/support/provider_test_helper.rb | 17 + test/system/imports_test.rb | 5 +- test/system/settings_test.rb | 1 + test/system/trades_test.rb | 20 +- test/vcr_cassettes/synth/exchange_rate.yml | 50 ++- .../synth/exchange_rate_historical.yml | 213 ---------- test/vcr_cassettes/synth/exchange_rates.yml | 81 ++++ test/vcr_cassettes/synth/health.yml | 82 ++++ test/vcr_cassettes/synth/security_info.yml | 105 +++++ test/vcr_cassettes/synth/security_price.yml | 83 ++++ test/vcr_cassettes/synth/security_prices.yml | 294 +++++++------- test/vcr_cassettes/synth/security_search.yml | 104 +++++ .../synth/transaction_enrich.yml | 82 ++++ test/vcr_cassettes/synth/usage.yml | 82 ++++ 95 files changed, 2014 insertions(+), 1638 deletions(-) delete mode 100644 .env.test delete mode 100644 app/controllers/issue/exchange_rate_provider_missings_controller.rb delete mode 100644 app/controllers/issues_controller.rb delete mode 100644 app/models/account/data_enricher.rb delete mode 100644 app/models/account/entry/provided.rb create mode 100644 app/models/account/transaction/provided.rb delete mode 100644 app/models/concerns/issuable.rb delete mode 100644 app/models/concerns/synthable.rb create mode 100644 app/models/exchange_rate/provideable.rb create mode 100644 app/models/financial_assistant.rb create mode 100644 app/models/financial_assistant/provided.rb delete mode 100644 app/models/issue.rb delete mode 100644 app/models/issue/exchange_rate_provider_missing.rb delete mode 100644 app/models/issue/exchange_rates_missing.rb delete mode 100644 app/models/issue/unknown.rb rename app/models/{concerns/plaidable.rb => plaid_item/provided.rb} (65%) create mode 100644 app/models/provider.rb delete mode 100644 app/models/provider/base.rb create mode 100644 app/models/providers.rb delete mode 100644 app/models/security/price/provided.rb create mode 100644 app/models/security/provideable.rb delete mode 100644 app/views/issue/_request_synth_data_action.html.erb delete mode 100644 app/views/issue/exchange_rate_provider_missings/show.html.erb delete mode 100644 app/views/issue/exchange_rates_missings/show.html.erb delete mode 100644 app/views/issue/prices_missings/show.html.erb delete mode 100644 app/views/issue/unknowns/show.html.erb delete mode 100644 app/views/issues/_issue.html.erb delete mode 100644 app/views/layouts/issues.html.erb delete mode 100644 config/locales/models/issue/en.yml create mode 100644 db/migrate/20250315191233_remove_ticker_from_security_prices.rb create mode 100644 db/migrate/20250316103753_remove_issues.rb create mode 100644 db/migrate/20250316122019_security_price_unique_index.rb delete mode 100644 test/controllers/issue/exchange_rate_provider_missings_controller_test.rb delete mode 100644 test/controllers/issues_controller_test.rb delete mode 100644 test/fixtures/issues.yml delete mode 100644 test/interfaces/security_price_provider_interface_test.rb create mode 100644 test/interfaces/security_provider_interface_test.rb create mode 100644 test/models/provider_test.rb create mode 100644 test/models/providers_test.rb create mode 100644 test/support/provider_test_helper.rb delete mode 100644 test/vcr_cassettes/synth/exchange_rate_historical.yml create mode 100644 test/vcr_cassettes/synth/exchange_rates.yml create mode 100644 test/vcr_cassettes/synth/health.yml create mode 100644 test/vcr_cassettes/synth/security_info.yml create mode 100644 test/vcr_cassettes/synth/security_price.yml create mode 100644 test/vcr_cassettes/synth/security_search.yml create mode 100644 test/vcr_cassettes/synth/transaction_enrich.yml create mode 100644 test/vcr_cassettes/synth/usage.yml diff --git a/.cursor/rules/project-design.mdc b/.cursor/rules/project-design.mdc index 84f994b5..6d4f2091 100644 --- a/.cursor/rules/project-design.mdc +++ b/.cursor/rules/project-design.mdc @@ -1,6 +1,7 @@ --- description: This rule explains the system architecture and data flow of the Rails app globs: * +alwaysApply: false --- This file outlines how the codebase is structured and how data flows through the app. @@ -131,4 +132,177 @@ A Plaid Item sync is an ETL (extract, transform, load) operation: A 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. +## Data Providers + +The 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. + +Because of this optionality, data providers must be configured at _runtime_ through the [providers.rb](mdc:app/models/providers.rb) module, utilizing [setting.rb](mdc:app/models/setting.rb) for runtime parameters like API keys: + +```rb +module Providers + module_function + + def synth + api_key = ENV.fetch("SYNTH_API_KEY", Setting.synth_api_key) + + return nil unless api_key.present? + + Provider::Synth.new(api_key) + end +end +``` + +There are two types of 3rd party data in the Maybe app: + +1. "Concept" data +2. One-off data + +### "Concept" data + +Since 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". + +Each "concept" _must_ have a `Provideable` concern that defines the methods that must be implemented along with the data shapes that are returned. For example, an "exchange rates concept" might look like this: + +``` +app/models/ + exchange_rate.rb # <- ActiveRecord model and "concept" + exchange_rate/ + provided.rb # <- Chooses the provider for this concept based on user settings / config + provideable.rb # <- Defines interface for providing exchange rates + provider.rb # <- Base provider class + provider/ + synth.rb # <- Concrete provider implementation +``` + +Where the `Provideable` and concrete provider implementations would be something like: + +```rb +# Defines the interface an exchange rate provider must implement +module ExchangeRate::Provideable + extend ActiveSupport::Concern + + FetchRateData = Data.define(:rate) + FetchRatesData = Data.define(:rates) + + def fetch_exchange_rate(from:, to:, date:) + raise NotImplementedError, "Subclasses must implement #fetch_exchange_rate" + end + + def fetch_exchange_rates(from:, to:, start_date:, end_date:) + raise NotImplementedError, "Subclasses must implement #fetch_exchange_rates" + end +end +``` + +Any provider that is a valid exchange rate provider must implement this interface: + +```rb +class ConcreteProvider < Provider + include ExchangeRate::Provideable + + def fetch_exchange_rate(from:, to:, date:) + provider_response do + ExchangeRate::Provideable::FetchRateData.new( + rate: ExchangeRate.new # build response + ) + end + end + + def fetch_exchange_rates(from:, to:, start_date:, end_date:) + # Implementation + end +end +``` + +### One-off data + +For data that does not fit neatly into a "concept", a `Provideable` 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: + +```rb +class SomeModel < Application + def synth_usage + Providers.synth.usage + end +end +``` + +## "Provided" Concerns + +In general, domain models should not be calling [providers.rb](mdc:app/models/providers.rb) (`Providers.some_provider`) 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: + +- Choosing the provider to use for this "concept" +- Providing convenience methods on the model for accessing data + +For 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: + +```rb +module ExchangeRate::Provided + extend ActiveSupport::Concern + + class_methods do + def provider + Providers.synth + end + + def find_or_fetch_rate(from:, to:, date: Date.current, cache: true) + # Implementation + end + + def sync_provider_rates(from:, to:, start_date:, end_date: Date.current) + # Implementation + end + end +end +``` + +This 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: + +```rb +def access_patterns_example + # Call exchange rate provider directly + ExchangeRate.provider.fetch_exchange_rate(from: "USD", to: "CAD", date: Date.current) + + # Call convenience method + ExchangeRate.sync_provider_rates(from: "USD", to: "CAD", start_date: 2.days.ago.to_date) +end +``` + +## Concrete provider implementations + +Each 3rd party data provider should have a class under the `Provider::` namespace that inherits from `Provider` and returns `provider_response`, which will return a `Provider::ProviderResponse` object: + +```rb +class ConcreteProvider < Provider + def fetch_some_data + provider_response do + ExampleData.new( + example: "data" + ) + end + end +end +``` + +The `provider_response` automatically catches provider errors, so concrete provider classes should raise when valid data is not possible: + +```rb +class ConcreteProvider < Provider + def fetch_some_data + provider_response do + data = nil + + # Raise an error if data cannot be returned + raise ProviderError.new("Could not find the data you need") if data.nil? + + data + end + end +end +``` + + + + + + diff --git a/.env.test b/.env.test deleted file mode 100644 index f47801f1..00000000 --- a/.env.test +++ /dev/null @@ -1,8 +0,0 @@ -SELF_HOSTED=false -SYNTH_API_KEY=fookey - -# Set to true if you want SimpleCov reports generated -COVERAGE=false - -# Set to true to run test suite serially -DISABLE_PARALLELIZATION=false \ No newline at end of file diff --git a/.env.test.example b/.env.test.example index e5133c42..37fb9ef9 100644 --- a/.env.test.example +++ b/.env.test.example @@ -1,3 +1,5 @@ +SELF_HOSTED=false + # ================ # Data Providers # --------------------------------------------------------------------------------- diff --git a/.gitignore b/.gitignore index b75bf5d4..0b8983aa 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,6 @@ # Ignore all environment files (except templates). /.env* !/.env*.erb -!.env.test !.env*.example # Ignore all logfiles and tempfiles. diff --git a/app/controllers/issue/exchange_rate_provider_missings_controller.rb b/app/controllers/issue/exchange_rate_provider_missings_controller.rb deleted file mode 100644 index 7e53f4df..00000000 --- a/app/controllers/issue/exchange_rate_provider_missings_controller.rb +++ /dev/null @@ -1,20 +0,0 @@ -class Issue::ExchangeRateProviderMissingsController < ApplicationController - before_action :set_issue, only: :update - - def update - Setting.synth_api_key = exchange_rate_params[:synth_api_key] - account = @issue.issuable - account.sync_later - redirect_back_or_to account - end - - private - - def set_issue - @issue = Current.family.issues.find(params[:id]) - end - - def exchange_rate_params - params.require(:issue_exchange_rate_provider_missing).permit(:synth_api_key) - end -end diff --git a/app/controllers/issues_controller.rb b/app/controllers/issues_controller.rb deleted file mode 100644 index 0585446d..00000000 --- a/app/controllers/issues_controller.rb +++ /dev/null @@ -1,13 +0,0 @@ -class IssuesController < ApplicationController - before_action :set_issue, only: :show - - def show - render template: "#{@issue.class.name.underscore.pluralize}/show", layout: "issues" - end - - private - - def set_issue - @issue = Current.family.issues.find(params[:id]) - end -end diff --git a/app/controllers/securities_controller.rb b/app/controllers/securities_controller.rb index 2c4124cf..f2e1b1b7 100644 --- a/app/controllers/securities_controller.rb +++ b/app/controllers/securities_controller.rb @@ -1,8 +1,8 @@ class SecuritiesController < ApplicationController def index - @securities = Security.search_provider({ - search: params[:q], - country: params[:country_code] == "US" ? "US" : nil - }) + @securities = Security.search_provider( + params[:q], + country_code: params[:country_code] == "US" ? "US" : nil + ) end end diff --git a/app/controllers/settings/hostings_controller.rb b/app/controllers/settings/hostings_controller.rb index 0740b0bc..637ff80f 100644 --- a/app/controllers/settings/hostings_controller.rb +++ b/app/controllers/settings/hostings_controller.rb @@ -5,7 +5,7 @@ class Settings::HostingsController < ApplicationController before_action :ensure_admin, only: :clear_cache def show - @synth_usage = Current.family.synth_usage + @synth_usage = Providers.synth&.usage end def update diff --git a/app/jobs/enrich_transaction_batch_job.rb b/app/jobs/enrich_transaction_batch_job.rb index 22d026f7..a796db67 100644 --- a/app/jobs/enrich_transaction_batch_job.rb +++ b/app/jobs/enrich_transaction_batch_job.rb @@ -2,7 +2,6 @@ class EnrichTransactionBatchJob < ApplicationJob queue_as :latency_high def perform(account, batch_size = 100, offset = 0) - enricher = Account::DataEnricher.new(account) - enricher.enrich_transaction_batch(batch_size, offset) + account.enrich_transaction_batch(batch_size, offset) end end diff --git a/app/models/account.rb b/app/models/account.rb index a50ef13a..5e1383e4 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -1,5 +1,5 @@ class Account < ApplicationRecord - include Syncable, Monetizable, Issuable, Chartable, Enrichable, Linkable, Convertible + include Syncable, Monetizable, Chartable, Enrichable, Linkable, Convertible validates :name, :balance, :currency, presence: true @@ -13,7 +13,6 @@ class Account < ApplicationRecord has_many :trades, through: :entries, source: :entryable, source_type: "Account::Trade" has_many :holdings, dependent: :destroy, class_name: "Account::Holding" has_many :balances, dependent: :destroy - has_many :issues, as: :issuable, dependent: :destroy monetize :balance, :cash_balance @@ -88,7 +87,6 @@ class Account < ApplicationRecord def post_sync broadcast_remove_to(family, target: "syncing-notice") - resolve_stale_issues accountable.post_sync end diff --git a/app/models/account/convertible.rb b/app/models/account/convertible.rb index 8f5a1199..fde6fa10 100644 --- a/app/models/account/convertible.rb +++ b/app/models/account/convertible.rb @@ -7,14 +7,13 @@ module Account::Convertible return end - rates = ExchangeRate.find_rates( + affected_row_count = ExchangeRate.sync_provider_rates( from: currency, to: target_currency, start_date: start_date, - cache: true # caches from provider to DB ) - Rails.logger.info("Synced #{rates.count} exchange rates for account #{id}") + Rails.logger.info("Synced #{affected_row_count} exchange rates for account #{id}") end private diff --git a/app/models/account/data_enricher.rb b/app/models/account/data_enricher.rb deleted file mode 100644 index 8d07eff8..00000000 --- a/app/models/account/data_enricher.rb +++ /dev/null @@ -1,66 +0,0 @@ -class Account::DataEnricher - attr_reader :account - - def initialize(account) - @account = account - end - - def run - total_unenriched = account.entries.account_transactions - .joins("JOIN account_transactions at ON at.id = account_entries.entryable_id AND account_entries.entryable_type = 'Account::Transaction'") - .where("account_entries.enriched_at IS NULL OR at.merchant_id IS NULL OR at.category_id IS NULL") - .count - - if total_unenriched > 0 - batch_size = 50 - batches = (total_unenriched.to_f / batch_size).ceil - - batches.times do |batch| - EnrichTransactionBatchJob.perform_later(account, batch_size, batch * batch_size) - end - end - end - - def enrich_transaction_batch(batch_size = 50, offset = 0) - candidates = account.entries.account_transactions - .includes(entryable: [ :merchant, :category ]) - .joins("JOIN account_transactions at ON at.id = account_entries.entryable_id AND account_entries.entryable_type = 'Account::Transaction'") - .where("account_entries.enriched_at IS NULL OR at.merchant_id IS NULL OR at.category_id IS NULL") - .offset(offset) - .limit(batch_size) - - Rails.logger.info("Enriching batch of #{candidates.count} transactions for account #{account.id} (offset: #{offset})") - - merchants = {} - - candidates.each do |entry| - begin - info = entry.fetch_enrichment_info - - next unless info.present? - - if info.name.present? - merchant = merchants[info.name] ||= account.family.merchants.find_or_create_by(name: info.name) - - if info.icon_url.present? - merchant.icon_url = info.icon_url - end - end - - entryable_attributes = { id: entry.entryable_id } - entryable_attributes[:merchant_id] = merchant.id if merchant.present? && entry.entryable.merchant_id.nil? - - Account.transaction do - merchant.save! if merchant.present? - entry.update!( - enriched_at: Time.current, - enriched_name: info.name, - entryable_attributes: entryable_attributes - ) - end - rescue => e - Rails.logger.warn("Error enriching transaction #{entry.id}: #{e.message}") - end - end - end -end diff --git a/app/models/account/enrichable.rb b/app/models/account/enrichable.rb index 236cce58..260aec5a 100644 --- a/app/models/account/enrichable.rb +++ b/app/models/account/enrichable.rb @@ -2,11 +2,70 @@ module Account::Enrichable extend ActiveSupport::Concern def enrich_data - DataEnricher.new(self).run + total_unenriched = entries.account_transactions + .joins("JOIN account_transactions at ON at.id = account_entries.entryable_id AND account_entries.entryable_type = 'Account::Transaction'") + .where("account_entries.enriched_at IS NULL OR at.merchant_id IS NULL OR at.category_id IS NULL") + .count + + if total_unenriched > 0 + batch_size = 50 + batches = (total_unenriched.to_f / batch_size).ceil + + batches.times do |batch| + EnrichTransactionBatchJob.perform_now(self, batch_size, batch * batch_size) + # EnrichTransactionBatchJob.perform_later(self, batch_size, batch * batch_size) + end + end + end + + def enrich_transaction_batch(batch_size = 50, offset = 0) + transactions_batch = enrichable_transactions.offset(offset).limit(batch_size) + + Rails.logger.info("Enriching batch of #{transactions_batch.count} transactions for account #{id} (offset: #{offset})") + + merchants = {} + + transactions_batch.each do |transaction| + begin + info = transaction.fetch_enrichment_info + + next unless info.present? + + if info.name.present? + merchant = merchants[info.name] ||= family.merchants.find_or_create_by(name: info.name) + + if info.icon_url.present? + merchant.icon_url = info.icon_url + end + end + + Account.transaction do + merchant.save! if merchant.present? + transaction.update!(merchant: merchant) if merchant.present? && transaction.merchant_id.nil? + + transaction.entry.update!( + enriched_at: Time.current, + enriched_name: info.name, + ) + end + rescue => e + Rails.logger.warn("Error enriching transaction #{transaction.id}: #{e.message}") + end + end end private def enrichable? family.data_enrichment_enabled? || (linked? && Rails.application.config.app_mode.hosted?) end + + def enrichable_transactions + transactions.active + .includes(:merchant, :category) + .where( + "account_entries.enriched_at IS NULL", + "OR merchant_id IS NULL", + "OR category_id IS NULL" + ) + end end diff --git a/app/models/account/entry.rb b/app/models/account/entry.rb index 25065efd..b53db19b 100644 --- a/app/models/account/entry.rb +++ b/app/models/account/entry.rb @@ -1,5 +1,5 @@ class Account::Entry < ApplicationRecord - include Monetizable, Provided + include Monetizable monetize :amount diff --git a/app/models/account/entry/provided.rb b/app/models/account/entry/provided.rb deleted file mode 100644 index c18654c9..00000000 --- a/app/models/account/entry/provided.rb +++ /dev/null @@ -1,11 +0,0 @@ -module Account::Entry::Provided - extend ActiveSupport::Concern - - include Synthable - - def fetch_enrichment_info - return nil unless synth_client.present? - - synth_client.enrich_transaction(name).info - end -end diff --git a/app/models/account/holding/portfolio_cache.rb b/app/models/account/holding/portfolio_cache.rb index bb6035cf..224d0b83 100644 --- a/app/models/account/holding/portfolio_cache.rb +++ b/app/models/account/holding/portfolio_cache.rb @@ -79,12 +79,11 @@ class Account::Holding::PortfolioCache securities.each do |security| Rails.logger.info "Loading security: ID=#{security.id} Ticker=#{security.ticker}" - # Highest priority prices - db_or_provider_prices = Security::Price.find_prices( - security: security, - start_date: account.start_date, - end_date: Date.current - ).map do |price| + # Load prices from provider to DB + security.sync_provider_prices(start_date: account.start_date) + + # High priority prices from DB (synced from provider) + db_prices = security.prices.where(date: account.start_date..Date.current).map do |price| PriceWithPriority.new( price: price, priority: 1 @@ -125,7 +124,7 @@ class Account::Holding::PortfolioCache @security_cache[security.id] = { security: security, - prices: db_or_provider_prices + trade_prices + holding_prices + prices: db_prices + trade_prices + holding_prices } end end diff --git a/app/models/account/transaction.rb b/app/models/account/transaction.rb index a500ef74..e31a5607 100644 --- a/app/models/account/transaction.rb +++ b/app/models/account/transaction.rb @@ -1,5 +1,5 @@ class Account::Transaction < ApplicationRecord - include Account::Entryable, Transferable + include Account::Entryable, Transferable, Provided belongs_to :category, optional: true belongs_to :merchant, optional: true diff --git a/app/models/account/transaction/provided.rb b/app/models/account/transaction/provided.rb new file mode 100644 index 00000000..14df5b55 --- /dev/null +++ b/app/models/account/transaction/provided.rb @@ -0,0 +1,15 @@ +module Account::Transaction::Provided + extend ActiveSupport::Concern + + def fetch_enrichment_info + return nil unless Providers.synth # Only Synth can provide this data + + response = Providers.synth.enrich_transaction( + entry.name, + amount: entry.amount, + date: entry.date + ) + + response.data + end +end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb deleted file mode 100644 index f295332f..00000000 --- a/app/models/concerns/issuable.rb +++ /dev/null @@ -1,52 +0,0 @@ -module Issuable - extend ActiveSupport::Concern - - included do - has_many :issues, dependent: :destroy, as: :issuable - end - - def has_issues? - issues.active.any? - end - - def resolve_stale_issues - issues.active.each do |issue| - issue.resolve! if issue.stale? - end - end - - def observe_unknown_issue(error) - observe_issue( - Issue::Unknown.new(data: { error: error.message }) - ) - end - - def observe_missing_exchange_rates(from:, to:, dates:) - observe_issue( - Issue::ExchangeRatesMissing.new(data: { from_currency: from, to_currency: to, dates: dates }) - ) - end - - def observe_missing_exchange_rate_provider - observe_issue( - Issue::ExchangeRateProviderMissing.new - ) - end - - def highest_priority_issue - issues.active.ordered.first - end - - private - - def observe_issue(new_issue) - existing_issue = issues.find_by(type: new_issue.type, resolved_at: nil) - - if existing_issue - existing_issue.update!(last_observed_at: Time.current, data: new_issue.data) - else - new_issue.issuable = self - new_issue.save! - end - end -end diff --git a/app/models/concerns/synthable.rb b/app/models/concerns/synthable.rb deleted file mode 100644 index 51adcade..00000000 --- a/app/models/concerns/synthable.rb +++ /dev/null @@ -1,37 +0,0 @@ -module Synthable - extend ActiveSupport::Concern - - class_methods do - def synth_usage - synth_client&.usage - end - - def synth_overage? - synth_usage&.usage&.utilization.to_i >= 100 - end - - def synth_healthy? - synth_client&.healthy? - end - - def synth_client - api_key = ENV.fetch("SYNTH_API_KEY", Setting.synth_api_key) - - return nil unless api_key.present? - - Provider::Synth.new(api_key) - end - end - - def synth_client - self.class.synth_client - end - - def synth_usage - self.class.synth_usage - end - - def synth_overage? - self.class.synth_overage? - end -end diff --git a/app/models/exchange_rate.rb b/app/models/exchange_rate.rb index 01b63e2b..cca0c41c 100644 --- a/app/models/exchange_rate.rb +++ b/app/models/exchange_rate.rb @@ -2,27 +2,5 @@ class ExchangeRate < ApplicationRecord include Provided validates :from_currency, :to_currency, :date, :rate, presence: true - - class << self - def find_rate(from:, to:, date:, cache: true) - result = find_by \ - from_currency: from, - to_currency: to, - date: date - - result || fetch_rate_from_provider(from:, to:, date:, cache:) - end - - def find_rates(from:, to:, start_date:, end_date: Date.current, cache: true) - rates = self.where(from_currency: from, to_currency: to, date: start_date..end_date).to_a - all_dates = (start_date..end_date).to_a - existing_dates = rates.map(&:date) - missing_dates = all_dates - existing_dates - if missing_dates.any? - rates += fetch_rates_from_provider(from:, to:, start_date: missing_dates.first, end_date: missing_dates.last, cache:) - end - - rates - end - end + validates :date, uniqueness: { scope: %i[from_currency to_currency] } end diff --git a/app/models/exchange_rate/provideable.rb b/app/models/exchange_rate/provideable.rb new file mode 100644 index 00000000..5f2278c6 --- /dev/null +++ b/app/models/exchange_rate/provideable.rb @@ -0,0 +1,15 @@ +# Defines the interface an exchange rate provider must implement +module ExchangeRate::Provideable + extend ActiveSupport::Concern + + FetchRateData = Data.define(:rate) + FetchRatesData = Data.define(:rates) + + def fetch_exchange_rate(from:, to:, date:) + raise NotImplementedError, "Subclasses must implement #fetch_exchange_rate" + end + + def fetch_exchange_rates(from:, to:, start_date:, end_date:) + raise NotImplementedError, "Subclasses must implement #fetch_exchange_rates" + end +end diff --git a/app/models/exchange_rate/provided.rb b/app/models/exchange_rate/provided.rb index d010ff98..6c502c05 100644 --- a/app/models/exchange_rate/provided.rb +++ b/app/models/exchange_rate/provided.rb @@ -1,63 +1,44 @@ module ExchangeRate::Provided extend ActiveSupport::Concern - include Synthable - class_methods do def provider - synth_client + Providers.synth end - private - def fetch_rates_from_provider(from:, to:, start_date:, end_date: Date.current, cache: false) - return [] unless provider.present? + def find_or_fetch_rate(from:, to:, date: Date.current, cache: true) + rate = find_by(from_currency: from, to_currency: to, date: date) + return rate if rate.present? - response = provider.fetch_exchange_rates \ - from: from, - to: to, - start_date: start_date, - end_date: end_date + return nil unless provider.present? # No provider configured (some self-hosted apps) - if response.success? - response.rates.map do |exchange_rate| - rate = ExchangeRate.new \ - from_currency: from, - to_currency: to, - date: exchange_rate.dig(:date).to_date, - rate: exchange_rate.dig(:rate) + response = provider.fetch_exchange_rate(from: from, to: to, date: date) - rate.save! if cache - rate - rescue ActiveRecord::RecordNotUnique - next - end - else - [] - end + return nil unless response.success? # Provider error + + rate = response.data.rate + rate.save! if cache + rate + end + + def sync_provider_rates(from:, to:, start_date:, end_date: Date.current) + unless provider.present? + Rails.logger.warn("No provider configured for ExchangeRate.sync_provider_rates") + return 0 end - def fetch_rate_from_provider(from:, to:, date:, cache: false) - return nil unless provider.present? + fetched_rates = provider.fetch_exchange_rates(from: from, to: to, start_date: start_date, end_date: end_date) - response = provider.fetch_exchange_rate \ - from: from, - to: to, - date: date - - if response.success? - rate = ExchangeRate.new \ - from_currency: from, - to_currency: to, - rate: response.rate, - date: date - - if cache - rate.save! rescue ActiveRecord::RecordNotUnique - end - rate - else - nil - end + unless fetched_rates.success? + Rails.logger.error("Provider error for ExchangeRate.sync_provider_rates: #{fetched_rates.error}") + return 0 end + + rates_data = fetched_rates.data.rates.map do |rate| + rate.attributes.slice("from_currency", "to_currency", "date", "rate") + end + + ExchangeRate.upsert_all(rates_data, unique_by: %i[from_currency to_currency date]) + end end end diff --git a/app/models/family.rb b/app/models/family.rb index 0f71731f..ec2d1bb6 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -1,5 +1,5 @@ class Family < ApplicationRecord - include Synthable, Plaidable, Syncable, AutoTransferMatchable + include Syncable, AutoTransferMatchable DATE_FORMATS = [ [ "MM-DD-YYYY", "%m-%d-%Y" ], @@ -19,7 +19,6 @@ class Family < ApplicationRecord has_many :invitations, dependent: :destroy has_many :imports, dependent: :destroy - has_many :issues, through: :accounts has_many :entries, through: :accounts has_many :transactions, through: :accounts @@ -75,9 +74,9 @@ class Family < ApplicationRecord def get_link_token(webhooks_url:, redirect_url:, accountable_type: nil, region: :us, access_token: nil) provider = if region.to_sym == :eu - self.class.plaid_eu_provider + Providers.plaid_eu else - self.class.plaid_us_provider + Providers.plaid_us end # early return when no provider diff --git a/app/models/financial_assistant.rb b/app/models/financial_assistant.rb new file mode 100644 index 00000000..7480becc --- /dev/null +++ b/app/models/financial_assistant.rb @@ -0,0 +1,11 @@ +class FinancialAssistant + include Provided + + def initialize(chat) + @chat = chat + end + + def query(prompt, model_key: "gpt-4o") + llm_provider = self.class.llm_provider_for(model_key) + end +end diff --git a/app/models/financial_assistant/provided.rb b/app/models/financial_assistant/provided.rb new file mode 100644 index 00000000..f88ad339 --- /dev/null +++ b/app/models/financial_assistant/provided.rb @@ -0,0 +1,13 @@ +module FinancialAssistant::Provided + extend ActiveSupport::Concern + + # Placeholder for AI chat PR + def llm_provider_for(model_key) + case model_key + when "gpt-4o" + Providers.openai + else + raise "Unknown LLM model key: #{model_key}" + end + end +end diff --git a/app/models/issue.rb b/app/models/issue.rb deleted file mode 100644 index 0f0cf2d2..00000000 --- a/app/models/issue.rb +++ /dev/null @@ -1,35 +0,0 @@ -class Issue < ApplicationRecord - belongs_to :issuable, polymorphic: true - - after_initialize :set_default_severity - - enum :severity, { critical: 1, error: 2, warning: 3, info: 4 } - - validates :severity, presence: true - - scope :active, -> { where(resolved_at: nil) } - scope :ordered, -> { order(:severity) } - - def title - model_name.human - end - - # The conditions that must be met for an issue to be fixed - def stale? - raise NotImplementedError, "#{self.class} must implement #{__method__}" - end - - def resolve! - update!(resolved_at: Time.current) - end - - def default_severity - :warning - end - - private - - def set_default_severity - self.severity ||= default_severity - end -end diff --git a/app/models/issue/exchange_rate_provider_missing.rb b/app/models/issue/exchange_rate_provider_missing.rb deleted file mode 100644 index 72411990..00000000 --- a/app/models/issue/exchange_rate_provider_missing.rb +++ /dev/null @@ -1,9 +0,0 @@ -class Issue::ExchangeRateProviderMissing < Issue - def default_severity - :error - end - - def stale? - ExchangeRate.provider_healthy? - end -end diff --git a/app/models/issue/exchange_rates_missing.rb b/app/models/issue/exchange_rates_missing.rb deleted file mode 100644 index 1527fec5..00000000 --- a/app/models/issue/exchange_rates_missing.rb +++ /dev/null @@ -1,15 +0,0 @@ -class Issue::ExchangeRatesMissing < Issue - store_accessor :data, :from_currency, :to_currency, :dates - - validates :from_currency, :to_currency, :dates, presence: true - - def stale? - if dates.length == 1 - ExchangeRate.find_rate(from: from_currency, to: to_currency, date: dates.first).present? - else - sorted_dates = dates.sort - rates = ExchangeRate.find_rates(from: from_currency, to: to_currency, start_date: sorted_dates.first, end_date: sorted_dates.last) - rates.length == dates.length - end - end -end diff --git a/app/models/issue/unknown.rb b/app/models/issue/unknown.rb deleted file mode 100644 index d232ebcb..00000000 --- a/app/models/issue/unknown.rb +++ /dev/null @@ -1,11 +0,0 @@ -class Issue::Unknown < Issue - def default_severity - :warning - end - - # Unknown issues are always stale because we only want to show them - # to the user once. If the same error occurs again, we'll create a new instance. - def stale? - true - end -end diff --git a/app/models/plaid_account.rb b/app/models/plaid_account.rb index 77a4288d..e0e71f67 100644 --- a/app/models/plaid_account.rb +++ b/app/models/plaid_account.rb @@ -1,6 +1,4 @@ class PlaidAccount < ApplicationRecord - include Plaidable - TYPE_MAPPING = { "depository" => Depository, "credit" => CreditCard, diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index 9ffdadf1..b990729a 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -1,5 +1,5 @@ class PlaidItem < ApplicationRecord - include Plaidable, Syncable + include Provided, Syncable enum :plaid_region, { us: "us", eu: "eu" } enum :status, { good: "good", requires_update: "requires_update" }, default: :good diff --git a/app/models/concerns/plaidable.rb b/app/models/plaid_item/provided.rb similarity index 65% rename from app/models/concerns/plaidable.rb rename to app/models/plaid_item/provided.rb index 8765559d..761a75c1 100644 --- a/app/models/concerns/plaidable.rb +++ b/app/models/plaid_item/provided.rb @@ -1,13 +1,13 @@ -module Plaidable +module PlaidItem::Provided extend ActiveSupport::Concern class_methods do def plaid_us_provider - Provider::Plaid.new(Rails.application.config.plaid, region: :us) if Rails.application.config.plaid + Providers.plaid_us end def plaid_eu_provider - Provider::Plaid.new(Rails.application.config.plaid_eu, region: :eu) if Rails.application.config.plaid_eu + Providers.plaid_eu end def plaid_provider_for_region(region) diff --git a/app/models/provider.rb b/app/models/provider.rb new file mode 100644 index 00000000..6843475b --- /dev/null +++ b/app/models/provider.rb @@ -0,0 +1,35 @@ +class Provider + include Retryable + + ProviderError = Class.new(StandardError) + ProviderResponse = Data.define(:success?, :data, :error) + + private + PaginatedData = Data.define(:paginated, :first_page, :total_pages) + UsageData = Data.define(:used, :limit, :utilization, :plan) + + # Subclasses can specify errors that can be retried + def retryable_errors + [] + end + + def provider_response(retries: nil, &block) + data = if retries + retrying(retryable_errors, max_retries: retries) { yield } + else + yield + end + + ProviderResponse.new( + success?: true, + data: data, + error: nil, + ) + rescue StandardError => error + ProviderResponse.new( + success?: false, + data: nil, + error: error, + ) + end +end diff --git a/app/models/provider/base.rb b/app/models/provider/base.rb deleted file mode 100644 index dcf438e5..00000000 --- a/app/models/provider/base.rb +++ /dev/null @@ -1,18 +0,0 @@ - -class Provider::Base - ProviderError = Class.new(StandardError) - - TRANSIENT_NETWORK_ERRORS = [ - Faraday::TimeoutError, - Faraday::ConnectionFailed, - Faraday::SSLError, - Faraday::ClientError, - Faraday::ServerError - ] - - class << self - def known_transient_errors - TRANSIENT_NETWORK_ERRORS + [ ProviderError ] - end - end -end diff --git a/app/models/provider/synth.rb b/app/models/provider/synth.rb index 829a5b7f..89850aa3 100644 --- a/app/models/provider/synth.rb +++ b/app/models/provider/synth.rb @@ -1,217 +1,226 @@ -class Provider::Synth - include Retryable +class Provider::Synth < Provider + include ExchangeRate::Provideable + include Security::Provideable def initialize(api_key) @api_key = api_key end def healthy? - response = client.get("#{base_url}/user") - JSON.parse(response.body).dig("id").present? + provider_response do + response = client.get("#{base_url}/user") + JSON.parse(response.body).dig("id").present? + end end def usage - response = client.get("#{base_url}/user") + provider_response do + response = client.get("#{base_url}/user") - if response.status == 401 - return UsageResponse.new( - success?: false, - error: "Unauthorized: Invalid API key", - raw_response: response + parsed = JSON.parse(response.body) + + remaining = parsed.dig("api_calls_remaining") + limit = parsed.dig("api_limit") + used = limit - remaining + + UsageData.new( + used: used, + limit: limit, + utilization: used.to_f / limit * 100, + plan: parsed.dig("plan"), ) end - - parsed = JSON.parse(response.body) - - remaining = parsed.dig("api_calls_remaining") - limit = parsed.dig("api_limit") - used = limit - remaining - - UsageResponse.new( - used: used, - limit: limit, - utilization: used.to_f / limit * 100, - plan: parsed.dig("plan"), - success?: true, - raw_response: response - ) - rescue StandardError => error - UsageResponse.new( - success?: false, - error: error, - raw_response: error - ) end - def fetch_security_prices(ticker:, start_date:, end_date:, mic_code: nil) - params = { - start_date: start_date, - end_date: end_date - } - - params[:mic_code] = mic_code if mic_code.present? - - prices = paginate( - "#{base_url}/tickers/#{ticker}/open-close", - params - ) do |body| - body.dig("prices").map do |price| - { - date: price.dig("date"), - price: price.dig("close")&.to_f || price.dig("open")&.to_f, - currency: body.dig("currency") || "USD" - } - end - end - - SecurityPriceResponse.new \ - prices: prices, - success?: true, - raw_response: prices.to_json - rescue StandardError => error - SecurityPriceResponse.new \ - success?: false, - error: error, - raw_response: error - end + # ================================ + # Exchange Rates + # ================================ def fetch_exchange_rate(from:, to:, date:) - retrying Provider::Base.known_transient_errors do |on_last_attempt| + provider_response retries: 2 do response = client.get("#{base_url}/rates/historical") do |req| req.params["date"] = date.to_s req.params["from"] = from req.params["to"] = to end - if response.success? - ExchangeRateResponse.new \ - rate: JSON.parse(response.body).dig("data", "rates", to), - success?: true, - raw_response: response - else - if on_last_attempt - ExchangeRateResponse.new \ - success?: false, - error: build_error(response), - raw_response: response - else - raise build_error(response) - end - end + rates = JSON.parse(response.body).dig("data", "rates") + + ExchangeRate::Provideable::FetchRateData.new( + rate: ExchangeRate.new( + from_currency: from, + to_currency: to, + date: date, + rate: rates.dig(to) + ) + ) end end def fetch_exchange_rates(from:, to:, start_date:, end_date:) - exchange_rates = paginate( - "#{base_url}/rates/historical-range", - from: from, - to: to, - date_start: start_date.to_s, - date_end: end_date.to_s - ) do |body| - body.dig("data").map do |exchange_rate| - { - date: exchange_rate.dig("date"), - rate: exchange_rate.dig("rates", to) - } + provider_response retries: 1 do + data = paginate( + "#{base_url}/rates/historical-range", + from: from, + to: to, + date_start: start_date.to_s, + date_end: end_date.to_s + ) do |body| + body.dig("data") end - end - ExchangeRatesResponse.new \ - rates: exchange_rates, - success?: true, - raw_response: exchange_rates.to_json - rescue StandardError => error - ExchangeRatesResponse.new \ - success?: false, - error: error, - raw_response: error + ExchangeRate::Provideable::FetchRatesData.new( + rates: data.paginated.map do |exchange_rate| + ExchangeRate.new( + from_currency: from, + to_currency: to, + date: exchange_rate.dig("date"), + rate: exchange_rate.dig("rates", to) + ) + end + ) + end end - def search_securities(query:, dataset: "limited", country_code: nil, exchange_operating_mic: nil) - response = client.get("#{base_url}/tickers/search") do |req| - req.params["name"] = query - req.params["dataset"] = dataset - req.params["country_code"] = country_code if country_code.present? - req.params["exchange_operating_mic"] = exchange_operating_mic if exchange_operating_mic.present? - req.params["limit"] = 25 + # ================================ + # Securities + # ================================ + + def search_securities(symbol, country_code: nil, exchange_operating_mic: nil) + provider_response do + response = client.get("#{base_url}/tickers/search") do |req| + req.params["name"] = symbol + req.params["dataset"] = "limited" + req.params["country_code"] = country_code if country_code.present? + req.params["exchange_operating_mic"] = exchange_operating_mic if exchange_operating_mic.present? + req.params["limit"] = 25 + end + + parsed = JSON.parse(response.body) + + Security::Provideable::Search.new( + securities: parsed.dig("data").map do |security| + Security.new( + ticker: security.dig("symbol"), + name: security.dig("name"), + logo_url: security.dig("logo_url"), + exchange_acronym: security.dig("exchange", "acronym"), + exchange_mic: security.dig("exchange", "mic_code"), + exchange_operating_mic: security.dig("exchange", "operating_mic_code"), + country_code: security.dig("exchange", "country_code") + ) + end + ) end + end - parsed = JSON.parse(response.body) + def fetch_security_info(security) + provider_response do + response = client.get("#{base_url}/tickers/#{security.ticker}") do |req| + req.params["mic_code"] = security.exchange_mic if security.exchange_mic.present? + req.params["operating_mic"] = security.exchange_operating_mic if security.exchange_operating_mic.present? + end - securities = parsed.dig("data").map do |security| - { - ticker: security.dig("symbol"), - name: security.dig("name"), - logo_url: security.dig("logo_url"), - exchange_acronym: security.dig("exchange", "acronym"), - exchange_mic: security.dig("exchange", "mic_code"), - exchange_operating_mic: security.dig("exchange", "operating_mic_code"), - country_code: security.dig("exchange", "country_code") + data = JSON.parse(response.body).dig("data") + + Security::Provideable::SecurityInfo.new( + ticker: security.ticker, + name: data.dig("name"), + links: data.dig("links"), + logo_url: data.dig("logo_url"), + description: data.dig("description"), + kind: data.dig("kind") + ) + end + end + + def fetch_security_price(security, date:) + provider_response do + historical_data = fetch_security_prices(security, start_date: date, end_date: date) + + raise ProviderError, "No prices found for security #{security.ticker} on date #{date}" if historical_data.data.prices.empty? + + Security::Provideable::PriceData.new( + price: historical_data.data.prices.first + ) + end + end + + def fetch_security_prices(security, start_date:, end_date:) + provider_response retries: 1 do + params = { + start_date: start_date, + end_date: end_date } - end - SearchSecuritiesResponse.new \ - securities: securities, - success?: true, - raw_response: response + params[:operating_mic_code] = security.exchange_operating_mic if security.exchange_operating_mic.present? + + data = paginate( + "#{base_url}/tickers/#{security.ticker}/open-close", + params + ) do |body| + body.dig("prices") + end + + currency = data.first_page.dig("currency") + country_code = data.first_page.dig("exchange", "country_code") + exchange_mic = data.first_page.dig("exchange", "mic_code") + exchange_operating_mic = data.first_page.dig("exchange", "operating_mic_code") + + Security::Provideable::PricesData.new( + prices: data.paginated.map do |price| + Security::Price.new( + security: security, + date: price.dig("date"), + price: price.dig("close") || price.dig("open"), + currency: currency + ) + end + ) + end end - def fetch_security_info(ticker:, mic_code: nil, operating_mic: nil) - response = client.get("#{base_url}/tickers/#{ticker}") do |req| - req.params["mic_code"] = mic_code if mic_code.present? - req.params["operating_mic"] = operating_mic if operating_mic.present? - end - - parsed = JSON.parse(response.body) - - SecurityInfoResponse.new \ - info: parsed.dig("data"), - success?: true, - raw_response: response - end + # ================================ + # Transactions + # ================================ def enrich_transaction(description, amount: nil, date: nil, city: nil, state: nil, country: nil) - params = { - description: description, - amount: amount, - date: date, - city: city, - state: state, - country: country - }.compact + provider_response do + params = { + description: description, + amount: amount, + date: date, + city: city, + state: state, + country: country + }.compact - response = client.get("#{base_url}/enrich", params) + response = client.get("#{base_url}/enrich", params) - parsed = JSON.parse(response.body) + parsed = JSON.parse(response.body) - EnrichTransactionResponse.new \ - info: EnrichTransactionInfo.new( + TransactionEnrichmentData.new( name: parsed.dig("merchant"), icon_url: parsed.dig("icon"), category: parsed.dig("category") - ), - success?: true, - raw_response: response - rescue StandardError => error - EnrichTransactionResponse.new \ - success?: false, - error: error, - raw_response: error + ) + end end private - attr_reader :api_key - ExchangeRateResponse = Struct.new :rate, :success?, :error, :raw_response, keyword_init: true - SecurityPriceResponse = Struct.new :prices, :success?, :error, :raw_response, keyword_init: true - ExchangeRatesResponse = Struct.new :rates, :success?, :error, :raw_response, keyword_init: true - UsageResponse = Struct.new :used, :limit, :utilization, :plan, :success?, :error, :raw_response, keyword_init: true - SearchSecuritiesResponse = Struct.new :securities, :success?, :error, :raw_response, keyword_init: true - SecurityInfoResponse = Struct.new :info, :success?, :error, :raw_response, keyword_init: true - EnrichTransactionResponse = Struct.new :info, :success?, :error, :raw_response, keyword_init: true - EnrichTransactionInfo = Struct.new :name, :icon_url, :category, keyword_init: true + TransactionEnrichmentData = Data.define(:name, :icon_url, :category) + + def retryable_errors + [ + Faraday::TimeoutError, + Faraday::ConnectionFailed, + Faraday::SSLError, + Faraday::ClientError, + Faraday::ServerError + ] + end def base_url ENV["SYNTH_URL"] || "https://api.synthfinance.com" @@ -227,26 +236,15 @@ class Provider::Synth def client @client ||= Faraday.new(url: base_url) do |faraday| + faraday.response :raise_error faraday.headers["Authorization"] = "Bearer #{api_key}" faraday.headers["X-Source"] = app_name faraday.headers["X-Source-Type"] = app_type end end - def build_error(response) - Provider::Base::ProviderError.new(<<~ERROR) - Failed to fetch data from #{self.class} - Status: #{response.status} - Body: #{response.body.inspect} - ERROR - end - def fetch_page(url, page, params = {}) - client.get(url) do |req| - req.headers["Authorization"] = "Bearer #{api_key}" - params.each { |k, v| req.params[k.to_s] = v.to_s } - req.params["page"] = page - end + client.get(url, params.merge(page: page)) end def paginate(url, params = {}) @@ -254,24 +252,26 @@ class Provider::Synth page = 1 current_page = 0 total_pages = 1 + first_page = nil while current_page < total_pages response = fetch_page(url, page, params) - if response.success? - body = JSON.parse(response.body) - page_results = yield(body) - results.concat(page_results) + body = JSON.parse(response.body) + first_page = body unless first_page + page_results = yield(body) + results.concat(page_results) - current_page = body.dig("paging", "current_page") - total_pages = body.dig("paging", "total_pages") + current_page = body.dig("paging", "current_page") + total_pages = body.dig("paging", "total_pages") - page += 1 - else - raise build_error(response) - end + page += 1 end - results + PaginatedData.new( + paginated: results, + first_page: first_page, + total_pages: total_pages + ) end end diff --git a/app/models/providers.rb b/app/models/providers.rb new file mode 100644 index 00000000..e0cd48ea --- /dev/null +++ b/app/models/providers.rb @@ -0,0 +1,35 @@ +module Providers + module_function + + def synth + api_key = ENV.fetch("SYNTH_API_KEY", Setting.synth_api_key) + + return nil unless api_key.present? + + Provider::Synth.new(api_key) + end + + def plaid_us + config = Rails.application.config.plaid + + return nil unless config.present? + + Provider::Plaid.new(config, region: :us) + end + + def plaid_eu + config = Rails.application.config.plaid_eu + + return nil unless config.present? + + Provider::Plaid.new(config, region: :eu) + end + + def github + Provider::Github.new + end + + def openai + # TODO: Placeholder for AI chat PR + end +end diff --git a/app/models/security.rb b/app/models/security.rb index 6d94c798..72a09705 100644 --- a/app/models/security.rb +++ b/app/models/security.rb @@ -10,7 +10,7 @@ class Security < ApplicationRecord validates :ticker, uniqueness: { scope: :exchange_operating_mic, case_sensitive: false } def current_price - @current_price ||= Security::Price.find_price(security: self, date: Date.current) + @current_price ||= find_or_fetch_price return nil if @current_price.nil? Money.new(@current_price.price, @current_price.currency) end diff --git a/app/models/security/price.rb b/app/models/security/price.rb index 3341a448..4143a0c8 100644 --- a/app/models/security/price.rb +++ b/app/models/security/price.rb @@ -1,33 +1,6 @@ class Security::Price < ApplicationRecord - include Provided - belongs_to :security - validates :price, :currency, presence: true - - class << self - def find_price(security:, date:, cache: true) - result = find_by(security:, date:) - - result || fetch_price_from_provider(security:, date:, cache:) - end - - def find_prices(security:, start_date:, end_date: Date.current, cache: true) - prices = where(security_id: security.id, date: start_date..end_date).to_a - all_dates = (start_date..end_date).to_a.to_set - existing_dates = prices.map(&:date).to_set - missing_dates = (all_dates - existing_dates).sort - - if missing_dates.any? - prices += fetch_prices_from_provider( - security: security, - start_date: missing_dates.first, - end_date: missing_dates.last, - cache: cache - ) - end - - prices - end - end + validates :date, :price, :currency, presence: true + validates :date, uniqueness: { scope: %i[security_id currency] } end diff --git a/app/models/security/price/provided.rb b/app/models/security/price/provided.rb deleted file mode 100644 index c429e0a6..00000000 --- a/app/models/security/price/provided.rb +++ /dev/null @@ -1,65 +0,0 @@ -module Security::Price::Provided - extend ActiveSupport::Concern - - include Synthable - - class_methods do - def provider - synth_client - end - - private - def fetch_price_from_provider(security:, date:, cache: false) - return nil unless provider.present? - return nil unless security.has_prices? - - response = provider.fetch_security_prices \ - ticker: security.ticker, - mic_code: security.exchange_operating_mic, - start_date: date, - end_date: date - - if response.success? && response.prices.size > 0 - price = Security::Price.new \ - security: security, - date: response.prices.first[:date], - price: response.prices.first[:price], - currency: response.prices.first[:currency] - - price.save! if cache - price - else - nil - end - end - - def fetch_prices_from_provider(security:, start_date:, end_date:, cache: false) - return [] unless provider.present? - return [] unless security - return [] unless security.has_prices? - - response = provider.fetch_security_prices \ - ticker: security.ticker, - mic_code: security.exchange_operating_mic, - start_date: start_date, - end_date: end_date - - if response.success? - response.prices.map do |price| - new_price = Security::Price.find_or_initialize_by( - security: security, - date: price[:date] - ) do |p| - p.price = price[:price] - p.currency = price[:currency] - end - - new_price.save! if cache && new_price.new_record? - new_price - end - else - [] - end - end - end -end diff --git a/app/models/security/provideable.rb b/app/models/security/provideable.rb new file mode 100644 index 00000000..2227e19f --- /dev/null +++ b/app/models/security/provideable.rb @@ -0,0 +1,31 @@ +module Security::Provideable + extend ActiveSupport::Concern + + Search = Data.define(:securities) + PriceData = Data.define(:price) + PricesData = Data.define(:prices) + SecurityInfo = Data.define( + :ticker, + :name, + :links, + :logo_url, + :description, + :kind, + ) + + def search_securities(symbol, country_code: nil, exchange_operating_mic: nil) + raise NotImplementedError, "Subclasses must implement #search_securities" + end + + def fetch_security_info(security) + raise NotImplementedError, "Subclasses must implement #fetch_security_info" + end + + def fetch_security_price(security, date:) + raise NotImplementedError, "Subclasses must implement #fetch_security_price" + end + + def fetch_security_prices(security, start_date:, end_date:) + raise NotImplementedError, "Subclasses must implement #fetch_security_prices" + end +end diff --git a/app/models/security/provided.rb b/app/models/security/provided.rb index c7e38fb5..4ef0f735 100644 --- a/app/models/security/provided.rb +++ b/app/models/security/provided.rb @@ -1,28 +1,65 @@ module Security::Provided extend ActiveSupport::Concern - include Synthable - class_methods do def provider - synth_client + Providers.synth end - def search_provider(query) - return [] if query[:search].blank? || query[:search].length < 2 + def search_provider(symbol, country_code: nil, exchange_operating_mic: nil) + return [] if symbol.blank? || symbol.length < 2 - response = provider.search_securities( - query: query[:search], - dataset: "limited", - country_code: query[:country], - exchange_operating_mic: query[:exchange_operating_mic] - ) + response = provider.search_securities(symbol, country_code: country_code, exchange_operating_mic: exchange_operating_mic) if response.success? - response.securities.map { |attrs| new(**attrs) } + response.data.securities else [] end end end + + def sync_provider_prices(start_date:, end_date: Date.current) + unless has_prices? + Rails.logger.warn("Security id=#{id} ticker=#{ticker} is not known by provider, skipping price sync") + return 0 + end + + unless provider.present? + Rails.logger.warn("No security provider configured, cannot sync prices for id=#{id} ticker=#{ticker}") + return 0 + end + + response = provider.fetch_security_prices(self, start_date: start_date, end_date: end_date) + + unless response.success? + Rails.logger.error("Provider error for sync_provider_prices with id=#{id} ticker=#{ticker}: #{response.error}") + return 0 + end + + fetched_prices = response.data.prices.map do |price| + price.attributes.slice("security_id", "date", "price", "currency") + end + + Security::Price.upsert_all(fetched_prices, unique_by: %i[security_id date currency]) + end + + def find_or_fetch_price(date: Date.current, cache: true) + price = prices.find_by(date: date) + + return price if price.present? + + response = provider.fetch_security_price(self, date: date) + + return nil unless response.success? # Provider error + + price = response.data.price + price.save! if cache + price + end + + private + def provider + self.class.provider + end end diff --git a/app/models/trade_import.rb b/app/models/trade_import.rb index 4bbffdaa..b4d464d1 100644 --- a/app/models/trade_import.rb +++ b/app/models/trade_import.rb @@ -97,7 +97,7 @@ class TradeImport < Import provider_security = @provider_securities_cache[cache_key] ||= begin Security.search_provider( - query: ticker, + ticker, exchange_operating_mic: exchange_operating_mic ).first end diff --git a/app/models/upgrader/provided.rb b/app/models/upgrader/provided.rb index fc1e65b7..c0eac5e0 100644 --- a/app/models/upgrader/provided.rb +++ b/app/models/upgrader/provided.rb @@ -4,11 +4,7 @@ module Upgrader::Provided class_methods do private def fetch_latest_upgrade_candidates_from_provider - git_repository_provider.fetch_latest_upgrade_candidates - end - - def git_repository_provider - Provider::Github.new + Providers.github.fetch_latest_upgrade_candidates end end end diff --git a/app/views/account/trades/_form.html.erb b/app/views/account/trades/_form.html.erb index 89c8c151..25f4cf0a 100644 --- a/app/views/account/trades/_form.html.erb +++ b/app/views/account/trades/_form.html.erb @@ -37,7 +37,7 @@ required: true %>
      <% else %> - <%= form.text_field :manual_ticker, label: "Ticker", placeholder: "AAPL", required: true %> + <%= form.text_field :manual_ticker, label: "Ticker symbol", placeholder: "AAPL", required: true %> <% end %> <% end %> diff --git a/app/views/accounts/_account.html.erb b/app/views/accounts/_account.html.erb index a037416a..80ad5a07 100644 --- a/app/views/accounts/_account.html.erb +++ b/app/views/accounts/_account.html.erb @@ -17,13 +17,6 @@

      <% else %> <%= link_to account.name, account, class: [(account.is_active ? "text-primary" : "text-subdued"), "text-sm font-medium hover:underline"], data: { turbo_frame: "_top" } %> - <% if account.has_issues? %> -
      - <%= lucide_icon "alert-octagon", class: "shrink-0 w-4 h-4" %> - <%= tag.span t(".has_issues") %> - <%= link_to t(".troubleshoot"), issue_path(account.issues.first), class: "underline", data: { turbo_frame: :drawer } %> -
      - <% end %> <% end %>
      diff --git a/app/views/accounts/_account_sidebar_tabs.html.erb b/app/views/accounts/_account_sidebar_tabs.html.erb index bb409492..2acbf65f 100644 --- a/app/views/accounts/_account_sidebar_tabs.html.erb +++ b/app/views/accounts/_account_sidebar_tabs.html.erb @@ -1,6 +1,6 @@ <%# locals: (family:) %> -<% if family.requires_data_provider? && family.synth_client.nil? %> +<% if family.requires_data_provider? && Providers.synth.nil? %>
      diff --git a/app/views/accounts/show/_header.html.erb b/app/views/accounts/show/_header.html.erb index 8dbaf273..6d822711 100644 --- a/app/views/accounts/show/_header.html.erb +++ b/app/views/accounts/show/_header.html.erb @@ -35,8 +35,4 @@ <%= render "accounts/show/menu", account: account %>
      - - <% if account.highest_priority_issue %> - <%= render partial: "issues/issue", locals: { issue: account.highest_priority_issue } %> - <% end %> diff --git a/app/views/issue/_request_synth_data_action.html.erb b/app/views/issue/_request_synth_data_action.html.erb deleted file mode 100644 index a805524b..00000000 --- a/app/views/issue/_request_synth_data_action.html.erb +++ /dev/null @@ -1,12 +0,0 @@ -

      The Synth data provider could not find the requested data.

      - -

      We are actively developing Synth to be a low cost and easy to use data provider. You can help us improve Synth by - requesting the data you need.

      - -

      Please post in our <%= link_to "Discord server", "https://link.maybe.co/discord", target: "_blank" %> with the - following information:

      - -
        -
      • What type of data is missing?
      • -
      • Any other information you think might be helpful
      • -
      diff --git a/app/views/issue/exchange_rate_provider_missings/show.html.erb b/app/views/issue/exchange_rate_provider_missings/show.html.erb deleted file mode 100644 index a00cef56..00000000 --- a/app/views/issue/exchange_rate_provider_missings/show.html.erb +++ /dev/null @@ -1,28 +0,0 @@ -<%= content_for :title, @issue.title %> - -<%= content_for :description do %> -

      You have set your family currency preference to <%= Current.family.currency %>. <%= @issue.issuable.name %> has - entries in another currency, which means we have to fetch exchange rates from a data provider to accurately show - historical results.

      - -

      We have detected that your exchange rates provider is not configured yet.

      -<% end %> - -<%= content_for :action do %> - <% if self_hosted? %> -

      To fix this issue, you need to provide an API key for your exchange rate provider.

      - -

      Currently, we support <%= link_to "Synth Finance", "https://synthfinance.com", target: "_blank" %>, so you need - to - get a free API key from the link provided.

      - -

      Once you have your API key, paste it below to configure it.

      - - <%= styled_form_with model: @issue, url: issue_exchange_rate_provider_missing_path(@issue), method: :patch, class: "space-y-3" do |form| %> - <%= form.text_field :synth_api_key, label: "Synth API Key", placeholder: "Synth API Key", type: "password", class: "w-full", value: Setting.synth_api_key %> - <%= form.submit "Save and Re-Sync Account", class: "btn-primary" %> - <% end %> - <% else %> -

      Please contact the Maybe team.

      - <% end %> -<% end %> diff --git a/app/views/issue/exchange_rates_missings/show.html.erb b/app/views/issue/exchange_rates_missings/show.html.erb deleted file mode 100644 index 65c624ed..00000000 --- a/app/views/issue/exchange_rates_missings/show.html.erb +++ /dev/null @@ -1,11 +0,0 @@ -<%= content_for :title, @issue.title %> - -<%= content_for :description do %> -

      Some exchange rates are missing for this account.

      - -
      <%= JSON.pretty_generate(@issue.data) %>
      -<% end %> - -<%= content_for :action do %> - <%= render "issue/request_synth_data_action" %> -<% end %> diff --git a/app/views/issue/prices_missings/show.html.erb b/app/views/issue/prices_missings/show.html.erb deleted file mode 100644 index c71e7bff..00000000 --- a/app/views/issue/prices_missings/show.html.erb +++ /dev/null @@ -1,11 +0,0 @@ -<%= content_for :title, @issue.title %> - -<%= content_for :description do %> -

      Some stock prices are missing for this account.

      - -
      <%= JSON.pretty_generate(@issue.data) %>
      -<% end %> - -<%= content_for :action do %> - <%= render "issue/request_synth_data_action" %> -<% end %> diff --git a/app/views/issue/unknowns/show.html.erb b/app/views/issue/unknowns/show.html.erb deleted file mode 100644 index a6ba2084..00000000 --- a/app/views/issue/unknowns/show.html.erb +++ /dev/null @@ -1,23 +0,0 @@ -<%= content_for :title, @issue.title %> - -<%= content_for :description do %> -

      An unknown issue has occurred.

      - -
      <%= JSON.pretty_generate(@issue.data || "No data provided for this issue") %>
      -<% end %> - -<%= content_for :action do %> -

      There is no fix for this issue yet.

      - -

      Maybe is in active development and we value your feedback. There are a couple ways you can report this issue to - help us make Maybe better:

      - -
        -
      • Post in our <%= link_to "Discord server", "https://link.maybe.co/discord", target: "_blank" %>
      • -
      • Open an issue on - our <%= link_to "Github repository", "https://github.com/maybe-finance/maybe/issues", target: "_blank" %>
      • -
      - -

      If there is data shown in the code block above that you think might be helpful, please include it in your - report.

      -<% end %> diff --git a/app/views/issues/_issue.html.erb b/app/views/issues/_issue.html.erb deleted file mode 100644 index 76ee7c62..00000000 --- a/app/views/issues/_issue.html.erb +++ /dev/null @@ -1,15 +0,0 @@ -<%# locals: (issue:) %> - -<% priority_class = issue.critical? || issue.error? ? "bg-error/5" : "bg-warning/5" %> -<% text_class = issue.critical? || issue.error? ? "text-error" : "text-warning" %> - -<%= tag.div class: "flex gap-6 items-center rounded-xl px-4 py-3 #{priority_class}" do %> -
      - <%= lucide_icon("alert-octagon", class: "w-5 h-5 shrink-0") %> -

      <%= issue.title %>

      -
      - -
      - <%= link_to "Troubleshoot", issue_path(issue), class: "#{text_class} font-medium hover:underline", data: { turbo_frame: :drawer } %> -
      -<% end %> diff --git a/app/views/layouts/issues.html.erb b/app/views/layouts/issues.html.erb deleted file mode 100644 index 4ae03b85..00000000 --- a/app/views/layouts/issues.html.erb +++ /dev/null @@ -1,15 +0,0 @@ -<%= render "layouts/shared/htmldoc" do %> - <%= drawer do %> -
      - <%= tag.h2 do %> - <%= yield :title %> - <% end %> - - <%= tag.h3 "Issue Description" %> - <%= yield :description %> - - <%= tag.h3 "How to fix this issue" %> - <%= yield :action %> -
      - <% end %> -<% end %> diff --git a/app/views/settings/hostings/_synth_settings.html.erb b/app/views/settings/hostings/_synth_settings.html.erb index 4ee9c182..d8f71839 100644 --- a/app/views/settings/hostings/_synth_settings.html.erb +++ b/app/views/settings/hostings/_synth_settings.html.erb @@ -30,18 +30,18 @@

      <%= t(".api_calls_used", - used: number_with_delimiter(@synth_usage.used), - limit: number_with_delimiter(@synth_usage.limit), - percentage: number_to_percentage(@synth_usage.utilization, precision: 1)) %> + used: number_with_delimiter(@synth_usage.data.used), + limit: number_with_delimiter(@synth_usage.data.limit), + percentage: number_to_percentage(@synth_usage.data.utilization, precision: 1)) %>

      + style="width: <%= [@synth_usage.data.utilization, 2].max %>%;">

      - <%= t(".plan", plan: @synth_usage.plan) %> + <%= t(".plan", plan: @synth_usage.data.plan) %>

      diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 65697755..0040f4b9 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -1,28 +1,5 @@ { "ignored_warnings": [ - { - "warning_type": "Dynamic Render Path", - "warning_code": 15, - "fingerprint": "03a2010b605b8bdb7d4e1566720904d69ef2fbf8e7bc35735b84e161b475215e", - "check_name": "Render", - "message": "Render path contains parameter value", - "file": "app/controllers/issues_controller.rb", - "line": 5, - "link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/", - "code": "render(template => \"#{Current.family.issues.find(params[:id]).class.name.underscore.pluralize}/show\", { :layout => \"issues\" })", - "render_path": null, - "location": { - "type": "method", - "class": "IssuesController", - "method": "show" - }, - "user_input": "params[:id]", - "confidence": "Weak", - "cwe_id": [ - 22 - ], - "note": "" - }, { "warning_type": "Mass Assignment", "warning_code": 105, @@ -30,7 +7,7 @@ "check_name": "PermitAttributes", "message": "Potentially dangerous key allowed for mass assignment", "file": "app/controllers/concerns/entryable_resource.rb", - "line": 122, + "line": 124, "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/", "code": "params.require(:account_entry).permit(:account_id, :name, :enriched_name, :date, :amount, :currency, :excluded, :notes, :nature, :entryable_attributes => self.class.permitted_entryable_attributes)", "render_path": null, @@ -53,7 +30,7 @@ "check_name": "PermitAttributes", "message": "Potentially dangerous key allowed for mass assignment", "file": "app/controllers/invitations_controller.rb", - "line": 40, + "line": 58, "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/", "code": "params.require(:invitation).permit(:email, :role)", "render_path": null, @@ -76,7 +53,7 @@ "check_name": "CrossSiteScripting", "message": "Unescaped model attribute", "file": "app/views/pages/changelog.html.erb", - "line": 22, + "line": 18, "link": "https://brakemanscanner.org/docs/warning_types/cross_site_scripting", "code": "Provider::Github.new.fetch_latest_release_notes[:body]", "render_path": [ @@ -84,7 +61,7 @@ "type": "controller", "class": "PagesController", "method": "changelog", - "line": 35, + "line": 15, "file": "app/controllers/pages_controller.rb", "rendered": { "name": "pages/changelog", @@ -134,7 +111,7 @@ "check_name": "Render", "message": "Render path contains parameter value", "file": "app/views/import/configurations/show.html.erb", - "line": 15, + "line": 34, "link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/", "code": "render(partial => permitted_import_configuration_path(Current.family.imports.find(params[:import_id])), { :locals => ({ :import => Current.family.imports.find(params[:import_id]) }) })", "render_path": [ diff --git a/config/locales/models/issue/en.yml b/config/locales/models/issue/en.yml deleted file mode 100644 index 9c0938b4..00000000 --- a/config/locales/models/issue/en.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -en: - activerecord: - models: - issue/exchange_rate_provider_missing: Exchange rate provider missing - issue/exchange_rates_missing: Exchange rates missing - issue/missing_prices: Missing prices - issue/unknown: Unknown issue occurred diff --git a/config/locales/views/accounts/en.yml b/config/locales/views/accounts/en.yml index ecfe68a1..f557755e 100644 --- a/config/locales/views/accounts/en.yml +++ b/config/locales/views/accounts/en.yml @@ -2,7 +2,6 @@ en: accounts: account: - has_issues: Issue detected. troubleshoot: Troubleshoot chart: no_change: no change diff --git a/config/routes.rb b/config/routes.rb index c714a3ef..da875dce 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -154,12 +154,6 @@ Rails.application.routes.draw do resources :invite_codes, only: %i[index create] - resources :issues, only: :show - - namespace :issue do - resources :exchange_rate_provider_missings, only: :update - end - resources :invitations, only: [ :new, :create, :destroy ] do get :accept, on: :member end diff --git a/db/migrate/20250315191233_remove_ticker_from_security_prices.rb b/db/migrate/20250315191233_remove_ticker_from_security_prices.rb new file mode 100644 index 00000000..6fd33f06 --- /dev/null +++ b/db/migrate/20250315191233_remove_ticker_from_security_prices.rb @@ -0,0 +1,5 @@ +class RemoveTickerFromSecurityPrices < ActiveRecord::Migration[7.2] + def change + remove_column :security_prices, :ticker + end +end diff --git a/db/migrate/20250316103753_remove_issues.rb b/db/migrate/20250316103753_remove_issues.rb new file mode 100644 index 00000000..31ae4179 --- /dev/null +++ b/db/migrate/20250316103753_remove_issues.rb @@ -0,0 +1,11 @@ +class RemoveIssues < ActiveRecord::Migration[7.2] + def change + drop_table :issues do |t| + t.references :issuable, polymorphic: true, null: false + t.string :type, null: false + t.integer :severity, null: false + t.datetime :last_observed_at + t.datetime :resolved_at + end + end +end diff --git a/db/migrate/20250316122019_security_price_unique_index.rb b/db/migrate/20250316122019_security_price_unique_index.rb new file mode 100644 index 00000000..4a8dea64 --- /dev/null +++ b/db/migrate/20250316122019_security_price_unique_index.rb @@ -0,0 +1,31 @@ +class SecurityPriceUniqueIndex < ActiveRecord::Migration[7.2] + def change + # First, we have to delete duplicate prices from DB so we can apply the unique index + reversible do |dir| + dir.up do + execute <<~SQL + DELETE FROM security_prices + WHERE id IN ( + SELECT id FROM ( + SELECT id, + ROW_NUMBER() OVER ( + PARTITION BY security_id, date, currency + ORDER BY updated_at DESC, id DESC + ) as row_num + FROM security_prices + ) as duplicates + WHERE row_num > 1 + ); + SQL + end + end + + add_index :security_prices, [ :security_id, :date, :currency ], unique: true + change_column_null :security_prices, :date, false + change_column_null :security_prices, :price, false + change_column_null :security_prices, :currency, false + + change_column_null :exchange_rates, :date, false + change_column_null :exchange_rates, :rate, false + end +end diff --git a/db/schema.rb b/db/schema.rb index 59eabedf..abbaacf5 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_03_04_140435) do +ActiveRecord::Schema[7.2].define(version: 2025_03_16_122019) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -219,8 +219,8 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_04_140435) do create_table "exchange_rates", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "from_currency", null: false t.string "to_currency", null: false - t.decimal "rate" - t.date "date" + t.decimal "rate", null: false + t.date "date", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["from_currency", "to_currency", "date"], name: "index_exchange_rates_on_base_converted_date_unique", unique: true @@ -449,19 +449,6 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_04_140435) do t.index ["token"], name: "index_invite_codes_on_token", unique: true end - create_table "issues", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "issuable_type" - t.uuid "issuable_id" - t.string "type" - t.integer "severity" - t.datetime "last_observed_at" - t.datetime "resolved_at" - t.jsonb "data" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["issuable_type", "issuable_id"], name: "index_issues_on_issuable" - end - create_table "loans", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -560,13 +547,13 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_04_140435) do end create_table "security_prices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "ticker" - t.date "date" - t.decimal "price", precision: 19, scale: 4 - t.string "currency", default: "USD" + t.date "date", null: false + t.decimal "price", precision: 19, scale: 4, null: false + t.string "currency", default: "USD", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.uuid "security_id" + t.index ["security_id", "date", "currency"], name: "index_security_prices_on_security_id_and_date_and_currency", unique: true t.index ["security_id"], name: "index_security_prices_on_security_id" end diff --git a/lib/money.rb b/lib/money.rb index a00fc836..ed1e00d4 100644 --- a/lib/money.rb +++ b/lib/money.rb @@ -46,7 +46,7 @@ class Money if iso_code == other_iso_code self else - exchange_rate = store.find_rate(from: iso_code, to: other_iso_code, date: date)&.rate || fallback_rate + exchange_rate = store.find_or_fetch_rate(from: iso_code, to: other_iso_code, date: date)&.rate || fallback_rate raise ConversionError.new(from_currency: iso_code, to_currency: other_iso_code, date: date) unless exchange_rate diff --git a/test/controllers/issue/exchange_rate_provider_missings_controller_test.rb b/test/controllers/issue/exchange_rate_provider_missings_controller_test.rb deleted file mode 100644 index 6b884014..00000000 --- a/test/controllers/issue/exchange_rate_provider_missings_controller_test.rb +++ /dev/null @@ -1,19 +0,0 @@ -require "test_helper" - -class Issue::ExchangeRateProviderMissingsControllerTest < ActionDispatch::IntegrationTest - setup do - sign_in users(:family_admin) - @issue = issues(:one) - end - - test "should update issue" do - patch issue_exchange_rate_provider_missing_url(@issue), params: { - issue_exchange_rate_provider_missing: { - synth_api_key: "1234" - } - } - - assert_enqueued_with job: SyncJob - assert_redirected_to @issue.issuable - end -end diff --git a/test/controllers/issues_controller_test.rb b/test/controllers/issues_controller_test.rb deleted file mode 100644 index d18970b7..00000000 --- a/test/controllers/issues_controller_test.rb +++ /dev/null @@ -1,18 +0,0 @@ -require "test_helper" - -class IssuesControllerTest < ActionDispatch::IntegrationTest - setup do - sign_in users(:family_admin) - end - - test "should get show polymorphically" do - issues.each do |issue| - get issue_url(issue) - - assert_response :success - assert_dom "h2", text: issue.title - assert_dom "h3", text: "Issue Description" - assert_dom "h3", text: "How to fix this issue" - end - end -end diff --git a/test/controllers/settings/hostings_controller_test.rb b/test/controllers/settings/hostings_controller_test.rb index 3ee8a226..2e092952 100644 --- a/test/controllers/settings/hostings_controller_test.rb +++ b/test/controllers/settings/hostings_controller_test.rb @@ -1,8 +1,22 @@ require "test_helper" +require "ostruct" class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest + include ProviderTestHelper + setup do sign_in users(:family_admin) + + @provider = mock + Providers.stubs(:synth).returns(@provider) + @usage_response = provider_success_response( + OpenStruct.new( + used: 10, + limit: 100, + utilization: 10, + plan: "free", + ) + ) end test "cannot edit when self hosting is disabled" do @@ -16,6 +30,8 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest end test "should get edit when self hosting is enabled" do + @provider.expects(:usage).returns(@usage_response) + with_self_hosting do get settings_hosting_url assert_response :success diff --git a/test/fixtures/issues.yml b/test/fixtures/issues.yml deleted file mode 100644 index 23ccd35d..00000000 --- a/test/fixtures/issues.yml +++ /dev/null @@ -1,5 +0,0 @@ -one: - issuable: depository - issuable_type: Account - type: Issue::Unknown - last_observed_at: 2024-08-15 08:54:04 diff --git a/test/interfaces/exchange_rate_provider_interface_test.rb b/test/interfaces/exchange_rate_provider_interface_test.rb index d45e546e..748c66f0 100644 --- a/test/interfaces/exchange_rate_provider_interface_test.rb +++ b/test/interfaces/exchange_rate_provider_interface_test.rb @@ -3,20 +3,34 @@ require "test_helper" module ExchangeRateProviderInterfaceTest extend ActiveSupport::Testing::Declarative - test "exchange rate provider interface" do - assert_respond_to @subject, :healthy? - assert_respond_to @subject, :fetch_exchange_rate - assert_respond_to @subject, :fetch_exchange_rates - end + test "fetches single exchange rate" do + VCR.use_cassette("#{vcr_key_prefix}/exchange_rate") do + response = @subject.fetch_exchange_rate( + from: "USD", + to: "GBP", + date: Date.parse("01.01.2024") + ) - test "exchange rate provider response contract" do - VCR.use_cassette "synth/exchange_rate" do - response = @subject.fetch_exchange_rate from: "USD", to: "MXN", date: Date.iso8601("2024-08-01") + rate = response.data.rate - assert_respond_to response, :rate - assert_respond_to response, :success? - assert_respond_to response, :error - assert_respond_to response, :raw_response + assert_kind_of ExchangeRate, rate + assert_equal "USD", rate.from_currency + assert_equal "GBP", rate.to_currency end end + + test "fetches paginated exchange_rate historical data" do + VCR.use_cassette("#{vcr_key_prefix}/exchange_rates") do + response = @subject.fetch_exchange_rates( + from: "USD", to: "GBP", start_date: Date.parse("01.01.2024"), end_date: Date.parse("31.07.2024") + ) + + assert 213, response.data.rates.count # 213 days between 01.01.2024 and 31.07.2024 + end + end + + private + def vcr_key_prefix + @subject.class.name.demodulize.underscore + end end diff --git a/test/interfaces/security_price_provider_interface_test.rb b/test/interfaces/security_price_provider_interface_test.rb deleted file mode 100644 index 484d25fa..00000000 --- a/test/interfaces/security_price_provider_interface_test.rb +++ /dev/null @@ -1,26 +0,0 @@ -require "test_helper" - -module SecurityPriceProviderInterfaceTest - extend ActiveSupport::Testing::Declarative - - test "security price provider interface" do - assert_respond_to @subject, :healthy? - assert_respond_to @subject, :fetch_security_prices - end - - test "security price provider response contract" do - VCR.use_cassette "synth/security_prices" do - response = @subject.fetch_security_prices( - ticker: "AAPL", - mic_code: "XNAS", - start_date: Date.iso8601("2024-01-01"), - end_date: Date.iso8601("2024-08-01") - ) - - assert_respond_to response, :prices - assert_respond_to response, :success? - assert_respond_to response, :error - assert_respond_to response, :raw_response - end - end -end diff --git a/test/interfaces/security_provider_interface_test.rb b/test/interfaces/security_provider_interface_test.rb new file mode 100644 index 00000000..b22a6a10 --- /dev/null +++ b/test/interfaces/security_provider_interface_test.rb @@ -0,0 +1,62 @@ +require "test_helper" + +module SecurityProviderInterfaceTest + extend ActiveSupport::Testing::Declarative + + test "fetches security price" do + aapl = securities(:aapl) + + VCR.use_cassette("#{vcr_key_prefix}/security_price") do + response = @subject.fetch_security_price(aapl, date: Date.iso8601("2024-08-01")) + assert response.success? + assert response.data.price.present? + end + end + + test "fetches paginated securities prices" do + aapl = securities(:aapl) + + VCR.use_cassette("#{vcr_key_prefix}/security_prices") do + response = @subject.fetch_security_prices( + aapl, + start_date: Date.iso8601("2024-01-01"), + end_date: Date.iso8601("2024-08-01") + ) + + assert response.success? + assert 213, response.data.prices.count + end + end + + test "searches securities" do + VCR.use_cassette("#{vcr_key_prefix}/security_search") do + response = @subject.search_securities("AAPL", country_code: "US") + securities = response.data.securities + + assert securities.any? + security = securities.first + assert_kind_of Security, security + assert_equal "AAPL", security.ticker + end + end + + test "fetches security info" do + aapl = securities(:aapl) + + VCR.use_cassette("#{vcr_key_prefix}/security_info") do + response = @subject.fetch_security_info(aapl) + info = response.data + + assert_equal "AAPL", info.ticker + assert_equal "Apple Inc.", info.name + assert info.logo_url.present? + assert_equal "common stock", info.kind + assert info.description.present? + end + end + + private + def vcr_key_prefix + @subject.class.name.demodulize.underscore + end +end diff --git a/test/lib/money_test.rb b/test/lib/money_test.rb index 60d04284..699a1ab3 100644 --- a/test/lib/money_test.rb +++ b/test/lib/money_test.rb @@ -91,13 +91,13 @@ class MoneyTest < ActiveSupport::TestCase end test "converts currency when rate available" do - ExchangeRate.expects(:find_rate).returns(OpenStruct.new(rate: 1.2)) + ExchangeRate.expects(:find_or_fetch_rate).returns(OpenStruct.new(rate: 1.2)) assert_equal Money.new(1000).exchange_to(:eur), Money.new(1000 * 1.2, :eur) end test "raises when no conversion rate available and no fallback rate provided" do - ExchangeRate.expects(:find_rate).returns(nil) + ExchangeRate.expects(:find_or_fetch_rate).returns(nil) assert_raises Money::ConversionError do Money.new(1000).exchange_to(:jpy) @@ -105,7 +105,7 @@ class MoneyTest < ActiveSupport::TestCase end test "converts currency with a fallback rate" do - ExchangeRate.expects(:find_rate).returns(nil).twice + ExchangeRate.expects(:find_or_fetch_rate).returns(nil).twice assert_equal 0, Money.new(1000).exchange_to(:jpy, fallback_rate: 0) assert_equal Money.new(1000, :jpy), Money.new(1000, :usd).exchange_to(:jpy, fallback_rate: 1) diff --git a/test/models/account/convertible_test.rb b/test/models/account/convertible_test.rb index a142f881..8fb739c6 100644 --- a/test/models/account/convertible_test.rb +++ b/test/models/account/convertible_test.rb @@ -2,7 +2,7 @@ require "test_helper" require "ostruct" class Account::ConvertibleTest < ActiveSupport::TestCase - include Account::EntriesTestHelper + include Account::EntriesTestHelper, ProviderTestHelper setup do @family = families(:empty) @@ -16,33 +16,28 @@ class Account::ConvertibleTest < ActiveSupport::TestCase end test "syncs required exchange rates for an account" do - create_valuation(account: @account, date: 5.days.ago.to_date, amount: 9500, currency: "EUR") + create_valuation(account: @account, date: 1.day.ago.to_date, amount: 9500, currency: "EUR") - # Since we had a valuation 5 days ago, this account starts 6 days ago and needs daily exchange rates looking forward - assert_equal 6.days.ago.to_date, @account.start_date + # Since we had a valuation 1 day ago, this account starts 2 days ago and needs daily exchange rates looking forward + assert_equal 2.days.ago.to_date, @account.start_date + + ExchangeRate.delete_all + + provider_response = provider_success_response( + ExchangeRate::Provideable::FetchRatesData.new( + rates: [ + ExchangeRate.new(from_currency: "EUR", to_currency: "USD", date: 2.days.ago.to_date, rate: 1.1), + ExchangeRate.new(from_currency: "EUR", to_currency: "USD", date: 1.day.ago.to_date, rate: 1.2), + ExchangeRate.new(from_currency: "EUR", to_currency: "USD", date: Date.current, rate: 1.3) + ] + ) + ) @provider.expects(:fetch_exchange_rates) - .with( - from: "EUR", - to: "USD", - start_date: 6.days.ago.to_date, - end_date: Date.current - ).returns( - OpenStruct.new( - success?: true, - rates: [ - OpenStruct.new(date: 6.days.ago.to_date, rate: 1.1), - OpenStruct.new(date: 5.days.ago.to_date, rate: 1.2), - OpenStruct.new(date: 4.days.ago.to_date, rate: 1.3), - OpenStruct.new(date: 3.days.ago.to_date, rate: 1.4), - OpenStruct.new(date: 2.days.ago.to_date, rate: 1.5), - OpenStruct.new(date: 1.day.ago.to_date, rate: 1.6), - OpenStruct.new(date: Date.current, rate: 1.7) - ] - ) - ) + .with(from: "EUR", to: "USD", start_date: 2.days.ago.to_date, end_date: Date.current) + .returns(provider_response) - assert_difference "ExchangeRate.count", 7 do + assert_difference "ExchangeRate.count", 3 do @account.sync_required_exchange_rates end end diff --git a/test/models/account/holding/portfolio_cache_test.rb b/test/models/account/holding/portfolio_cache_test.rb index b973fa00..bebc66c2 100644 --- a/test/models/account/holding/portfolio_cache_test.rb +++ b/test/models/account/holding/portfolio_cache_test.rb @@ -1,63 +1,93 @@ require "test_helper" class Account::Holding::PortfolioCacheTest < ActiveSupport::TestCase - include Account::EntriesTestHelper + include Account::EntriesTestHelper, ProviderTestHelper setup do - # Prices, highest to lowest priority - @db_price = 210 - @provider_price = 220 - @trade_price = 200 - @holding_price = 250 + @provider = mock + Security.stubs(:provider).returns(@provider) - @account = families(:empty).accounts.create!(name: "Test Brokerage", balance: 10000, currency: "USD", accountable: Investment.new) - @test_security = Security.create!(name: "Test Security", ticker: "TEST") + @account = families(:empty).accounts.create!( + name: "Test Brokerage", + balance: 10000, + currency: "USD", + accountable: Investment.new + ) - @trade = create_trade(@test_security, account: @account, qty: 1, date: Date.current, price: @trade_price) - @holding = Account::Holding.create!(security: @test_security, account: @account, date: Date.current, qty: 1, price: @holding_price, amount: @holding_price, currency: "USD") - Security::Price.create!(security: @test_security, date: Date.current, price: @db_price) + @security = Security.create!(name: "Test Security", ticker: "TEST", exchange_operating_mic: "TEST") + + @trade = create_trade(@security, account: @account, qty: 1, date: 2.days.ago.to_date, price: 210.23).account_trade end test "gets price from DB if available" do - cache = Account::Holding::PortfolioCache.new(@account) + db_price = 210 - assert_equal @db_price, cache.get_price(@test_security.id, Date.current).price + Security::Price.create!( + security: @security, + date: Date.current, + price: db_price + ) + + expect_provider_prices([], start_date: @account.start_date) + + cache = Account::Holding::PortfolioCache.new(@account) + assert_equal db_price, cache.get_price(@security.id, Date.current).price end test "if no price in DB, try fetching from provider" do - Security::Price.destroy_all - Security::Price.expects(:find_prices) - .with(security: @test_security, start_date: @account.start_date, end_date: Date.current) - .returns([ - Security::Price.new(security: @test_security, date: Date.current, price: @provider_price, currency: "USD") - ]) + Security::Price.delete_all + + provider_price = Security::Price.new( + security: @security, + date: Date.current, + price: 220, + currency: "USD" + ) + + expect_provider_prices([ provider_price ], start_date: @account.start_date) cache = Account::Holding::PortfolioCache.new(@account) - - assert_equal @provider_price, cache.get_price(@test_security.id, Date.current).price + assert_equal provider_price.price, cache.get_price(@security.id, Date.current).price end test "if no price from db or provider, try getting the price from trades" do - Security::Price.destroy_all # No DB prices - Security::Price.expects(:find_prices) - .with(security: @test_security, start_date: @account.start_date, end_date: Date.current) - .returns([]) # No provider prices + Security::Price.destroy_all + expect_provider_prices([], start_date: @account.start_date) cache = Account::Holding::PortfolioCache.new(@account) - - assert_equal @trade_price, cache.get_price(@test_security.id, Date.current).price + assert_equal @trade.price, cache.get_price(@security.id, @trade.entry.date).price end test "if no price from db, provider, or trades, search holdings" do - Security::Price.destroy_all # No DB prices - Security::Price.expects(:find_prices) - .with(security: @test_security, start_date: @account.start_date, end_date: Date.current) - .returns([]) # No provider prices + Security::Price.delete_all + Account::Entry.delete_all - @account.entries.destroy_all # No prices from trades + holding = Account::Holding.create!( + security: @security, + account: @account, + date: Date.current, + qty: 1, + price: 250, + amount: 250 * 1, + currency: "USD" + ) + + expect_provider_prices([], start_date: @account.start_date) cache = Account::Holding::PortfolioCache.new(@account, use_holdings: true) - - assert_equal @holding_price, cache.get_price(@test_security.id, Date.current).price + assert_equal holding.price, cache.get_price(@security.id, holding.date).price end + + private + def expect_provider_prices(prices, start_date:, end_date: Date.current) + @provider.expects(:fetch_security_prices) + .with(@security, start_date: start_date, end_date: end_date) + .returns( + provider_success_response( + Security::Provideable::PricesData.new( + prices: prices + ) + ) + ) + end end diff --git a/test/models/exchange_rate_test.rb b/test/models/exchange_rate_test.rb index 7bc7ba61..720162f4 100644 --- a/test/models/exchange_rate_test.rb +++ b/test/models/exchange_rate_test.rb @@ -2,116 +2,99 @@ require "test_helper" require "ostruct" class ExchangeRateTest < ActiveSupport::TestCase + include ProviderTestHelper + setup do @provider = mock ExchangeRate.stubs(:provider).returns(@provider) end - test "exchange rate provider nil if no api key configured" do - ExchangeRate.unstub(:provider) + test "finds rate in DB" do + existing_rate = exchange_rates(:one) - Setting.stubs(:synth_api_key).returns(nil) - - with_env_overrides SYNTH_API_KEY: nil do - assert_not ExchangeRate.provider - end - end - - test "finds single rate in DB" do @provider.expects(:fetch_exchange_rate).never - rate = exchange_rates(:one) - - assert_equal rate, ExchangeRate.find_rate(from: rate.from_currency, to: rate.to_currency, date: rate.date) + assert_equal existing_rate, ExchangeRate.find_or_fetch_rate( + from: existing_rate.from_currency, + to: existing_rate.to_currency, + date: existing_rate.date + ) end - test "finds single rate from provider and caches to DB" do - expected_rate = 1.21 - @provider.expects(:fetch_exchange_rate).once.returns(OpenStruct.new(success?: true, rate: expected_rate)) + test "fetches rate from provider without cache" do + ExchangeRate.delete_all - fetched_rate = ExchangeRate.find_rate(from: "USD", to: "EUR", date: Date.current, cache: true) - refetched_rate = ExchangeRate.find_rate(from: "USD", to: "EUR", date: Date.current, cache: true) + provider_response = provider_success_response( + ExchangeRate::Provideable::FetchRateData.new( + rate: ExchangeRate.new( + from_currency: "USD", + to_currency: "EUR", + date: Date.current, + rate: 1.2 + ) + ) + ) - assert_equal expected_rate, fetched_rate.rate - assert_equal expected_rate, refetched_rate.rate - end + @provider.expects(:fetch_exchange_rate).returns(provider_response) - test "nil if rate is not found in DB and provider throws an error" do - @provider.expects(:fetch_exchange_rate).with(from: "USD", to: "EUR", date: Date.current).once.returns(OpenStruct.new(success?: false)) - - assert_not ExchangeRate.find_rate(from: "USD", to: "EUR", date: Date.current) - end - - test "nil if rate is not found in DB and provider is disabled" do - ExchangeRate.unstub(:provider) - - Setting.stubs(:synth_api_key).returns(nil) - - with_env_overrides SYNTH_API_KEY: nil do - assert_not ExchangeRate.find_rate(from: "USD", to: "EUR", date: Date.current) + assert_no_difference "ExchangeRate.count" do + assert_equal 1.2, ExchangeRate.find_or_fetch_rate(from: "USD", to: "EUR", date: Date.current, cache: false).rate end end - test "finds multiple rates in DB" do - @provider.expects(:fetch_exchange_rate).never + test "fetches rate from provider with cache" do + ExchangeRate.delete_all - rate1 = exchange_rates(:one) # EUR -> GBP, today - rate2 = exchange_rates(:two) # EUR -> GBP, yesterday - - fetched_rates = ExchangeRate.find_rates(from: rate1.from_currency, to: rate1.to_currency, start_date: 1.day.ago.to_date).sort_by(&:date) - - assert_equal rate1, fetched_rates[1] - assert_equal rate2, fetched_rates[0] - end - - test "finds multiple rates from provider and caches to DB" do - @provider.expects(:fetch_exchange_rates).with(from: "EUR", to: "USD", start_date: 1.day.ago.to_date, end_date: Date.current) - .returns( - OpenStruct.new( - rates: [ - OpenStruct.new(date: 1.day.ago.to_date, rate: 1.1), - OpenStruct.new(date: Date.current, rate: 1.2) - ], - success?: true + provider_response = provider_success_response( + ExchangeRate::Provideable::FetchRateData.new( + rate: ExchangeRate.new( + from_currency: "USD", + to_currency: "EUR", + date: Date.current, + rate: 1.2 ) - ).once + ) + ) - fetched_rates = ExchangeRate.find_rates(from: "EUR", to: "USD", start_date: 1.day.ago.to_date, cache: true) - refetched_rates = ExchangeRate.find_rates(from: "EUR", to: "USD", start_date: 1.day.ago.to_date) + @provider.expects(:fetch_exchange_rate).returns(provider_response) - assert_equal [ 1.1, 1.2 ], fetched_rates.sort_by(&:date).map(&:rate) - assert_equal [ 1.1, 1.2 ], refetched_rates.sort_by(&:date).map(&:rate) - end - - test "finds missing db rates from provider and appends to results" do - @provider.expects(:fetch_exchange_rates).with(from: "EUR", to: "GBP", start_date: 2.days.ago.to_date, end_date: 2.days.ago.to_date) - .returns( - OpenStruct.new( - rates: [ - OpenStruct.new(date: 2.day.ago.to_date, rate: 1.1) - ], - success?: true - ) - ).once - - rate1 = exchange_rates(:one) # EUR -> GBP, today - rate2 = exchange_rates(:two) # EUR -> GBP, yesterday - - fetched_rates = ExchangeRate.find_rates(from: "EUR", to: "GBP", start_date: 2.days.ago.to_date, cache: true) - refetched_rates = ExchangeRate.find_rates(from: "EUR", to: "GBP", start_date: 2.days.ago.to_date) - - assert_equal [ 1.1, rate2.rate, rate1.rate ], fetched_rates.sort_by(&:date).map(&:rate) - assert_equal [ 1.1, rate2.rate, rate1.rate ], refetched_rates.sort_by(&:date).map(&:rate) - end - - test "returns empty array if no rates found in DB or provider" do - ExchangeRate.unstub(:provider) - - Setting.stubs(:synth_api_key).returns(nil) - - with_env_overrides SYNTH_API_KEY: nil do - assert_equal [], ExchangeRate.find_rates(from: "USD", to: "JPY", start_date: 10.days.ago.to_date) + assert_difference "ExchangeRate.count", 1 do + assert_equal 1.2, ExchangeRate.find_or_fetch_rate(from: "USD", to: "EUR", date: Date.current, cache: true).rate end end + + test "returns nil on provider error" do + provider_response = provider_error_response(Provider::ProviderError.new("Test error")) + + @provider.expects(:fetch_exchange_rate).returns(provider_response) + + assert_nil ExchangeRate.find_or_fetch_rate(from: "USD", to: "EUR", date: Date.current, cache: true) + end + + test "upserts rates for currency pair and date range" do + ExchangeRate.delete_all + + ExchangeRate.create!(date: 1.day.ago.to_date, from_currency: "USD", to_currency: "EUR", rate: 0.9) + + provider_response = provider_success_response( + ExchangeRate::Provideable::FetchRatesData.new( + rates: [ + ExchangeRate.new(from_currency: "USD", to_currency: "EUR", date: Date.current, rate: 1.3), + ExchangeRate.new(from_currency: "USD", to_currency: "EUR", date: 1.day.ago.to_date, rate: 1.4), + ExchangeRate.new(from_currency: "USD", to_currency: "EUR", date: 2.days.ago.to_date, rate: 1.5) + ] + ) + ) + + @provider.expects(:fetch_exchange_rates) + .with(from: "USD", to: "EUR", start_date: 2.days.ago.to_date, end_date: Date.current) + .returns(provider_response) + + ExchangeRate.sync_provider_rates(from: "USD", to: "EUR", start_date: 2.days.ago.to_date) + + assert_equal 1.3, ExchangeRate.find_by(from_currency: "USD", to_currency: "EUR", date: Date.current).rate + assert_equal 1.4, ExchangeRate.find_by(from_currency: "USD", to_currency: "EUR", date: 1.day.ago.to_date).rate + assert_equal 1.5, ExchangeRate.find_by(from_currency: "USD", to_currency: "EUR", date: 2.days.ago.to_date).rate + end end diff --git a/test/models/provider/synth_test.rb b/test/models/provider/synth_test.rb index fdca07c3..b489d136 100644 --- a/test/models/provider/synth_test.rb +++ b/test/models/provider/synth_test.rb @@ -2,55 +2,42 @@ require "test_helper" require "ostruct" class Provider::SynthTest < ActiveSupport::TestCase - include ExchangeRateProviderInterfaceTest, SecurityPriceProviderInterfaceTest + include ExchangeRateProviderInterfaceTest, SecurityProviderInterfaceTest setup do @subject = @synth = Provider::Synth.new(ENV["SYNTH_API_KEY"]) end - test "fetches paginated securities prices" do - VCR.use_cassette("synth/security_prices") do - response = @synth.fetch_security_prices( - ticker: "AAPL", - mic_code: "XNAS", - start_date: Date.iso8601("2024-01-01"), - end_date: Date.iso8601("2024-08-01") - ) - - assert 213, response.size + test "health check" do + VCR.use_cassette("synth/health") do + assert @synth.healthy? end end - test "fetches paginated exchange_rate historical data" do - VCR.use_cassette("synth/exchange_rate_historical") do - response = @synth.fetch_exchange_rates( - from: "USD", to: "GBP", start_date: Date.parse("01.01.2024"), end_date: Date.parse("31.07.2024") - ) - - assert 213, response.rates.size # 213 days between 01.01.2024 and 31.07.2024 - assert_equal [ :date, :rate ], response.rates.first.keys + test "usage info" do + VCR.use_cassette("synth/usage") do + usage = @synth.usage.data + assert usage.used.present? + assert usage.limit.present? + assert usage.utilization.present? + assert usage.plan.present? end end - test "retries then provides failed response" do - @client = mock - Faraday.stubs(:new).returns(@client) + test "enriches transaction" do + VCR.use_cassette("synth/transaction_enrich") do + response = @synth.enrich_transaction( + "UBER EATS", + amount: 25.50, + date: Date.iso8601("2025-03-16"), + city: "San Francisco", + state: "CA", + country: "US" + ) - @client.expects(:get).returns(OpenStruct.new(success?: false)).times(3) - - response = @synth.fetch_exchange_rate from: "USD", to: "MXN", date: Date.iso8601("2024-08-01") - - assert_match "Failed to fetch data from Provider::Synth", response.error.message - end - - test "retrying, then raising on network error" do - @client = mock - Faraday.stubs(:new).returns(@client) - - @client.expects(:get).raises(Faraday::TimeoutError).times(3) - - assert_raises Faraday::TimeoutError do - @synth.fetch_exchange_rate from: "USD", to: "MXN", date: Date.iso8601("2024-08-01") + data = response.data + assert data.name.present? + assert data.category.present? end end end diff --git a/test/models/provider_test.rb b/test/models/provider_test.rb new file mode 100644 index 00000000..afa770e4 --- /dev/null +++ b/test/models/provider_test.rb @@ -0,0 +1,61 @@ +require "test_helper" +require "ostruct" + +class TestProvider < Provider + def fetch_data + provider_response(retries: 3) do + client.get("/test") + end + end + + private + def client + @client ||= Faraday.new + end + + def retryable_errors + [ Faraday::TimeoutError ] + end +end + +class ProviderTest < ActiveSupport::TestCase + setup do + @provider = TestProvider.new + end + + test "retries then provides failed response" do + client = mock + Faraday.stubs(:new).returns(client) + + client.expects(:get) + .with("/test") + .raises(Faraday::TimeoutError) + .times(3) + + response = @provider.fetch_data + + assert_not response.success? + assert_match "timeout", response.error.message + end + + test "fail, retry, succeed" do + client = mock + Faraday.stubs(:new).returns(client) + + sequence = sequence("retry_sequence") + + client.expects(:get) + .with("/test") + .raises(Faraday::TimeoutError) + .in_sequence(sequence) + + client.expects(:get) + .with("/test") + .returns(Provider::ProviderResponse.new(success?: true, data: "success", error: nil)) + .in_sequence(sequence) + + response = @provider.fetch_data + + assert response.success? + end +end diff --git a/test/models/providers_test.rb b/test/models/providers_test.rb new file mode 100644 index 00000000..d7851cd8 --- /dev/null +++ b/test/models/providers_test.rb @@ -0,0 +1,27 @@ +require "test_helper" + +class ProvidersTest < ActiveSupport::TestCase + test "synth configured with ENV" do + Setting.stubs(:synth_api_key).returns(nil) + + with_env_overrides SYNTH_API_KEY: "123" do + assert_instance_of Provider::Synth, Providers.synth + end + end + + test "synth configured with Setting" do + Setting.stubs(:synth_api_key).returns("123") + + with_env_overrides SYNTH_API_KEY: nil do + assert_instance_of Provider::Synth, Providers.synth + end + end + + test "synth not configured" do + Setting.stubs(:synth_api_key).returns(nil) + + with_env_overrides SYNTH_API_KEY: nil do + assert_nil Providers.synth + end + end +end diff --git a/test/models/security/price_test.rb b/test/models/security/price_test.rb index 32dd00f3..84412c29 100644 --- a/test/models/security/price_test.rb +++ b/test/models/security/price_test.rb @@ -2,120 +2,82 @@ require "test_helper" require "ostruct" class Security::PriceTest < ActiveSupport::TestCase + include ProviderTestHelper + setup do @provider = mock + Security.stubs(:provider).returns(@provider) - Security::Price.stubs(:provider).returns(@provider) - end - - test "security price provider nil if no api key provided" do - Security::Price.unstub(:provider) - - Setting.stubs(:synth_api_key).returns(nil) - - with_env_overrides SYNTH_API_KEY: nil do - assert_not Security::Price.provider - end + @security = securities(:aapl) end test "finds single security price in DB" do - @provider.expects(:fetch_security_prices).never - security = securities(:aapl) - + @provider.expects(:fetch_security_price).never price = security_prices(:one) - assert_equal price, Security::Price.find_price(security: security, date: price.date) + assert_equal price, @security.find_or_fetch_price(date: price.date) end - test "caches prices to DB" do - expected_price = 314.34 - security = securities(:aapl) - tomorrow = Date.current + 1.day + test "caches prices from provider to DB" do + price_date = 10.days.ago.to_date - @provider.expects(:fetch_security_prices) - .with(ticker: security.ticker, mic_code: security.exchange_operating_mic, start_date: tomorrow, end_date: tomorrow) - .once - .returns( - OpenStruct.new( - success?: true, - prices: [ { date: tomorrow, price: expected_price, currency: "USD" } ] - ) - ) + expected_price = Security::Price.new( + security: @security, + date: price_date, + price: 314.34, + currency: "USD" + ) - fetched_rate = Security::Price.find_price(security: security, date: tomorrow, cache: true) - refetched_rate = Security::Price.find_price(security: security, date: tomorrow, cache: true) + expect_provider_price(security: @security, price: expected_price, date: price_date) - assert_equal expected_price, fetched_rate.price - assert_equal expected_price, refetched_rate.price + assert_difference "Security::Price.count", 1 do + fetched_price = @security.find_or_fetch_price(date: price_date, cache: true) + assert_equal expected_price.price, fetched_price.price + end end test "returns nil if no price found in DB or from provider" do security = securities(:aapl) Security::Price.delete_all # Clear any existing prices - @provider.expects(:fetch_security_prices) - .with(ticker: security.ticker, mic_code: security.exchange_operating_mic, start_date: Date.current, end_date: Date.current) - .once - .returns(OpenStruct.new(success?: false)) + provider_response = provider_error_response(Provider::ProviderError.new("Test error")) - assert_not Security::Price.find_price(security: security, date: Date.current) + @provider.expects(:fetch_security_price) + .with(security, date: Date.current) + .returns(provider_response) + + assert_not @security.find_or_fetch_price(date: Date.current) end - test "returns nil if price not found in DB and provider disabled" do - Security::Price.unstub(:provider) + test "upserts historical prices from provider" do + Security::Price.delete_all - Setting.stubs(:synth_api_key).returns(nil) + # Will be overwritten by upsert + Security::Price.create!(security: @security, date: 1.day.ago.to_date, price: 190, currency: "USD") - security = Security.new(ticker: "NVDA") + expect_provider_prices(security: @security, start_date: 2.days.ago.to_date, end_date: Date.current, prices: [ + Security::Price.new(security: @security, date: Date.current, price: 215, currency: "USD"), + Security::Price.new(security: @security, date: 1.day.ago.to_date, price: 214, currency: "USD"), + Security::Price.new(security: @security, date: 2.days.ago.to_date, price: 213, currency: "USD") + ]) - with_env_overrides SYNTH_API_KEY: nil do - assert_not Security::Price.find_price(security: security, date: Date.current) + @security.sync_provider_prices(start_date: 2.days.ago.to_date) + + assert_equal 215, @security.prices.find_by(date: Date.current).price + assert_equal 214, @security.prices.find_by(date: 1.day.ago.to_date).price + assert_equal 213, @security.prices.find_by(date: 2.days.ago.to_date).price + end + + private + def expect_provider_price(security:, price:, date:) + @provider.expects(:fetch_security_price) + .with(security, date: date) + .returns(provider_success_response(Security::Provideable::PriceData.new(price: price))) end - end - test "fetches multiple dates at once" do - @provider.expects(:fetch_security_prices).never - security = securities(:aapl) - price1 = security_prices(:one) # AAPL today - price2 = security_prices(:two) # AAPL yesterday - - fetched_prices = Security::Price.find_prices(security: security, start_date: 1.day.ago.to_date, end_date: Date.current).sort_by(&:date) - - assert_equal price1, fetched_prices[1] - assert_equal price2, fetched_prices[0] - end - - test "caches multiple prices to DB" do - missing_price = 213.21 - security = securities(:aapl) - - @provider.expects(:fetch_security_prices) - .with(ticker: security.ticker, - mic_code: security.exchange_operating_mic, - start_date: 2.days.ago.to_date, - end_date: 2.days.ago.to_date) - .returns(OpenStruct.new(success?: true, prices: [ { date: 2.days.ago.to_date, price: missing_price, currency: "USD" } ])) - .once - - price1 = security_prices(:one) # AAPL today - price2 = security_prices(:two) # AAPL yesterday - - fetched_prices = Security::Price.find_prices(security: security, start_date: 2.days.ago.to_date, end_date: Date.current, cache: true) - refetched_prices = Security::Price.find_prices(security: security, start_date: 2.days.ago.to_date, end_date: Date.current, cache: true) - - assert_equal [ missing_price, price2.price, price1.price ], fetched_prices.sort_by(&:date).map(&:price) - assert_equal [ missing_price, price2.price, price1.price ], refetched_prices.sort_by(&:date).map(&:price) - - assert Security::Price.exists?(security: security, date: 2.days.ago.to_date, price: missing_price) - end - - test "returns empty array if no prices found in DB or from provider" do - Security::Price.unstub(:provider) - - Setting.stubs(:synth_api_key).returns(nil) - - with_env_overrides SYNTH_API_KEY: nil do - assert_equal [], Security::Price.find_prices(security: Security.new(ticker: "NVDA"), start_date: 10.days.ago.to_date, end_date: Date.current) + def expect_provider_prices(security:, prices:, start_date:, end_date:) + @provider.expects(:fetch_security_prices) + .with(security, start_date: start_date, end_date: end_date) + .returns(provider_success_response(Security::Provideable::PricesData.new(prices: prices))) end - end end diff --git a/test/models/trade_import_test.rb b/test/models/trade_import_test.rb index f0ee3e68..b6293b0e 100644 --- a/test/models/trade_import_test.rb +++ b/test/models/trade_import_test.rb @@ -6,6 +6,8 @@ class TradeImportTest < ActiveSupport::TestCase setup do @subject = @import = imports(:trade) + @provider = mock + Security.stubs(:provider).returns(@provider) end test "imports trades and accounts" do @@ -14,7 +16,7 @@ class TradeImportTest < ActiveSupport::TestCase # We should only hit the provider for GOOGL since AAPL already exists Security.expects(:search_provider).with( - query: "GOOGL", + "GOOGL", exchange_operating_mic: "XNAS" ).returns([ Security.new( diff --git a/test/support/provider_test_helper.rb b/test/support/provider_test_helper.rb new file mode 100644 index 00000000..45c7de2b --- /dev/null +++ b/test/support/provider_test_helper.rb @@ -0,0 +1,17 @@ +module ProviderTestHelper + def provider_success_response(data) + Provider::ProviderResponse.new( + success?: true, + data: data, + error: nil + ) + end + + def provider_error_response(error) + Provider::ProviderResponse.new( + success?: false, + data: nil, + error: error + ) + end +end diff --git a/test/system/imports_test.rb b/test/system/imports_test.rb index 85c4707c..bdc50f0e 100644 --- a/test/system/imports_test.rb +++ b/test/system/imports_test.rb @@ -5,6 +5,9 @@ class ImportsTest < ApplicationSystemTestCase setup do sign_in @user = users(:family_admin) + + # Trade securities will be imported as "offline" tickers + Security.stubs(:provider).returns(nil) end test "transaction import" do @@ -52,8 +55,6 @@ class ImportsTest < ApplicationSystemTestCase end test "trade import" do - Security.stubs(:search_provider).returns([]) - visit new_import_path click_on "Import investments" diff --git a/test/system/settings_test.rb b/test/system/settings_test.rb index 9d0338b0..dbc8ca99 100644 --- a/test/system/settings_test.rb +++ b/test/system/settings_test.rb @@ -33,6 +33,7 @@ class SettingsTest < ApplicationSystemTestCase test "can update self hosting settings" do Rails.application.config.app_mode.stubs(:self_hosted?).returns(true) + Providers.stubs(:synth).returns(nil) open_settings_from_sidebar assert_selector "li", text: "Self hosting" click_link "Self hosting" diff --git a/test/system/trades_test.rb b/test/system/trades_test.rb index a1f19139..cff4a35f 100644 --- a/test/system/trades_test.rb +++ b/test/system/trades_test.rb @@ -10,16 +10,8 @@ class TradesTest < ApplicationSystemTestCase visit_account_portfolio - Security.stubs(:search_provider).returns([ - Security.new( - ticker: "AAPL", - name: "Apple Inc.", - logo_url: "https://logo.synthfinance.com/ticker/AAPL", - exchange_acronym: "NASDAQ", - exchange_mic: "XNAS", - country_code: "US" - ) - ]) + # Disable provider to focus on form testing + Security.stubs(:provider).returns(nil) end test "can create buy transaction" do @@ -28,7 +20,6 @@ class TradesTest < ApplicationSystemTestCase open_new_trade_modal fill_in "Ticker symbol", with: "AAPL" - select_combobox_option("Apple") fill_in "Date", with: Date.current fill_in "Quantity", with: shares_qty fill_in "account_entry[price]", with: 214.23 @@ -50,7 +41,6 @@ class TradesTest < ApplicationSystemTestCase select "Sell", from: "Type" fill_in "Ticker symbol", with: aapl.ticker - select_combobox_option(aapl.security.name) fill_in "Date", with: Date.current fill_in "Quantity", with: aapl.qty fill_in "account_entry[price]", with: 215.33 @@ -81,10 +71,4 @@ class TradesTest < ApplicationSystemTestCase def visit_account_portfolio visit account_path(@account, tab: "holdings") end - - def select_combobox_option(text) - within "#account_entry_ticker-hw-listbox" do - find("li", text: text).click - end - end end diff --git a/test/vcr_cassettes/synth/exchange_rate.yml b/test/vcr_cassettes/synth/exchange_rate.yml index 40fb9995..be1fa77a 100644 --- a/test/vcr_cassettes/synth/exchange_rate.yml +++ b/test/vcr_cassettes/synth/exchange_rate.yml @@ -2,15 +2,19 @@ http_interactions: - request: method: get - uri: https://api.synthfinance.com/rates/historical?date=2024-08-01&from=USD&to=MXN + uri: https://api.synthfinance.com/rates/historical?date=2024-01-01&from=USD&to=GBP body: encoding: US-ASCII string: '' headers: - User-Agent: - - Faraday v2.10.0 Authorization: - Bearer + X-Source: + - maybe_app + X-Source-Type: + - managed + User-Agent: + - Faraday v2.12.2 Accept-Encoding: - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Accept: @@ -21,29 +25,25 @@ http_interactions: message: OK headers: Date: - - Thu, 01 Aug 2024 17:20:28 GMT + - Sat, 15 Mar 2025 22:18:46 GMT Content-Type: - application/json; charset=utf-8 Transfer-Encoding: - - chunked + - chunked Connection: - keep-alive - Cf-Ray: - - 8ac77fbcc9d013ae-CMH - Cf-Cache-Status: - - DYNAMIC Cache-Control: - max-age=0, private, must-revalidate Etag: - - W/"668c8ac287a5ff6d6a705c35c69823b1" + - W/"b0b21c870fe53492404cc5ac258fa465" + Referrer-Policy: + - strict-origin-when-cross-origin + Rndr-Id: + - 44367fcb-e5b4-457d Strict-Transport-Security: - max-age=63072000; includeSubDomains Vary: - Accept-Encoding - Referrer-Policy: - - strict-origin-when-cross-origin - Rndr-Id: - - ff56c2fe-6252-4b2c X-Content-Type-Options: - nosniff X-Frame-Options: @@ -53,17 +53,29 @@ http_interactions: X-Render-Origin-Server: - Render X-Request-Id: - - 61992b01-969b-4af5-8119-9b17e385da07 + - 8ce9dc85-afbd-437c-b18d-ec788b712334 X-Runtime: - - '0.369358' + - '0.031963' X-Xss-Protection: - '0' + Cf-Cache-Status: + - DYNAMIC + Report-To: + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=SwRPS1vBsrKtk%2Ftb7Ix8j%2FCWYw9tZgbJxR1FCmotWn%2FIZAE3Ri%2FUwHtvkOSqBq6HN5pLVetfem5hp%2BkqWmD5GRCVho0mp3VgRr3J1tBMwrVK2p50tfpmb3X22Jj%2BOfapq1C22PnN"}],"group":"cf-nel","max_age":604800}' + Nel: + - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' + Speculation-Rules: + - '"/cdn-cgi/speculation"' Server: - cloudflare + Cf-Ray: + - 920f6378fe582237-ORD Alt-Svc: - h3=":443"; ma=86400 + Server-Timing: + - cfL4;desc="?proto=TCP&rtt=26670&min_rtt=26569&rtt_var=10167&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2829&recv_bytes=922&delivery_rate=105759&cwnd=181&unsent_bytes=0&cid=f0a872e0b2909c59&ts=188&x=0" body: encoding: ASCII-8BIT - string: '{"data":{"date":"2024-08-01","source":"USD","rates":{"MXN":18.645877}},"meta":{"total_records":1,"credits_used":1,"credits_remaining":248999}}' - recorded_at: Thu, 01 Aug 2024 17:20:28 GMT -recorded_with: VCR 6.2.0 + string: '{"data":{"date":"2024-01-01","source":"USD","rates":{"GBP":0.785476}},"meta":{"total_records":1,"credits_used":1,"credits_remaining":249830,"date":"2024-01-01"}}' + recorded_at: Sat, 15 Mar 2025 22:18:46 GMT +recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/synth/exchange_rate_historical.yml b/test/vcr_cassettes/synth/exchange_rate_historical.yml deleted file mode 100644 index 7e34a6ae..00000000 --- a/test/vcr_cassettes/synth/exchange_rate_historical.yml +++ /dev/null @@ -1,213 +0,0 @@ ---- -http_interactions: -- request: - method: get - uri: https://api.synthfinance.com/rates/historical-range?date_end=2024-07-31&date_start=2024-01-01&from=USD&page=1&to=GBP - body: - encoding: US-ASCII - string: '' - headers: - Authorization: - - Bearer - X-Source: - - maybe_app - X-Source-Type: - - managed - User-Agent: - - Faraday v2.10.1 - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 200 - message: OK - headers: - Date: - - Thu, 08 Aug 2024 17:57:48 GMT - Content-Type: - - application/json; charset=utf-8 - Transfer-Encoding: - - chunked - Connection: - - keep-alive - Cf-Ray: - - 8b01640eb8e8451c-TXL - Cf-Cache-Status: - - DYNAMIC - Cache-Control: - - max-age=0, private, must-revalidate - Etag: - - W/"fe9bd64a1b712e0577da8fbfd5bad08d" - Strict-Transport-Security: - - max-age=63072000; includeSubDomains - Vary: - - Accept-Encoding - Referrer-Policy: - - strict-origin-when-cross-origin - Rndr-Id: - - d8c1b21e-a6f4-48a0 - X-Content-Type-Options: - - nosniff - X-Frame-Options: - - SAMEORIGIN - X-Permitted-Cross-Domain-Policies: - - none - X-Render-Origin-Server: - - Render - X-Request-Id: - - 0003eaec-e246-4769-84f5-7a062eef0908 - X-Runtime: - - '0.040177' - X-Xss-Protection: - - '0' - Server: - - cloudflare - Alt-Svc: - - h3=":443"; ma=86400 - body: - encoding: ASCII-8BIT - 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}}],"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=2\u0026to=GBP","total_records":213,"current_page":1,"per_page":100,"total_pages":3},"meta":{"credits_used":1,"credits_remaining":53,"date_start":"2024-01-01","date_end":"2024-07-31"}}' - recorded_at: Thu, 08 Aug 2024 17:57:48 GMT -- request: - method: get - uri: https://api.synthfinance.com/rates/historical-range?date_end=2024-07-31&date_start=2024-01-01&from=USD&page=2&to=GBP - body: - encoding: US-ASCII - string: '' - headers: - Authorization: - - Bearer - X-Source: - - maybe_app - X-Source-Type: - - managed - User-Agent: - - Faraday v2.10.1 - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 200 - message: OK - headers: - Date: - - Thu, 08 Aug 2024 17:57:48 GMT - Content-Type: - - application/json; charset=utf-8 - Transfer-Encoding: - - chunked - Connection: - - keep-alive - Cf-Ray: - - 8b016411c8da2685-TXL - Cf-Cache-Status: - - DYNAMIC - Cache-Control: - - max-age=0, private, must-revalidate - Etag: - - W/"7617d44c8da4ad2ecd071eae8522f17c" - Strict-Transport-Security: - - max-age=63072000; includeSubDomains - Vary: - - Accept-Encoding - Referrer-Policy: - - strict-origin-when-cross-origin - Rndr-Id: - - a882d8f9-da35-4532 - X-Content-Type-Options: - - nosniff - X-Frame-Options: - - SAMEORIGIN - X-Permitted-Cross-Domain-Policies: - - none - X-Render-Origin-Server: - - Render - X-Request-Id: - - 06a85aa9-8288-484c-80f2-f90ddd97c36e - X-Runtime: - - '0.026746' - X-Xss-Protection: - - '0' - Server: - - cloudflare - Alt-Svc: - - h3=":443"; ma=86400 - body: - encoding: ASCII-8BIT - string: '{"data":[{"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}}],"paging":{"prev":"/rates/historical-range?date_end=2024-07-31\u0026date_start=2024-01-01\u0026from=USD\u0026page=1\u0026to=GBP","next":"/rates/historical-range?date_end=2024-07-31\u0026date_start=2024-01-01\u0026from=USD\u0026page=3\u0026to=GBP","total_records":213,"current_page":2,"per_page":100,"total_pages":3},"meta":{"credits_used":1,"credits_remaining":52,"date_start":"2024-01-01","date_end":"2024-07-31"}}' - recorded_at: Thu, 08 Aug 2024 17:57:48 GMT -- request: - method: get - uri: https://api.synthfinance.com/rates/historical-range?date_end=2024-07-31&date_start=2024-01-01&from=USD&page=3&to=GBP - body: - encoding: US-ASCII - string: '' - headers: - Authorization: - - Bearer - X-Source: - - maybe_app - X-Source-Type: - - managed - User-Agent: - - Faraday v2.10.1 - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 200 - message: OK - headers: - Date: - - Thu, 08 Aug 2024 17:57:49 GMT - Content-Type: - - application/json; charset=utf-8 - Transfer-Encoding: - - chunked - Connection: - - keep-alive - Cf-Ray: - - 8b016414b8f758de-TXL - Cf-Cache-Status: - - DYNAMIC - Cache-Control: - - max-age=0, private, must-revalidate - Etag: - - W/"6efe5a0b3e3e58e3c8d2fa5d6525bd61" - Strict-Transport-Security: - - max-age=63072000; includeSubDomains - Vary: - - Accept-Encoding - Referrer-Policy: - - strict-origin-when-cross-origin - Rndr-Id: - - bcf6b2fc-a331-4293 - X-Content-Type-Options: - - nosniff - X-Frame-Options: - - SAMEORIGIN - X-Permitted-Cross-Domain-Policies: - - none - X-Render-Origin-Server: - - Render - X-Request-Id: - - 87b3e27e-08bd-4784-8c6f-350622aa08e6 - X-Runtime: - - '0.029529' - X-Xss-Protection: - - '0' - Server: - - cloudflare - Alt-Svc: - - h3=":443"; ma=86400 - body: - encoding: ASCII-8BIT - string: '{"data":[{"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.774027}},{"date":"2024-07-22","source":"USD","rates":{"GBP":0.773514}},{"date":"2024-07-23","source":"USD","rates":{"GBP":0.77348}},{"date":"2024-07-24","source":"USD","rates":{"GBP":0.775341}},{"date":"2024-07-25","source":"USD","rates":{"GBP":0.775425}},{"date":"2024-07-26","source":"USD","rates":{"GBP":0.777798}},{"date":"2024-07-27","source":"USD","rates":{"GBP":0.777333}},{"date":"2024-07-28","source":"USD","rates":{"GBP":0.77693}},{"date":"2024-07-29","source":"USD","rates":{"GBP":0.77605}},{"date":"2024-07-30","source":"USD","rates":{"GBP":0.77799}},{"date":"2024-07-31","source":"USD","rates":{"GBP":0.778763}}],"paging":{"prev":"/rates/historical-range?date_end=2024-07-31\u0026date_start=2024-01-01\u0026from=USD\u0026page=2\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":3,"per_page":100,"total_pages":3},"meta":{"credits_used":1,"credits_remaining":51,"date_start":"2024-01-01","date_end":"2024-07-31"}}' - recorded_at: Thu, 08 Aug 2024 17:57:49 GMT -recorded_with: VCR 6.2.0 diff --git a/test/vcr_cassettes/synth/exchange_rates.yml b/test/vcr_cassettes/synth/exchange_rates.yml new file mode 100644 index 00000000..ffb7b69a --- /dev/null +++ b/test/vcr_cassettes/synth/exchange_rates.yml @@ -0,0 +1,81 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.synthfinance.com/rates/historical-range?date_end=2024-07-31&date_start=2024-01-01&from=USD&page=1&to=GBP + body: + encoding: US-ASCII + string: '' + headers: + Authorization: + - Bearer + X-Source: + - maybe_app + X-Source-Type: + - managed + User-Agent: + - Faraday v2.12.2 + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Sat, 15 Mar 2025 21:48:33 GMT + Content-Type: + - application/json; charset=utf-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Cache-Control: + - max-age=0, private, must-revalidate + Etag: + - W/"8081859271e9ca46ee021f706a0cc683" + Referrer-Policy: + - strict-origin-when-cross-origin + Rndr-Id: + - 6d036078-7f2f-4037 + Strict-Transport-Security: + - max-age=63072000; includeSubDomains + Vary: + - Accept-Encoding + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-Permitted-Cross-Domain-Policies: + - none + X-Render-Origin-Server: + - Render + X-Request-Id: + - 9ec8d111-aa67-4fb9-8885-7de64e1b1219 + X-Runtime: + - '0.025769' + X-Xss-Protection: + - '0' + Cf-Cache-Status: + - DYNAMIC + Report-To: + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=3PGbjN13Yz7GFiZNw1N13jCnLyMkC1O69nVw4k9Y0Iif7pu0H1eBKZxhkRTGzeECSRtzryqMRpzh9lG11e9SVXA9PNTSTR1%2BC%2FZkOMTsFUk%2Fajh29RmkkGeYrQgCAPEWBST36B3V"}],"group":"cf-nel","max_age":604800}' + Nel: + - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' + Speculation-Rules: + - '"/cdn-cgi/speculation"' + Server: + - cloudflare + Cf-Ray: + - 920f37347b14e7f9-ORD + Alt-Svc: + - h3=":443"; ma=86400 + Server-Timing: + - cfL4;desc="?proto=TCP&rtt=27528&min_rtt=26760&rtt_var=11571&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2828&recv_bytes=961&delivery_rate=88005&cwnd=248&unsent_bytes=0&cid=28a3fac05fc0df52&ts=177&x=0" + body: + encoding: ASCII-8BIT + 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":249832,"date_start":"2024-01-01","date_end":"2024-07-31"}}' + recorded_at: Sat, 15 Mar 2025 21:48:33 GMT +recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/synth/health.yml b/test/vcr_cassettes/synth/health.yml new file mode 100644 index 00000000..4d8ca054 --- /dev/null +++ b/test/vcr_cassettes/synth/health.yml @@ -0,0 +1,82 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.synthfinance.com/user + body: + encoding: US-ASCII + string: '' + headers: + Authorization: + - Bearer + X-Source: + - maybe_app + X-Source-Type: + - managed + User-Agent: + - Faraday v2.12.2 + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Sat, 15 Mar 2025 22:18:47 GMT + Content-Type: + - application/json; charset=utf-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Cache-Control: + - max-age=0, private, must-revalidate + Etag: + - W/"4ec3e0a20895d90b1e1241ca67f10ca3" + Referrer-Policy: + - strict-origin-when-cross-origin + Rndr-Id: + - 0cab64c9-e312-4bec + Strict-Transport-Security: + - max-age=63072000; includeSubDomains + Vary: + - Accept-Encoding + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-Permitted-Cross-Domain-Policies: + - none + X-Render-Origin-Server: + - Render + X-Request-Id: + - 1958563c-7c18-4201-a03c-a4b343dc68ab + X-Runtime: + - '0.014938' + X-Xss-Protection: + - '0' + Cf-Cache-Status: + - DYNAMIC + Report-To: + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=P3OWn4c8LFFWI0Dwr2CSYwHLaNhf9iD9TfAhqdx5PtLoWZ0pSImebfUsh00ZbOmh4r2cRJEQOmvy67wAwl6p0W%2Fx9017EkCnCaXibBBCKqJTBOdGnsSuV%2B45LrHsQmg%2BGeBwrw4b"}],"group":"cf-nel","max_age":604800}' + Nel: + - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' + Speculation-Rules: + - '"/cdn-cgi/speculation"' + Server: + - cloudflare + Cf-Ray: + - 920f637aa8cf1152-ORD + Alt-Svc: + - h3=":443"; ma=86400 + Server-Timing: + - cfL4;desc="?proto=TCP&rtt=25627&min_rtt=25594&rtt_var=9664&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2827&recv_bytes=878&delivery_rate=111991&cwnd=248&unsent_bytes=0&cid=c8e4c4e269114d14&ts=263&x=0" + body: + encoding: ASCII-8BIT + string: '{"id":"user_3208c49393f54b3e974795e4bea5b864","email":"test@maybe.co","name":"Test + User","plan":"Business","api_calls_remaining":249830,"api_limit":250000,"credits_reset_at":"2025-04-01T00:00:00.000-04:00","current_period_start":"2025-03-01T00:00:00.000-05:00"}' + recorded_at: Sat, 15 Mar 2025 22:18:47 GMT +recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/synth/security_info.yml b/test/vcr_cassettes/synth/security_info.yml new file mode 100644 index 00000000..8122b882 --- /dev/null +++ b/test/vcr_cassettes/synth/security_info.yml @@ -0,0 +1,105 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.synthfinance.com/tickers/AAPL?operating_mic=XNAS + body: + encoding: US-ASCII + string: '' + headers: + Authorization: + - Bearer + X-Source: + - maybe_app + X-Source-Type: + - managed + User-Agent: + - Faraday v2.12.2 + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Sun, 16 Mar 2025 12:04:12 GMT + Content-Type: + - application/json; charset=utf-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Cache-Control: + - max-age=0, private, must-revalidate + Etag: + - W/"a9deeb6437d359f080be449b9b2c547b" + Referrer-Policy: + - strict-origin-when-cross-origin + Rndr-Id: + - 1e77ae49-050a-45fc + Strict-Transport-Security: + - max-age=63072000; includeSubDomains + Vary: + - Accept-Encoding + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-Permitted-Cross-Domain-Policies: + - none + X-Render-Origin-Server: + - Render + X-Request-Id: + - 222dacf1-37f3-4eb8-91d5-edf13d732d46 + X-Runtime: + - '0.059222' + X-Xss-Protection: + - '0' + Cf-Cache-Status: + - DYNAMIC + Report-To: + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=%2BLW%2Fd%2BbcNg4%2FleO6ECyB4RJBMbm6vWG3%2FX4oKQXfn1ROSPVrISc3ZFVlXfITGW4XYJSPyUDF%2FXrrRF6p3Wzow07QamOrsux7sxBMvtWmcubgpCMFI4zgnhESklW6KcmAefwrgj9i"}],"group":"cf-nel","max_age":604800}' + Nel: + - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' + Speculation-Rules: + - '"/cdn-cgi/speculation"' + Server: + - cloudflare + Cf-Ray: + - 92141c97bfd9124c-ORD + Alt-Svc: + - h3=":443"; ma=86400 + Server-Timing: + - cfL4;desc="?proto=TCP&rtt=27459&min_rtt=26850&rtt_var=11288&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2828&recv_bytes=905&delivery_rate=91272&cwnd=104&unsent_bytes=0&cid=ccd6aa7e48e4b0eb&ts=287&x=0" + body: + encoding: ASCII-8BIT + string: '{"data":{"ticker":"AAPL","name":"Apple Inc.","links":{"homepage_url":"https://www.apple.com"},"logo_url":"https://logo.synthfinance.com/ticker/AAPL","description":"Apple + Inc. designs, manufactures, and markets smartphones, personal computers, tablets, + wearables, and accessories worldwide. The company offers iPhone, a line of + smartphones; Mac, a line of personal computers; iPad, a line of multi-purpose + tablets; and wearables, home, and accessories comprising AirPods, Apple TV, + Apple Watch, Beats products, and HomePod. It also provides AppleCare support + and cloud services; and operates various platforms, including the App Store + that allow customers to discover and download applications and digital content, + such as books, music, video, games, and podcasts. In addition, the company + offers various services, such as Apple Arcade, a game subscription service; + Apple Fitness+, a personalized fitness service; Apple Music, which offers + users a curated listening experience with on-demand radio stations; Apple + News+, a subscription news and magazine service; Apple TV+, which offers exclusive + original content; Apple Card, a co-branded credit card; and Apple Pay, a cashless + payment service, as well as licenses its intellectual property. The company + serves consumers, and small and mid-sized businesses; and the education, enterprise, + and government markets. It distributes third-party applications for its products + through the App Store. The company also sells its products through its retail + and online stores, and direct sales force; and third-party cellular network + carriers, wholesalers, retailers, and resellers. Apple Inc. was founded in + 1976 and is headquartered in Cupertino, California.","kind":"common stock","cik":"0000320193","currency":"USD","address":{"country":"USA","address_line1":"One + Apple Park Way","city":"Cupertino","state":"CA","postal_code":"95014"},"exchange":{"name":"Nasdaq/Ngs + (Global Select Market)","mic_code":"XNGS","operating_mic_code":"XNAS","acronym":"NGS","country":"United + States","country_code":"US","timezone":"America/New_York"},"ceo":"Mr. Timothy + D. Cook","founding_year":1976,"industry":"Consumer Electronics","sector":"Technology","phone":"408-996-1010","total_employees":161000,"composite_figi":"BBG000B9Y5X2","market_data":{"high_today":213.95,"low_today":209.58,"open_today":211.25,"close_today":213.49,"volume_today":60060200.0,"fifty_two_week_high":260.1,"fifty_two_week_low":164.08,"average_volume":62848099.37313433,"price_change":0.0,"percent_change":0.0}},"meta":{"credits_used":1,"credits_remaining":249808}}' + recorded_at: Sun, 16 Mar 2025 12:04:12 GMT +recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/synth/security_price.yml b/test/vcr_cassettes/synth/security_price.yml new file mode 100644 index 00000000..2e6d1dfb --- /dev/null +++ b/test/vcr_cassettes/synth/security_price.yml @@ -0,0 +1,83 @@ +--- +http_interactions: +- request: + method: get + 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 + body: + encoding: US-ASCII + string: '' + headers: + Authorization: + - Bearer + X-Source: + - maybe_app + X-Source-Type: + - managed + User-Agent: + - Faraday v2.12.2 + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Sun, 16 Mar 2025 12:08:00 GMT + Content-Type: + - application/json; charset=utf-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Cache-Control: + - max-age=0, private, must-revalidate + Etag: + - W/"cdf04c2cd77e230c03117dd13d0921f9" + Referrer-Policy: + - strict-origin-when-cross-origin + Rndr-Id: + - e74b3425-0b7c-447d + Strict-Transport-Security: + - max-age=63072000; includeSubDomains + Vary: + - Accept-Encoding + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-Permitted-Cross-Domain-Policies: + - none + X-Render-Origin-Server: + - Render + X-Request-Id: + - b906c5e1-18cc-44cc-9085-313ff066a6ce + X-Runtime: + - '0.544708' + X-Xss-Protection: + - '0' + Cf-Cache-Status: + - DYNAMIC + Report-To: + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=dZNe6qCGGI2XGXgByLr69%2FYrDQdy2FLtnXafxJnlsvyVjrRFiCvmbbIzgF5CDgtj9HZ8RC5Rh9jbuEI6hPokpa3Al4FEIAZB5AbfZ9toP%2Bc5muG%2FuBgHR%2FnIZpsWG%2BQKmBPu9MBa"}],"group":"cf-nel","max_age":604800}' + Nel: + - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' + Speculation-Rules: + - '"/cdn-cgi/speculation"' + Server: + - cloudflare + Cf-Ray: + - 921422292d0feacc-ORD + Alt-Svc: + - h3=":443"; ma=86400 + Server-Timing: + - cfL4;desc="?proto=TCP&rtt=30826&min_rtt=26727&rtt_var=12950&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2827&recv_bytes=970&delivery_rate=108354&cwnd=219&unsent_bytes=0&cid=43c717161effdc57&ts=695&x=0" + body: + encoding: ASCII-8BIT + string: '{"ticker":"AAPL","currency":"USD","exchange":{"name":"Nasdaq/Ngs (Global + Select Market)","mic_code":"XNGS","operating_mic_code":"XNAS","acronym":"NGS","country":"United + 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":{"credits_used":1,"credits_remaining":249807}}' + recorded_at: Sun, 16 Mar 2025 12:08:00 GMT +recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/synth/security_prices.yml b/test/vcr_cassettes/synth/security_prices.yml index 6cdf1fe6..1da82461 100644 --- a/test/vcr_cassettes/synth/security_prices.yml +++ b/test/vcr_cassettes/synth/security_prices.yml @@ -1,135 +1,163 @@ --- http_interactions: - - request: - method: get - uri: https://api.synthfinance.com/tickers/AAPL/open-close?end_date=2024-08-01&page=1&start_date=2024-01-01 - body: - encoding: US-ASCII - string: '' - headers: - User-Agent: - - Faraday v2.10.0 - Authorization: - - Bearer - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 200 - message: OK - headers: - Date: - - Thu, 01 Aug 2024 17:21:42 GMT - Content-Type: - - application/json; charset=utf-8 - Transfer-Encoding: - - chunked - Connection: - - keep-alive - Cf-Ray: - - 8ac781877cbb13ae-CMH - Cf-Cache-Status: - - DYNAMIC - Cache-Control: - - max-age=0, private, must-revalidate - Etag: - - W/"c1f8d4686b33c94fa18354b54c960f43" - Strict-Transport-Security: - - max-age=63072000; includeSubDomains - Vary: - - Accept-Encoding - Referrer-Policy: - - strict-origin-when-cross-origin - Rndr-Id: - - e6566c15-53f8-44b0 - X-Content-Type-Options: - - nosniff - X-Frame-Options: - - SAMEORIGIN - X-Permitted-Cross-Domain-Policies: - - none - X-Render-Origin-Server: - - Render - X-Request-Id: - - 14434e85-b1d4-4c36-a69a-efd14c562649 - X-Runtime: - - '1.397922' - X-Xss-Protection: - - '0' - Server: - - cloudflare - Alt-Svc: - - h3=":443"; ma=86400 - body: - encoding: ASCII-8BIT - string: '{"ticker":"AAPL","prices":[{"date":"2024-01-02","open":187.15,"close":185.64,"high":188.44,"low":183.89,"volume":81964874},{"date":"2024-01-03","open":184.22,"close":184.25,"high":185.88,"low":183.43,"volume":58414460},{"date":"2024-01-04","open":182.15,"close":181.91,"high":183.09,"low":180.88,"volume":71878670},{"date":"2024-01-05","open":181.99,"close":181.18,"high":182.76,"low":180.17,"volume":62371161},{"date":"2024-01-08","open":182.09,"close":185.56,"high":185.6,"low":181.5,"volume":59144470},{"date":"2024-01-09","open":183.92,"close":185.14,"high":185.15,"low":182.73,"volume":42841809},{"date":"2024-01-10","open":184.35,"close":186.19,"high":186.4,"low":183.92,"volume":46192908},{"date":"2024-01-11","open":186.54,"close":185.59,"high":187.05,"low":183.62,"volume":49128408},{"date":"2024-01-12","open":186.06,"close":185.92,"high":186.74,"low":185.19,"volume":40477782},{"date":"2024-01-16","open":182.16,"close":183.63,"high":184.26,"low":180.93,"volume":65076641},{"date":"2024-01-17","open":181.27,"close":182.68,"high":182.93,"low":180.3,"volume":47317433},{"date":"2024-01-18","open":186.09,"close":188.63,"high":189.14,"low":185.83,"volume":77722754},{"date":"2024-01-19","open":189.33,"close":191.56,"high":191.95,"low":188.82,"volume":68887985},{"date":"2024-01-22","open":192.3,"close":193.89,"high":195.33,"low":192.26,"volume":60131852},{"date":"2024-01-23","open":195.02,"close":195.18,"high":195.75,"low":193.83,"volume":42355590},{"date":"2024-01-24","open":195.42,"close":194.5,"high":196.38,"low":194.34,"volume":53631316},{"date":"2024-01-25","open":195.22,"close":194.17,"high":196.27,"low":193.11,"volume":54822126},{"date":"2024-01-26","open":194.27,"close":192.42,"high":194.76,"low":191.94,"volume":44587111},{"date":"2024-01-29","open":192.01,"close":191.73,"high":192.2,"low":189.58,"volume":47145622},{"date":"2024-01-30","open":190.94,"close":188.04,"high":191.8,"low":187.47,"volume":55836970},{"date":"2024-01-31","open":187.04,"close":184.4,"high":187.1,"low":184.35,"volume":55467803},{"date":"2024-02-01","open":183.99,"close":186.86,"high":186.95,"low":183.82,"volume":64885408},{"date":"2024-02-02","open":179.86,"close":185.85,"high":187.33,"low":179.25,"volume":102527680},{"date":"2024-02-05","open":188.15,"close":187.68,"high":189.25,"low":185.84,"volume":69654320},{"date":"2024-02-06","open":186.86,"close":189.3,"high":189.31,"low":186.77,"volume":43490759},{"date":"2024-02-07","open":190.64,"close":189.41,"high":191.05,"low":188.61,"volume":53438955},{"date":"2024-02-08","open":189.39,"close":188.32,"high":189.54,"low":187.35,"volume":40962046},{"date":"2024-02-09","open":188.65,"close":188.85,"high":189.99,"low":188.0,"volume":45155216},{"date":"2024-02-12","open":188.42,"close":187.15,"high":188.67,"low":186.79,"volume":41781934},{"date":"2024-02-13","open":185.77,"close":185.04,"high":186.21,"low":183.51,"volume":56529529},{"date":"2024-02-14","open":185.32,"close":184.15,"high":185.53,"low":182.44,"volume":54617917},{"date":"2024-02-15","open":183.55,"close":183.86,"high":184.49,"low":181.35,"volume":65434496},{"date":"2024-02-16","open":183.42,"close":182.31,"high":184.85,"low":181.67,"volume":49752465},{"date":"2024-02-20","open":181.79,"close":181.56,"high":182.43,"low":180.0,"volume":53574453},{"date":"2024-02-21","open":181.94,"close":182.32,"high":182.89,"low":180.66,"volume":41496371},{"date":"2024-02-22","open":183.48,"close":184.37,"high":184.96,"low":182.46,"volume":52284192},{"date":"2024-02-23","open":185.01,"close":182.52,"high":185.04,"low":182.23,"volume":44926677},{"date":"2024-02-26","open":182.24,"close":181.16,"high":182.76,"low":180.65,"volume":40867421},{"date":"2024-02-27","open":181.1,"close":182.63,"high":183.92,"low":179.56,"volume":54318851},{"date":"2024-02-28","open":182.51,"close":181.42,"high":183.12,"low":180.13,"volume":48943139},{"date":"2024-02-29","open":181.27,"close":180.75,"high":182.57,"low":179.53,"volume":136682597},{"date":"2024-03-01","open":179.55,"close":179.66,"high":180.53,"low":177.38,"volume":73450582},{"date":"2024-03-04","open":176.15,"close":175.1,"high":176.9,"low":173.79,"volume":81505451},{"date":"2024-03-05","open":170.76,"close":170.12,"high":172.04,"low":169.62,"volume":94702355},{"date":"2024-03-06","open":171.06,"close":169.12,"high":171.24,"low":168.68,"volume":68568907},{"date":"2024-03-07","open":169.15,"close":169.0,"high":170.73,"low":168.49,"volume":71763761},{"date":"2024-03-08","open":169.0,"close":170.73,"high":173.7,"low":168.94,"volume":76267041},{"date":"2024-03-11","open":172.94,"close":172.75,"high":174.38,"low":172.05,"volume":60139473},{"date":"2024-03-12","open":173.15,"close":173.23,"high":174.03,"low":171.01,"volume":59813522},{"date":"2024-03-13","open":172.77,"close":171.13,"high":173.19,"low":170.76,"volume":52488692},{"date":"2024-03-14","open":172.91,"close":173.0,"high":174.31,"low":172.05,"volume":72913507},{"date":"2024-03-15","open":171.17,"close":172.62,"high":172.62,"low":170.29,"volume":121752699},{"date":"2024-03-18","open":175.57,"close":173.72,"high":177.71,"low":173.52,"volume":75606556},{"date":"2024-03-19","open":174.34,"close":176.08,"high":176.61,"low":173.03,"volume":55215244},{"date":"2024-03-20","open":175.72,"close":178.67,"high":178.67,"low":175.09,"volume":53423102},{"date":"2024-03-21","open":177.05,"close":171.37,"high":177.49,"low":170.84,"volume":106181270},{"date":"2024-03-22","open":171.76,"close":172.28,"high":173.05,"low":170.06,"volume":71146138},{"date":"2024-03-25","open":170.57,"close":170.85,"high":171.94,"low":169.45,"volume":54288328},{"date":"2024-03-26","open":170.0,"close":169.71,"high":171.42,"low":169.58,"volume":57388449},{"date":"2024-03-27","open":170.41,"close":173.31,"high":173.6,"low":170.11,"volume":60263665},{"date":"2024-03-28","open":171.75,"close":171.48,"high":172.23,"low":170.51,"volume":65671690},{"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":49297581},{"date":"2024-04-03","open":168.79,"close":169.65,"high":170.68,"low":168.58,"volume":47691715},{"date":"2024-04-04","open":170.29,"close":168.82,"high":171.92,"low":168.82,"volume":53682486},{"date":"2024-04-05","open":169.59,"close":169.58,"high":170.39,"low":168.95,"volume":42104826},{"date":"2024-04-08","open":169.03,"close":168.45,"high":169.2,"low":168.24,"volume":37425513},{"date":"2024-04-09","open":168.7,"close":169.67,"high":170.08,"low":168.35,"volume":42451209},{"date":"2024-04-10","open":168.8,"close":167.78,"high":169.09,"low":167.11,"volume":49691936},{"date":"2024-04-11","open":168.34,"close":175.04,"high":175.46,"low":168.16,"volume":91053075},{"date":"2024-04-12","open":174.26,"close":176.55,"high":178.36,"low":174.21,"volume":101282386},{"date":"2024-04-15","open":175.36,"close":172.69,"high":176.63,"low":172.5,"volume":70733115},{"date":"2024-04-16","open":171.75,"close":169.38,"high":173.76,"low":168.27,"volume":71583932},{"date":"2024-04-17","open":169.61,"close":168.0,"high":170.65,"low":168.0,"volume":48503680},{"date":"2024-04-18","open":168.03,"close":167.04,"high":168.64,"low":166.55,"volume":40735511},{"date":"2024-04-19","open":166.21,"close":165.0,"high":166.4,"low":164.08,"volume":66084170},{"date":"2024-04-22","open":165.52,"close":165.84,"high":167.26,"low":164.77,"volume":46488244},{"date":"2024-04-23","open":165.35,"close":166.9,"high":167.05,"low":164.92,"volume":46956672},{"date":"2024-04-24","open":166.54,"close":169.02,"high":169.3,"low":166.21,"volume":47007455},{"date":"2024-04-25","open":169.53,"close":169.89,"high":170.61,"low":168.15,"volume":48858902},{"date":"2024-04-26","open":169.88,"close":169.3,"high":171.34,"low":169.18,"volume":44014087},{"date":"2024-04-29","open":173.37,"close":173.5,"high":176.03,"low":173.1,"volume":66891905},{"date":"2024-04-30","open":173.33,"close":170.33,"high":174.99,"low":170.0,"volume":64066593},{"date":"2024-05-01","open":169.58,"close":169.3,"high":172.71,"low":169.11,"volume":48416441},{"date":"2024-05-02","open":172.51,"close":173.03,"high":173.42,"low":170.89,"volume":91402452},{"date":"2024-05-03","open":186.65,"close":183.38,"high":187.0,"low":182.66,"volume":160948084},{"date":"2024-05-06","open":182.35,"close":181.71,"high":184.2,"low":180.42,"volume":75883763},{"date":"2024-05-07","open":183.45,"close":182.4,"high":184.9,"low":181.32,"volume":74139796},{"date":"2024-05-08","open":182.85,"close":182.74,"high":183.07,"low":181.45,"volume":43762264},{"date":"2024-05-09","open":182.56,"close":184.57,"high":184.66,"low":182.11,"volume":47493785},{"date":"2024-05-10","open":184.9,"close":183.05,"high":185.09,"low":182.13,"volume":48525869},{"date":"2024-05-13","open":185.44,"close":186.28,"high":187.1,"low":184.62,"volume":68586935},{"date":"2024-05-14","open":187.51,"close":187.43,"high":188.3,"low":186.29,"volume":50551025},{"date":"2024-05-15","open":187.91,"close":189.72,"high":190.65,"low":187.37,"volume":67561123},{"date":"2024-05-16","open":190.47,"close":189.84,"high":191.1,"low":189.66,"volume":51938566},{"date":"2024-05-17","open":189.51,"close":189.87,"high":190.81,"low":189.18,"volume":39819440},{"date":"2024-05-20","open":189.33,"close":191.04,"high":191.92,"low":189.01,"volume":43637717},{"date":"2024-05-21","open":191.09,"close":192.35,"high":192.73,"low":190.92,"volume":41192656},{"date":"2024-05-22","open":192.27,"close":190.9,"high":192.82,"low":190.27,"volume":33510741},{"date":"2024-05-23","open":190.98,"close":186.88,"high":191.0,"low":186.63,"volume":48553611}],"paging":{"prev":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026page=\u0026start_date=2024-01-01","next":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026page=2\u0026start_date=2024-01-01","total_records":147,"current_page":1,"per_page":100,"total_pages":2},"meta":{"credits_used":1,"credits_remaining":248998}}' - recorded_at: Thu, 01 Aug 2024 17:21:42 GMT - - request: - method: get - uri: https://api.synthfinance.com/tickers/AAPL/open-close?end_date=2024-08-01&page=2&start_date=2024-01-01 - body: - encoding: US-ASCII - string: '' - headers: - User-Agent: - - Faraday v2.10.0 - Authorization: - - Bearer - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 200 - message: OK - headers: - Date: - - Thu, 01 Aug 2024 17:21:44 GMT - Content-Type: - - application/json; charset=utf-8 - Transfer-Encoding: - - chunked - Connection: - - keep-alive - Cf-Ray: - - 8ac78191cc326a4c-CMH - Cf-Cache-Status: - - DYNAMIC - Cache-Control: - - max-age=0, private, must-revalidate - Etag: - - W/"88e17df1f20118595ae291a4a025291f" - Strict-Transport-Security: - - max-age=63072000; includeSubDomains - Vary: - - Accept-Encoding - Referrer-Policy: - - strict-origin-when-cross-origin - Rndr-Id: - - e8700161-b44e-49ce - X-Content-Type-Options: - - nosniff - X-Frame-Options: - - SAMEORIGIN - X-Permitted-Cross-Domain-Policies: - - none - X-Render-Origin-Server: - - Render - X-Request-Id: - - 6072beb1-2e89-4ce0-95e0-72d436adc033 - X-Runtime: - - '1.296361' - X-Xss-Protection: - - '0' - Server: - - cloudflare - Alt-Svc: - - h3=":443"; ma=86400 - body: - encoding: ASCII-8BIT - string: '{"ticker":"AAPL","prices":[{"date":"2024-05-24","open":188.82,"close":189.98,"high":190.58,"low":188.04,"volume":35429737},{"date":"2024-05-28","open":191.51,"close":189.99,"high":193.0,"low":189.1,"volume":51021752},{"date":"2024-05-29","open":189.61,"close":190.29,"high":192.25,"low":189.51,"volume":51934816},{"date":"2024-05-30","open":190.76,"close":191.29,"high":192.18,"low":190.63,"volume":48211467},{"date":"2024-05-31","open":191.44,"close":192.25,"high":192.57,"low":189.91,"volume":71937580},{"date":"2024-06-03","open":192.9,"close":194.03,"high":194.99,"low":192.52,"volume":48702790},{"date":"2024-06-04","open":194.64,"close":194.35,"high":195.32,"low":193.03,"volume":46573003},{"date":"2024-06-05","open":195.4,"close":195.87,"high":196.9,"low":194.87,"volume":53100041},{"date":"2024-06-06","open":195.69,"close":194.48,"high":196.5,"low":194.17,"volume":39591471},{"date":"2024-06-07","open":194.65,"close":196.89,"high":196.94,"low":194.14,"volume":52508446},{"date":"2024-06-10","open":196.9,"close":193.12,"high":197.3,"low":192.15,"volume":95034362},{"date":"2024-06-11","open":193.65,"close":207.15,"high":207.16,"low":193.63,"volume":169677009},{"date":"2024-06-12","open":207.37,"close":213.07,"high":220.2,"low":206.9,"volume":197067068},{"date":"2024-06-13","open":214.74,"close":214.24,"high":216.75,"low":211.6,"volume":96562134},{"date":"2024-06-14","open":213.85,"close":212.49,"high":215.17,"low":211.3,"volume":69150814},{"date":"2024-06-17","open":213.37,"close":216.67,"high":218.95,"low":212.72,"volume":92964543},{"date":"2024-06-18","open":217.59,"close":214.29,"high":218.63,"low":213.0,"volume":78534656},{"date":"2024-06-20","open":213.93,"close":209.68,"high":214.24,"low":208.85,"volume":83863022},{"date":"2024-06-21","open":210.39,"close":207.49,"high":211.89,"low":207.11,"volume":204018186},{"date":"2024-06-24","open":207.72,"close":208.14,"high":212.7,"low":206.59,"volume":76303387},{"date":"2024-06-25","open":209.15,"close":209.07,"high":211.38,"low":208.61,"volume":54266550},{"date":"2024-06-26","open":211.5,"close":213.25,"high":214.86,"low":210.64,"volume":64531178},{"date":"2024-06-27","open":214.69,"close":214.1,"high":215.74,"low":212.35,"volume":48631748},{"date":"2024-06-28","open":215.77,"close":210.62,"high":216.07,"low":210.3,"volume":80927625},{"date":"2024-07-01","open":212.09,"close":216.75,"high":217.51,"low":211.92,"volume":59475152},{"date":"2024-07-02","open":216.15,"close":220.27,"high":220.38,"low":215.1,"volume":57112299},{"date":"2024-07-03","open":220.0,"close":221.55,"high":221.55,"low":219.03,"volume":36707517},{"date":"2024-07-05","open":221.65,"close":226.34,"high":226.45,"low":221.65,"volume":58287571},{"date":"2024-07-08","open":227.09,"close":227.82,"high":227.85,"low":223.25,"volume":57456163},{"date":"2024-07-09","open":227.93,"close":228.68,"high":229.4,"low":226.37,"volume":47531745},{"date":"2024-07-10","open":229.3,"close":232.98,"high":233.08,"low":229.25,"volume":61539280},{"date":"2024-07-11","open":231.39,"close":227.57,"high":232.39,"low":225.77,"volume":63197762},{"date":"2024-07-12","open":228.92,"close":230.54,"high":232.64,"low":228.68,"volume":51621443},{"date":"2024-07-15","open":236.48,"close":234.4,"high":237.23,"low":233.09,"volume":60513737},{"date":"2024-07-16","open":235.0,"close":234.82,"high":236.27,"low":232.33,"volume":38468546},{"date":"2024-07-17","open":229.45,"close":228.88,"high":231.46,"low":226.64,"volume":55878906},{"date":"2024-07-18","open":230.28,"close":224.18,"high":230.44,"low":222.27,"volume":64346030},{"date":"2024-07-19","open":224.82,"close":224.31,"high":226.8,"low":223.28,"volume":48020038},{"date":"2024-07-22","open":227.01,"close":223.96,"high":227.78,"low":223.09,"volume":44958139},{"date":"2024-07-23","open":224.37,"close":225.01,"high":226.94,"low":222.68,"volume":37919040},{"date":"2024-07-24","open":224.0,"close":218.54,"high":224.8,"low":217.13,"volume":59687424},{"date":"2024-07-25","open":218.93,"close":217.49,"high":220.85,"low":214.62,"volume":50451768},{"date":"2024-07-26","open":218.7,"close":217.96,"high":219.49,"low":216.01,"volume":39827645},{"date":"2024-07-29","open":216.96,"close":218.24,"high":219.3,"low":215.75,"volume":35153729},{"date":"2024-07-30","open":219.19,"close":218.8,"high":220.33,"low":216.12,"volume":40681625},{"date":"2024-07-31","open":221.44,"close":222.08,"high":223.82,"low":220.63,"volume":48422974},{"date":"2024-08-01","open":224.37,"high":224.81,"low":217.78,"volume":25116548}],"paging":{"prev":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026page=1\u0026start_date=2024-01-01","next":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026page=\u0026start_date=2024-01-01","total_records":147,"current_page":2,"per_page":100,"total_pages":2},"meta":{"credits_used":1,"credits_remaining":248997}}' - recorded_at: Thu, 01 Aug 2024 17:21:44 GMT -recorded_with: VCR 6.2.0 +- request: + method: get + 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 + body: + encoding: US-ASCII + string: '' + headers: + Authorization: + - Bearer + X-Source: + - maybe_app + X-Source-Type: + - managed + User-Agent: + - Faraday v2.12.2 + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Sun, 16 Mar 2025 12:02:51 GMT + Content-Type: + - application/json; charset=utf-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Cache-Control: + - max-age=0, private, must-revalidate + Etag: + - W/"eb6f73b7cb267ae753291839d20c72e4" + Referrer-Policy: + - strict-origin-when-cross-origin + Rndr-Id: + - e0119cf5-873c-4315 + Strict-Transport-Security: + - max-age=63072000; includeSubDomains + Vary: + - Accept-Encoding + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-Permitted-Cross-Domain-Policies: + - none + X-Render-Origin-Server: + - Render + X-Request-Id: + - 590af10c-b7c1-47a1-9a9a-7f8a5f031734 + X-Runtime: + - '0.511130' + X-Xss-Protection: + - '0' + Cf-Cache-Status: + - DYNAMIC + Report-To: + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=BgSpKtsSHqPqVuO8FUQ0Zb4nT2VXJ9Q%2F3QrLGiZyq1%2FvGm4KnPL2jbgehp8fTKMHqK64Dm4aoEfwI6iK22Gz%2BG9Kq8wpHPGugon0YRBz1tiYLnq5QVoyvNJi6HV%2B6IBWQ5jCK5wA"}],"group":"cf-nel","max_age":604800}' + Nel: + - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' + Speculation-Rules: + - '"/cdn-cgi/speculation"' + Server: + - cloudflare + Cf-Ray: + - 92141a99ed232306-ORD + Alt-Svc: + - h3=":443"; ma=86400 + Server-Timing: + - cfL4;desc="?proto=TCP&rtt=26790&min_rtt=26357&rtt_var=10751&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2828&recv_bytes=970&delivery_rate=97096&cwnd=126&unsent_bytes=0&cid=5ac523d87c018022&ts=666&x=0" + body: + encoding: ASCII-8BIT + string: '{"ticker":"AAPL","currency":"USD","exchange":{"name":"Nasdaq/Ngs (Global + Select Market)","mic_code":"XNGS","operating_mic_code":"XNAS","acronym":"NGS","country":"United + 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":{"credits_used":1,"credits_remaining":249810}}' + recorded_at: Sun, 16 Mar 2025 12:02:51 GMT +- request: + method: get + 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 + body: + encoding: US-ASCII + string: '' + headers: + Authorization: + - Bearer + X-Source: + - maybe_app + X-Source-Type: + - managed + User-Agent: + - Faraday v2.12.2 + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Sun, 16 Mar 2025 12:02:52 GMT + Content-Type: + - application/json; charset=utf-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Cache-Control: + - max-age=0, private, must-revalidate + Etag: + - W/"78f6663a1523295a82d0ded13df426e4" + Referrer-Policy: + - strict-origin-when-cross-origin + Rndr-Id: + - b0cd3704-937c-4017 + Strict-Transport-Security: + - max-age=63072000; includeSubDomains + Vary: + - Accept-Encoding + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-Permitted-Cross-Domain-Policies: + - none + X-Render-Origin-Server: + - Render + X-Request-Id: + - 59a55ec3-49af-4fa1-a104-77480fa6914e + X-Runtime: + - '0.583469' + X-Xss-Protection: + - '0' + Cf-Cache-Status: + - DYNAMIC + Report-To: + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=ze9Rfqww2cEeTSTiP5axby5TPvYyBZDoEHRZKniMMybJrqYiBI1oGlCViODsaOXisw23njq1YaO%2Fhc0yGlPaqYdTcMXc6bQbVnWANjASqMS%2BQoVmPBFPr3nvSqeU99huB4BKWGlY"}],"group":"cf-nel","max_age":604800}' + Nel: + - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' + Speculation-Rules: + - '"/cdn-cgi/speculation"' + Server: + - cloudflare + Cf-Ray: + - 92141a9edbeee15f-ORD + Alt-Svc: + - h3=":443"; ma=86400 + Server-Timing: + - cfL4;desc="?proto=TCP&rtt=32376&min_rtt=31508&rtt_var=12435&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2828&recv_bytes=970&delivery_rate=91913&cwnd=171&unsent_bytes=0&cid=d782914cf2ed620a&ts=758&x=0" + body: + encoding: ASCII-8BIT + string: '{"ticker":"AAPL","currency":"USD","exchange":{"name":"Nasdaq/Ngs (Global + Select Market)","mic_code":"XNGS","operating_mic_code":"XNAS","acronym":"NGS","country":"United + 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":{"credits_used":1,"credits_remaining":249809}}' + recorded_at: Sun, 16 Mar 2025 12:02:52 GMT +recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/synth/security_search.yml b/test/vcr_cassettes/synth/security_search.yml new file mode 100644 index 00000000..f9504804 --- /dev/null +++ b/test/vcr_cassettes/synth/security_search.yml @@ -0,0 +1,104 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.synthfinance.com/tickers/search?country_code=US&dataset=limited&limit=25&name=AAPL + body: + encoding: US-ASCII + string: '' + headers: + Authorization: + - Bearer + X-Source: + - maybe_app + X-Source-Type: + - managed + User-Agent: + - Faraday v2.12.2 + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Sun, 16 Mar 2025 12:01:58 GMT + Content-Type: + - application/json; charset=utf-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Cache-Control: + - max-age=0, private, must-revalidate + Etag: + - W/"3e444869eacbaf17006766a691cc8fdc" + Referrer-Policy: + - strict-origin-when-cross-origin + Rndr-Id: + - 2effb56b-f67f-402d + Strict-Transport-Security: + - max-age=63072000; includeSubDomains + Vary: + - Accept-Encoding + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-Permitted-Cross-Domain-Policies: + - none + X-Render-Origin-Server: + - Render + X-Request-Id: + - 33470619-5119-4923-b4e0-e9a0eeb532a1 + X-Runtime: + - '0.453770' + X-Xss-Protection: + - '0' + Cf-Cache-Status: + - DYNAMIC + Report-To: + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=ayZOlXkCwLgUl%2FrB2%2BlqtqR5HCllubf4HLDipEt3klWKyHS4nilHi9XZ1fiEQWx7xwiRMJZ5EW0Xzm7ISoHWTtEbkgMQHWYQwSTeg30ahFFHK1pkOOnET1fuW1UxiZwlJtq1XZGB"}],"group":"cf-nel","max_age":604800}' + Nel: + - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' + Speculation-Rules: + - '"/cdn-cgi/speculation"' + Server: + - cloudflare + Cf-Ray: + - 921419514e0a6399-ORD + Alt-Svc: + - h3=":443"; ma=86400 + Server-Timing: + - cfL4;desc="?proto=TCP&rtt=25809&min_rtt=25801&rtt_var=9692&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2829&recv_bytes=939&delivery_rate=111952&cwnd=121&unsent_bytes=0&cid=2beb787f15cd8ab9&ts=610&x=0" + body: + encoding: ASCII-8BIT + string: '{"data":[{"symbol":"AAPL","name":"Apple Inc.","logo_url":"https://logo.synthfinance.com/ticker/AAPL","currency":"USD","exchange":{"name":"Nasdaq/Ngs + (Global Select Market)","mic_code":"XNGS","operating_mic_code":"XNAS","acronym":"NGS","country":"United + States","country_code":"US","timezone":"America/New_York"}},{"symbol":"APLY","isin":"US88634T8577","name":"YieldMax + AAPL Option Income ETF","logo_url":"https://logo.synthfinance.com/ticker/APLY","currency":"USD","exchange":{"name":"Nyse + Arca","mic_code":"ARCX","operating_mic_code":"XNYS","acronym":"NYSE","country":"United + States","country_code":"US","timezone":"America/New_York"}},{"symbol":"AAPD","name":"Direxion + Daily AAPL Bear 1X ETF","logo_url":"https://logo.synthfinance.com/ticker/AAPD","currency":"USD","exchange":{"name":"Nasdaq/Nms + (Global Market)","mic_code":"XNMS","operating_mic_code":"XNAS","acronym":"","country":"United + States","country_code":"US","timezone":"America/New_York"}},{"symbol":"AAPU","isin":"US25461A8743","name":"Direxion + Daily AAPL Bull 2X Shares","logo_url":"https://logo.synthfinance.com/ticker/AAPU","currency":"USD","exchange":{"name":"Nasdaq/Nms + (Global Market)","mic_code":"XNMS","operating_mic_code":"XNAS","acronym":"","country":"United + States","country_code":"US","timezone":"America/New_York"}},{"symbol":"AAPB","isin":"XXXXXXXR8842","name":"GraniteShares + 2x Long AAPL Daily ETF","logo_url":"https://logo.synthfinance.com/ticker/AAPB","currency":"USD","exchange":{"name":"Nasdaq/Ngs + (Global Select Market)","mic_code":"XNGS","operating_mic_code":"XNAS","acronym":"NGS","country":"United + States","country_code":"US","timezone":"America/New_York"}},{"symbol":"AAPD","isin":"US25461A3041","name":"Direxion + Daily AAPL Bear 1X Shares","logo_url":"https://logo.synthfinance.com/ticker/AAPD","currency":"USD","exchange":{"name":"Nasdaq/Ngs + (Global Select Market)","mic_code":"XNGS","operating_mic_code":"XNAS","acronym":"NGS","country":"United + States","country_code":"US","timezone":"America/New_York"}},{"symbol":"AAPU","isin":"US25461A8743","name":"Direxion + Daily AAPL Bull 1.5X Shares","logo_url":"https://logo.synthfinance.com/ticker/AAPU","currency":"USD","exchange":{"name":"Nasdaq/Ngs + (Global Select Market)","mic_code":"XNGS","operating_mic_code":"XNAS","acronym":"NGS","country":"United + States","country_code":"US","timezone":"America/New_York"}},{"symbol":"AAPJ","isin":"US00037T1034","name":"AAP, + Inc.","logo_url":"https://logo.synthfinance.com/ticker/AAPJ","currency":"USD","exchange":{"name":"Otc + Pink Marketplace","mic_code":"PINX","operating_mic_code":"OTCM","acronym":"","country":"United + States","country_code":"US","timezone":"America/New_York"}}]}' + recorded_at: Sun, 16 Mar 2025 12:01:58 GMT +recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/synth/transaction_enrich.yml b/test/vcr_cassettes/synth/transaction_enrich.yml new file mode 100644 index 00000000..08463ed7 --- /dev/null +++ b/test/vcr_cassettes/synth/transaction_enrich.yml @@ -0,0 +1,82 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.synthfinance.com/enrich?amount=25.5&city=San%20Francisco&country=US&date=2025-03-16&description=UBER%20EATS&state=CA + body: + encoding: US-ASCII + string: '' + headers: + Authorization: + - Bearer + X-Source: + - maybe_app + X-Source-Type: + - managed + User-Agent: + - Faraday v2.12.2 + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Sun, 16 Mar 2025 12:09:33 GMT + Content-Type: + - application/json; charset=utf-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Cache-Control: + - max-age=0, private, must-revalidate + Etag: + - W/"00411c83cfeaade519bcc3e57d9e461e" + Referrer-Policy: + - strict-origin-when-cross-origin + Rndr-Id: + - 56a8791d-85ed-4342 + Strict-Transport-Security: + - max-age=63072000; includeSubDomains + Vary: + - Accept-Encoding + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-Permitted-Cross-Domain-Policies: + - none + X-Render-Origin-Server: + - Render + X-Request-Id: + - 1b35b9c1-0092-40b1-8b70-2bce7c5796af + X-Runtime: + - '0.884634' + X-Xss-Protection: + - '0' + Cf-Cache-Status: + - DYNAMIC + Report-To: + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=qUtB0aWbK%2Fh5W7cV%2FugsUGbWKtJzsf%2FXd5i8cm8KlepEtLyuVPH7XX0fqwzHp43OCWQkGr9r8hRBBSEcx9LWW5vS7%2B1kXCJaKPaTRn%2BWtsEymHg78OHqDcMahwSuy%2FkpSGLWo0or"}],"group":"cf-nel","max_age":604800}' + Nel: + - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' + Speculation-Rules: + - '"/cdn-cgi/speculation"' + Server: + - cloudflare + Cf-Ray: + - 921424681aa4acab-ORD + Alt-Svc: + - h3=":443"; ma=86400 + Server-Timing: + - cfL4;desc="?proto=TCP&rtt=26975&min_rtt=26633&rtt_var=10231&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2829&recv_bytes=969&delivery_rate=108737&cwnd=210&unsent_bytes=0&cid=318ff675628918e1&ts=1035&x=0" + body: + encoding: ASCII-8BIT + string: '{"merchant":"Uber Eats","merchant_id":"mer_aea41e7f29ce47b5873f3caf49d5972d","category":"Dining + Out","website":"ubereats.com","icon":"https://logo.synthfinance.com/ubereats.com","meta":{"credits_used":1,"credits_remaining":249806}}' + recorded_at: Sun, 16 Mar 2025 12:09:33 GMT +recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/synth/usage.yml b/test/vcr_cassettes/synth/usage.yml new file mode 100644 index 00000000..27e5300f --- /dev/null +++ b/test/vcr_cassettes/synth/usage.yml @@ -0,0 +1,82 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.synthfinance.com/user + body: + encoding: US-ASCII + string: '' + headers: + Authorization: + - Bearer + X-Source: + - maybe_app + X-Source-Type: + - managed + User-Agent: + - Faraday v2.12.2 + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Sat, 15 Mar 2025 22:18:47 GMT + Content-Type: + - application/json; charset=utf-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Cache-Control: + - max-age=0, private, must-revalidate + Etag: + - W/"4ec3e0a20895d90b1e1241ca67f10ca3" + Referrer-Policy: + - strict-origin-when-cross-origin + Rndr-Id: + - 54c8ecf9-6858-4db6 + Strict-Transport-Security: + - max-age=63072000; includeSubDomains + Vary: + - Accept-Encoding + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-Permitted-Cross-Domain-Policies: + - none + X-Render-Origin-Server: + - Render + X-Request-Id: + - a4112cfb-0eac-4e3e-a880-7536d90dcba0 + X-Runtime: + - '0.007036' + X-Xss-Protection: + - '0' + Cf-Cache-Status: + - DYNAMIC + Report-To: + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=Rt0BTtrgXzYjWOQFgb%2Bg6N4xKvXtPI66Q251bq9nWtqUhGHo17GmVVAPkutwN7Gisw1RmvYfxYUiMCCxlc4%2BjuHxbU1%2BXr9KHy%2F5pUpLhgLNNrtkqqKOCW4GduODnDbw2I38Rocu"}],"group":"cf-nel","max_age":604800}' + Nel: + - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' + Speculation-Rules: + - '"/cdn-cgi/speculation"' + Server: + - cloudflare + Cf-Ray: + - 920f637d1fe8eb68-ORD + Alt-Svc: + - h3=":443"; ma=86400 + Server-Timing: + - cfL4;desc="?proto=TCP&rtt=28779&min_rtt=27036&rtt_var=11384&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2828&recv_bytes=878&delivery_rate=107116&cwnd=203&unsent_bytes=0&cid=52bc39ad09dd9eff&ts=145&x=0" + body: + encoding: ASCII-8BIT + string: '{"id":"user_3208c49393f54b3e974795e4bea5b864","email":"test@maybe.co","name":"Test + User","plan":"Business","api_calls_remaining":1200,"api_limit":5000,"credits_reset_at":"2025-04-01T00:00:00.000-04:00","current_period_start":"2025-03-01T00:00:00.000-05:00"}' + recorded_at: Sat, 15 Mar 2025 22:18:47 GMT +recorded_with: VCR 6.3.1 From 56203b04d31c6286e3beed4b176d9515e2286324 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Mar 2025 11:55:07 -0400 Subject: [PATCH 062/380] Bump sentry-rails from 5.22.4 to 5.23.0 (#1996) Bumps [sentry-rails](https://github.com/getsentry/sentry-ruby) from 5.22.4 to 5.23.0. - [Release notes](https://github.com/getsentry/sentry-ruby/releases) - [Changelog](https://github.com/getsentry/sentry-ruby/blob/master/CHANGELOG.md) - [Commits](https://github.com/getsentry/sentry-ruby/compare/5.22.4...5.23.0) --- updated-dependencies: - dependency-name: sentry-rails dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 6fe445f8..8c18e78f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -278,7 +278,7 @@ GEM benchmark logger mini_mime (1.1.5) - minitest (5.25.4) + minitest (5.25.5) mocha (2.7.1) ruby2_keywords (>= 0.0.5) msgpack (1.8.0) @@ -295,21 +295,21 @@ GEM net-smtp (0.5.0) net-protocol nio4r (2.7.4) - nokogiri (1.18.3-aarch64-linux-gnu) + nokogiri (1.18.4-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.3-aarch64-linux-musl) + nokogiri (1.18.4-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.18.3-arm-linux-gnu) + nokogiri (1.18.4-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.18.3-arm-linux-musl) + nokogiri (1.18.4-arm-linux-musl) racc (~> 1.4) - nokogiri (1.18.3-arm64-darwin) + nokogiri (1.18.4-arm64-darwin) racc (~> 1.4) - nokogiri (1.18.3-x86_64-darwin) + nokogiri (1.18.4-x86_64-darwin) racc (~> 1.4) - nokogiri (1.18.3-x86_64-linux-gnu) + nokogiri (1.18.4-x86_64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.3-x86_64-linux-musl) + nokogiri (1.18.4-x86_64-linux-musl) racc (~> 1.4) octokit (9.2.0) faraday (>= 1, < 3) @@ -343,7 +343,7 @@ GEM nio4r (~> 2.0) raabro (1.4.0) racc (1.8.1) - rack (3.1.11) + rack (3.1.12) rack-mini-profiler (3.3.1) rack (>= 1.2.0) rack-session (2.1.0) @@ -457,10 +457,10 @@ GEM rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) websocket (~> 1.0) - sentry-rails (5.22.4) + sentry-rails (5.23.0) railties (>= 5.0) - sentry-ruby (~> 5.22.4) - sentry-ruby (5.22.4) + sentry-ruby (~> 5.23.0) + sentry-ruby (5.23.0) bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) simplecov (0.22.0) From 78baf2b3275ca557b6a8485b6d3e017bac847d7c Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 17 Mar 2025 13:04:59 -0400 Subject: [PATCH 063/380] Update deps --- Gemfile | 3 +-- Gemfile.lock | 67 ++++++++++++++++++++++++---------------------------- 2 files changed, 32 insertions(+), 38 deletions(-) diff --git a/Gemfile b/Gemfile index 247a4849..c195af9b 100644 --- a/Gemfile +++ b/Gemfile @@ -22,8 +22,7 @@ gem "lucide-rails", github: "maybe-finance/lucide-rails" gem "stimulus-rails" gem "turbo-rails" -# Temporary pin to commit to fix crypto.randomUUID() errors. Revert this when the change has been released. -gem "hotwire_combobox", github: "josefarias/hotwire_combobox", ref: "b827048a8305e1115d5f96931ba1c9750d1e59fc" +gem "hotwire_combobox" # Background Jobs gem "good_job" diff --git a/Gemfile.lock b/Gemfile.lock index 8c18e78f..79a48413 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,14 +1,3 @@ -GIT - remote: https://github.com/josefarias/hotwire_combobox.git - revision: b827048a8305e1115d5f96931ba1c9750d1e59fc - ref: b827048a8305e1115d5f96931ba1c9750d1e59fc - specs: - hotwire_combobox (0.3.2) - platform_agent (>= 1.0.1) - rails (>= 7.0.7.2) - stimulus-rails (>= 1.2) - turbo-rails (>= 1.2) - GIT remote: https://github.com/maybe-finance/lucide-rails.git revision: 272e5fb8418ea458da3995d6abe0ba0ceee9c9f0 @@ -93,14 +82,15 @@ GEM addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) ast (2.4.2) - aws-eventstream (1.3.0) - aws-partitions (1.1043.0) - aws-sdk-core (3.217.0) + aws-eventstream (1.3.2) + aws-partitions (1.1067.0) + aws-sdk-core (3.220.1) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) + base64 jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.97.0) + aws-sdk-kms (1.99.0) aws-sdk-core (~> 3, >= 3.216.0) aws-sigv4 (~> 1.5) aws-sdk-s3 (1.177.0) @@ -206,6 +196,11 @@ GEM actioncable (>= 7.0.0) listen (>= 3.0.0) railties (>= 7.0.0) + hotwire_combobox (0.4.0) + platform_agent (>= 1.0.1) + rails (>= 7.0.7.2) + stimulus-rails (>= 1.2) + turbo-rails (>= 1.2) i18n (1.14.7) concurrent-ruby (~> 1.0) i18n-tasks (1.0.15) @@ -238,11 +233,11 @@ GEM rdoc (>= 4.0.0) reline (>= 0.4.2) jmespath (1.6.2) - json (2.10.1) + json (2.10.2) jwt (2.10.1) base64 language_server-protocol (3.17.0.4) - launchy (3.1.0) + launchy (3.1.1) addressable (~> 2.8) childprocess (~> 5.0) logger (~> 1.6) @@ -253,7 +248,7 @@ GEM rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) logger (1.6.6) - logtail (0.1.15) + logtail (0.1.17) msgpack (~> 1.0) logtail-rack (0.2.6) logtail (~> 0.1) @@ -274,7 +269,7 @@ GEM net-smtp marcel (1.0.4) matrix (0.4.2) - mini_magick (5.1.2) + mini_magick (5.2.0) benchmark logger mini_mime (1.1.5) @@ -285,14 +280,14 @@ GEM multipart-post (2.4.1) net-http (0.6.0) uri - net-imap (0.5.5) + net-imap (0.5.6) date net-protocol net-pop (0.1.2) net-protocol net-protocol (0.2.2) timeout - net-smtp (0.5.0) + net-smtp (0.5.1) net-protocol nio4r (2.7.4) nokogiri (1.18.4-aarch64-linux-gnu) @@ -314,7 +309,7 @@ GEM octokit (9.2.0) faraday (>= 1, < 3) sawyer (~> 0.9) - pagy (9.3.3) + pagy (9.3.4) parallel (1.26.3) parser (3.3.7.1) ast (~> 2.4.1) @@ -407,7 +402,7 @@ GEM chunky_png (~> 1.0) rqrcode_core (~> 1.0) rqrcode_core (1.2.0) - rubocop (1.73.2) + rubocop (1.74.0) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -418,7 +413,7 @@ GEM rubocop-ast (>= 1.38.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.38.1) + rubocop-ast (1.39.0) parser (>= 3.3.1.0) rubocop-performance (1.24.0) lint_roller (~> 1.1) @@ -434,7 +429,7 @@ GEM rubocop (>= 1.72) rubocop-performance (>= 1.24) rubocop-rails (>= 2.30) - ruby-lsp (0.23.9) + ruby-lsp (0.23.11) language_server-protocol (~> 3.17.0) prism (>= 1.2, < 2.0) rbs (>= 3, < 4) @@ -470,21 +465,21 @@ GEM simplecov-html (0.13.1) simplecov_json_formatter (0.1.4) smart_properties (1.17.0) - sorbet-runtime (0.5.11813) + sorbet-runtime (0.5.11934) stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.1.5) stripe (13.5.0) - tailwindcss-rails (4.0.0) + tailwindcss-rails (4.2.0) railties (>= 7.0.0) tailwindcss-ruby (~> 4.0) - tailwindcss-ruby (4.0.6) - tailwindcss-ruby (4.0.6-aarch64-linux-gnu) - tailwindcss-ruby (4.0.6-aarch64-linux-musl) - tailwindcss-ruby (4.0.6-arm64-darwin) - tailwindcss-ruby (4.0.6-x86_64-darwin) - tailwindcss-ruby (4.0.6-x86_64-linux-gnu) - tailwindcss-ruby (4.0.6-x86_64-linux-musl) + tailwindcss-ruby (4.0.14) + tailwindcss-ruby (4.0.14-aarch64-linux-gnu) + tailwindcss-ruby (4.0.14-aarch64-linux-musl) + tailwindcss-ruby (4.0.14-arm64-darwin) + tailwindcss-ruby (4.0.14-x86_64-darwin) + tailwindcss-ruby (4.0.14-x86_64-linux-gnu) + tailwindcss-ruby (4.0.14-x86_64-linux-musl) terminal-table (4.0.0) unicode-display_width (>= 1.1.1, < 4) thor (1.3.2) @@ -497,7 +492,7 @@ GEM unicode-display_width (3.1.4) unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (4.0.4) - uri (1.0.2) + uri (1.0.3) useragent (0.16.11) vcr (6.3.1) base64 @@ -551,7 +546,7 @@ DEPENDENCIES faraday-retry good_job hotwire-livereload - hotwire_combobox! + hotwire_combobox i18n-tasks image_processing (>= 1.2) importmap-rails From 087dd720c1813b7c2a7a7aa22ce223621f663383 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 17 Mar 2025 17:26:19 -0400 Subject: [PATCH 064/380] Report ActionCable errors to Sentry --- app/channels/application_cable/connection.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb index 0ff5442f..8415947f 100644 --- a/app/channels/application_cable/connection.rb +++ b/app/channels/application_cable/connection.rb @@ -1,4 +1,10 @@ module ApplicationCable class Connection < ActionCable::Connection::Base + rescue_from StandardError, with: :report_error + + private + def report_error(e) + Sentry.capture_exception(e) + end end end From 06468a05b1146b597e295a7b367c9b1bd2b2fa88 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 17 Mar 2025 20:04:38 -0400 Subject: [PATCH 065/380] Update default DB pool size --- config/database.yml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/config/database.yml b/config/database.yml index a6aa2d20..c3142c2a 100644 --- a/config/database.yml +++ b/config/database.yml @@ -1,8 +1,22 @@ default: &default adapter: postgresql encoding: unicode - # 3 connections for Puma, 15 for GoodJob (in async mode, the default for self-hosters) = 18 connections - pool: <%= ENV.fetch("DB_POOL_SIZE") { 18 } %> + # Note on DB_POOL_SIZE: + # ------------------------------------------------------------------------------------------------------------- + # To optimize for the simplest self-hosting setup, we run ActionCable, GoodJob, and Rails in the same process. + # + # This requires DB connections for each: + # + # Puma: Requires 3 connections (Rails default) + # ActionCable: 5 connections (Rails defaults to 4 workers + 1 listener for Postgres adapter) + # GoodJob: 15 connections to run in "async" mode. See `good_job.rb` for the breakdown. + # -------------------------------------------------------------------------------------------- + # Total: 23 connections + # + # We default to this value so that self-hosters don't need to configure anything. Hosted mode will require + # a different pool size, as we run ActionCable, GoodJob, and Rails in separate processes. + # + pool: <%= ENV.fetch("DB_POOL_SIZE") { 23 } %> host: <%= ENV.fetch("DB_HOST") { "127.0.0.1" } %> port: <%= ENV.fetch("DB_PORT") { "5432" } %> user: <%= ENV.fetch("POSTGRES_USER") { nil } %> From a7db914005853b06036f0f49d365a32e2f986901 Mon Sep 17 00:00:00 2001 From: Vaibhav Agrawal <78676331+vaibhav-if@users.noreply.github.com> Date: Wed, 19 Mar 2025 18:19:30 +0530 Subject: [PATCH 066/380] Update security price query in demo generator (#2000) --- app/models/demo/generator.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb index 369f18c8..9d62e684 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -374,7 +374,7 @@ class Demo::Generator date = Faker::Number.positive(to: 730).days.ago.to_date security = trade[:security] qty = trade[:qty] - price = Security::Price.find_by(ticker: security.ticker, date: date)&.price || 1 + price = Security::Price.find_by(security: security, date: date)&.price || 1 name_prefix = qty < 0 ? "Sell " : "Buy " account.entries.create! \ From 19cc63c8f4ca8c3950226cf9d1bfc7e46ff313a8 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Wed, 19 Mar 2025 12:36:16 -0400 Subject: [PATCH 067/380] Use Redis for ActiveJob and ActionCable (#2004) * Use Redis for ActiveJob and ActionCable * Fix alwaysApply setting * Update queue names and weights * Tweak weights * Update job queues * Update docker setup guide * Remove deprecated upgrade columns from users table * Refactor Redis configuration for Sidekiq and caching in production environment * Add Sidekiq Sentry monitoring * queue naming fix * Clean up schema --- .cursor/rules/project-conventions.mdc | 7 +- .env.example | 96 ++----- .gitignore | 4 +- Gemfile | 4 +- Gemfile.lock | 30 +-- Procfile.dev | 2 +- README.md | 43 +--- app/controllers/pages_controller.rb | 2 +- .../settings/hostings_controller.rb | 14 +- app/controllers/upgrades_controller.rb | 56 ----- app/helpers/upgrades_helper.rb | 14 -- app/jobs/application_job.rb | 8 +- app/jobs/auto_upgrade_job.rb | 31 --- app/jobs/data_cache_clear_job.rb | 2 +- app/jobs/destroy_job.rb | 2 +- app/jobs/enrich_transaction_batch_job.rb | 2 +- app/jobs/family_reset_job.rb | 2 +- app/jobs/fetch_security_info_job.rb | 2 +- app/jobs/import_job.rb | 2 +- app/jobs/revert_import_job.rb | 2 +- app/jobs/sync_job.rb | 3 +- app/jobs/user_purge_job.rb | 2 +- app/models/provider/github.rb | 41 +-- app/models/setting.rb | 17 -- app/models/upgrader.rb | 57 ----- app/models/upgrader/config.rb | 17 -- app/models/upgrader/deployer.rb | 12 - app/models/upgrader/deployer/null.rb | 8 - app/models/upgrader/deployer/render.rb | 41 --- app/models/upgrader/provided.rb | 10 - app/models/upgrader/upgrade.rb | 29 --- app/models/user.rb | 16 -- .../_super_admin_bar.html.erb | 2 +- app/views/layouts/shared/_htmldoc.html.erb | 4 - .../layouts/shared/_notifications.html.erb | 4 - .../hostings/_provider_settings.html.erb | 9 - .../hostings/_upgrade_settings.html.erb | 41 --- app/views/settings/hostings/show.html.erb | 2 - .../shared/_upgrade_notification.html.erb | 17 -- bin/render-build.sh | 13 - compose.example.yml | 100 ++++++++ config/application.rb | 2 - config/cable.yml | 6 +- config/credentials.yml.enc | 2 +- config/database.yml | 17 +- config/environments/production.rb | 10 +- config/initializers/good_job.rb | 33 --- config/initializers/sidekiq.rb | 9 + config/locales/models/upgrader/en.yml | 13 - config/locales/views/settings/hostings/en.yml | 15 -- config/locales/views/shared/en.yml | 5 - config/locales/views/upgrades/en.yml | 10 - config/routes.rb | 13 +- config/sidekiq.yml | 5 + config/storage.yml | 14 +- .../20241022170439_create_stock_exchanges.rb | 6 - db/migrate/20250318212559_remove_good_job.rb | 14 ++ ...0250319145426_remove_self_host_upgrades.rb | 6 + db/schema.rb | 92 +------ db/seeds/.keep | 0 db/seeds/exchanges.rb | 36 --- docker-compose.example.yml | 74 ------ docs/hosting/docker.md | 32 +-- docs/hosting/one-click-deploy.md | 90 ------- render.yaml | 60 ----- test/controllers/sessions_controller_test.rb | 11 - .../settings/hostings_controller_test.rb | 22 +- test/controllers/upgrades_controller_test.rb | 89 ------- .../git_repository_provider_interface_test.rb | 25 -- test/jobs/auto_upgrade_job_test.rb | 7 - test/models/provider/github_test.rb | 9 - test/models/upgrader/upgrade_test.rb | 36 --- test/models/upgrader_test.rb | 88 ------- .../fetch_latest_release_notes.yml | 156 ++++++------ .../fetch_latest_upgrade_candidates.yml | 235 ------------------ 75 files changed, 328 insertions(+), 1684 deletions(-) delete mode 100644 app/controllers/upgrades_controller.rb delete mode 100644 app/helpers/upgrades_helper.rb delete mode 100644 app/jobs/auto_upgrade_job.rb delete mode 100644 app/models/upgrader.rb delete mode 100644 app/models/upgrader/config.rb delete mode 100644 app/models/upgrader/deployer.rb delete mode 100644 app/models/upgrader/deployer/null.rb delete mode 100644 app/models/upgrader/deployer/render.rb delete mode 100644 app/models/upgrader/provided.rb delete mode 100644 app/models/upgrader/upgrade.rb delete mode 100644 app/views/settings/hostings/_provider_settings.html.erb delete mode 100644 app/views/settings/hostings/_upgrade_settings.html.erb delete mode 100644 app/views/shared/_upgrade_notification.html.erb create mode 100644 compose.example.yml delete mode 100644 config/initializers/good_job.rb create mode 100644 config/initializers/sidekiq.rb delete mode 100644 config/locales/models/upgrader/en.yml delete mode 100644 config/locales/views/upgrades/en.yml create mode 100644 config/sidekiq.yml create mode 100644 db/migrate/20250318212559_remove_good_job.rb create mode 100644 db/migrate/20250319145426_remove_self_host_upgrades.rb create mode 100644 db/seeds/.keep delete mode 100644 db/seeds/exchanges.rb delete mode 100644 docker-compose.example.yml delete mode 100644 docs/hosting/one-click-deploy.md delete mode 100644 render.yaml delete mode 100644 test/controllers/upgrades_controller_test.rb delete mode 100644 test/interfaces/git_repository_provider_interface_test.rb delete mode 100644 test/jobs/auto_upgrade_job_test.rb delete mode 100644 test/models/provider/github_test.rb delete mode 100644 test/models/upgrader/upgrade_test.rb delete mode 100644 test/models/upgrader_test.rb delete mode 100644 test/vcr_cassettes/git_repository_provider/fetch_latest_upgrade_candidates.yml diff --git a/.cursor/rules/project-conventions.mdc b/.cursor/rules/project-conventions.mdc index ddc5cdc7..32ecf705 100644 --- a/.cursor/rules/project-conventions.mdc +++ b/.cursor/rules/project-conventions.mdc @@ -1,6 +1,7 @@ --- -description: This rule explains the project's tech stack and code conventions -globs: * +description: +globs: +alwaysApply: true --- This rule serves as high-level documentation for how the Maybe codebase is structured. @@ -19,7 +20,7 @@ This rule serves as high-level documentation for how the Maybe codebase is struc - TailwindCSS for styles - Lucide Icons for icons - Database: PostgreSQL -- Jobs: GoodJob +- Jobs: Sidekiq + Redis - External - Payments: Stripe - User bank data syncing: Plaid diff --git a/.env.example b/.env.example index 5253039c..528dad8b 100644 --- a/.env.example +++ b/.env.example @@ -1,20 +1,31 @@ -# ================================ PLEASE READ ========================================== -# This file outlines all the possible environment variables supported by the Maybe app. -# -# This includes several features that are for our "hosted" version of Maybe, which most -# open-source contributors won't need. +# ================================ PLEASE READ =========================================================== +# This file outlines all the possible environment variables supported by the Maybe app for self hosting. # -# If you are developing locally, you should be referencing `.env.local.example` instead. -# ======================================================================================= +# If you're a developer setting up your local environment, please use `.env.local.example` instead. +# ======================================================================================================== + +# Required self-hosting vars +# -------------------------------------------------------------------------------------------------------- + +# Enables self hosting features (should be set to true unless you know what you're doing) +SELF_HOSTED=true + +# Secret key used to encrypt credentials (https://api.rubyonrails.org/v7.1.3.2/classes/Rails/Application.html#method-i-secret_key_base) +# Has to be a random string, generated eg. by running `openssl rand -hex 64` +SECRET_KEY_BASE=secret-value + +# Optional self-hosting vars +# -------------------------------------------------------------------------------------------------------- + +# Optional: Synth API Key for exchange rates + stock prices +# (you can also set this in your self-hosted settings page) +# Get it here: https://synthfinance.com/ +SYNTH_API_KEY= # Custom port config # For users who have other applications listening at 3000, this allows them to set a value puma will listen to. PORT=3000 -# Exchange Rate & Stock Pricing API -# This is used to convert between different currencies in the app. In addition, it fetches global stock prices. We use Synth, which is a Maybe product. You can sign up for a free account at synthfinance.com. -SYNTH_API_KEY= - # SMTP Configuration # This is only needed if you intend on sending emails from your Maybe instance (such as for password resets or email financial reports). # Resend.com is a good option that offers a free tier for sending emails. @@ -37,60 +48,20 @@ POSTGRES_USER=postgres # This is the domain that your Maybe instance will be hosted at. It is used to generate links in emails and other places. APP_DOMAIN= -## Error and Performance Monitoring -# The app uses Sentry to monitor errors and performance. In reality, you likely don't need this unless you're deploying Maybe to many users. -SENTRY_DSN= - -# If enabled, an invite code generated by `rake invites:create` is required to sign up as a new user. -# This is useful for controlling who can sign up for your Maybe instance. -REQUIRE_INVITE_CODE=false - -# Enables self hosting features (should be set to true for most folks) -SELF_HOSTED=true - -# The hosting platform used to deploy the app (e.g. "render") -# `localhost` (or unset) is used for local development and testing -HOSTING_PLATFORM=localhost - -# Secret key used to encrypt credentials (https://api.rubyonrails.org/v7.1.3.2/classes/Rails/Application.html#method-i-secret_key_base) -# Has to be a random string, generated eg. by running `openssl rand -hex 64` -SECRET_KEY_BASE=secret-value - # Disable enforcing SSL connections # DISABLE_SSL=true -# ====================================================================================================== -# Upgrades Module - responsible for triggering upgrade alerts, prompts, and auto-upgrade functionality -# ====================================================================================================== -# -# UPGRADES_ENABLED: Enables Upgrader class functionality. -# UPGRADES_MODE: Controls how the app will upgrade. `manual` means the user must manually upgrade the app. `auto` means the app will upgrade automatically (great for self-hosting) -# UPGRADES_TARGET: Controls what the app will upgrade to. `release` means the app will upgrade to the latest release. `commit` means the app will upgrade to the latest commit. -# -UPGRADES_ENABLED=false # unless editing the flow, you should keep this `false` locally in development -UPGRADES_MODE=manual # `manual` or `auto` -UPGRADES_TARGET=release # `release` or `commit` - - -# ====================================================================================================== -# Git Repository Module - responsible for fetching latest commit data for upgrades -# ====================================================================================================== -# -GITHUB_REPO_OWNER=maybe-finance -GITHUB_REPO_NAME=maybe -GITHUB_REPO_BRANCH=main - # ====================================================================================================== # Active Storage Configuration - responsible for storing file uploads # ====================================================================================================== # -# * Defaults to disk storage but you can also use Amazon S3, Google Cloud Storage, or Microsoft Azure Storage. +# * Defaults to disk storage but you can also use Amazon S3 or Cloudflare R2 # * Set the appropriate environment variables to use these services. # * Ensure libvips is installed on your system for image processing - https://github.com/libvips/libvips # # Amazon S3 # ========== -# ACTIVE_STORAGE_SERVICE=amazon +# ACTIVE_STORAGE_SERVICE=amazon <- Enables Amazon S3 storage # S3_ACCESS_KEY_ID= # S3_SECRET_ACCESS_KEY= # S3_REGION= # defaults to `us-east-1` if not set @@ -98,26 +69,9 @@ GITHUB_REPO_BRANCH=main # # Cloudflare R2 # ============= -# ACTIVE_STORAGE_SERVICE=cloudflare +# ACTIVE_STORAGE_SERVICE=cloudflare <- Enables Cloudflare R2 storage # CLOUDFLARE_ACCOUNT_ID= # CLOUDFLARE_ACCESS_KEY_ID= # CLOUDFLARE_SECRET_ACCESS_KEY= # CLOUDFLARE_BUCKET= - -# ====================================================================================================== -# Billing Module - responsible for handling billing -# ====================================================================================================== # -STRIPE_PUBLISHABLE_KEY= -STRIPE_SECRET_KEY= -STRIPE_WEBHOOK_SECRET= - -# ====================================================================================================== -# Plaid Configuration -# ====================================================================================================== -# -PLAID_CLIENT_ID= -PLAID_SECRET= -PLAID_ENV= -PLAID_EU_CLIENT_ID= -PLAID_EU_SECRET= diff --git a/.gitignore b/.gitignore index 0b8983aa..3a37df24 100644 --- a/.gitignore +++ b/.gitignore @@ -64,4 +64,6 @@ coverage .cursorrules # Ignore node related files -node_modules \ No newline at end of file +node_modules + +compose.yml \ No newline at end of file diff --git a/Gemfile b/Gemfile index c195af9b..86027d7f 100644 --- a/Gemfile +++ b/Gemfile @@ -7,6 +7,7 @@ gem "rails", "~> 7.2.2" # Drivers gem "pg", "~> 1.5" +gem "redis", "~> 5.4" # Deployment gem "puma", ">= 5.0" @@ -25,13 +26,14 @@ gem "turbo-rails" gem "hotwire_combobox" # Background Jobs -gem "good_job" +gem "sidekiq" # Error logging gem "vernier" gem "rack-mini-profiler" gem "sentry-ruby" gem "sentry-rails" +gem "sentry-sidekiq" gem "logtail-rails" # Active Storage diff --git a/Gemfile.lock b/Gemfile.lock index 79a48413..6d0899c1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -155,8 +155,6 @@ GEM rubocop (>= 1) smart_properties erubi (1.13.1) - et-orbi (1.2.11) - tzinfo faker (3.5.1) i18n (>= 1.8.11, < 2) faraday (2.12.2) @@ -177,18 +175,8 @@ GEM ffi (1.17.1-x86_64-darwin) ffi (1.17.1-x86_64-linux-gnu) ffi (1.17.1-x86_64-linux-musl) - fugit (1.11.1) - et-orbi (~> 1, >= 1.2.11) - raabro (~> 1.4) globalid (1.2.1) activesupport (>= 6.1) - good_job (4.9.3) - activejob (>= 6.1.0) - activerecord (>= 6.1.0) - concurrent-ruby (>= 1.3.1) - fugit (>= 1.11.0) - railties (>= 6.1.0) - thor (>= 1.0.0) hashdiff (1.1.2) highline (3.1.2) reline @@ -336,7 +324,6 @@ GEM public_suffix (6.0.1) puma (6.6.0) nio4r (~> 2.0) - raabro (1.4.0) racc (1.8.1) rack (3.1.12) rack-mini-profiler (3.3.1) @@ -393,6 +380,10 @@ GEM rdoc (6.12.0) psych (>= 4.0.0) redcarpet (3.6.1) + redis (5.4.0) + redis-client (>= 0.22.0) + redis-client (0.24.0) + connection_pool regexp_parser (2.10.0) reline (0.6.0) io-console (~> 0.5) @@ -458,6 +449,15 @@ GEM sentry-ruby (5.23.0) bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) + sentry-sidekiq (5.23.0) + sentry-ruby (~> 5.23.0) + sidekiq (>= 3.0) + sidekiq (8.0.1) + connection_pool (>= 2.5.0) + json (>= 2.9.0) + logger (>= 1.6.2) + rack (>= 3.1.0) + redis-client (>= 0.23.2) simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) @@ -544,7 +544,6 @@ DEPENDENCIES faraday faraday-multipart faraday-retry - good_job hotwire-livereload hotwire_combobox i18n-tasks @@ -567,6 +566,7 @@ DEPENDENCIES rails (~> 7.2.2) rails-settings-cached redcarpet + redis (~> 5.4) rotp (~> 6.3) rqrcode (~> 2.2) rubocop-rails-omakase @@ -574,6 +574,8 @@ DEPENDENCIES selenium-webdriver sentry-rails sentry-ruby + sentry-sidekiq + sidekiq simplecov stimulus-rails stripe diff --git a/Procfile.dev b/Procfile.dev index f3cfa172..68cba921 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,3 +1,3 @@ web: bundle exec ${DEBUG:+rdbg -O -n -c --} bin/rails server -b 0.0.0.0 css: bundle exec bin/rails tailwindcss:watch -worker: bundle exec good_job start +worker: bundle exec sidekiq diff --git a/README.md b/README.md index 969f4a80..2a384012 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,6 @@ Get involved: [Discord](https://link.maybe.co/discord) • [Website](https://maybefinance.com) • [Issues](https://github.com/maybe-finance/maybe/issues) -_If you're looking for the previous React codebase, you can find it -at [maybe-finance/maybe-archive](https://github.com/maybe-finance/maybe-archive)._ - ## Backstory We spent the better part of 2021/2022 building a personal finance + wealth @@ -29,9 +26,8 @@ and eventually offer a hosted version of the app for a small monthly fee. There are 3 primary ways to use the Maybe app: -1. Managed (easiest) - _coming soon..._ -2. [One-click deploy](docs/hosting/one-click-deploy.md) -3. [Self-host with Docker](docs/hosting/docker.md) +1. Managed (easiest) - we're in alpha and release invites in our Discord +2. [Self-host with Docker](docs/hosting/docker.md) ## Contributing @@ -84,37 +80,10 @@ If you'd like multi-currency support, there are a few extra steps to follow. ### Setup Guides -#### Dev Container (optional) - -This is 100% optional and meant for devs who don't want to worry about -installing requirements manually for their platform. You can -follow [this guide](https://code.visualstudio.com/docs/devcontainers/containers) -to learn more about Dev Containers. - -If you run into `could not connect to server` errors, you may need to change -your `.env`'s `DB_HOST` environment variable value to `db` to point to the -Postgres container. - -#### Mac - -Please visit -our [Mac dev setup guide](https://github.com/maybe-finance/maybe/wiki/Mac-Dev-Setup-Guide). - -#### Linux - -Please visit -our [Linux dev setup guide](https://github.com/maybe-finance/maybe/wiki/Linux-Dev-Setup-Guide). - -#### Windows - -Please visit -our [Windows dev setup guide](https://github.com/maybe-finance/maybe/wiki/Windows-Dev-Setup-Guide). - -### Testing Emails - -In development, we use `letter_opener` to automatically open emails in your -browser. When an email sends locally, a new browser tab will open with a -preview. +- [Mac dev setup guide](https://github.com/maybe-finance/maybe/wiki/Mac-Dev-Setup-Guide) +- [Linux dev setup guide](https://github.com/maybe-finance/maybe/wiki/Linux-Dev-Setup-Guide) +- [Windows dev setup guide](https://github.com/maybe-finance/maybe/wiki/Windows-Dev-Setup-Guide) +- Dev containers - visit [this guide](https://code.visualstudio.com/docs/devcontainers/containers) to learn more ## Repo Activity diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index f2a91f62..e39e4975 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -10,7 +10,7 @@ class PagesController < ApplicationController end def changelog - @release_notes = Provider::Github.new.fetch_latest_release_notes + @release_notes = Providers.github.fetch_latest_release_notes render layout: "settings" end diff --git a/app/controllers/settings/hostings_controller.rb b/app/controllers/settings/hostings_controller.rb index 637ff80f..f461fc20 100644 --- a/app/controllers/settings/hostings_controller.rb +++ b/app/controllers/settings/hostings_controller.rb @@ -9,18 +9,6 @@ class Settings::HostingsController < ApplicationController end def update - if hosting_params[:upgrades_setting].present? - mode = hosting_params[:upgrades_setting] == "manual" ? "manual" : "auto" - target = hosting_params[:upgrades_setting] == "commit" ? "commit" : "release" - - Setting.upgrades_mode = mode - Setting.upgrades_target = target - end - - if hosting_params.key?(:render_deploy_hook) - Setting.render_deploy_hook = hosting_params[:render_deploy_hook] - end - if hosting_params.key?(:require_invite_for_signup) Setting.require_invite_for_signup = hosting_params[:require_invite_for_signup] end @@ -46,7 +34,7 @@ class Settings::HostingsController < ApplicationController private def hosting_params - params.require(:setting).permit(:render_deploy_hook, :upgrades_setting, :require_invite_for_signup, :require_email_confirmation, :synth_api_key) + params.require(:setting).permit(:require_invite_for_signup, :require_email_confirmation, :synth_api_key) end def raise_if_not_self_hosted diff --git a/app/controllers/upgrades_controller.rb b/app/controllers/upgrades_controller.rb deleted file mode 100644 index baf4cd8d..00000000 --- a/app/controllers/upgrades_controller.rb +++ /dev/null @@ -1,56 +0,0 @@ -class UpgradesController < ApplicationController - before_action :verify_upgrades_enabled - - def acknowledge - commit_sha = params[:id] - upgrade = Upgrader.find_upgrade(commit_sha) - - if upgrade - if upgrade.available? - Current.user.acknowledge_upgrade_prompt(upgrade.commit_sha) - flash[:notice] = t(".upgrade_dismissed") - elsif upgrade.complete? - Current.user.acknowledge_upgrade_alert(upgrade.commit_sha) - flash[:notice] = t(".upgrade_complete_dismiss") - else - flash[:alert] = t(".upgrade_not_available") - end - else - flash[:alert] = t(".upgrade_not_found") - end - - redirect_back(fallback_location: root_path) - end - - def deploy - commit_sha = params[:id] - upgrade = Upgrader.find_upgrade(commit_sha) - - unless upgrade - flash[:alert] = t(".upgrade_not_found") - return redirect_back(fallback_location: root_path) - end - - prior_acknowledged_upgrade_commit_sha = Current.user.last_prompted_upgrade_commit_sha - - # Optimistically acknowledge the upgrade prompt - Current.user.acknowledge_upgrade_prompt(upgrade.commit_sha) - - upgrade_result = Upgrader.upgrade_to(upgrade) - - if upgrade_result[:success] - flash[:notice] = upgrade_result[:message] - else - # If the upgrade fails, revert to the prior acknowledged upgrade - Current.user.acknowledge_upgrade_prompt(prior_acknowledged_upgrade_commit_sha) - flash[:alert] = upgrade_result[:message] - end - - redirect_back(fallback_location: root_path) - end - - private - def verify_upgrades_enabled - head :not_found unless ENV["UPGRADES_ENABLED"] == "true" - end -end diff --git a/app/helpers/upgrades_helper.rb b/app/helpers/upgrades_helper.rb deleted file mode 100644 index 6e1584d9..00000000 --- a/app/helpers/upgrades_helper.rb +++ /dev/null @@ -1,14 +0,0 @@ -module UpgradesHelper - def get_upgrade_for_notification(user, upgrades_mode) - return nil unless ENV["UPGRADES_ENABLED"] == "true" - return nil unless user.present? - - completed_upgrade = Upgrader.completed_upgrade - return completed_upgrade if completed_upgrade && user.last_alerted_upgrade_commit_sha != completed_upgrade.commit_sha - - available_upgrade = Upgrader.available_upgrade - if available_upgrade && upgrades_mode == "manual" && user.last_prompted_upgrade_commit_sha != available_upgrade.commit_sha - available_upgrade - end - end -end diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb index d394c3d1..25727230 100644 --- a/app/jobs/application_job.rb +++ b/app/jobs/application_job.rb @@ -1,7 +1,5 @@ class ApplicationJob < ActiveJob::Base - # Automatically retry jobs that encountered a deadlock - # retry_on ActiveRecord::Deadlocked - - # Most jobs are safe to ignore if the underlying records are no longer available - # discard_on ActiveJob::DeserializationError + retry_on ActiveRecord::Deadlocked + discard_on ActiveJob::DeserializationError + queue_as :low_priority # default queue end diff --git a/app/jobs/auto_upgrade_job.rb b/app/jobs/auto_upgrade_job.rb deleted file mode 100644 index 34a0f3dc..00000000 --- a/app/jobs/auto_upgrade_job.rb +++ /dev/null @@ -1,31 +0,0 @@ -class AutoUpgradeJob < ApplicationJob - queue_as :latency_low - - def perform(*args) - raise_if_disabled - - return Rails.logger.info "Skipping auto-upgrades because app is set to manual upgrades. Please set UPGRADES_MODE=auto to enable auto-upgrades" if Setting.upgrades_mode == "manual" - - Rails.logger.info "Searching for available auto-upgrades..." - - candidate = Upgrader.available_upgrade_by_type(Setting.upgrades_target) - - if candidate - if Rails.cache.read("last_auto_upgrade_commit_sha") == candidate.commit_sha - Rails.logger.info "Skipping auto upgrade: #{candidate.type} #{candidate.commit_sha} deploy in progress" - return - end - - Rails.logger.info "Auto upgrading to #{candidate.type} #{candidate.commit_sha}..." - Upgrader.upgrade_to(candidate) - Rails.cache.write("last_auto_upgrade_commit_sha", candidate.commit_sha, expires_in: 1.day) - else - Rails.logger.info "No auto upgrade available at this time" - end - end - - private - def raise_if_disabled - raise "Upgrades module is disabled. Please set UPGRADES_ENABLED=true to enable upgrade features" unless ENV["UPGRADES_ENABLED"] == "true" - end -end diff --git a/app/jobs/data_cache_clear_job.rb b/app/jobs/data_cache_clear_job.rb index 49e18880..045e1e10 100644 --- a/app/jobs/data_cache_clear_job.rb +++ b/app/jobs/data_cache_clear_job.rb @@ -1,5 +1,5 @@ class DataCacheClearJob < ApplicationJob - queue_as :default + queue_as :low_priority def perform(family) ActiveRecord::Base.transaction do diff --git a/app/jobs/destroy_job.rb b/app/jobs/destroy_job.rb index 8ea120f6..74348555 100644 --- a/app/jobs/destroy_job.rb +++ b/app/jobs/destroy_job.rb @@ -1,5 +1,5 @@ class DestroyJob < ApplicationJob - queue_as :latency_low + queue_as :low_priority def perform(model) model.destroy diff --git a/app/jobs/enrich_transaction_batch_job.rb b/app/jobs/enrich_transaction_batch_job.rb index a796db67..71aac720 100644 --- a/app/jobs/enrich_transaction_batch_job.rb +++ b/app/jobs/enrich_transaction_batch_job.rb @@ -1,5 +1,5 @@ class EnrichTransactionBatchJob < ApplicationJob - queue_as :latency_high + queue_as :low_priority def perform(account, batch_size = 100, offset = 0) account.enrich_transaction_batch(batch_size, offset) diff --git a/app/jobs/family_reset_job.rb b/app/jobs/family_reset_job.rb index 20dc2499..185df111 100644 --- a/app/jobs/family_reset_job.rb +++ b/app/jobs/family_reset_job.rb @@ -1,5 +1,5 @@ class FamilyResetJob < ApplicationJob - queue_as :default + queue_as :low_priority def perform(family) # Delete all family data except users diff --git a/app/jobs/fetch_security_info_job.rb b/app/jobs/fetch_security_info_job.rb index 484a47e1..e789222f 100644 --- a/app/jobs/fetch_security_info_job.rb +++ b/app/jobs/fetch_security_info_job.rb @@ -1,5 +1,5 @@ class FetchSecurityInfoJob < ApplicationJob - queue_as :latency_low + queue_as :low_priority def perform(security_id) return unless Security.provider.present? diff --git a/app/jobs/import_job.rb b/app/jobs/import_job.rb index 8a7c490e..2e45f508 100644 --- a/app/jobs/import_job.rb +++ b/app/jobs/import_job.rb @@ -1,5 +1,5 @@ class ImportJob < ApplicationJob - queue_as :latency_medium + queue_as :high_priority def perform(import) import.publish diff --git a/app/jobs/revert_import_job.rb b/app/jobs/revert_import_job.rb index ac7090b4..818244cb 100644 --- a/app/jobs/revert_import_job.rb +++ b/app/jobs/revert_import_job.rb @@ -1,5 +1,5 @@ class RevertImportJob < ApplicationJob - queue_as :latency_low + queue_as :medium_priority def perform(import) import.revert diff --git a/app/jobs/sync_job.rb b/app/jobs/sync_job.rb index 187d18f7..3c7497df 100644 --- a/app/jobs/sync_job.rb +++ b/app/jobs/sync_job.rb @@ -1,7 +1,8 @@ class SyncJob < ApplicationJob - queue_as :latency_medium + queue_as :high_priority def perform(sync) + sleep 1 # simulate work for faster jobs sync.perform end end diff --git a/app/jobs/user_purge_job.rb b/app/jobs/user_purge_job.rb index 2f173f7a..70b94be8 100644 --- a/app/jobs/user_purge_job.rb +++ b/app/jobs/user_purge_job.rb @@ -1,5 +1,5 @@ class UserPurgeJob < ApplicationJob - queue_as :latency_low + queue_as :low_priority def perform(user) user.purge diff --git a/app/models/provider/github.rb b/app/models/provider/github.rb index 51e2556c..208049c5 100644 --- a/app/models/provider/github.rb +++ b/app/models/provider/github.rb @@ -1,43 +1,10 @@ class Provider::Github attr_reader :name, :owner, :branch - def initialize(config = {}) - @name = config[:name] || ENV.fetch("GITHUB_REPO_NAME", "maybe") - @owner = config[:owner] || ENV.fetch("GITHUB_REPO_OWNER", "maybe-finance") - @branch = config[:branch] || ENV.fetch("GITHUB_REPO_BRANCH", "main") - end - - def fetch_latest_upgrade_candidates - Rails.cache.fetch("latest_github_upgrade_candidates", expires_in: 30.minutes) do - Rails.logger.info "Fetching latest GitHub upgrade candidates from #{repo} on branch #{branch}..." - begin - latest_release = Octokit.releases(repo).first - latest_version = latest_release ? Semver.from_release_tag(latest_release.tag_name) : Semver.new(Maybe.version) - latest_commit = Octokit.branch(repo, branch) - - release_info = if latest_release - { - version: latest_version, - url: latest_release.html_url, - commit_sha: Octokit.commit(repo, latest_release.tag_name).sha - } - end - - commit_info = { - version: latest_version, - commit_sha: latest_commit.commit.sha, - url: latest_commit.commit.html_url - } - - { - release: release_info, - commit: commit_info - } - rescue => e - Rails.logger.error "Failed to fetch latest GitHub commits: #{e.message}" - nil - end - end + def initialize + @name = "maybe" + @owner = "maybe-finance" + @branch = "main" end def fetch_latest_release_notes diff --git a/app/models/setting.rb b/app/models/setting.rb index 41355fee..da829d7e 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -2,24 +2,7 @@ class Setting < RailsSettings::Base cache_prefix { "v1" } - field :render_deploy_hook, - type: :string, - default: ENV["RENDER_DEPLOY_HOOK"], - validates: { allow_blank: true, format: { with: /\Ahttps:\/\/api\.render\.com\/deploy\/srv-.+\z/ } } - - field :upgrades_mode, - type: :string, - default: ENV.fetch("UPGRADES_MODE", "manual"), - validates: { inclusion: { in: %w[manual auto] } } - - field :upgrades_target, - type: :string, - default: ENV.fetch("UPGRADES_TARGET", "release"), - validates: { inclusion: { in: %w[release commit] } } - field :synth_api_key, type: :string, default: ENV["SYNTH_API_KEY"] - field :require_invite_for_signup, type: :boolean, default: false - field :require_email_confirmation, type: :boolean, default: ENV.fetch("REQUIRE_EMAIL_CONFIRMATION", "true") == "true" end diff --git a/app/models/upgrader.rb b/app/models/upgrader.rb deleted file mode 100644 index 605c7da8..00000000 --- a/app/models/upgrader.rb +++ /dev/null @@ -1,57 +0,0 @@ -class Upgrader - include Provided - - class << self - attr_writer :config - - def config - @config ||= Config.new - end - - def upgrade_to(commit_or_upgrade) - upgrade = commit_or_upgrade.is_a?(String) ? find_upgrade(commit_or_upgrade) : commit_or_upgrade - config.deployer.deploy(upgrade) - end - - def find_upgrade(commit) - upgrade_candidates.find { |candidate| candidate.commit_sha == commit } - end - - def available_upgrade - available_upgrades.first - end - - # Default to showing releases first, then commits - def completed_upgrade - completed_upgrades.find { |upgrade| upgrade.type == "release" } || completed_upgrades.first - end - - def available_upgrade_by_type(type) - if type == "commit" - commit_upgrade = available_upgrades.find { |upgrade| upgrade.type == "commit" } - commit_upgrade || available_upgrades.first - elsif type == "release" - available_upgrades.find { |upgrade| upgrade.type == "release" } - end - end - - private - def available_upgrades - upgrade_candidates.select(&:available?) - end - - def completed_upgrades - upgrade_candidates.select(&:complete?) - end - - def upgrade_candidates - latest_candidates = fetch_latest_upgrade_candidates_from_provider - return [] unless latest_candidates - - commit_candidate = Upgrade.new("commit", latest_candidates[:commit]) - release_candidate = latest_candidates[:release] && Upgrade.new("release", latest_candidates[:release]) - - [ release_candidate, commit_candidate ].compact.uniq { |candidate| candidate.commit_sha } - end - end -end diff --git a/app/models/upgrader/config.rb b/app/models/upgrader/config.rb deleted file mode 100644 index a78e51e7..00000000 --- a/app/models/upgrader/config.rb +++ /dev/null @@ -1,17 +0,0 @@ -class Upgrader::Config - attr_reader :env, :options - - def initialize(options = {}, env: ENV) - @env = env - @options = options - end - - def deployer - factory = Upgrader::Deployer - factory.for(hosting_platform) - end - - def hosting_platform - options[:hosting_platform] || env["HOSTING_PLATFORM"] - end -end diff --git a/app/models/upgrader/deployer.rb b/app/models/upgrader/deployer.rb deleted file mode 100644 index cb12992e..00000000 --- a/app/models/upgrader/deployer.rb +++ /dev/null @@ -1,12 +0,0 @@ -class Upgrader::Deployer - def self.for(platform) - case platform - when nil, "localhost" - Upgrader::Deployer::Null.new - when "render" - Upgrader::Deployer::Render.new - else - raise "Unknown platform: #{platform}" - end - end -end diff --git a/app/models/upgrader/deployer/null.rb b/app/models/upgrader/deployer/null.rb deleted file mode 100644 index d4d217fa..00000000 --- a/app/models/upgrader/deployer/null.rb +++ /dev/null @@ -1,8 +0,0 @@ -class Upgrader::Deployer::Null - def deploy(upgrade) - { - success: true, - message: I18n.t("upgrader.deployer.null_deployer.success_message") - } - end -end diff --git a/app/models/upgrader/deployer/render.rb b/app/models/upgrader/deployer/render.rb deleted file mode 100644 index 5df86857..00000000 --- a/app/models/upgrader/deployer/render.rb +++ /dev/null @@ -1,41 +0,0 @@ -class Upgrader::Deployer::Render - def deploy(upgrade) - if Setting.render_deploy_hook.blank? - return { - success: false, - message: I18n.t("upgrader.deployer.render.error_message_not_set"), - troubleshooting_url: "/settings/self_hosting/edit" - } - end - - Rails.logger.info I18n.t("upgrader.deployer.render.deploy_log_info", type: upgrade.type, commit_sha: upgrade.commit_sha) - - begin - uri = URI.parse(Setting.render_deploy_hook) - uri.query = [ uri.query, "ref=#{upgrade.commit_sha}" ].compact.join("&") - response = Faraday.post(uri.to_s) - - unless response.success? - Rails.logger.error I18n.t("upgrader.deployer.render.deploy_log_error", type: upgrade.type, commit_sha: upgrade.commit_sha, error_message: response.body) - return default_error_response - end - - { - success: true, - message: I18n.t("upgrader.deployer.render.success_message", commit_sha: upgrade.commit_sha.slice(0, 7)) - } - rescue => e - Rails.logger.error I18n.t("upgrader.deployer.render.deploy_log_error", type: upgrade.type, commit_sha: upgrade.commit_sha, error_message: e.message) - default_error_response - end - end - - private - def default_error_response - { - success: false, - message: I18n.t("upgrader.deployer.render.error_message_failed_deploy"), - troubleshooting_url: I18n.t("upgrader.deployer.render.troubleshooting_url") - } - end -end diff --git a/app/models/upgrader/provided.rb b/app/models/upgrader/provided.rb deleted file mode 100644 index c0eac5e0..00000000 --- a/app/models/upgrader/provided.rb +++ /dev/null @@ -1,10 +0,0 @@ -module Upgrader::Provided - extend ActiveSupport::Concern - - class_methods do - private - def fetch_latest_upgrade_candidates_from_provider - Providers.github.fetch_latest_upgrade_candidates - end - end -end diff --git a/app/models/upgrader/upgrade.rb b/app/models/upgrader/upgrade.rb deleted file mode 100644 index 6a842947..00000000 --- a/app/models/upgrader/upgrade.rb +++ /dev/null @@ -1,29 +0,0 @@ -class Upgrader::Upgrade - attr_reader :type, :commit_sha, :version, :url - - def initialize(type, data) - @type = %w[release commit].include?(type) ? type : raise(ArgumentError, "Type must be either 'release' or 'commit'") - @commit_sha = data[:commit_sha] - @version = normalize_version(data[:version]) - @url = data[:url] - end - - def complete? - commit_sha == Maybe.commit_sha - end - - def available? - return false if commit_sha == Maybe.commit_sha || version < Maybe.version - return false if version == Maybe.version && type == "release" - true - end - - def to_s - type == "release" ? version.to_release_tag : "#{commit_sha.first(7)} (pre-release)" - end - - private - def normalize_version(version) - version.is_a?(Semver) ? version : Semver.new(version) - end -end diff --git a/app/models/user.rb b/app/models/user.rb index 479ce225..db929953 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -69,22 +69,6 @@ class User < ApplicationRecord (display_name&.first || email.first).upcase end - def acknowledge_upgrade_prompt(commit_sha) - update!(last_prompted_upgrade_commit_sha: commit_sha) - end - - def acknowledge_upgrade_alert(commit_sha) - update!(last_alerted_upgrade_commit_sha: commit_sha) - end - - def has_seen_upgrade_prompt?(upgrade) - last_prompted_upgrade_commit_sha == upgrade.commit_sha - end - - def has_seen_upgrade_alert?(upgrade) - last_alerted_upgrade_commit_sha == upgrade.commit_sha - end - # Deactivation validate :can_deactivate, if: -> { active_changed? && !active } after_update_commit :purge_later, if: -> { saved_change_to_active?(from: true, to: false) } diff --git a/app/views/impersonation_sessions/_super_admin_bar.html.erb b/app/views/impersonation_sessions/_super_admin_bar.html.erb index 41bf6d13..57bfa825 100644 --- a/app/views/impersonation_sessions/_super_admin_bar.html.erb +++ b/app/views/impersonation_sessions/_super_admin_bar.html.erb @@ -4,7 +4,7 @@ Super Admin
      - <%= link_to "Jobs", good_job_url, class: "text-white underline hover:text-gray-100" %> + <%= link_to "Jobs", sidekiq_web_url, class: "text-white underline hover:text-gray-100" %>
      diff --git a/app/views/layouts/shared/_htmldoc.html.erb b/app/views/layouts/shared/_htmldoc.html.erb index 6123b318..bfeafd27 100644 --- a/app/views/layouts/shared/_htmldoc.html.erb +++ b/app/views/layouts/shared/_htmldoc.html.erb @@ -19,10 +19,6 @@ <%= family_notifications_stream %> <%= family_stream %> - <% if self_hosted? && (upgrade = get_upgrade_for_notification(Current.user, Setting.upgrades_mode)) %> - <%= render partial: "shared/upgrade_notification", locals: { upgrade: upgrade } %> - <% end %> - <%= turbo_frame_tag "modal" %> <%= turbo_frame_tag "drawer" %> <%= render "shared/confirm_modal" %> diff --git a/app/views/layouts/shared/_notifications.html.erb b/app/views/layouts/shared/_notifications.html.erb index 29cb8c8d..4d55e272 100644 --- a/app/views/layouts/shared/_notifications.html.erb +++ b/app/views/layouts/shared/_notifications.html.erb @@ -10,7 +10,3 @@ <%= family_notifications_stream %> <%= family_stream %> - -<% if self_hosted? && (upgrade = get_upgrade_for_notification(Current.user, Setting.upgrades_mode)) %> - <%= render partial: "shared/upgrade_notification", locals: { upgrade: upgrade } %> -<% end %> diff --git a/app/views/settings/hostings/_provider_settings.html.erb b/app/views/settings/hostings/_provider_settings.html.erb deleted file mode 100644 index f6191e89..00000000 --- a/app/views/settings/hostings/_provider_settings.html.erb +++ /dev/null @@ -1,9 +0,0 @@ -<% if ENV["HOSTING_PLATFORM"] == "render" %> - <%= styled_form_with model: Setting.new, url: settings_hosting_path, method: :patch, data: { controller: "auto-submit-form", "auto-submit-form-trigger-event-value" => "blur" } do |form| %> -
      -

      <%= t(".title") %>

      -

      <%= t(".description") %>

      - <%= form.url_field :render_deploy_hook, label: t(".render_deploy_hook_label"), placeholder: t(".render_deploy_hook_placeholder"), value: Setting.render_deploy_hook, data: { "auto-submit-form-target" => "auto" } %> -
      - <% end %> -<% end %> diff --git a/app/views/settings/hostings/_upgrade_settings.html.erb b/app/views/settings/hostings/_upgrade_settings.html.erb deleted file mode 100644 index fc558b9d..00000000 --- a/app/views/settings/hostings/_upgrade_settings.html.erb +++ /dev/null @@ -1,41 +0,0 @@ -<% if ENV["HOSTING_PLATFORM"] == "render" %> -
      -

      <%= t(".title") %>

      -

      <%= t(".description") %>

      - - <%= styled_form_with model: Setting.new, url: settings_hosting_path, method: :patch, data: { controller: "auto-submit-form", "auto-submit-form-trigger-event-value" => "blur" } do |form| %> -
      -
      - <%= form.radio_button :upgrades_setting, "manual", checked: Setting.upgrades_mode == "manual", data: { "auto-submit-form-target" => "auto", "autosubmit-trigger-event": "input" } %> - <%= form.label :upgrades_mode_manual, t(".manual_title"), class: "text-primary text-sm" do %> - <%= t(".manual_title") %> -
      - - <%= t(".manual_description") %> - - <% end %> -
      -
      - <%= form.radio_button :upgrades_setting, "release", checked: Setting.upgrades_mode == "auto" && Setting.upgrades_target == "release", data: { "auto-submit-form-target" => "auto", "autosubmit-trigger-event": "input" } %> - <%= form.label :upgrades_mode_release, t(".latest_release_title"), class: "text-primary text-sm" do %> - <%= t(".latest_release_title") %> -
      - - <%= t(".latest_release_description") %> - - <% end %> -
      -
      - <%= form.radio_button :upgrades_setting, "commit", checked: Setting.upgrades_mode == "auto" && Setting.upgrades_target == "commit", data: { "auto-submit-form-target" => "auto", "autosubmit-trigger-event": "input" } %> - <%= form.label :upgrades_mode_commit, t(".latest_commit_title"), class: "text-primary text-sm" do %> - <%= t(".latest_commit_title") %> -
      - - <%= t(".latest_commit_description") %> - - <% end %> -
      -
      - <% end %> -
      -<% end %> diff --git a/app/views/settings/hostings/show.html.erb b/app/views/settings/hostings/show.html.erb index bd1916f3..8f944323 100644 --- a/app/views/settings/hostings/show.html.erb +++ b/app/views/settings/hostings/show.html.erb @@ -2,8 +2,6 @@ <%= settings_section title: t(".general") do %>
      - <%= render "settings/hostings/upgrade_settings" %> - <%= render "settings/hostings/provider_settings" %> <%= render "settings/hostings/synth_settings" %>
      <% end %> diff --git a/app/views/shared/_upgrade_notification.html.erb b/app/views/shared/_upgrade_notification.html.erb deleted file mode 100644 index e2391b63..00000000 --- a/app/views/shared/_upgrade_notification.html.erb +++ /dev/null @@ -1,17 +0,0 @@ -<%# locals: (upgrade:) %> -
      -
      -

      <%= link_to upgrade.to_s, upgrade.url, class: "text-sm text-blue-500 underline hover:text-blue-700", target: "_blank" %>

      - <% if upgrade.complete? %> -

      <%= t(".app_upgraded", version: upgrade.to_s) %>

      - <% else %> -

      <%= t(".new_version_available") %>

      - <% end %> -
      -
      - <%= button_to t(".dismiss"), acknowledge_upgrade_path(upgrade.commit_sha), method: :post, class: "#{upgrade.complete? ? 'bg-gray-900 text-white' : 'bg-gray-100 text-primary'} text-sm font-bold p-2 rounded-lg" %> - <% if upgrade.available? %> - <%= button_to t(".upgrade_now"), deploy_upgrade_path(upgrade.commit_sha), method: :post, class: "bg-gray-900 hover:bg-gray-700 text-white font-medium text-sm p-2 rounded-lg" %> - <% end %> -
      -
      diff --git a/bin/render-build.sh b/bin/render-build.sh index 5f260f64..1f95dc7f 100755 --- a/bin/render-build.sh +++ b/bin/render-build.sh @@ -10,16 +10,3 @@ echo "Precompiling assets..." ./bin/rails assets:clean echo "Build complete" - -# Self Hosters: -# -# By default, one-click deploys are free-tier instances (to avoid unexpected charges) -# Render does NOT allow free-tier instances to use the `preDeployCommand` feature, so -# database migrations must be run in the build step. -# -# If you're on a paid Render plan, you can remove the `RUN_DB_MIGRATIONS_IN_BUILD_STEP` (or set to `false`) -if [ "$RUN_DB_MIGRATIONS_IN_BUILD_STEP" = "true" ]; then - echo "Initiating database migrations for the free tier..." - bundle exec rails db:migrate - echo "Database migrations completed. Reminder: If you've moved to a Render paid plan, you can remove the RUN_DB_MIGRATIONS_IN_BUILD_STEP environment variable to utilize the `preDeployCommand` feature for migrations." -fi diff --git a/compose.example.yml b/compose.example.yml new file mode 100644 index 00000000..ea6b5b63 --- /dev/null +++ b/compose.example.yml @@ -0,0 +1,100 @@ +# =========================================================================== +# Example Docker Compose file +# =========================================================================== +# +# Purpose: +# -------- +# +# This file is an example Docker Compose configuration for self hosting +# Maybe on your local machine or on a cloud VPS. +# +# The configuration below is a "standard" setup that works out of the box, +# but if you're running this outside of a local network, it is recommended +# to set the environment variables for extra security. +# +# Setup: +# ------ +# +# To run this, you should read the setup guide: +# +# https://github.com/maybe-finance/maybe/blob/main/docs/hosting/docker.md +# +# Troubleshooting: +# ---------------- +# +# If you run into problems, you should open a Discussion here: +# +# https://github.com/maybe-finance/maybe/discussions/categories/general +# + +x-db-env: &db_env + POSTGRES_USER: ${POSTGRES_USER:-maybe_user} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-maybe_password} + POSTGRES_DB: ${POSTGRES_DB:-maybe_production} + +x-rails-env: &rails_env + <<: *db_env + SECRET_KEY_BASE: ${SECRET_KEY_BASE:-a7523c3d0ae56415046ad8abae168d71074a79534a7062258f8d1d51ac2f76d3c3bc86d86b6b0b307df30d9a6a90a2066a3fa9e67c5e6f374dbd7dd4e0778e13} + SELF_HOSTED: "true" + RAILS_FORCE_SSL: "false" + RAILS_ASSUME_SSL: "false" + DB_HOST: db + DB_PORT: 5432 + REDIS_URL: redis://redis:6379/1 + +services: + web: + image: ghcr.io/maybe-finance/maybe:latest + volumes: + - app-storage:/rails/storage + ports: + - 3000:3000 + restart: unless-stopped + environment: + <<: *rails_env + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + + worker: + image: ghcr.io/maybe-finance/maybe:latest + command: bundle exec sidekiq + restart: unless-stopped + depends_on: + redis: + condition: service_healthy + environment: + <<: *rails_env + + db: + image: postgres:16 + restart: unless-stopped + volumes: + - postgres-data:/var/lib/postgresql/data + environment: + <<: *db_env + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" ] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:latest + ports: + - 6379:6379 + restart: unless-stopped + volumes: + - redis-data:/data + healthcheck: + test: [ "CMD", "redis-cli", "ping" ] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + app-storage: + postgres-data: + redis-data: \ No newline at end of file diff --git a/config/application.rb b/config/application.rb index df4c0f36..a5d2e280 100644 --- a/config/application.rb +++ b/config/application.rb @@ -24,8 +24,6 @@ module Maybe # config.time_zone = "Central Time (US & Canada)" # config.eager_load_paths << Rails.root.join("extras") - config.active_job.queue_adapter = :good_job - # TODO: This is here for incremental adoption of localization. This can be removed when all translations are implemented. config.i18n.fallbacks = true diff --git a/config/cable.yml b/config/cable.yml index cc73650e..3474a21a 100644 --- a/config/cable.yml +++ b/config/cable.yml @@ -1,8 +1,10 @@ development: - adapter: postgresql + adapter: async test: adapter: test production: - adapter: postgresql + adapter: redis + url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> + channel_prefix: maybe_production diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc index 142afb99..06708655 100644 --- a/config/credentials.yml.enc +++ b/config/credentials.yml.enc @@ -1 +1 @@ -HMC62biPQuF61XA8tnd/kvwdV2xr/zpfJxG+IHNgGtpuvPXi9oS+YemBGMLte+1Q7elzAAbmKg73699hVLkRcBCk/FaMQjGRF2lnJ9MpxSR/br8Uma2bSH40lIEjxAfzjr4JPSfsHxlArF30hfd+B9obPDOptLQbpENPBsmiuEHX7S0Y8SmKuzDUVrvdfeLoVuMiAZqOP5izpBAbXfvMjI3YH70iJAaPlfAxQqR89O2nSt+N27siyyfkypE3NHQKZFz+Rmo8uJDlaD3eo/uvQN4xsgRCMUar4X2iY4UOd+MIGAPqLzIUhhJ56G5MRDJ4XpJA6RDuGFc/LNyxdXt0WinUX8Yz7zKiKah1NkEhTkH+b2ylFbsN6cjlqcX0yw8Gw8B4osyHQGnj7Tuf1c8k1z3gBoaQALm8zxKCaJ9k6CopVM2GmbpCLcJqjN1L71wCe6MiWsv9LDF/pwuZNG6hWn0oykdkWeBEQyK8g4Wo1AHqgEi8XtRwbaX6yugO5WQFhjQG/LzXcG02E5Co5/r/G7ZSFpRC9ngoOx3LY6MihPRkTIOumCg3HHtAsWBeHe4L/rDIe4A=--hlLxVbnyuYXf7Rku--A6Cwdr3CAW6bRkl1rcRmRw== \ No newline at end of file +Be5nAlhacgJFHZJBgO8noswyX/VOrmkMem7wS3YQhoogzG0MCSVxCAVMbFyYFYUwqZrSPkAqUTpgH5OJJ1FB1gZfL9IYYWnEdTzMxM7IvhdDwYllYcM6smbvZEbOiqxLs9VdfC/qFS+1iFtsezBaqxfGdANJsJt3TxoRWl/ZbQ4Od1s0BNkMis1CDZt5RMEQlTz813cE5sXBlxhqEr9/2CaktwPIe5S/Oxrwo8vPFBvrNdox8BysiK9WDik8jJFSVwPSCvg43/MaIJUT0cOILdSxqrATXV143/h6ghNYtrJgoUNFT7wuu0FTU/ovTgtTqQEKG+7PDO1WLFn606bVknjPwfNMGBa9hX3LbRErDDIXNq69um9fPZ8Yq5f9jP++dPbAqbWBEg+JYsZmDgzr7LmtXVzQgAcuMkHaBbL8uxod8S1B6qhXhLNc8Dd1oeHVu0kcLFO2zaqdYRFNEY30JSjjXlG3GExXQE6aEluXvdF2gj9Hjhp7tEXZEJbIx+ZFy+6Xbrd1E2BE8AZUbalExAfudkPSYlAZ+z3fWc2RlNIuBzTYDOWH9Ai8mqsdyGNVEyizXQ==--j/6QtlLtP4mYXIFw--c+AKfDPo9stantWni+u+4Q== \ No newline at end of file diff --git a/config/database.yml b/config/database.yml index c3142c2a..d3a412be 100644 --- a/config/database.yml +++ b/config/database.yml @@ -1,22 +1,7 @@ default: &default adapter: postgresql encoding: unicode - # Note on DB_POOL_SIZE: - # ------------------------------------------------------------------------------------------------------------- - # To optimize for the simplest self-hosting setup, we run ActionCable, GoodJob, and Rails in the same process. - # - # This requires DB connections for each: - # - # Puma: Requires 3 connections (Rails default) - # ActionCable: 5 connections (Rails defaults to 4 workers + 1 listener for Postgres adapter) - # GoodJob: 15 connections to run in "async" mode. See `good_job.rb` for the breakdown. - # -------------------------------------------------------------------------------------------- - # Total: 23 connections - # - # We default to this value so that self-hosters don't need to configure anything. Hosted mode will require - # a different pool size, as we run ActionCable, GoodJob, and Rails in separate processes. - # - pool: <%= ENV.fetch("DB_POOL_SIZE") { 23 } %> + pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 3 } %> host: <%= ENV.fetch("DB_HOST") { "127.0.0.1" } %> port: <%= ENV.fetch("DB_PORT") { "5432" } %> user: <%= ENV.fetch("POSTGRES_USER") { nil } %> diff --git a/config/environments/production.rb b/config/environments/production.rb index 4670492d..e8327dc6 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -69,11 +69,12 @@ Rails.application.configure do # want to log everything, set the level to "debug". config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") - # Use a different cache store in production. - # config.cache_store = :mem_cache_store + if ENV["CACHE_REDIS_URL"].present? + config.cache_store = :redis_cache_store, { url: ENV["CACHE_REDIS_URL"] } + end config.action_mailer.perform_caching = false - + config.action_mailer.deliver_later_queue_name = :high_priority config.action_mailer.default_url_options = { host: ENV["APP_DOMAIN"] } config.action_mailer.delivery_method = :smtp config.action_mailer.smtp_settings = { @@ -105,4 +106,7 @@ Rails.application.configure do # ] # Skip DNS rebinding protection for the default health check endpoint. # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } + + # set REDIS_URL for Sidekiq to use Redis + config.active_job.queue_adapter = :sidekiq end diff --git a/config/initializers/good_job.rb b/config/initializers/good_job.rb deleted file mode 100644 index fcfacc19..00000000 --- a/config/initializers/good_job.rb +++ /dev/null @@ -1,33 +0,0 @@ -Rails.application.configure do - config.good_job.enable_cron = true - - if ENV["UPGRADES_ENABLED"] == "true" - config.good_job.cron = { - auto_upgrade: { - cron: "every 2 minutes", - class: "AutoUpgradeJob", - description: "Check for new versions of the app and upgrade if necessary" - } - } - end - - config.good_job.on_thread_error = ->(exception) { Rails.error.report(exception) } - - # 7 dedicated queue threads + 5 catch-all threads + 3 for job listener, cron, executor = 15 threads allocated - # `latency_low` queue for jobs ~30s - # `latency_medium` queue for jobs ~1-2 min - # `latency_high` queue for jobs ~5+ min - config.good_job.queues = "latency_low:2;latency_low,latency_medium:3;latency_low,latency_medium,latency_high:2;*" - - # Auth for jobs admin dashboard - ActiveSupport.on_load(:good_job_application_controller) do - before_action do - raise ActionController::RoutingError.new("Not Found") unless current_user&.super_admin? || Rails.env.development? - end - - def current_user - session = Session.find_by(id: cookies.signed[:session_token]) - session&.user - end - end -end diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb new file mode 100644 index 00000000..1209a4fa --- /dev/null +++ b/config/initializers/sidekiq.rb @@ -0,0 +1,9 @@ +require "sidekiq/web" + +Sidekiq::Web.use(Rack::Auth::Basic) do |username, password| + configured_username = ::Digest::SHA256.hexdigest(ENV.fetch("SIDEKIQ_WEB_USERNAME", "maybe")) + configured_password = ::Digest::SHA256.hexdigest(ENV.fetch("SIDEKIQ_WEB_PASSWORD", "maybe")) + + ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(username), configured_username) && + ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(password), configured_password) +end diff --git a/config/locales/models/upgrader/en.yml b/config/locales/models/upgrader/en.yml deleted file mode 100644 index 6a655717..00000000 --- a/config/locales/models/upgrader/en.yml +++ /dev/null @@ -1,13 +0,0 @@ ---- -en: - upgrader: - deployer: - null_deployer: - success_message: 'No-op: null deployer initiated deploy successfully' - render: - deploy_log_error: 'Failed to deploy %{type} %{commit_sha} to Render: %{error_message}' - deploy_log_info: Deploying %{type} %{commit_sha} to Render... - error_message_failed_deploy: Failed to deploy to Render - error_message_not_set: Render deploy hook URL is not set - success_message: 'Triggered deployment to Render for commit: %{commit_sha}' - troubleshooting_url: https://render.com/docs/deploy-hooks diff --git a/config/locales/views/settings/hostings/en.yml b/config/locales/views/settings/hostings/en.yml index bbcb8ee9..7377c492 100644 --- a/config/locales/views/settings/hostings/en.yml +++ b/config/locales/views/settings/hostings/en.yml @@ -11,11 +11,6 @@ en: generate_tokens: Generate new code generated_tokens: Generated codes title: Require invite code for signup - provider_settings: - description: Configure settings for your hosting provider - render_deploy_hook_label: Render Deploy Hook URL - render_deploy_hook_placeholder: https://api.render.com/deploy/srv-xyz... - title: Provider Settings show: general: General Settings invites: Invite Codes @@ -38,14 +33,4 @@ en: success: Settings updated clear_cache: cache_cleared: Data cache has been cleared. This may take a few moments to complete. - upgrade_settings: - description: Configure how your application receives updates - latest_commit_description: Automatically update to the latest commit (unstable) - latest_commit_title: Latest Commit - latest_release_description: Automatically update to the most recent release - (stable) - latest_release_title: Latest Release - manual_description: You control when to download and install updates - manual_title: Manual - title: Auto Upgrade not_authorized: You are not authorized to perform this action diff --git a/config/locales/views/shared/en.yml b/config/locales/views/shared/en.yml index 509cbd41..cb632738 100644 --- a/config/locales/views/shared/en.yml +++ b/config/locales/views/shared/en.yml @@ -10,8 +10,3 @@ en: label: Amount syncing_notice: syncing: Syncing accounts data... - upgrade_notification: - app_upgraded: The app has been upgraded to %{version}. - dismiss: Dismiss - new_version_available: A new version of Maybe is available for upgrade. - upgrade_now: Upgrade Now diff --git a/config/locales/views/upgrades/en.yml b/config/locales/views/upgrades/en.yml deleted file mode 100644 index b6dd9708..00000000 --- a/config/locales/views/upgrades/en.yml +++ /dev/null @@ -1,10 +0,0 @@ ---- -en: - upgrades: - acknowledge: - upgrade_complete_dismiss: We hope you enjoy the new features! - upgrade_dismissed: Upgrade dismissed - upgrade_not_available: Upgrade not available - upgrade_not_found: Upgrade not found - deploy: - upgrade_not_found: Upgrade not found diff --git a/config/routes.rb b/config/routes.rb index da875dce..92845bc1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,3 +1,5 @@ +require "sidekiq/web" + Rails.application.routes.draw do # MFA routes resource :mfa, controller: "mfa", only: [ :new, :create ] do @@ -6,7 +8,8 @@ Rails.application.routes.draw do delete :disable end - mount GoodJob::Engine => "good_job" + # Uses basic auth - see config/initializers/sidekiq.rb + mount Sidekiq::Web => "/sidekiq" get "changelog", to: "pages#changelog" get "feedback", to: "pages#feedback" @@ -158,14 +161,6 @@ Rails.application.routes.draw do get :accept, on: :member end - # For managing self-hosted upgrades and release notifications - resources :upgrades, only: [] do - member do - post :acknowledge - post :deploy - end - end - resources :currencies, only: %i[show] resources :impersonation_sessions, only: [ :create ] do diff --git a/config/sidekiq.yml b/config/sidekiq.yml new file mode 100644 index 00000000..8d519d57 --- /dev/null +++ b/config/sidekiq.yml @@ -0,0 +1,5 @@ +concurrency: <%= ENV.fetch("RAILS_MAX_THREADS") { 3 } %> +queues: + - [high_priority, 7] + - [medium_priority, 2] + - [low_priority, 1] diff --git a/config/storage.yml b/config/storage.yml index 6c92b2ba..c6acb48e 100644 --- a/config/storage.yml +++ b/config/storage.yml @@ -22,16 +22,4 @@ cloudflare: bucket: <%= ENV['CLOUDFLARE_BUCKET'] %> request_checksum_calculation: "when_required" response_checksum_validation: "when_required" - -# Removed in #702. Uncomment, add gems, update .env.example to enable. -#google: -# service: GCS -# project: <%#= ENV["GCS_PROJECT"] %> -# credentials: <%#= Rails.root.join("gcp-storage-keyfile.json") %> -# bucket: <%#= ENV["GCS_BUCKET"] %> - -#azure: -# service: AzureStorage -# storage_account_name: <%#= ENV["AZURE_STORAGE_ACCOUNT_NAME"] %> -# storage_access_key: <%#= ENV["AZURE_STORAGE_ACCESS_KEY"] %> -# container: <%#= ENV["AZURE_STORAGE_CONTAINER"] %> + diff --git a/db/migrate/20241022170439_create_stock_exchanges.rb b/db/migrate/20241022170439_create_stock_exchanges.rb index f2e6ff5b..5eee20e4 100644 --- a/db/migrate/20241022170439_create_stock_exchanges.rb +++ b/db/migrate/20241022170439_create_stock_exchanges.rb @@ -20,11 +20,5 @@ class CreateStockExchanges < ActiveRecord::Migration[7.2] add_index :stock_exchanges, :country add_index :stock_exchanges, :country_code add_index :stock_exchanges, :currency_code - - reversible do |dir| - dir.up do - load Rails.root.join('db/seeds/exchanges.rb') - end - end end end diff --git a/db/migrate/20250318212559_remove_good_job.rb b/db/migrate/20250318212559_remove_good_job.rb new file mode 100644 index 00000000..e3b79eb6 --- /dev/null +++ b/db/migrate/20250318212559_remove_good_job.rb @@ -0,0 +1,14 @@ +class RemoveGoodJob < ActiveRecord::Migration[7.2] + def up + drop_table :good_job_batches + drop_table :good_job_executions + drop_table :good_job_processes + drop_table :good_job_settings + drop_table :good_jobs + end + + def down + # Add the tables back if needed - see schema.rb for the full table definitions + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/db/migrate/20250319145426_remove_self_host_upgrades.rb b/db/migrate/20250319145426_remove_self_host_upgrades.rb new file mode 100644 index 00000000..a69cd79a --- /dev/null +++ b/db/migrate/20250319145426_remove_self_host_upgrades.rb @@ -0,0 +1,6 @@ +class RemoveSelfHostUpgrades < ActiveRecord::Migration[7.2] + def change + remove_column :users, :last_prompted_upgrade_commit_sha + remove_column :users, :last_alerted_upgrade_commit_sha + end +end diff --git a/db/schema.rb b/db/schema.rb index abbaacf5..7f30fe84 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_03_16_122019) do +ActiveRecord::Schema[7.2].define(version: 2025_03_19_145426) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -244,94 +244,6 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_16_122019) do t.boolean "data_enrichment_enabled", default: false end - create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.text "description" - t.jsonb "serialized_properties" - t.text "on_finish" - t.text "on_success" - t.text "on_discard" - t.text "callback_queue_name" - t.integer "callback_priority" - t.datetime "enqueued_at" - t.datetime "discarded_at" - t.datetime "finished_at" - end - - create_table "good_job_executions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.uuid "active_job_id", null: false - t.text "job_class" - t.text "queue_name" - t.jsonb "serialized_params" - t.datetime "scheduled_at" - t.datetime "finished_at" - t.text "error" - t.integer "error_event", limit: 2 - t.text "error_backtrace", array: true - t.uuid "process_id" - t.interval "duration" - t.index ["active_job_id", "created_at"], name: "index_good_job_executions_on_active_job_id_and_created_at" - t.index ["process_id", "created_at"], name: "index_good_job_executions_on_process_id_and_created_at" - end - - create_table "good_job_processes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.jsonb "state" - t.integer "lock_type", limit: 2 - end - - create_table "good_job_settings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.text "key" - t.jsonb "value" - t.index ["key"], name: "index_good_job_settings_on_key", unique: true - end - - create_table "good_jobs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.text "queue_name" - t.integer "priority" - t.jsonb "serialized_params" - t.datetime "scheduled_at" - t.datetime "performed_at" - t.datetime "finished_at" - t.text "error" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.uuid "active_job_id" - t.text "concurrency_key" - t.text "cron_key" - t.uuid "retried_good_job_id" - t.datetime "cron_at" - t.uuid "batch_id" - t.uuid "batch_callback_id" - t.boolean "is_discrete" - t.integer "executions_count" - t.text "job_class" - t.integer "error_event", limit: 2 - t.text "labels", array: true - t.uuid "locked_by_id" - t.datetime "locked_at" - t.index ["active_job_id", "created_at"], name: "index_good_jobs_on_active_job_id_and_created_at" - t.index ["batch_callback_id"], name: "index_good_jobs_on_batch_callback_id", where: "(batch_callback_id IS NOT NULL)" - t.index ["batch_id"], name: "index_good_jobs_on_batch_id", where: "(batch_id IS NOT NULL)" - t.index ["concurrency_key"], name: "index_good_jobs_on_concurrency_key_when_unfinished", where: "(finished_at IS NULL)" - t.index ["cron_key", "created_at"], name: "index_good_jobs_on_cron_key_and_created_at_cond", where: "(cron_key IS NOT NULL)" - t.index ["cron_key", "cron_at"], name: "index_good_jobs_on_cron_key_and_cron_at_cond", unique: true, where: "(cron_key IS NOT NULL)" - t.index ["finished_at"], name: "index_good_jobs_jobs_on_finished_at", where: "((retried_good_job_id IS NULL) AND (finished_at IS NOT NULL))" - t.index ["labels"], name: "index_good_jobs_on_labels", where: "(labels IS NOT NULL)", using: :gin - t.index ["locked_by_id"], name: "index_good_jobs_on_locked_by_id", where: "(locked_by_id IS NOT NULL)" - t.index ["priority", "created_at"], name: "index_good_job_jobs_for_candidate_lookup", where: "(finished_at IS NULL)" - t.index ["priority", "created_at"], name: "index_good_jobs_jobs_on_priority_created_at_when_unfinished", order: { priority: "DESC NULLS LAST" }, where: "(finished_at IS NULL)" - t.index ["priority", "scheduled_at"], name: "index_good_jobs_on_priority_scheduled_at_unfinished_unlocked", where: "((finished_at IS NULL) AND (locked_by_id IS NULL))" - t.index ["queue_name", "scheduled_at"], name: "index_good_jobs_on_queue_name_and_scheduled_at", where: "(finished_at IS NULL)" - t.index ["scheduled_at"], name: "index_good_jobs_on_scheduled_at", where: "(finished_at IS NULL)" - end - create_table "impersonation_session_logs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "impersonation_session_id", null: false t.string "controller" @@ -652,8 +564,6 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_16_122019) do t.string "password_digest" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.string "last_prompted_upgrade_commit_sha" - t.string "last_alerted_upgrade_commit_sha" t.string "role", default: "member", null: false t.boolean "active", default: true, null: false t.datetime "onboarded_at" diff --git a/db/seeds/.keep b/db/seeds/.keep new file mode 100644 index 00000000..e69de29b diff --git a/db/seeds/exchanges.rb b/db/seeds/exchanges.rb deleted file mode 100644 index 3f5cc39f..00000000 --- a/db/seeds/exchanges.rb +++ /dev/null @@ -1,36 +0,0 @@ -# Load exchanges from YAML configuration -exchanges_config = YAML.safe_load( - File.read(Rails.root.join('config', 'exchanges.yml')), - permitted_classes: [], - permitted_symbols: [], - aliases: true -) - -exchanges_config.each do |exchange| - next unless exchange['mic'].present? # Skip any invalid entries - - StockExchange.find_or_create_by!(mic: exchange['mic']) do |ex| - ex.name = exchange['name'] - ex.acronym = exchange['acronym'] - ex.country = exchange['country'] - ex.country_code = exchange['country_code'] - ex.city = exchange['city'] - ex.website = exchange['website'] - - # Timezone details - if exchange['timezone'] - ex.timezone_name = exchange['timezone']['timezone'] - ex.timezone_abbr = exchange['timezone']['abbr'] - ex.timezone_abbr_dst = exchange['timezone']['abbr_dst'] - end - - # Currency details - if exchange['currency'] - ex.currency_code = exchange['currency']['code'] - ex.currency_symbol = exchange['currency']['symbol'] - ex.currency_name = exchange['currency']['name'] - end - end -end - -puts "Created #{StockExchange.count} stock exchanges" diff --git a/docker-compose.example.yml b/docker-compose.example.yml deleted file mode 100644 index 683d35de..00000000 --- a/docker-compose.example.yml +++ /dev/null @@ -1,74 +0,0 @@ -# =========================================================================== -# Example Docker Compose file -# =========================================================================== -# -# Purpose: -# -------- -# -# This file is an example Docker Compose configuration for self hosting -# Maybe on your local machine or on a cloud VPS. -# -# The configuration below is a "standard" setup, but may require modification -# for your specific environment. -# -# Setup: -# ------ -# -# To run this, you should read the setup guide: -# -# https://github.com/maybe-finance/maybe/blob/main/docs/hosting/docker.md -# -# Troubleshooting: -# ---------------- -# -# If you run into problems, you should open a Discussion here: -# -# https://github.com/maybe-finance/maybe/discussions/categories/general -# - -services: - - app: - image: ghcr.io/maybe-finance/maybe:latest - - volumes: - - app-storage:/rails/storage - - ports: - - 3000:3000 - - restart: unless-stopped - - environment: - SELF_HOSTED: "true" - RAILS_FORCE_SSL: "false" - RAILS_ASSUME_SSL: "false" - GOOD_JOB_EXECUTION_MODE: async - SECRET_KEY_BASE: ${SECRET_KEY_BASE:?} - DB_HOST: postgres - POSTGRES_DB: ${POSTGRES_DB:-maybe_production} - POSTGRES_USER: ${POSTGRES_USER:-maybe_user} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?} - - depends_on: - postgres: - condition: service_healthy - - postgres: - image: postgres:16 - restart: unless-stopped - volumes: - - postgres-data:/var/lib/postgresql/data - environment: - POSTGRES_USER: ${POSTGRES_USER:-maybe_user} - POSTGRES_DB: ${POSTGRES_DB:-maybe_production} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?} - healthcheck: - test: [ "CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" ] - interval: 5s - timeout: 5s - retries: 5 - -volumes: - app-storage: - postgres-data: \ No newline at end of file diff --git a/docs/hosting/docker.md b/docs/hosting/docker.md index c112f42d..940a685f 100644 --- a/docs/hosting/docker.md +++ b/docs/hosting/docker.md @@ -2,10 +2,6 @@ This 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. -If you want a _less -technical_ way to host the Maybe app, you can [host on Render](/docs/hosting/one-click-deploy.md) as an -_**alternative** to Docker Compose_. - ## Setup Guide Follow the guide below to get your app running. @@ -30,7 +26,7 @@ docker run hello-world Open your terminal and create a directory where your app will run. Below is an example command with a recommended directory: ```bash -# Create a directory on your computer for Docker files +# Create a directory on your computer for Docker files (name whatever you'd like) mkdir -p ~/docker-apps/maybe # Once created, navigate your current working directory to the new folder @@ -42,8 +38,8 @@ cd ~/docker-apps/maybe Make sure you are in the directory you just created and run the following command: ```bash -# Download the sample docker-compose.yml file from the Maybe Github repository -curl -o compose.yml https://raw.githubusercontent.com/maybe-finance/maybe/main/docker-compose.example.yml +# Download the sample compose.yml file from the Maybe Github repository +curl -o compose.yml https://raw.githubusercontent.com/maybe-finance/maybe/main/compose.example.yml ``` This command will do the following: @@ -53,6 +49,12 @@ This command will do the following: At this point, the only file in your current working directory should be `compose.yml`. +### Step 3 (optional): Configure your environment + +By 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. + +If you're running the app locally and don't care much about security, you can skip this step. + #### Create your environment file In order to configure the app, you will need to create a file called `.env`, which is where Docker will read environment variables from. @@ -92,7 +94,7 @@ SECRET_KEY_BASE="replacemewiththegeneratedstringfromthepriorstep" POSTGRES_PASSWORD="replacemewithyourdesireddatabasepassword" ``` -### Step 3: Test your app +### Step 4: Run the app You are now ready to run the app. Start with the following command to make sure everything is working: @@ -106,14 +108,14 @@ Open your browser, and navigate to `http://localhost:3000`. If everything is working, you will see the Maybe login screen. -### Step 4: Create your account +### Step 5: Create your account The first time you run the app, you will need to register a new account by hitting "create your account" on the login page. 1. Enter your email 2. Enter a password -### Step 5: Run the app in the background +### Step 6: Run the app in the background Most 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: @@ -127,7 +129,7 @@ The `-d` flag will run Docker Compose in "detached" mode. To verify it is runnin docker compose ls ``` -### Step 6: Enjoy! +### Step 7: Enjoy! Your app is now set up. You can visit it at `http://localhost:3000` in your browser. @@ -135,7 +137,7 @@ If you find bugs or have a feature request, be sure to read through our [contrib ## How to update your app -The mechanism that updates your self-hosted Maybe app is the GHCR (Github Container Registry) Docker image that you see in the `docker-compose.yml` file: +The mechanism that updates your self-hosted Maybe app is the GHCR (Github Container Registry) Docker image that you see in the `compose.yml` file: ```yml image: ghcr.io/maybe-finance/maybe:latest @@ -152,13 +154,13 @@ NOT_ automatically update. To update your self-hosted app, run the following com ```bash cd ~/docker-apps/maybe # Navigate to whatever directory you configured the app in docker compose pull # This pulls the "latest" published image from GHCR -docker compose build app # This rebuilds the app with updates +docker compose build # This rebuilds the app with updates docker compose up --no-deps -d app # This restarts the app using the newest version ``` ## How to change which updates your app receives -If you'd like to pin the app to a specific version or tag, all you need to do is edit the `docker-compose.yml` file: +If you'd like to pin the app to a specific version or tag, all you need to do is edit the `compose.yml` file: ```yml image: ghcr.io/maybe-finance/maybe:stable @@ -168,7 +170,7 @@ After doing this, make sure and restart the app: ```bash docker compose pull # This pulls the "latest" published image from GHCR -docker compose build app # This rebuilds the app with updates +docker compose build # This rebuilds the app with updates docker compose up --no-deps -d app # This restarts the app using the newest version ``` diff --git a/docs/hosting/one-click-deploy.md b/docs/hosting/one-click-deploy.md deleted file mode 100644 index 629f87fb..00000000 --- a/docs/hosting/one-click-deploy.md +++ /dev/null @@ -1,90 +0,0 @@ -# Deploy Maybe in One Click - -Below are our "one-click deploy" options for running Maybe in the cloud: - -## Render - -Welcome to the one-click deploy guide for Maybe on [Render](https://render.com/)! - -Render is a hosting platform with a generous free tier and makes it easy to get -started with Maybe: - -- Getting started is FREE -- Up and running in <5 minutes -- Your Maybe app is automatically deployed to a live URL - -### Estimated Costs - -- FREE to _get up and running_ -- $7 per month for a basic app (Render requires you to upgrade your database to - keep using it) -- $14+ per month for optimal performance - -_**IMPORTANT:** if you plan to host Maybe on Render long-term, you MUST upgrade -your database to a paid Render service._ - -### Instructions - -#### Step 1: Create Render Blueprint - - -Deploy to Render - - -1. Click the button above. -2. Sign in or create your account with Render (FREE) -3. Give your blueprint a name (we suggest `Maybe`) -4. Select the `main` branch -5. You should see a section at the bottom with a "Key:Value" field - for `SECRET_KEY_BASE`. Do NOT click "generate". -6. On your computer, open a terminal and make sure you have - the [openssl](https://github.com/openssl/openssl) utility installed on your - computer. You can run `openssl --version` to verify it is installed. -7. Generate your `SECRET_KEY_BASE` by running the following command in your - terminal: `openssl rand -hex 64` ([docs](https://www.openssl.org/docs/man1.1.1/man1/rand.html)). -8. Do NOT share this value with anyone. -9. Go back to your browser and paste this value in the "Value" field - for `SECRET_KEY_BASE` -10. Click "Apply". This will take a few minutes. -11. Once complete, click on the `maybe` "Web Service". You should see a custom - URL in the format `https://maybe-abcd.onrender.com`. Click on it, and you'll - see your running Maybe app! - -#### Step 2: Add your deploy hook for auto-updates - -To get new releases, you will need to add your deploy hook to the app. - -1. Click on the `maybe` "Web Service" -2. Click "Settings" -3. Scroll down to the end of the "Build and Deploy" section until you find the " - Deploy Hook" -4. Copy this value -5. Open your new Maybe app, click your profile, click "Self Host Settings" -6. Paste your deploy hook in the settings and save -7. You're all set! - -#### Step 3 (IMPORTANT!!!): Upgrade your Render services - -By default, we set you up with a FREE Render web service and a FREE postgres -database. We do this for a few reasons: - -- It allows you to take self-hosted Maybe for a FREE test-drive -- It prevents newcomers from incurring unexpected hosting charges - -##### Upgrade your Database (REQUIRED) - -All FREE Render databases **will be deleted after a few months**. This means -that **you will lose all of your Maybe data**. - -**To avoid losing data, you MUST upgrade your Render database** (a "starter" -instance is $7/month) - -You can upgrade your instance directly in the Render dashboard. - -##### Upgrade your Web Service (RECOMMENDED) - -All FREE Render web services use a small amount of memory and "sleep" after -periods of inactivity. - -For the _fastest_ Maybe experience, you should upgrade your web service (a " -starter" instance is $7/month) diff --git a/render.yaml b/render.yaml deleted file mode 100644 index e527a335..00000000 --- a/render.yaml +++ /dev/null @@ -1,60 +0,0 @@ -databases: - - name: maybe - user: maybe - plan: free - -services: - - type: web - plan: free - autoDeploy: false - runtime: ruby - name: maybe - repo: https://github.com/maybe-finance/maybe.git - branch: main - healthCheckPath: /up - buildCommand: "./bin/render-build.sh" - - # Uncomment if you are on a paid plan, and remove RUN_DB_MIGRATIONS_IN_BUILD_STEP from below - # preDeployCommand: "bundle exec rails db:migrate" - - startCommand: "bundle exec rails server" - envVars: - - key: DATABASE_URL - fromDatabase: - name: maybe - property: connectionString - - - key: SELF_HOSTED - value: true - - key: HOSTING_PLATFORM - value: render - - # Since the app is self-hosted, we cannot use master.key to encrypt credentials. App depends entirely on ENV variables - # https://api.rubyonrails.org/v7.1.3.2/classes/Rails/Application.html#method-i-secret_key_base - # - # To generate this, run: `openssl rand -hex 64` or `rails secret` - - key: SECRET_KEY_BASE - sync: false - - - key: WEB_CONCURRENCY - value: 2 - - key: GOOD_JOB_EXECUTION_MODE - value: async # Typically, `external` is used in prod, but this avoids another cron service and is generally fine for a self-hoster given low traffic - - # The app uses this info to know which repo to fetch latest commit data from for upgrades - # This should MATCH the `repo` and `branch` keys in the config above ALWAYS - - key: GITHUB_REPO_OWNER - value: maybe-finance - - key: GITHUB_REPO_NAME - value: maybe - - key: GITHUB_REPO_BRANCH - value: main - - # Required to allow your self-hosted instance to be able to upgrade itself - - key: UPGRADES_ENABLED - value: true - - # If you upgrade your instance to a paid plan, you can set this to false (or remove it) - # See note in `render-build.sh` script. - - key: RUN_DB_MIGRATIONS_IN_BUILD_STEP - value: true diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index 1ea37ca7..b0e91f62 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -37,17 +37,6 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest assert_nil Session.find_by(id: session_record.id) end - test "super admins can access the jobs page" do - sign_in users(:maybe_support_staff) - get good_job_url - assert_redirected_to "http://www.example.com/good_job/jobs?locale=en" - end - - test "non-super admins cannot access the jobs page" do - get good_job_url - assert_response :not_found - end - test "redirects to MFA verification when MFA enabled" do @user.setup_mfa! @user.enable_mfa! diff --git a/test/controllers/settings/hostings_controller_test.rb b/test/controllers/settings/hostings_controller_test.rb index 2e092952..48213260 100644 --- a/test/controllers/settings/hostings_controller_test.rb +++ b/test/controllers/settings/hostings_controller_test.rb @@ -25,7 +25,7 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest end assert_raises(RuntimeError, "Settings not available on non-self-hosted instance") do - patch settings_hosting_url, params: { setting: { render_deploy_hook: "https://example.com" } } + patch settings_hosting_url, params: { setting: { require_invite_for_signup: true } } end end @@ -40,25 +40,11 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest test "can update settings when self hosting is enabled" do with_self_hosting do - NEW_RENDER_DEPLOY_HOOK = "https://api.render.com/deploy/srv-abc123" - assert_nil Setting.render_deploy_hook + assert_nil Setting.synth_api_key - patch settings_hosting_url, params: { setting: { render_deploy_hook: NEW_RENDER_DEPLOY_HOOK } } + patch settings_hosting_url, params: { setting: { synth_api_key: "1234567890" } } - assert_equal NEW_RENDER_DEPLOY_HOOK, Setting.render_deploy_hook - end - end - - test "can choose auto upgrades mode with a deploy hook" do - with_self_hosting do - NEW_RENDER_DEPLOY_HOOK = "https://api.render.com/deploy/srv-abc123" - assert_nil Setting.render_deploy_hook - - patch settings_hosting_url, params: { setting: { render_deploy_hook: NEW_RENDER_DEPLOY_HOOK, upgrades_setting: "release" } } - - assert_equal "auto", Setting.upgrades_mode - assert_equal "release", Setting.upgrades_target - assert_equal NEW_RENDER_DEPLOY_HOOK, Setting.render_deploy_hook + assert_equal "1234567890", Setting.synth_api_key end end diff --git a/test/controllers/upgrades_controller_test.rb b/test/controllers/upgrades_controller_test.rb deleted file mode 100644 index c5978488..00000000 --- a/test/controllers/upgrades_controller_test.rb +++ /dev/null @@ -1,89 +0,0 @@ -require "test_helper" - -class UpgradesControllerTest < ActionDispatch::IntegrationTest - setup do - sign_in @user = users(:family_admin) - - @completed_upgrade = Upgrader::Upgrade.new( - "commit", - commit_sha: "47bb430954292d2fdcc81082af731a16b9587da3", - version: Semver.new("0.0.0"), - url: "" - ) - - @completed_upgrade.stubs(:complete?).returns(true) - @completed_upgrade.stubs(:available?).returns(false) - - @available_upgrade = Upgrader::Upgrade.new( - "commit", - commit_sha: "47bb430954292d2fdcc81082af731a16b9587da4", - version: Semver.new("0.1.0"), - url: "" - ) - - @available_upgrade.stubs(:available?).returns(true) - @available_upgrade.stubs(:complete?).returns(false) - end - - test "controller not available when upgrades are disabled" do - MOCK_COMMIT = "47bb430954292d2fdcc81082af731a16b9587da3" - - post acknowledge_upgrade_url(MOCK_COMMIT) - assert_response :not_found - - post deploy_upgrade_url(MOCK_COMMIT) - assert_response :not_found - end - - test "should acknowledge an upgrade prompt" do - with_env_overrides UPGRADES_ENABLED: "true" do - Upgrader.stubs(:find_upgrade).returns(@available_upgrade) - - post acknowledge_upgrade_url(@available_upgrade.commit_sha) - - @user.reload - assert_equal @user.last_prompted_upgrade_commit_sha, @available_upgrade.commit_sha - assert :redirect - end - end - - test "should acknowledge an upgrade alert" do - with_env_overrides UPGRADES_ENABLED: "true" do - Upgrader.stubs(:find_upgrade).returns(@completed_upgrade) - - post acknowledge_upgrade_url(@completed_upgrade.commit_sha) - - @user.reload - assert_equal @user.last_alerted_upgrade_commit_sha, @completed_upgrade.commit_sha - assert :redirect - end - end - - test "should deploy an upgrade" do - with_env_overrides UPGRADES_ENABLED: "true" do - Upgrader.stubs(:find_upgrade).returns(@available_upgrade) - - post deploy_upgrade_path(@available_upgrade.commit_sha) - - @user.reload - assert_equal @user.last_prompted_upgrade_commit_sha, @available_upgrade.commit_sha - assert :redirect - end - end - - test "should rollback user state if upgrade fails" do - with_env_overrides UPGRADES_ENABLED: "true" do - PRIOR_COMMIT = "47bb430954292d2fdcc81082af731a16b9587da2" - @user.update!(last_prompted_upgrade_commit_sha: PRIOR_COMMIT) - - Upgrader.stubs(:find_upgrade).returns(@available_upgrade) - Upgrader.stubs(:upgrade_to).returns({ success: false }) - - post deploy_upgrade_path(@available_upgrade.commit_sha) - - @user.reload - assert_equal @user.last_prompted_upgrade_commit_sha, PRIOR_COMMIT - assert :redirect - end - end -end diff --git a/test/interfaces/git_repository_provider_interface_test.rb b/test/interfaces/git_repository_provider_interface_test.rb deleted file mode 100644 index 2b0dff8e..00000000 --- a/test/interfaces/git_repository_provider_interface_test.rb +++ /dev/null @@ -1,25 +0,0 @@ -require "test_helper" - -module GitRepositoryProviderInterfaceTest - extend ActiveSupport::Testing::Declarative - - test "git repository provider interface" do - assert_respond_to @subject, :fetch_latest_upgrade_candidates - end - - test "git repository provider response contract" do - VCR.use_cassette "git_repository_provider/fetch_latest_upgrade_candidates" do - response = @subject.fetch_latest_upgrade_candidates - - assert_valid_upgrade_candidate(response[:release]) - assert_valid_upgrade_candidate(response[:commit]) - end - end - - private - def assert_valid_upgrade_candidate(candidate) - assert_equal Semver, candidate[:version].class - assert_match URI::DEFAULT_PARSER.make_regexp, candidate[:url] - assert_match(/\A[0-9a-f]{40}\z/, candidate[:commit_sha]) - end -end diff --git a/test/jobs/auto_upgrade_job_test.rb b/test/jobs/auto_upgrade_job_test.rb deleted file mode 100644 index d68aa2a1..00000000 --- a/test/jobs/auto_upgrade_job_test.rb +++ /dev/null @@ -1,7 +0,0 @@ -require "test_helper" - -class AutoUpgradeJobTest < ActiveJob::TestCase - # test "the truth" do - # assert true - # end -end diff --git a/test/models/provider/github_test.rb b/test/models/provider/github_test.rb deleted file mode 100644 index 6e425bf2..00000000 --- a/test/models/provider/github_test.rb +++ /dev/null @@ -1,9 +0,0 @@ -require "test_helper" - -class Provider::GithubTest < ActiveSupport::TestCase - include GitRepositoryProviderInterfaceTest - - setup do - @subject = Provider::Github.new(owner: "rails", name: "rails", branch: "main") - end -end diff --git a/test/models/upgrader/upgrade_test.rb b/test/models/upgrader/upgrade_test.rb deleted file mode 100644 index cf99ed7c..00000000 --- a/test/models/upgrader/upgrade_test.rb +++ /dev/null @@ -1,36 +0,0 @@ -require "test_helper" - -class UpgradeTest < ActiveSupport::TestCase - setup do - data = { - commit_sha: "latestcommit", - version: Semver.new("0.1.0-alpha.2") - } - - @commit_upgrade = Upgrader::Upgrade.new "commit", data - @release_upgrade = Upgrader::Upgrade.new "release", data - end - - test "available if latest commit and app not upgraded" do - Maybe.stubs(:version).returns(@commit_upgrade.version) - Maybe.stubs(:commit_sha).returns("outdatedcommitsha") - - assert @commit_upgrade.available? - assert_not @release_upgrade.available? - end - - test "available if latest release and app not upgraded" do - Maybe.stubs(:version).returns(Semver.new("0.1.0-alpha.1")) - Maybe.stubs(:commit_sha).returns("outdatedcommitsha") - - assert @commit_upgrade.available? - assert @release_upgrade.available? - end - - test "not available if app commit greater or equal to" do - Maybe.stubs(:version).returns(@commit_upgrade.version) - Maybe.stubs(:commit_sha).returns(@commit_upgrade.commit_sha) - - assert_not @commit_upgrade.available? - end -end diff --git a/test/models/upgrader_test.rb b/test/models/upgrader_test.rb deleted file mode 100644 index 6bad83ff..00000000 --- a/test/models/upgrader_test.rb +++ /dev/null @@ -1,88 +0,0 @@ -require "test_helper" - -class UpgraderTest < ActiveSupport::TestCase - PRIOR_COMMIT = "47bb430954292d2fdcc81082af731a16b9587da2" - CURRENT_COMMIT = "47bb430954292d2fdcc81082af731a16b9587da3" - NEXT_COMMIT = "47bb430954292d2fdcc81082af731a16b9587da4" - - PRIOR_VERSION = Semver.new("0.1.0-alpha.3") - CURRENT_VERSION = Semver.new("0.1.0-alpha.4") - NEXT_VERSION = Semver.new("0.1.0-alpha.5") - - # Default setup assumes app is up to date - setup do - Upgrader.config = Upgrader::Config.new({ mode: :enabled }) - - Maybe.stubs(:version).returns(CURRENT_VERSION) - Maybe.stubs(:commit_sha).returns(CURRENT_COMMIT) - - stub_github_data( - commit: create_upgrade_stub(CURRENT_VERSION, CURRENT_COMMIT), - release: create_upgrade_stub(CURRENT_VERSION, CURRENT_COMMIT) - ) - end - - test "finds 1 completed upgrade, 0 available upgrades when app is up to date" do - assert_instance_of Upgrader::Upgrade, Upgrader.completed_upgrade - assert_nil Upgrader.available_upgrade - end - - test "finds 1 available and 1 completed upgrade when app is on latest release but behind latest commit" do - stub_github_data( - commit: create_upgrade_stub(CURRENT_VERSION, NEXT_COMMIT), - release: create_upgrade_stub(CURRENT_VERSION, CURRENT_COMMIT) - ) - - assert_instance_of Upgrader::Upgrade, Upgrader.available_upgrade # commit is ahead of release - assert_instance_of Upgrader::Upgrade, Upgrader.completed_upgrade # release is completed - end - - test "when app is behind latest version and latest commit is ahead of release finds release upgrade and no completed upgrades" do - Maybe.stubs(:version).returns(PRIOR_VERSION) - Maybe.stubs(:commit_sha).returns(PRIOR_COMMIT) - - stub_github_data( - commit: create_upgrade_stub(CURRENT_VERSION, NEXT_COMMIT), - release: create_upgrade_stub(CURRENT_VERSION, CURRENT_COMMIT) - ) - - assert_equal "release", Upgrader.available_upgrade.type - assert_nil Upgrader.completed_upgrade - end - - test "defaults to app version when no release is found" do - stub_github_data( - commit: create_upgrade_stub(CURRENT_VERSION, NEXT_COMMIT), - release: nil - ) - - # Upstream is 1 commit ahead, and we assume we're on the same release - assert_equal "commit", Upgrader.available_upgrade.type - end - - test "gracefully handles empty github info" do - Provider::Github.any_instance.stubs(:fetch_latest_upgrade_candidates).returns(nil) - - assert_nil Upgrader.available_upgrade - assert_nil Upgrader.completed_upgrade - end - - test "deployer is null by default" do - Upgrader.config = Upgrader::Config.new({ mode: :enabled }) - Upgrader::Deployer::Null.any_instance.expects(:deploy).with(nil).once - Upgrader.upgrade_to(nil) - end - - private - def create_upgrade_stub(version, commit_sha) - { - version: version, - commit_sha: commit_sha, - url: "" - } - end - - def stub_github_data(commit: create_upgrade_stub(LATEST_VERSION, LATEST_COMMIT), release: create_upgrade_stub(LATEST_VERSION, LATEST_COMMIT)) - Provider::Github.any_instance.stubs(:fetch_latest_upgrade_candidates).returns({ commit:, release: }) - end -end diff --git a/test/vcr_cassettes/git_repository_provider/fetch_latest_release_notes.yml b/test/vcr_cassettes/git_repository_provider/fetch_latest_release_notes.yml index aef67d92..a14b1755 100644 --- a/test/vcr_cassettes/git_repository_provider/fetch_latest_release_notes.yml +++ b/test/vcr_cassettes/git_repository_provider/fetch_latest_release_notes.yml @@ -10,7 +10,7 @@ http_interactions: Accept: - application/vnd.github.v3+json User-Agent: - - Octokit Ruby Gem 9.1.0 + - Octokit Ruby Gem 9.2.0 Content-Type: - application/json Accept-Encoding: @@ -21,7 +21,7 @@ http_interactions: message: OK headers: Date: - - Mon, 09 Sep 2024 20:03:24 GMT + - Wed, 19 Mar 2025 12:40:58 GMT Content-Type: - application/json; charset=utf-8 Cache-Control: @@ -29,7 +29,7 @@ http_interactions: Vary: - Accept,Accept-Encoding, Accept, X-Requested-With Etag: - - W/"17af70a435e3513d63e7fe569e0863ad17c500158f39055d1b1cab704ab54b96" + - W/"cc42fb8190e3219e91e46d75f709c8b5762a1e8bf472008a702a4adf1e7dfb95" X-Github-Media-Type: - github.v3; format=json X-Github-Api-Version-Selected: @@ -55,62 +55,68 @@ http_interactions: - default-src 'none' Server: - github.com + Accept-Ranges: + - bytes X-Ratelimit-Limit: - '60' X-Ratelimit-Remaining: - - '57' + - '59' X-Ratelimit-Reset: - - '1725915804' + - '1742391658' X-Ratelimit-Resource: - core X-Ratelimit-Used: - - '3' - Accept-Ranges: - - bytes - Content-Length: - - '52546' + - '1' + Transfer-Encoding: + - chunked X-Github-Request-Id: - - E979:365B86:C03B37:171BC72:66DF5497 + - DDED:38A4A1:15FB4B:2C2CC0:67DABB5A body: encoding: ASCII-8BIT string: !binary |- - W3sidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE3MTY4NDg3MCIsImFzc2V0c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTcxNjg0ODcwL2Fzc2V0cyIsInVwbG9hZF91cmwiOiJodHRwczovL3VwbG9hZHMuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE3MTY4NDg3MC9hc3NldHN7P25hbWUsbGFiZWx9IiwiaHRtbF91cmwiOiJodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy90YWcvdjAuMS4wLWFscGhhLjE2IiwiaWQiOjE3MTY4NDg3MCwiYXV0aG9yIjp7ImxvZ2luIjoiemFjaGdvbGwiLCJpZCI6MTY2NzYxNTcsIm5vZGVfaWQiOiJNRFE2VlhObGNqRTJOamMyTVRVMyIsImF2YXRhcl91cmwiOiJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvMTY2NzYxNTc/dj00IiwiZ3JhdmF0YXJfaWQiOiIiLCJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsIiwiaHRtbF91cmwiOiJodHRwczovL2dpdGh1Yi5jb20vemFjaGdvbGwiLCJmb2xsb3dlcnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dlcnMiLCJmb2xsb3dpbmdfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dpbmd7L290aGVyX3VzZXJ9IiwiZ2lzdHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9naXN0c3svZ2lzdF9pZH0iLCJzdGFycmVkX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvc3RhcnJlZHsvb3duZXJ9ey9yZXBvfSIsInN1YnNjcmlwdGlvbnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9zdWJzY3JpcHRpb25zIiwib3JnYW5pemF0aW9uc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL29yZ3MiLCJyZXBvc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlcG9zIiwiZXZlbnRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZXZlbnRzey9wcml2YWN5fSIsInJlY2VpdmVkX2V2ZW50c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlY2VpdmVkX2V2ZW50cyIsInR5cGUiOiJVc2VyIiwic2l0ZV9hZG1pbiI6ZmFsc2V9LCJub2RlX2lkIjoiUkVfa3dET0tfdHhITTRLTzdRRyIsInRhZ19uYW1lIjoidjAuMS4wLWFscGhhLjE2IiwidGFyZ2V0X2NvbW1pdGlzaCI6Im1haW4iLCJuYW1lIjoidjAuMS4wLWFscGhhLjE2IiwiZHJhZnQiOmZhbHNlLCJwcmVyZWxlYXNlIjp0cnVlLCJjcmVhdGVkX2F0IjoiMjAyNC0wOC0yM1QxNDozOToxNFoiLCJwdWJsaXNoZWRfYXQiOiIyMDI0LTA4LTIzVDE0OjQ0OjE1WiIsImFzc2V0cyI6W10sInRhcmJhbGxfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3RhcmJhbGwvdjAuMS4wLWFscGhhLjE2IiwiemlwYmFsbF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvemlwYmFsbC92MC4xLjAtYWxwaGEuMTYiLCJib2R5IjoiVGhpcyB3ZWVrJ3MgcmVsZWFzZSBjb21lcyB3aXRoIGEgdmFyaWV0eSBvZiBidWcgZml4ZXMgYW5kIGltcHJvdmVtZW50cyB0byB0aGUgVUkuXHJcblxyXG5BZGRpdGlvbmFsbHksIHVzZXJzIGNhbiBub3cgaW5wdXQgZGV0YWlscyBmb3IgdGhlaXIgcHJvcGVydHkgYW5kIHZlaGljbGUgYWNjb3VudHMgYXMgc2hvd24gaW4gdGhlIHZpZGVvIGJlbG93LiAgSW4gdGhlIG5lYXIgZnV0dXJlLCBNYXliZSB3aWxsIHN1cHBvcnQgZGF0YSBwcm92aWRlcnMgcmVsYXRlZCB0byB0aGUgXCJ2YWx1YXRpb25cIiBvZiBwcm9wZXJ0aWVzIGFuZCB2ZWhpY2xlcyAoaS5lLiBaaWxsb3csIEtCQikuICBXZSB3aWxsIHVzZSB0aGUgaW5mb3JtYXRpb24gZnJvbSB1c2VyIGFjY291bnRzIHRvIGF1dG9tYXRpY2FsbHkgZmV0Y2ggZXN0aW1hdGVkIG1hcmtldCB2YWx1ZXMgZm9yIHRoZXNlIGFzc2V0cyB3aGljaCB3aWxsIHRoZW4gYmUgYWRkZWQgcGVyaW9kaWNhbGx5IGFzIFwiVmFsdWF0aW9uc1wiIGluIHRoZSB2YWx1ZSB0YWIgb2YgZWFjaCBhY2NvdW50LiAgVGhpcyB3aWxsIHRoZW4gc2hvdyB1cCBpbiB0aGUgaGlzdG9yeSBncmFwaCBmb3IgdGhlIGFjY291bnQgYmFsYW5jZS5cclxuXHJcbmh0dHBzOi8vZ2l0aHViLmNvbS91c2VyLWF0dGFjaG1lbnRzL2Fzc2V0cy9mZDc1OWM4Mi1hMjVjLTRjOGQtOGYxNi1mNTc3ZTA0MTBmYjVcclxuXHJcbiMjIFdoYXQncyBDaGFuZ2VkXHJcblxyXG4qIFJlZmFjdG9yOiBBbGxvdyBvdGhlciBpbXBvcnQgZmlsZXMgYnkgQHBlZHJvY2FybW9uYSBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEwOTlcclxuKiBCdW1wIHNlbnRyeS1ydWJ5IGZyb20gNS4xOC4yIHRvIDUuMTkuMCBieSBAZGVwZW5kYWJvdCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzExMDhcclxuKiBCdW1wIHN0aW11bHVzLXJhaWxzIGZyb20gMS4zLjMgdG8gMS4zLjQgYnkgQGRlcGVuZGFib3QgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMTA2XHJcbiogQnVtcCBhd3Mtc2RrLXMzIGZyb20gMS4xNTcuMCB0byAxLjE1OC4wIGJ5IEBkZXBlbmRhYm90IGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTEwNVxyXG4qIEJ1bXAgcnVieS1sc3AtcmFpbHMgZnJvbSAwLjMuMTIgdG8gMC4zLjEzIGJ5IEBkZXBlbmRhYm90IGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTEwN1xyXG4qIEJ1bXAgcHJvcHNoYWZ0IGZyb20gMC45LjAgdG8gMC45LjEgYnkgQGRlcGVuZGFib3QgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMTA0XHJcbiogQnVtcCBnb29kX2pvYiBmcm9tIDQuMS4xIHRvIDQuMi4wIGJ5IEBkZXBlbmRhYm90IGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTEwMlxyXG4qIEJ1bXAgdGFpbHdpbmRjc3MtcmFpbHMgZnJvbSAyLjcuMiB0byAyLjcuMyBieSBAZGVwZW5kYWJvdCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzExMDNcclxuKiBGaXggcXVlcnkgd2hlbiBhY2NvdW50IGhhcyB6ZXJvIGluY29tZSBhbmQgZXhwZW5zZSBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMTEyXHJcbiogRml4IGhvbGRpbmcgbmFtZSBlcnJvciBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMTEzXHJcbiogQWRkIFByb3BlcnR5IERldGFpbHMgVmlldyBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMTE2XHJcbiogQmFzaWMgVmVoaWNsZSBWaWV3IGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzExMTdcclxuKiBSdWJvY29wIHVwZGF0ZXMgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTExOFxyXG4qIEZpeCBmaWxlIHVwbG9hZCBVSSBvcGVuaW5nIHR3aWNlIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzExMTlcclxuXHJcblxyXG4qKkZ1bGwgQ2hhbmdlbG9nKio6IGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL2NvbXBhcmUvdjAuMS4wLWFscGhhLjE1Li4udjAuMS4wLWFscGhhLjE2IiwicmVhY3Rpb25zIjp7InVybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNzE2ODQ4NzAvcmVhY3Rpb25zIiwidG90YWxfY291bnQiOjE5LCIrMSI6OCwiLTEiOjAsImxhdWdoIjowLCJob29yYXkiOjAsImNvbmZ1c2VkIjowLCJoZWFydCI6MCwicm9ja2V0Ijo5LCJleWVzIjoyfSwibWVudGlvbnNfY291bnQiOjN9LHsidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE3MDY1NTkyNCIsImFzc2V0c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTcwNjU1OTI0L2Fzc2V0cyIsInVwbG9hZF91cmwiOiJodHRwczovL3VwbG9hZHMuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE3MDY1NTkyNC9hc3NldHN7P25hbWUsbGFiZWx9IiwiaHRtbF91cmwiOiJodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy90YWcvdjAuMS4wLWFscGhhLjE1IiwiaWQiOjE3MDY1NTkyNCwiYXV0aG9yIjp7ImxvZ2luIjoiemFjaGdvbGwiLCJpZCI6MTY2NzYxNTcsIm5vZGVfaWQiOiJNRFE2VlhObGNqRTJOamMyTVRVMyIsImF2YXRhcl91cmwiOiJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvMTY2NzYxNTc/dj00IiwiZ3JhdmF0YXJfaWQiOiIiLCJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsIiwiaHRtbF91cmwiOiJodHRwczovL2dpdGh1Yi5jb20vemFjaGdvbGwiLCJmb2xsb3dlcnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dlcnMiLCJmb2xsb3dpbmdfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dpbmd7L290aGVyX3VzZXJ9IiwiZ2lzdHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9naXN0c3svZ2lzdF9pZH0iLCJzdGFycmVkX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvc3RhcnJlZHsvb3duZXJ9ey9yZXBvfSIsInN1YnNjcmlwdGlvbnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9zdWJzY3JpcHRpb25zIiwib3JnYW5pemF0aW9uc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL29yZ3MiLCJyZXBvc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlcG9zIiwiZXZlbnRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZXZlbnRzey9wcml2YWN5fSIsInJlY2VpdmVkX2V2ZW50c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlY2VpdmVkX2V2ZW50cyIsInR5cGUiOiJVc2VyIiwic2l0ZV9hZG1pbiI6ZmFsc2V9LCJub2RlX2lkIjoiUkVfa3dET0tfdHhITTRLTEFDMCIsInRhZ19uYW1lIjoidjAuMS4wLWFscGhhLjE1IiwidGFyZ2V0X2NvbW1pdGlzaCI6Im1haW4iLCJuYW1lIjoidjAuMS4wLWFscGhhLjE1IiwiZHJhZnQiOmZhbHNlLCJwcmVyZWxlYXNlIjp0cnVlLCJjcmVhdGVkX2F0IjoiMjAyNC0wOC0xNlQyMDowOTozN1oiLCJwdWJsaXNoZWRfYXQiOiIyMDI0LTA4LTE2VDIwOjE4OjIyWiIsImFzc2V0cyI6W10sInRhcmJhbGxfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3RhcmJhbGwvdjAuMS4wLWFscGhhLjE1IiwiemlwYmFsbF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvemlwYmFsbC92MC4xLjAtYWxwaGEuMTUiLCJib2R5IjoiVGhpcyB3ZWVrLCB3ZSByZWxlYXNlZCB0aGUgZmlyc3QgdmVyc2lvbiBvZiBhbiBpc3N1ZSB0cmFja2luZyBzeXN0ZW0gZGlyZWN0bHkgd2l0aGluIHRoZSBNYXliZSBhcHAhXHJcblxyXG5XaHk/XHJcblxyXG5BcyBhbiBvcGVuLXNvdXJjZSBwZXJzb25hbCBmaW5hbmNlIGFwcCwgTWF5YmUgaGFzIGEgbXVjaCBsYXJnZXIgXCJzZWxmIHNlcnZpY2VcIiBjb21wb25lbnQgdGhhbiBtb3N0IGFwcGxpY2F0aW9ucy4gIFdoaWxlIHdlJ3JlIGluIHRoZSBlYXJseSBkYXlzLCB0aGUgZ29hbCBpcyB0byBzdXBwb3J0IGEgZ2xvYmFsIHVzZXIgYmFzZSwgd2hpY2ggbWVhbnMgd2UgaGF2ZSB0byBzdXBwb3J0IF9hIGxvdF8gb2YgZGF0YSBwcm92aWRlcnMuXHJcblxyXG5FYWNoIGRhdGEgcHJvdmlkZXIgY29tZXMgd2l0aCBpdHMgb3duIHNldCBvZiBudWFuY2VzIGFuZCBlcnJvcnMgdGhhdCBjYW4gYmUgY2hhbGxlbmdpbmcgdG8gZGVidWc7IGVzcGVjaWFsbHkgZm9yIG91ciBzbWFsbCB0ZWFtIHRoYXQgZG9lc24ndCBoYXZlIGFjY2VzcyB0byB5b3VyIHNlbGYgaG9zdGVkIGNvbmZpZ3VyYXRpb24uICBXaGVuIHRoaW5ncyBnbyB3cm9uZywgdGhlcmUgYXJlIGEgaGFuZGZ1bCBvZiBwb3NzaWJsZSBjYXVzZXMuXHJcblxyXG5JbiBvcmRlciB0byBjb21iYXQgdGhpcywgdGhlIGB2MC4xLjAtYWxwaGEuMTVgIHJlbGVhc2UgaW50cm9kdWNlcyBhIFwic2VsZiByZXNvbHV0aW9uXCIgaXNzdWUgdHJhY2tpbmcgc3lzdGVtLiAgVGhlIHZpZGVvIGJlbG93IGRlbW9uc3RyYXRlcyBob3cgYSB1c2VyIHdobyBoYXMgaW5jb3JyZWN0bHkgY29uZmlndXJlZCB0aGVpciBleGNoYW5nZSByYXRlcyBwcm92aWRlciBjYW4gKippZGVudGlmeSwgZGlhZ25vc2UsIGFuZCBmaXggdGhlIGlzc3VlIGFsbCB3aXRoaW4gdGhlIE1heWJlIGFwcCoqOlxyXG5cclxuaHR0cHM6Ly9naXRodWIuY29tL3VzZXItYXR0YWNobWVudHMvYXNzZXRzLzdjYzk4Yzc5LTliNzItNDI2MC1iYzE3LTkwN2JjMzNiMTM5MFxyXG5cclxuIyMgV2hhdCdzIENoYW5nZWRcclxuKiBEZXBvc2l0LCBXaXRoZHJhd2FsLCBhbmQgSW50ZXJlc3QgVHJhbnNhY3Rpb25zIGZvciBJbnZlc3RtZW50IFZpZXcgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTA3NVxyXG4qIEJ1bXAgdGFpbHdpbmRjc3MtcmFpbHMgZnJvbSAyLjYuNSB0byAyLjcuMiBieSBAZGVwZW5kYWJvdCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEwNzhcclxuKiBCdW1wIGJvb3RzbmFwIGZyb20gMS4xOC4zIHRvIDEuMTguNCBieSBAZGVwZW5kYWJvdCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEwNzlcclxuKiBCdW1wIHJ1YnktbHNwLXJhaWxzIGZyb20gMC4zLjExIHRvIDAuMy4xMiBieSBAZGVwZW5kYWJvdCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEwODFcclxuKiBSZWZhY3RvcjogVXNlIG5hdGl2ZSBlcnJvciBpMThuIGxvb2t1cCBieSBAcGVkcm9jYXJtb25hIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTA3NlxyXG4qIEJ1bXAgcmFpbHMgZnJvbSBgNDM1MzBiNGAgdG8gYGY2ZDYyYjVgIGJ5IEBkZXBlbmRhYm90IGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTA4M1xyXG4qIEZpeDogaTE4biBzeW1ib2wgdHlwbyBieSBAcGVkcm9jYXJtb25hIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTA4NVxyXG4qIEJ1bXAgcnVieSBmcm9tIDMuMy4xIHRvIDMuMy40IGJ5IEBDbHVzdGVyNDQ0IGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTA4NFxyXG4qIEZpeCBmb3IgaW52YWxpZCBhY2NvdW50YWJsZSBkYXRhIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEwODZcclxuKiBhZGQgcGFnaW5hdGlvbiB0byBhY2NvdW50IHRyYW5zYWN0aW9ucyBsaXN0IGJ5IEBjb2RlLWNvbnN0cnVjdG9yIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTA5NVxyXG4qIEFjY291bnQgSXNzdWUgTW9kZWwgYW5kIFJlc29sdXRpb24gRmxvdyArIFRyb3VibGVzaG9vdGluZyBndWlkZXMgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTA5MFxyXG4qIEFkZCBzdXBwb3J0IGZvciBkaWZmZXJlbnQgY29sdW1uIHNlcGFyYXRvciBpbiBjc3YgaW1wb3J0IGxvZ2ljIGJ5IEBjb2RlLWNvbnN0cnVjdG9yIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTA5NlxyXG4qIEltcHJvdmVkIFVJIHdhcm5pbmcgc3RhdGVzIGZvciBob2xkaW5ncyB3aXRoIG1pc3NpbmcgZGF0YSBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMDk4XHJcblxyXG4jIyBOZXcgQ29udHJpYnV0b3JzXHJcbiogQHBlZHJvY2FybW9uYSBtYWRlIHRoZWlyIGZpcnN0IGNvbnRyaWJ1dGlvbiBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEwNzZcclxuKiBAQ2x1c3RlcjQ0NCBtYWRlIHRoZWlyIGZpcnN0IGNvbnRyaWJ1dGlvbiBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEwODRcclxuKiBAY29kZS1jb25zdHJ1Y3RvciBtYWRlIHRoZWlyIGZpcnN0IGNvbnRyaWJ1dGlvbiBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEwOTVcclxuXHJcbioqRnVsbCBDaGFuZ2Vsb2cqKjogaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvY29tcGFyZS92MC4xLjAtYWxwaGEuMTQuLi52MC4xLjAtYWxwaGEuMTUiLCJyZWFjdGlvbnMiOnsidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE3MDY1NTkyNC9yZWFjdGlvbnMiLCJ0b3RhbF9jb3VudCI6MTEsIisxIjoxMSwiLTEiOjAsImxhdWdoIjowLCJob29yYXkiOjAsImNvbmZ1c2VkIjowLCJoZWFydCI6MCwicm9ja2V0IjowLCJleWVzIjowfSwibWVudGlvbnNfY291bnQiOjV9LHsidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE2OTU5MTAyOSIsImFzc2V0c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTY5NTkxMDI5L2Fzc2V0cyIsInVwbG9hZF91cmwiOiJodHRwczovL3VwbG9hZHMuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE2OTU5MTAyOS9hc3NldHN7P25hbWUsbGFiZWx9IiwiaHRtbF91cmwiOiJodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy90YWcvdjAuMS4wLWFscGhhLjE0IiwiaWQiOjE2OTU5MTAyOSwiYXV0aG9yIjp7ImxvZ2luIjoiemFjaGdvbGwiLCJpZCI6MTY2NzYxNTcsIm5vZGVfaWQiOiJNRFE2VlhObGNqRTJOamMyTVRVMyIsImF2YXRhcl91cmwiOiJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvMTY2NzYxNTc/dj00IiwiZ3JhdmF0YXJfaWQiOiIiLCJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsIiwiaHRtbF91cmwiOiJodHRwczovL2dpdGh1Yi5jb20vemFjaGdvbGwiLCJmb2xsb3dlcnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dlcnMiLCJmb2xsb3dpbmdfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dpbmd7L290aGVyX3VzZXJ9IiwiZ2lzdHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9naXN0c3svZ2lzdF9pZH0iLCJzdGFycmVkX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvc3RhcnJlZHsvb3duZXJ9ey9yZXBvfSIsInN1YnNjcmlwdGlvbnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9zdWJzY3JpcHRpb25zIiwib3JnYW5pemF0aW9uc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL29yZ3MiLCJyZXBvc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlcG9zIiwiZXZlbnRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZXZlbnRzey9wcml2YWN5fSIsInJlY2VpdmVkX2V2ZW50c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlY2VpdmVkX2V2ZW50cyIsInR5cGUiOiJVc2VyIiwic2l0ZV9hZG1pbiI6ZmFsc2V9LCJub2RlX2lkIjoiUkVfa3dET0tfdHhITTRLRzhEMSIsInRhZ19uYW1lIjoidjAuMS4wLWFscGhhLjE0IiwidGFyZ2V0X2NvbW1pdGlzaCI6Im1haW4iLCJuYW1lIjoidjAuMS4wLWFscGhhLjE0IiwiZHJhZnQiOmZhbHNlLCJwcmVyZWxlYXNlIjp0cnVlLCJjcmVhdGVkX2F0IjoiMjAyNC0wOC0wOVQyMTo0Mjo0OFoiLCJwdWJsaXNoZWRfYXQiOiIyMDI0LTA4LTA5VDIxOjQ5OjIwWiIsImFzc2V0cyI6W10sInRhcmJhbGxfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3RhcmJhbGwvdjAuMS4wLWFscGhhLjE0IiwiemlwYmFsbF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvemlwYmFsbC92MC4xLjAtYWxwaGEuMTQiLCJib2R5IjoiQmFzaWMgaW52ZXN0bWVudCBwb3J0Zm9saW9zIGFyZSBoZXJlISDwn6WzIFxyXG5cclxuV2hpbGUgd2UncmUgc3RpbGwgaW4gdGhlIHZlcnkgZWFybHkgZGF5cyBmb3IgaW52ZXN0bWVudCBhY2NvdW50cywgdGhpcyB3ZWVrIGNhcHBlZCBvZmYgc29tZSByZWFsbHkgaW1wb3J0YW50IGFkZGl0aW9ucyB0byB0aGUgaW52ZXN0bWVudCBhY2NvdW50IHBhZ2Ugdmlldy5cclxuXHJcblVzZXJzIGNhbiBub3c6XHJcblxyXG4tIFNlZSBhIGJyZWFrZG93biBvZiB0aGVpciBjYXNoICsgaG9sZGluZ3MgYmFsYW5jZSBpbiBhIHRvb2x0aXBcclxuLSBBZGQgYnV5L3NlbGwgaW52ZXN0bWVudCB0cmFkZXMgdGhhdCB3aWxsIGF1dG9tYXRpY2FsbHkgc3luYyBhbmQgcmVidWlsZCB0aGVpciBwb3J0Zm9saW8gYW5kIGhpc3RvcmljYWwgZ3JhcGhcclxuXHJcbk1vdmluZyBmb3J3YXJkLCB3ZSdsbCBiZSByZWZpbmluZyBhbmQgYWRkaW5nIHRvIHRoZSBpbnZlc3RtZW50IHBvcnRmb2xpbyBmZWF0dXJlIGFuZCBjb21wbGVtZW50aW5nIGl0IHdpdGggdHJvdWJsZXNob290aW5nIGd1aWRlcyBzbyB5b3Uga25vdyBleGFjdGx5IHdoYXQncyBjYXVzaW5nIGFueSBkaXNjcmVwYW5jaWVzIGJldHdlZW4gTWF5YmUncyBjYWxjdWxhdGlvbnMgYW5kIHlvdXIgYnJva2VyYWdlJ3MgY2FsY3VsYXRpb25zLlxyXG5cclxuaHR0cHM6Ly9naXRodWIuY29tL3VzZXItYXR0YWNobWVudHMvYXNzZXRzL2Y0YzhiYzY1LTMxZjYtNDYyNy1iNGU3LTQ3N2QwNjg3YzU3MFxyXG5cclxuIyMgV2hhdCdzIENoYW5nZWRcclxuXHJcbiogQnVtcCB0YWlsd2luZGNzcy1yYWlscyBmcm9tIDIuNi40IHRvIDIuNi41IGJ5IEBkZXBlbmRhYm90IGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTA1OFxyXG4qIEJ1bXAgZmFyYWRheSBmcm9tIDIuMTAuMCB0byAyLjEwLjEgYnkgQGRlcGVuZGFib3QgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMDU1XHJcbiogQnVtcCBlcmJfbGludCBmcm9tIDAuNS4wIHRvIDAuNi4wIGJ5IEBkZXBlbmRhYm90IGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTA1N1xyXG4qIEJ1bXAgYXdzLXNkay1zMyBmcm9tIDEuMTU2LjAgdG8gMS4xNTcuMCBieSBAZGVwZW5kYWJvdCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEwNTRcclxuKiBCdW1wIGdvb2Rfam9iIGZyb20gNC4xLjAgdG8gNC4xLjEgYnkgQGRlcGVuZGFib3QgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMDUzXHJcbiogQnVtcCBwYWd5IGZyb20gOS4wLjMgdG8gOS4wLjUgYnkgQGRlcGVuZGFib3QgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMDU2XHJcbiogQnVtcCByYWlscyBmcm9tIGA1Y2I1Y2FkYCB0byBgNDM1MzBiNGAgYnkgQGRlcGVuZGFib3QgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMDU5XHJcbiogQWRkIHNvdXJjZSBoZWFkZXJzIHRvIFN5bnRoIGNhbGxzIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEwNjJcclxuKiBBZGQgc3RpbXVsdXMgdG9vbHRpcCBjb250cm9sbGVyIGJ5IEB0b255dmluY2UgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMDY1XHJcbiogRmV0Y2ggZXhjaGFuZ2UgcmF0ZXMgaW4gYnVsayBmcm9tIHN5bnRoIGJ5IEB0b255dmluY2UgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMDY5XHJcbiogRml4IG1pbml0ZXN0IGFzc2VydF9uaWwgd2FybmluZyBieSBAdG9ueXZpbmNlIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTA3MFxyXG4qIEFsbG93IHVzZXIgdG8gYWRkIGJ1eSBhbmQgc2VsbCB0cmFkZSB0cmFuc2FjdGlvbnMgZm9yIGludmVzdG1lbnQgYWNjb3VudHMgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTA2NlxyXG4qIFRlbXAgZml4IGZvciBtaXNzaW5nIGFjY291bnRhYmxlcyBvbiBzZWxmIGhvc3RlZCBpbnN0YW5jZXMgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTA3MVxyXG5cclxuXHJcbioqRnVsbCBDaGFuZ2Vsb2cqKjogaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvY29tcGFyZS92MC4xLjAtYWxwaGEuMTMuLi52MC4xLjAtYWxwaGEuMTQiLCJyZWFjdGlvbnMiOnsidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE2OTU5MTAyOS9yZWFjdGlvbnMiLCJ0b3RhbF9jb3VudCI6MTYsIisxIjowLCItMSI6MCwibGF1Z2giOjAsImhvb3JheSI6MTYsImNvbmZ1c2VkIjowLCJoZWFydCI6MCwicm9ja2V0IjowLCJleWVzIjowfSwibWVudGlvbnNfY291bnQiOjN9LHsidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE2ODUxMDU1OSIsImFzc2V0c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTY4NTEwNTU5L2Fzc2V0cyIsInVwbG9hZF91cmwiOiJodHRwczovL3VwbG9hZHMuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE2ODUxMDU1OS9hc3NldHN7P25hbWUsbGFiZWx9IiwiaHRtbF91cmwiOiJodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy90YWcvdjAuMS4wLWFscGhhLjEzIiwiaWQiOjE2ODUxMDU1OSwiYXV0aG9yIjp7ImxvZ2luIjoiemFjaGdvbGwiLCJpZCI6MTY2NzYxNTcsIm5vZGVfaWQiOiJNRFE2VlhObGNqRTJOamMyTVRVMyIsImF2YXRhcl91cmwiOiJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvMTY2NzYxNTc/dj00IiwiZ3JhdmF0YXJfaWQiOiIiLCJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsIiwiaHRtbF91cmwiOiJodHRwczovL2dpdGh1Yi5jb20vemFjaGdvbGwiLCJmb2xsb3dlcnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dlcnMiLCJmb2xsb3dpbmdfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dpbmd7L290aGVyX3VzZXJ9IiwiZ2lzdHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9naXN0c3svZ2lzdF9pZH0iLCJzdGFycmVkX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvc3RhcnJlZHsvb3duZXJ9ey9yZXBvfSIsInN1YnNjcmlwdGlvbnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9zdWJzY3JpcHRpb25zIiwib3JnYW5pemF0aW9uc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL29yZ3MiLCJyZXBvc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlcG9zIiwiZXZlbnRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZXZlbnRzey9wcml2YWN5fSIsInJlY2VpdmVkX2V2ZW50c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlY2VpdmVkX2V2ZW50cyIsInR5cGUiOiJVc2VyIiwic2l0ZV9hZG1pbiI6ZmFsc2V9LCJub2RlX2lkIjoiUkVfa3dET0tfdHhITTRLQzBSZiIsInRhZ19uYW1lIjoidjAuMS4wLWFscGhhLjEzIiwidGFyZ2V0X2NvbW1pdGlzaCI6Im1haW4iLCJuYW1lIjoidjAuMS4wLWFscGhhLjEzIiwiZHJhZnQiOmZhbHNlLCJwcmVyZWxlYXNlIjp0cnVlLCJjcmVhdGVkX2F0IjoiMjAyNC0wOC0wMlQyMToxMDoxNloiLCJwdWJsaXNoZWRfYXQiOiIyMDI0LTA4LTAyVDIxOjEwOjU5WiIsImFzc2V0cyI6W10sInRhcmJhbGxfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3RhcmJhbGwvdjAuMS4wLWFscGhhLjEzIiwiemlwYmFsbF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvemlwYmFsbC92MC4xLjAtYWxwaGEuMTMiLCJib2R5IjoiQ29taW5nIG9mZiB0aGUgYmFjayBvZiBsYXN0IHdlZWsncyByZWxlYXNlIHdoaWNoIGludHJvZHVjZWQgYmFzaWMgaW52ZXN0bWVudCBwb3J0Zm9saW8gdmlld3MgYW5kIHRoZSBhYmlsaXR5IHRvIHN5bmMgaW52ZXN0bWVudCBob2xkaW5ncywgdGhpcyB3ZWVrJ3MgcmVsZWFzZSBpbnRyb2R1Y2VzIGZ1bGwgc3VwcG9ydCBmb3Igc3RvY2sgcHJpY2VzIHdpdGggdGhlIFtTeW50aCBBUEldKGh0dHBzOi8vc3ludGhmaW5hbmNlLmNvbS8pISAod2hpY2ggaGFzIGEgZ2VuZXJvdXMgZnJlZSB0aWVyIHRvIGdldCBzdGFydGVkKVxyXG5cclxuVGhpcyBtZWFucyB0aGF0IGludmVzdG1lbnQgYWNjb3VudCB2aWV3cyBjYW4gbm93IHByb3Blcmx5IGNhbGN1bGF0ZSBhbmQgc2hvdyBhbiBhZ2dyZWdhdGVkIG1hcmtldCB2YWx1ZSBvZiBhbGwgdGhlIGludmVzdG1lbnRzIGluIGl0IGFuZCBncmFjZWZ1bGx5IGhhbmRsZSBtaXNzaW5nIHN0b2NrIHByaWNlcy5cclxuXHJcbkluIGEgZnV0dXJlIHJlbGVhc2UsIHVzZXJzIHdpbGwgaGF2ZSBmdWxsIGNvbnRyb2wgdG8gY3JlYXRlIGJ1eSAmIHNlbGwgdHJhZGVzLCBtYW5hZ2UgdGhlaXIgaW52ZXN0bWVudCBwb3J0Zm9saW8sIGFuZCBzZWUgdG90YWwgcmV0dXJucyBvdmVyIHZhcmlvdXMgdGltZSBwZXJpb2RzIGFsb25nc2lkZSB0aGVpciBoaXN0b3JpY2FsIHZhbHVlIGdyYXBoLlxyXG5cclxuaHR0cHM6Ly9naXRodWIuY29tL3VzZXItYXR0YWNobWVudHMvYXNzZXRzL2MzOWIxNzA2LTk3YTctNGUzZS1iNTY4LTlkOWIwOWUzOGUwNVxyXG5cclxuIyMgV2hhdCdzIENoYW5nZWRcclxuKiBGaXg6IE9taXQgbGF5b3V0IGZvciB0dXJibyBmcmFtZXMgd2l0aCBjdXN0b20gc2lkZWJhciBsYXlvdXQgYnkgQHByYW5hdmJhYnUgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMDI0XHJcbiogZml4OiBsb25nIGVtYWlscyBvdmVyZmxvdyBpbiBhY2NvdW50IG1lbnUgZHJvcGRvd24gYnkgQE1pa2hhaWxXYWhpYiBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEwMzRcclxuKiBGaXggZGVtbyBkYXRhIHJlc2V0IGJ5IEB0b255dmluY2UgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMDQxXHJcbiogRW5zdXJlIHRyYW5zZmVyIG5hbWUgaXMgcG9wdWxhdGVkIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEwNDJcclxuKiBBZGQgc2VjdXJpdHkgcHJpY2VzIHByb3ZpZGVyIChTeW50aCBpbnRlZ3JhdGlvbikgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTAzOVxyXG4qIFNob3cgY2FzaCArIGhvbGRpbmdzIHZhbHVlIGZvciBpbnZlc3RtZW50IGFjY291bnQgdmlldyBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMDQ2XHJcblxyXG4jIyBOZXcgQ29udHJpYnV0b3JzXHJcbiogQHByYW5hdmJhYnUgbWFkZSB0aGVpciBmaXJzdCBjb250cmlidXRpb24gaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMDI0XHJcbiogQE1pa2hhaWxXYWhpYiBtYWRlIHRoZWlyIGZpcnN0IGNvbnRyaWJ1dGlvbiBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEwMzRcclxuXHJcbioqRnVsbCBDaGFuZ2Vsb2cqKjogaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvY29tcGFyZS92MC4xLjAtYWxwaGEuMTIuLi52MC4xLjAtYWxwaGEuMTMiLCJyZWFjdGlvbnMiOnsidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE2ODUxMDU1OS9yZWFjdGlvbnMiLCJ0b3RhbF9jb3VudCI6MzAsIisxIjo0LCItMSI6MCwibGF1Z2giOjAsImhvb3JheSI6MTMsImNvbmZ1c2VkIjowLCJoZWFydCI6Niwicm9ja2V0Ijo3LCJleWVzIjowfSwibWVudGlvbnNfY291bnQiOjR9LHsidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE2NzM4MjI3NyIsImFzc2V0c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTY3MzgyMjc3L2Fzc2V0cyIsInVwbG9hZF91cmwiOiJodHRwczovL3VwbG9hZHMuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE2NzM4MjI3Ny9hc3NldHN7P25hbWUsbGFiZWx9IiwiaHRtbF91cmwiOiJodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy90YWcvdjAuMS4wLWFscGhhLjEyIiwiaWQiOjE2NzM4MjI3NywiYXV0aG9yIjp7ImxvZ2luIjoiemFjaGdvbGwiLCJpZCI6MTY2NzYxNTcsIm5vZGVfaWQiOiJNRFE2VlhObGNqRTJOamMyTVRVMyIsImF2YXRhcl91cmwiOiJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvMTY2NzYxNTc/dj00IiwiZ3JhdmF0YXJfaWQiOiIiLCJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsIiwiaHRtbF91cmwiOiJodHRwczovL2dpdGh1Yi5jb20vemFjaGdvbGwiLCJmb2xsb3dlcnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dlcnMiLCJmb2xsb3dpbmdfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dpbmd7L290aGVyX3VzZXJ9IiwiZ2lzdHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9naXN0c3svZ2lzdF9pZH0iLCJzdGFycmVkX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvc3RhcnJlZHsvb3duZXJ9ey9yZXBvfSIsInN1YnNjcmlwdGlvbnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9zdWJzY3JpcHRpb25zIiwib3JnYW5pemF0aW9uc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL29yZ3MiLCJyZXBvc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlcG9zIiwiZXZlbnRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZXZlbnRzey9wcml2YWN5fSIsInJlY2VpdmVkX2V2ZW50c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlY2VpdmVkX2V2ZW50cyIsInR5cGUiOiJVc2VyIiwic2l0ZV9hZG1pbiI6ZmFsc2V9LCJub2RlX2lkIjoiUkVfa3dET0tfdHhITTRKLWcwRiIsInRhZ19uYW1lIjoidjAuMS4wLWFscGhhLjEyIiwidGFyZ2V0X2NvbW1pdGlzaCI6Im1haW4iLCJuYW1lIjoidjAuMS4wLWFscGhhLjEyIiwiZHJhZnQiOmZhbHNlLCJwcmVyZWxlYXNlIjp0cnVlLCJjcmVhdGVkX2F0IjoiMjAyNC0wNy0yNlQxNDo0ODoyMVoiLCJwdWJsaXNoZWRfYXQiOiIyMDI0LTA3LTI2VDE0OjUwOjM2WiIsImFzc2V0cyI6W10sInRhcmJhbGxfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3RhcmJhbGwvdjAuMS4wLWFscGhhLjEyIiwiemlwYmFsbF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvemlwYmFsbC92MC4xLjAtYWxwaGEuMTIiLCJib2R5IjoiIyMgV2hhdCdzIENoYW5nZWRcclxuKiBTZXQgbGFzdF9sb2dpbl9hdCBvbmx5IG9uIGxvZ2luIGluc3RlYWQgb2YgZXZlcnkgc2luZ2xlIGFjdGlvbiBieSBAdG9ueXZpbmNlIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTAxN1xyXG4qIEJ1bXAgcGFneSB3aXRoIGZpeCBmb3IgYnJlYWtpbmcgY2hhbmdlcyAgYnkgQHRvbnl2aW5jZSBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEwMTZcclxuKiBGaXggZm9ybSBsYWJlbHMgYnkgQHRvbnl2aW5jZSBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEwMDRcclxuKiBGaXggY3VyZW5jeSBmb3JtYXQgYnkgQEp1bGl1c01pZWxpYXVza2FzIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTAyMFxyXG4qIEltcGxlbWVudCBhdXRvIGZhbWlseSBzeW5jcyBvbiBsb2dpbiBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMDIxXHJcbiogQmFzaWMgUG9ydGZvbGlvIFZpZXdzIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEwMDBcclxuKiBGaXggY3VycmVuY3kgZm9ybWF0dGluZyBpbiBwaWUgY2hhcnQgdmlzdWFsaXphdGlvbiBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMDIyXHJcbiogU2V0IG1pbmltdW0gc3VwcG9ydGVkIGRhdGUgZm9yIGFjY291bnQgZW50cmllcyBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMDIzXHJcblxyXG4jIyBOZXcgQ29udHJpYnV0b3JzXHJcbiogQEp1bGl1c01pZWxpYXVza2FzIG1hZGUgdGhlaXIgZmlyc3QgY29udHJpYnV0aW9uIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTAyMFxyXG5cclxuKipGdWxsIENoYW5nZWxvZyoqOiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9jb21wYXJlL3YwLjEuMC1hbHBoYS4xMS4uLnYwLjEuMC1hbHBoYS4xMiIsIm1lbnRpb25zX2NvdW50IjozfSx7InVybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNjYyOTY5NTMiLCJhc3NldHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE2NjI5Njk1My9hc3NldHMiLCJ1cGxvYWRfdXJsIjoiaHR0cHM6Ly91cGxvYWRzLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNjYyOTY5NTMvYXNzZXRzez9uYW1lLGxhYmVsfSIsImh0bWxfdXJsIjoiaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvdGFnL3YwLjEuMC1hbHBoYS4xMSIsImlkIjoxNjYyOTY5NTMsImF1dGhvciI6eyJsb2dpbiI6InphY2hnb2xsIiwiaWQiOjE2Njc2MTU3LCJub2RlX2lkIjoiTURRNlZYTmxjakUyTmpjMk1UVTMiLCJhdmF0YXJfdXJsIjoiaHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzE2Njc2MTU3P3Y9NCIsImdyYXZhdGFyX2lkIjoiIiwidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbCIsImh0bWxfdXJsIjoiaHR0cHM6Ly9naXRodWIuY29tL3phY2hnb2xsIiwiZm9sbG93ZXJzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZm9sbG93ZXJzIiwiZm9sbG93aW5nX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZm9sbG93aW5ney9vdGhlcl91c2VyfSIsImdpc3RzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZ2lzdHN7L2dpc3RfaWR9Iiwic3RhcnJlZF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3N0YXJyZWR7L293bmVyfXsvcmVwb30iLCJzdWJzY3JpcHRpb25zX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvc3Vic2NyaXB0aW9ucyIsIm9yZ2FuaXphdGlvbnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9vcmdzIiwicmVwb3NfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9yZXBvcyIsImV2ZW50c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2V2ZW50c3svcHJpdmFjeX0iLCJyZWNlaXZlZF9ldmVudHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9yZWNlaXZlZF9ldmVudHMiLCJ0eXBlIjoiVXNlciIsInNpdGVfYWRtaW4iOmZhbHNlfSwibm9kZV9pZCI6IlJFX2t3RE9LX3R4SE00SjZYMTUiLCJ0YWdfbmFtZSI6InYwLjEuMC1hbHBoYS4xMSIsInRhcmdldF9jb21taXRpc2giOiJtYWluIiwibmFtZSI6InYwLjEuMC1hbHBoYS4xMSIsImRyYWZ0IjpmYWxzZSwicHJlcmVsZWFzZSI6dHJ1ZSwiY3JlYXRlZF9hdCI6IjIwMjQtMDctMTlUMjA6MDk6MDVaIiwicHVibGlzaGVkX2F0IjoiMjAyNC0wNy0xOVQyMDowOTozOFoiLCJhc3NldHMiOltdLCJ0YXJiYWxsX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS90YXJiYWxsL3YwLjEuMC1hbHBoYS4xMSIsInppcGJhbGxfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3ppcGJhbGwvdjAuMS4wLWFscGhhLjExIiwiYm9keSI6IiMjIFdoYXQncyBDaGFuZ2VkXHJcbiogV3JhcCBhY2NvdW50IHVwZGF0ZSBpbiBhIHRyYW5zYWN0aW9uIGJ5IEBhY2Nlc3NkIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvOTg1XHJcbiogQWxsb3cgQ1NWIGZpbGUgdXBsb2FkIGluIGltcG9ydCBmbG93IGJ5IEB0b255dmluY2UgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC85ODZcclxuKiBTYW5pdGl6ZSBpbnB1dCBmb3IgaWxpa2UgaW4gQWNjb3VudDo6RW50cnkuc2VhcmNoIGJ5IEB0b255dmluY2UgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC85ODhcclxuKiBJbnZlc3RtZW50IFBvcnRmb2xpbyBTeW5jIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzk3NFxyXG4qIE1vcmUgY29tcG9zYWJsZSBmb3JtcyBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC85ODlcclxuKiBBZGQgZGVmYXVsdCBjdXJyZW5jaWVzIHRvIGZvcm1zIGJhc2VkIG9uIHByZWZlcmVuY2UgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvOTk0XHJcbiogQnVpbGQgc2FtcGxlIHBvcnRmb2xpbyBkZXRlcm1pbmlzdGljYWxseSBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC85OTNcclxuKiBBZGQgY3VycmVuY3kgdmFsaWRhdGlvbiB0byBhY2NvdW50LCB1cGRhdGUgZGVtbyBkYXRhIGdlbmVyYXRvciBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC85OTZcclxuKiBTeW5jIG5vdGlmaWNhdGlvbnMgYW5kIHRyb3VibGVzaG9vdGluZyBndWlkZXMgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvOTk4XHJcblxyXG4jIyBOZXcgQ29udHJpYnV0b3JzXHJcbiogQGFjY2Vzc2QgbWFkZSB0aGVpciBmaXJzdCBjb250cmlidXRpb24gaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC85ODVcclxuXHJcbioqRnVsbCBDaGFuZ2Vsb2cqKjogaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvY29tcGFyZS92MC4xLjAtYWxwaGEuMTAuLi52MC4xLjAtYWxwaGEuMTEiLCJyZWFjdGlvbnMiOnsidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE2NjI5Njk1My9yZWFjdGlvbnMiLCJ0b3RhbF9jb3VudCI6MTQsIisxIjowLCItMSI6MCwibGF1Z2giOjAsImhvb3JheSI6MCwiY29uZnVzZWQiOjAsImhlYXJ0IjowLCJyb2NrZXQiOjE0LCJleWVzIjowfSwibWVudGlvbnNfY291bnQiOjN9LHsidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE2NTI2MzMyNCIsImFzc2V0c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTY1MjYzMzI0L2Fzc2V0cyIsInVwbG9hZF91cmwiOiJodHRwczovL3VwbG9hZHMuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE2NTI2MzMyNC9hc3NldHN7P25hbWUsbGFiZWx9IiwiaHRtbF91cmwiOiJodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy90YWcvdjAuMS4wLWFscGhhLjEwIiwiaWQiOjE2NTI2MzMyNCwiYXV0aG9yIjp7ImxvZ2luIjoiemFjaGdvbGwiLCJpZCI6MTY2NzYxNTcsIm5vZGVfaWQiOiJNRFE2VlhObGNqRTJOamMyTVRVMyIsImF2YXRhcl91cmwiOiJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvMTY2NzYxNTc/dj00IiwiZ3JhdmF0YXJfaWQiOiIiLCJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsIiwiaHRtbF91cmwiOiJodHRwczovL2dpdGh1Yi5jb20vemFjaGdvbGwiLCJmb2xsb3dlcnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dlcnMiLCJmb2xsb3dpbmdfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dpbmd7L290aGVyX3VzZXJ9IiwiZ2lzdHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9naXN0c3svZ2lzdF9pZH0iLCJzdGFycmVkX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvc3RhcnJlZHsvb3duZXJ9ey9yZXBvfSIsInN1YnNjcmlwdGlvbnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9zdWJzY3JpcHRpb25zIiwib3JnYW5pemF0aW9uc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL29yZ3MiLCJyZXBvc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlcG9zIiwiZXZlbnRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZXZlbnRzey9wcml2YWN5fSIsInJlY2VpdmVkX2V2ZW50c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlY2VpdmVkX2V2ZW50cyIsInR5cGUiOiJVc2VyIiwic2l0ZV9hZG1pbiI6ZmFsc2V9LCJub2RlX2lkIjoiUkVfa3dET0tfdHhITTRKMmJmYyIsInRhZ19uYW1lIjoidjAuMS4wLWFscGhhLjEwIiwidGFyZ2V0X2NvbW1pdGlzaCI6Im1haW4iLCJuYW1lIjoidjAuMS4wLWFscGhhLjEwIiwiZHJhZnQiOmZhbHNlLCJwcmVyZWxlYXNlIjp0cnVlLCJjcmVhdGVkX2F0IjoiMjAyNC0wNy0xMlQyMjozODoxN1oiLCJwdWJsaXNoZWRfYXQiOiIyMDI0LTA3LTEyVDIyOjM5OjExWiIsImFzc2V0cyI6W10sInRhcmJhbGxfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3RhcmJhbGwvdjAuMS4wLWFscGhhLjEwIiwiemlwYmFsbF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvemlwYmFsbC92MC4xLjAtYWxwaGEuMTAiLCJib2R5IjoiIyMgV2hhdCdzIENoYW5nZWRcclxuKiBBZGQgZXJyb3IgaGFuZGxpbmcgZm9yIEFjY291bnRzQ29udHJvbGxlciNjcmVhdGUgYnkgQHRvbnl2aW5jZSBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzk1N1xyXG4qIGZpeDogIzk1MSBwb2ludGVyIGN1cnNvciBhbmQgYmcgaG92ZXIgZm9yIGltcG9ydCBmbG93IGJ1dHRvbnMgYnkgQE1hZ251c0hKZW5zZW4gaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC85NTRcclxuKiBIYW5kbGUgbWlzc2luZyBleGNoYW5nZSByYXRlIHByb3ZpZGVyLCBhbGxvdyBmYWxsYmFjayBmb3IgbWlzc2luZyByYXRlcyBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC85NTVcclxuKiBBZGQgbWlzc2luZyBtaWdyYXRpb25zIGZvciBnb29kX2pvYiA0eCBieSBAdG9ueXZpbmNlIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvOTY3XHJcbiogQWNjb3VudDo6U3luYyBtb2RlbCBhbmQgdGVzdCBmaXh0dXJlIHNpbXBsaWZpY2F0aW9ucyBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC85NjhcclxuKiBEZW1vIEZhbWlseSBkYXRhIHVwZGF0ZXMgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvOTcyXHJcbiogTWFrZSBiYWxhbmNlIGVkaXRpbmcgZWFzaWVyIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzk3NlxyXG5cclxuIyMgTmV3IENvbnRyaWJ1dG9yc1xyXG4qIEBNYWdudXNISmVuc2VuIG1hZGUgdGhlaXIgZmlyc3QgY29udHJpYnV0aW9uIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvOTU0XHJcblxyXG4qKkZ1bGwgQ2hhbmdlbG9nKio6IGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL2NvbXBhcmUvdjAuMS4wLWFscGhhLjkuLi52MC4xLjAtYWxwaGEuMTAiLCJyZWFjdGlvbnMiOnsidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE2NTI2MzMyNC9yZWFjdGlvbnMiLCJ0b3RhbF9jb3VudCI6NywiKzEiOjcsIi0xIjowLCJsYXVnaCI6MCwiaG9vcmF5IjowLCJjb25mdXNlZCI6MCwiaGVhcnQiOjAsInJvY2tldCI6MCwiZXllcyI6MH0sIm1lbnRpb25zX2NvdW50IjozfSx7InVybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNjQxOTg4NDgiLCJhc3NldHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE2NDE5ODg0OC9hc3NldHMiLCJ1cGxvYWRfdXJsIjoiaHR0cHM6Ly91cGxvYWRzLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNjQxOTg4NDgvYXNzZXRzez9uYW1lLGxhYmVsfSIsImh0bWxfdXJsIjoiaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvdGFnL3YwLjEuMC1hbHBoYS45IiwiaWQiOjE2NDE5ODg0OCwiYXV0aG9yIjp7ImxvZ2luIjoiemFjaGdvbGwiLCJpZCI6MTY2NzYxNTcsIm5vZGVfaWQiOiJNRFE2VlhObGNqRTJOamMyTVRVMyIsImF2YXRhcl91cmwiOiJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvMTY2NzYxNTc/dj00IiwiZ3JhdmF0YXJfaWQiOiIiLCJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsIiwiaHRtbF91cmwiOiJodHRwczovL2dpdGh1Yi5jb20vemFjaGdvbGwiLCJmb2xsb3dlcnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dlcnMiLCJmb2xsb3dpbmdfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dpbmd7L290aGVyX3VzZXJ9IiwiZ2lzdHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9naXN0c3svZ2lzdF9pZH0iLCJzdGFycmVkX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvc3RhcnJlZHsvb3duZXJ9ey9yZXBvfSIsInN1YnNjcmlwdGlvbnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9zdWJzY3JpcHRpb25zIiwib3JnYW5pemF0aW9uc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL29yZ3MiLCJyZXBvc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlcG9zIiwiZXZlbnRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZXZlbnRzey9wcml2YWN5fSIsInJlY2VpdmVkX2V2ZW50c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlY2VpdmVkX2V2ZW50cyIsInR5cGUiOiJVc2VyIiwic2l0ZV9hZG1pbiI6ZmFsc2V9LCJub2RlX2lkIjoiUkVfa3dET0tfdHhITTRKeVhuQSIsInRhZ19uYW1lIjoidjAuMS4wLWFscGhhLjkiLCJ0YXJnZXRfY29tbWl0aXNoIjoibWFpbiIsIm5hbWUiOiJ2MC4xLjAtYWxwaGEuOSIsImRyYWZ0IjpmYWxzZSwicHJlcmVsZWFzZSI6dHJ1ZSwiY3JlYXRlZF9hdCI6IjIwMjQtMDctMDVUMTg6MTY6NDFaIiwicHVibGlzaGVkX2F0IjoiMjAyNC0wNy0wNVQxODoxNzoyMloiLCJhc3NldHMiOltdLCJ0YXJiYWxsX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS90YXJiYWxsL3YwLjEuMC1hbHBoYS45IiwiemlwYmFsbF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvemlwYmFsbC92MC4xLjAtYWxwaGEuOSIsImJvZHkiOiIjIyBXaGF0J3MgQ2hhbmdlZFxyXG4qIEFjY291bnQ6OkVudHJ5IERlbGVnYXRlZCBUeXBlIChuYW1lc3BhY2UgdXBkYXRlcyBwYXJ0IDcpIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzkyM1xyXG4qIEZpeCBkZWNpbWFsIGRpc3BsYXkgZm9yIGV1cm8gY3VycmVuY3kgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvOTM3XHJcbiogRW5hYmxlIHVwZGF0aW5nIEFjY291bnQ6OkVudHJ5I2Ftb3VudCBieSBAdG9ueXZpbmNlIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvOTQyXHJcbiogRml4IGJ1ZyB3aGVyZSB0cmFuc2FjdGlvbnMgd2VyZSBkdXBsaWNhdGVkIGluIGltcG9ydCBjb25maXJtIGJ5IEBsamh1cnN0IGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvOTQxXHJcbiogRW5xdWUgYWNjb3VudCBzeW5jIGpvYiBhZnRlciBjcmVhdGluZyB0cmFuc2ZlciBieSBAdG9ueXZpbmNlIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvOTQ2XHJcbiogRW5hYmxlIHN5bmNpbmcgYWxsIGFjY291bnRzIGluIG9uZSBjbGljayBieSBAdG9ueXZpbmNlIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvOTQ4XHJcbiogVXBkYXRlIGRvY2tlciBjb21wb3NlIGV4YW1wbGUgd2l0aCBmaXhlZCBzdG9yYWdlIHZvbHVtZSBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC85NTBcclxuXHJcbiMjIE5ldyBDb250cmlidXRvcnNcclxuKiBAbGpodXJzdCBtYWRlIHRoZWlyIGZpcnN0IGNvbnRyaWJ1dGlvbiBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzk0MVxyXG5cclxuKipGdWxsIENoYW5nZWxvZyoqOiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9jb21wYXJlL3YwLjEuMC1hbHBoYS44Li4udjAuMS4wLWFscGhhLjkiLCJyZWFjdGlvbnMiOnsidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE2NDE5ODg0OC9yZWFjdGlvbnMiLCJ0b3RhbF9jb3VudCI6OSwiKzEiOjksIi0xIjowLCJsYXVnaCI6MCwiaG9vcmF5IjowLCJjb25mdXNlZCI6MCwiaGVhcnQiOjAsInJvY2tldCI6MCwiZXllcyI6MH0sIm1lbnRpb25zX2NvdW50IjozfSx7InVybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNjMwNjU4NDQiLCJhc3NldHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE2MzA2NTg0NC9hc3NldHMiLCJ1cGxvYWRfdXJsIjoiaHR0cHM6Ly91cGxvYWRzLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNjMwNjU4NDQvYXNzZXRzez9uYW1lLGxhYmVsfSIsImh0bWxfdXJsIjoiaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvdGFnL3YwLjEuMC1hbHBoYS44IiwiaWQiOjE2MzA2NTg0NCwiYXV0aG9yIjp7ImxvZ2luIjoiemFjaGdvbGwiLCJpZCI6MTY2NzYxNTcsIm5vZGVfaWQiOiJNRFE2VlhObGNqRTJOamMyTVRVMyIsImF2YXRhcl91cmwiOiJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvMTY2NzYxNTc/dj00IiwiZ3JhdmF0YXJfaWQiOiIiLCJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsIiwiaHRtbF91cmwiOiJodHRwczovL2dpdGh1Yi5jb20vemFjaGdvbGwiLCJmb2xsb3dlcnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dlcnMiLCJmb2xsb3dpbmdfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dpbmd7L290aGVyX3VzZXJ9IiwiZ2lzdHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9naXN0c3svZ2lzdF9pZH0iLCJzdGFycmVkX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvc3RhcnJlZHsvb3duZXJ9ey9yZXBvfSIsInN1YnNjcmlwdGlvbnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9zdWJzY3JpcHRpb25zIiwib3JnYW5pemF0aW9uc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL29yZ3MiLCJyZXBvc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlcG9zIiwiZXZlbnRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZXZlbnRzey9wcml2YWN5fSIsInJlY2VpdmVkX2V2ZW50c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlY2VpdmVkX2V2ZW50cyIsInR5cGUiOiJVc2VyIiwic2l0ZV9hZG1pbiI6ZmFsc2V9LCJub2RlX2lkIjoiUkVfa3dET0tfdHhITTRKdUNfMCIsInRhZ19uYW1lIjoidjAuMS4wLWFscGhhLjgiLCJ0YXJnZXRfY29tbWl0aXNoIjoibWFpbiIsIm5hbWUiOiJ2MC4xLjAtYWxwaGEuOCIsImRyYWZ0IjpmYWxzZSwicHJlcmVsZWFzZSI6dHJ1ZSwiY3JlYXRlZF9hdCI6IjIwMjQtMDYtMjhUMjE6MjI6MjZaIiwicHVibGlzaGVkX2F0IjoiMjAyNC0wNi0yOFQyMToyMzozM1oiLCJhc3NldHMiOltdLCJ0YXJiYWxsX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS90YXJiYWxsL3YwLjEuMC1hbHBoYS44IiwiemlwYmFsbF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvemlwYmFsbC92MC4xLjAtYWxwaGEuOCIsImJvZHkiOiIjIyBXaGF0J3MgQ2hhbmdlZFxyXG4qIFVuaWZ5IHByaW1hcnkgYnV0dG9uIHN0eWxlcyBhbmQgY2hhbmdlIGN1cnNvciBvbiBhY2NvdW50IGdyb3VwIGJ5IEBqYWt1YmtvdHRuYXVlciBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzkwNVxyXG4qIEZpeCBpc3N1ZSAjODYxOiBDb3JyZWN0IGhlYWRlciBzZWxlY3Rpb24gbG9naWMgaW4gZ2V0X3NlbGVjdGVkX2hlYWRlcl9mb3JfZmllbGQgbWV0aG9kIGJ5IEBpZ29yY2FydmFsaGggaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC85MThcclxuKiBGaXggIzkxMCBieSBAdG9ueXZpbmNlIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvOTE3XHJcbiogQWNjb3VudCBuYW1lc3BhY2UgdXBkYXRlczogcGFydCA2ICh0cmFuc2FjdGlvbnMpIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzkwNFxyXG4qIGltcHJvdmVtZW50LyM4OTAvY2xlYW5fdXBfdG9hc3Rfbm90aWZpY2F0aW9uX3N0eWxlc19hbmRfYWxsb3dfdXNlcl90b19jbG9zZV9vbi1kZW1hbmQgYnkgQGV2YW5nZWxvcy1jb20gaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC85MTlcclxuKiBGaXggIzkyMSBieSBAdG9ueXZpbmNlIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvOTIyXHJcblxyXG4jIyBOZXcgQ29udHJpYnV0b3JzXHJcbiogQGlnb3JjYXJ2YWxoaCBtYWRlIHRoZWlyIGZpcnN0IGNvbnRyaWJ1dGlvbiBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzkxOFxyXG4qIEBldmFuZ2Vsb3MtY29tIG1hZGUgdGhlaXIgZmlyc3QgY29udHJpYnV0aW9uIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvOTE5XHJcblxyXG4qKkZ1bGwgQ2hhbmdlbG9nKio6IGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL2NvbXBhcmUvdjAuMS4wLWFscGhhLjcuLi52MC4xLjAtYWxwaGEuOCIsInJlYWN0aW9ucyI6eyJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTYzMDY1ODQ0L3JlYWN0aW9ucyIsInRvdGFsX2NvdW50IjoxMSwiKzEiOjgsIi0xIjowLCJsYXVnaCI6MCwiaG9vcmF5IjowLCJjb25mdXNlZCI6MCwiaGVhcnQiOjEsInJvY2tldCI6MiwiZXllcyI6MH0sIm1lbnRpb25zX2NvdW50Ijo1fSx7InVybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNjE3NDIxMzAiLCJhc3NldHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE2MTc0MjEzMC9hc3NldHMiLCJ1cGxvYWRfdXJsIjoiaHR0cHM6Ly91cGxvYWRzLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNjE3NDIxMzAvYXNzZXRzez9uYW1lLGxhYmVsfSIsImh0bWxfdXJsIjoiaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvdGFnL3YwLjEuMC1hbHBoYS43IiwiaWQiOjE2MTc0MjEzMCwiYXV0aG9yIjp7ImxvZ2luIjoiemFjaGdvbGwiLCJpZCI6MTY2NzYxNTcsIm5vZGVfaWQiOiJNRFE2VlhObGNqRTJOamMyTVRVMyIsImF2YXRhcl91cmwiOiJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvMTY2NzYxNTc/dj00IiwiZ3JhdmF0YXJfaWQiOiIiLCJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsIiwiaHRtbF91cmwiOiJodHRwczovL2dpdGh1Yi5jb20vemFjaGdvbGwiLCJmb2xsb3dlcnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dlcnMiLCJmb2xsb3dpbmdfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dpbmd7L290aGVyX3VzZXJ9IiwiZ2lzdHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9naXN0c3svZ2lzdF9pZH0iLCJzdGFycmVkX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvc3RhcnJlZHsvb3duZXJ9ey9yZXBvfSIsInN1YnNjcmlwdGlvbnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9zdWJzY3JpcHRpb25zIiwib3JnYW5pemF0aW9uc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL29yZ3MiLCJyZXBvc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlcG9zIiwiZXZlbnRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZXZlbnRzey9wcml2YWN5fSIsInJlY2VpdmVkX2V2ZW50c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlY2VpdmVkX2V2ZW50cyIsInR5cGUiOiJVc2VyIiwic2l0ZV9hZG1pbiI6ZmFsc2V9LCJub2RlX2lkIjoiUkVfa3dET0tfdHhITTRKb18weSIsInRhZ19uYW1lIjoidjAuMS4wLWFscGhhLjciLCJ0YXJnZXRfY29tbWl0aXNoIjoibWFpbiIsIm5hbWUiOiJ2MC4xLjAtYWxwaGEuNyIsImRyYWZ0IjpmYWxzZSwicHJlcmVsZWFzZSI6dHJ1ZSwiY3JlYXRlZF9hdCI6IjIwMjQtMDYtMjFUMjE6MDQ6NTlaIiwicHVibGlzaGVkX2F0IjoiMjAyNC0wNi0yMVQyMTowODowNFoiLCJhc3NldHMiOltdLCJ0YXJiYWxsX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS90YXJiYWxsL3YwLjEuMC1hbHBoYS43IiwiemlwYmFsbF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvemlwYmFsbC92MC4xLjAtYWxwaGEuNyIsImJvZHkiOiIjIyBXaGF0J3MgQ2hhbmdlZFxyXG5cclxuTWF5YmUgbm93IHN1cHBvcnRzIHRyYW5zZmVyIG1hdGNoaW5nIPCfpbMg8J+MriBcclxuXHJcblRoaXMgc2lnbmlmaWNhbnRseSBpbXByb3ZlcyBpbmNvbWUgYW5kIGV4cGVuc2UgY2FsY3VsYXRpb25zIGJ5IGV4Y2x1ZGluZyB0cmFuc2ZlcnMgZnJvbSB0aGUgY2FsY3VsYXRpb246XHJcblxyXG5odHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9hc3NldHMvMTY2NzYxNTcvYTFjZGU0OTQtYjg5Zi00NjZhLThjNzQtZGExOTQ5MzQyODhhXHJcblxyXG4qIEFkZCBtZXJjaGFudCBzZWxlY3Qgd2hlbiBlZGl0aW5nIHRyYW5zYWN0aW9uIGJ5IEBqYWt1YmtvdHRuYXVlciBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzg4NVxyXG4qIFRyYW5zYWN0aW9uIHRyYW5zZmVycywgcGF5bWVudHMsIGFuZCBtYXRjaGluZyBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC84ODNcclxuKiBFbnN1cmUgY29ycmVjdCBmb3JtJ3MgaGlkZGVuIGlucHV0IGZvciBzZWxlY3RlZElkcyBieSBAemlyYXF5b3VuZyBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzg5MVxyXG4qIEFjY291bnQgbmFtZXNwYWNlIHVwZGF0ZXM6IHBhcnQgMSAoYWNjb3VudGFibGUgdHlwZXMpIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzg5M1xyXG4qIEFjY291bnQgbmFtZXNwYWNlIHVwZGF0ZXM6IHBhcnQgMiAoY2F0ZWdvcmllcykgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvODk0XHJcbiogQWNjb3VudCBuYW1lc3BhY2UgdXBkYXRlczogcGFydCAzIChtZXJjaGFudHMpIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzg5NVxyXG4qIEFjY291bnQgbmFtZXNwYWNlIHVwZGF0ZXM6IHBhcnQgNCAodHJhbnNmZXJzLCBzaW5ndWxhciBuYW1lc3BhY2luZykgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvODk2XHJcbiogRml4IEJ1ZzogYWZ0ZXIgZWRpdGluZyBhbiBhY2NvdW50IGhpc3RvcnkgdmFsdWUsIGl0IHJlcXVpcmVzIDIgY2xpY2tzIHRvIGNsb3NlIHRoZSBtZW51IGJ5IEB0b255dmluY2UgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC85MDBcclxuKiBmZWF0OiBUcmFuc2FjdGlvbiBwYWdpbmF0aW9uIEltcHJvdmVtZW50cyBieSBAa2FyYW5raXJpIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvODczXHJcbiogRml4IHRyYW5zZmVyIG5vdGUgb3ZlcmZsb3cgc3R5bGUgYnkgQGpha3Via290dG5hdWVyIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvOTAyXHJcbiogQWNjb3VudCBuYW1lc3BhY2UgdXBkYXRlczogcGFydCA1ICh2YWx1YXRpb25zKSBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC85MDFcclxuKiBBbGxvdyB0cmFuc2ZlcnMgYmFzZWQgb24gdHJhbnNhY3Rpb25zIGluIGRpZmZlcmVudCBjdXJyZW5jaWVzIGJ5IEBqYWt1YmtvdHRuYXVlciBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzkwM1xyXG5cclxuIyMgTmV3IENvbnRyaWJ1dG9yc1xyXG4qIEB6aXJhcXlvdW5nIG1hZGUgdGhlaXIgZmlyc3QgY29udHJpYnV0aW9uIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvODkxXHJcbiogQHRvbnl2aW5jZSBtYWRlIHRoZWlyIGZpcnN0IGNvbnRyaWJ1dGlvbiBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzkwMFxyXG4qIEBrYXJhbmtpcmkgbWFkZSB0aGVpciBmaXJzdCBjb250cmlidXRpb24gaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC84NzNcclxuXHJcbioqRnVsbCBDaGFuZ2Vsb2cqKjogaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvY29tcGFyZS92MC4xLjAtYWxwaGEuNi4uLnYwLjEuMC1hbHBoYS43IiwicmVhY3Rpb25zIjp7InVybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNjE3NDIxMzAvcmVhY3Rpb25zIiwidG90YWxfY291bnQiOjI2LCIrMSI6MCwiLTEiOjAsImxhdWdoIjowLCJob29yYXkiOjksImNvbmZ1c2VkIjowLCJoZWFydCI6Nywicm9ja2V0Ijo5LCJleWVzIjoxfSwibWVudGlvbnNfY291bnQiOjV9LHsidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE2MDU2NjQ3MSIsImFzc2V0c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTYwNTY2NDcxL2Fzc2V0cyIsInVwbG9hZF91cmwiOiJodHRwczovL3VwbG9hZHMuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE2MDU2NjQ3MS9hc3NldHN7P25hbWUsbGFiZWx9IiwiaHRtbF91cmwiOiJodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy90YWcvdjAuMS4wLWFscGhhLjYiLCJpZCI6MTYwNTY2NDcxLCJhdXRob3IiOnsibG9naW4iOiJ6YWNoZ29sbCIsImlkIjoxNjY3NjE1Nywibm9kZV9pZCI6Ik1EUTZWWE5sY2pFMk5qYzJNVFUzIiwiYXZhdGFyX3VybCI6Imh0dHBzOi8vYXZhdGFycy5naXRodWJ1c2VyY29udGVudC5jb20vdS8xNjY3NjE1Nz92PTQiLCJncmF2YXRhcl9pZCI6IiIsInVybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwiLCJodG1sX3VybCI6Imh0dHBzOi8vZ2l0aHViLmNvbS96YWNoZ29sbCIsImZvbGxvd2Vyc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2ZvbGxvd2VycyIsImZvbGxvd2luZ191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2ZvbGxvd2luZ3svb3RoZXJfdXNlcn0iLCJnaXN0c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2dpc3Rzey9naXN0X2lkfSIsInN0YXJyZWRfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9zdGFycmVkey9vd25lcn17L3JlcG99Iiwic3Vic2NyaXB0aW9uc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3N1YnNjcmlwdGlvbnMiLCJvcmdhbml6YXRpb25zX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvb3JncyIsInJlcG9zX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvcmVwb3MiLCJldmVudHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9ldmVudHN7L3ByaXZhY3l9IiwicmVjZWl2ZWRfZXZlbnRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvcmVjZWl2ZWRfZXZlbnRzIiwidHlwZSI6IlVzZXIiLCJzaXRlX2FkbWluIjpmYWxzZX0sIm5vZGVfaWQiOiJSRV9rd0RPS190eEhNNEprZ3pIIiwidGFnX25hbWUiOiJ2MC4xLjAtYWxwaGEuNiIsInRhcmdldF9jb21taXRpc2giOiJtYWluIiwibmFtZSI6InYwLjEuMC1hbHBoYS42IiwiZHJhZnQiOmZhbHNlLCJwcmVyZWxlYXNlIjp0cnVlLCJjcmVhdGVkX2F0IjoiMjAyNC0wNi0xNFQyMDo1MDowOFoiLCJwdWJsaXNoZWRfYXQiOiIyMDI0LTA2LTE0VDIxOjAxOjA0WiIsImFzc2V0cyI6W10sInRhcmJhbGxfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3RhcmJhbGwvdjAuMS4wLWFscGhhLjYiLCJ6aXBiYWxsX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS96aXBiYWxsL3YwLjEuMC1hbHBoYS42IiwiYm9keSI6IiMjIFdoYXQncyBDaGFuZ2VkXHJcblxyXG5UaGlzIHJlbGVhc2UgY29tZXMgd2l0aCBhIGNvbXBsZXRlIG92ZXJoYXVsIG9mIG91ciBEb2NrZXIgc2V0dXAgZ3VpZGUsIGJldHRlciBpbnRlcm5hbCBuYXZpZ2F0aW9uLCBhbmQgdGhlIGFiaWxpdHkgdG8gZ3JvdXAgeW91ciBhY2NvdW50cyBieSBmaW5hbmNpYWwgaW5zdGl0dXRpb24hXHJcblxyXG5odHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9hc3NldHMvMTY2NzYxNTcvYmU0ZDFjZTgtNzA1NS00ZjcxLTljM2YtYzliYmM5Y2I0NTFjXHJcblxyXG4qIEltcHJvdmUgYWNjb3VudCBpbnRlcm5hbCBsaW5raW5nIGFuZCByZWRpcmVjdCBiZWhhdmlvciBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC84NjRcclxuKiBBbGxvdyBmb3Igb3B0aW9uYWwgc3RhcnQgZGF0ZSBvbiBhY2NvdW50IGNyZWF0aW9uIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzg2NlxyXG4qIEFkZCBpbnN0aXR1dGlvbiBtYW5hZ2VtZW50IGFuZCBhY2NvdW50IGVkaXRpbmcgY29udHJvbHMgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvODY4XHJcbiogTmV3IERvY2tlciBDb21wb3NlIFNlbGYgSG9zdGluZyBHdWlkZSArIFVJIEZpeGVzIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzg3MFxyXG4qIFNpbXBsaWZ5IGFjY291bnQgc3luYyBsb2dpYyBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC84NzFcclxuKiBDaGFuZ2Vsb2cgcGFnZSB0aGF0IHB1bGxzIGZyb20gR2l0aHViIFJlbGVhc2Ugbm90ZXMgYnkgQG1hdHRpYS1tYWxuaXMgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC84NjdcclxuXHJcblxyXG4qKkZ1bGwgQ2hhbmdlbG9nKio6IGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL2NvbXBhcmUvdjAuMS4wLWFscGhhLjUuLi52MC4xLjAtYWxwaGEuNiIsInJlYWN0aW9ucyI6eyJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTYwNTY2NDcxL3JlYWN0aW9ucyIsInRvdGFsX2NvdW50IjozNiwiKzEiOjI3LCItMSI6MCwibGF1Z2giOjAsImhvb3JheSI6MCwiY29uZnVzZWQiOjAsImhlYXJ0Ijo5LCJyb2NrZXQiOjAsImV5ZXMiOjB9LCJtZW50aW9uc19jb3VudCI6Mn0seyJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTU5NDUyMjQ0IiwiYXNzZXRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNTk0NTIyNDQvYXNzZXRzIiwidXBsb2FkX3VybCI6Imh0dHBzOi8vdXBsb2Fkcy5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTU5NDUyMjQ0L2Fzc2V0c3s/bmFtZSxsYWJlbH0iLCJodG1sX3VybCI6Imh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzL3RhZy92MC4xLjAtYWxwaGEuNSIsImlkIjoxNTk0NTIyNDQsImF1dGhvciI6eyJsb2dpbiI6InphY2hnb2xsIiwiaWQiOjE2Njc2MTU3LCJub2RlX2lkIjoiTURRNlZYTmxjakUyTmpjMk1UVTMiLCJhdmF0YXJfdXJsIjoiaHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzE2Njc2MTU3P3Y9NCIsImdyYXZhdGFyX2lkIjoiIiwidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbCIsImh0bWxfdXJsIjoiaHR0cHM6Ly9naXRodWIuY29tL3phY2hnb2xsIiwiZm9sbG93ZXJzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZm9sbG93ZXJzIiwiZm9sbG93aW5nX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZm9sbG93aW5ney9vdGhlcl91c2VyfSIsImdpc3RzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZ2lzdHN7L2dpc3RfaWR9Iiwic3RhcnJlZF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3N0YXJyZWR7L293bmVyfXsvcmVwb30iLCJzdWJzY3JpcHRpb25zX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvc3Vic2NyaXB0aW9ucyIsIm9yZ2FuaXphdGlvbnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9vcmdzIiwicmVwb3NfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9yZXBvcyIsImV2ZW50c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2V2ZW50c3svcHJpdmFjeX0iLCJyZWNlaXZlZF9ldmVudHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9yZWNlaXZlZF9ldmVudHMiLCJ0eXBlIjoiVXNlciIsInNpdGVfYWRtaW4iOmZhbHNlfSwibm9kZV9pZCI6IlJFX2t3RE9LX3R4SE00SmdReFUiLCJ0YWdfbmFtZSI6InYwLjEuMC1hbHBoYS41IiwidGFyZ2V0X2NvbW1pdGlzaCI6Im1haW4iLCJuYW1lIjoidjAuMS4wLWFscGhhLjUiLCJkcmFmdCI6ZmFsc2UsInByZXJlbGVhc2UiOnRydWUsImNyZWF0ZWRfYXQiOiIyMDI0LTA2LTA3VDIzOjI5OjAxWiIsInB1Ymxpc2hlZF9hdCI6IjIwMjQtMDYtMDdUMjM6MzQ6MTFaIiwiYXNzZXRzIjpbXSwidGFyYmFsbF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvdGFyYmFsbC92MC4xLjAtYWxwaGEuNSIsInppcGJhbGxfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3ppcGJhbGwvdjAuMS4wLWFscGhhLjUiLCJib2R5IjoiIyMgV2hhdCdzIENoYW5nZWRcclxuXHJcbkJ1bGsgdHJhbnNhY3Rpb24gZWRpdGluZyBhbmQgZGVsZXRpb24gaXMgaGVyZSEgIENoZWNrIG91dCB0aGUgZGVtbyBiZWxvdzpcclxuXHJcbmh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL2Fzc2V0cy8xNjY3NjE1Ny9kOGEzYWUzOS00OTMxLTQ3NzUtOGM3Ni0yMTFkNmRjOTZiZjZcclxuXHJcbiogQWRkIGJ1bGsgc2VsZWN0aW9uIFVJIGNvbnRyb2xzIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzg0MFxyXG4qIEJ1bGsgdHJhbnNhY3Rpb24gZGVsZXRpb24gYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvODQ1XHJcbiogQnVsayBlZGl0aW5nIG9mIHRyYW5zYWN0aW9ucyBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC84NDZcclxuXHJcbioqRnVsbCBDaGFuZ2Vsb2cqKjogaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvY29tcGFyZS92MC4xLjAtYWxwaGEuNC4uLnYwLjEuMC1hbHBoYS41IiwicmVhY3Rpb25zIjp7InVybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNTk0NTIyNDQvcmVhY3Rpb25zIiwidG90YWxfY291bnQiOjQ4LCIrMSI6MzUsIi0xIjowLCJsYXVnaCI6MCwiaG9vcmF5Ijo2LCJjb25mdXNlZCI6MCwiaGVhcnQiOjcsInJvY2tldCI6MCwiZXllcyI6MH0sIm1lbnRpb25zX2NvdW50IjoxfSx7InVybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNTgzOTA2NzEiLCJhc3NldHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE1ODM5MDY3MS9hc3NldHMiLCJ1cGxvYWRfdXJsIjoiaHR0cHM6Ly91cGxvYWRzLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNTgzOTA2NzEvYXNzZXRzez9uYW1lLGxhYmVsfSIsImh0bWxfdXJsIjoiaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvdGFnL3YwLjEuMC1hbHBoYS40IiwiaWQiOjE1ODM5MDY3MSwiYXV0aG9yIjp7ImxvZ2luIjoiemFjaGdvbGwiLCJpZCI6MTY2NzYxNTcsIm5vZGVfaWQiOiJNRFE2VlhObGNqRTJOamMyTVRVMyIsImF2YXRhcl91cmwiOiJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvMTY2NzYxNTc/dj00IiwiZ3JhdmF0YXJfaWQiOiIiLCJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsIiwiaHRtbF91cmwiOiJodHRwczovL2dpdGh1Yi5jb20vemFjaGdvbGwiLCJmb2xsb3dlcnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dlcnMiLCJmb2xsb3dpbmdfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dpbmd7L290aGVyX3VzZXJ9IiwiZ2lzdHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9naXN0c3svZ2lzdF9pZH0iLCJzdGFycmVkX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvc3RhcnJlZHsvb3duZXJ9ey9yZXBvfSIsInN1YnNjcmlwdGlvbnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9zdWJzY3JpcHRpb25zIiwib3JnYW5pemF0aW9uc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL29yZ3MiLCJyZXBvc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlcG9zIiwiZXZlbnRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZXZlbnRzey9wcml2YWN5fSIsInJlY2VpdmVkX2V2ZW50c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlY2VpdmVkX2V2ZW50cyIsInR5cGUiOiJVc2VyIiwic2l0ZV9hZG1pbiI6ZmFsc2V9LCJub2RlX2lkIjoiUkVfa3dET0tfdHhITTRKY05tUCIsInRhZ19uYW1lIjoidjAuMS4wLWFscGhhLjQiLCJ0YXJnZXRfY29tbWl0aXNoIjoibWFpbiIsIm5hbWUiOiJ2MC4xLjAtYWxwaGEuNCIsImRyYWZ0IjpmYWxzZSwicHJlcmVsZWFzZSI6dHJ1ZSwiY3JlYXRlZF9hdCI6IjIwMjQtMDUtMzFUMTg6MDk6MTJaIiwicHVibGlzaGVkX2F0IjoiMjAyNC0wNS0zMVQxODoxMDozMVoiLCJhc3NldHMiOltdLCJ0YXJiYWxsX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS90YXJiYWxsL3YwLjEuMC1hbHBoYS40IiwiemlwYmFsbF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvemlwYmFsbC92MC4xLjAtYWxwaGEuNCIsImJvZHkiOiIjIyBXaGF0J3MgQ2hhbmdlZFxyXG4qIGZpeDogcG5nIGZpbGUgY2FuIGJlIHNlbGVjdGVkIGFzIHByb2ZpbGUgaW1hZ2VzIGJ5IEBwZWEtc3lzIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvODA5XHJcbiogVmFsaWRhdGUgdHJhbnNhY3Rpb24gZmlsdGVyaW5nIHBhcmFtcyBieSBAamFrdWJrb3R0bmF1ZXIgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC84MTBcclxuKiBGaXggZm9yZWlnbiBhY2NvdW50IHN5bmMgY3Jhc2ggYnkgQGpha3Via290dG5hdWVyIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvNzk0XHJcbiogU29ydCBhY2NvdW50cyBpbiB0aGUgc2lkZWJhciBieSBAamFrdWJrb3R0bmF1ZXIgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC84MTVcclxuKiBSZXVzYWJsZSBDSSB3b3JrZmxvdyBmb3IgR0ggYWN0aW9ucyBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC84MTlcclxuKiBUcmFuc2FjdGlvbnMgY2xlYW51cCBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC84MTdcclxuKiBTeW5jIGFjY291bnQgYWZ0ZXIgdHJhbnNhY3Rpb24gaW1wb3J0IGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzgyMFxyXG4qIEZpeCBvdmVyZmxvdyBlcnJvciBvbiBhY2NvdW50IHZhbHVlIGlucHV0cyBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC84MjFcclxuXHJcbiMjIE5ldyBDb250cmlidXRvcnNcclxuKiBAcGVhLXN5cyBtYWRlIHRoZWlyIGZpcnN0IGNvbnRyaWJ1dGlvbiBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzgwOVxyXG5cclxuKipGdWxsIENoYW5nZWxvZyoqOiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9jb21wYXJlL3YwLjEuMC1hbHBoYS4zLi4udjAuMS4wLWFscGhhLjQiLCJyZWFjdGlvbnMiOnsidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE1ODM5MDY3MS9yZWFjdGlvbnMiLCJ0b3RhbF9jb3VudCI6MjEsIisxIjowLCItMSI6MCwibGF1Z2giOjAsImhvb3JheSI6MjEsImNvbmZ1c2VkIjowLCJoZWFydCI6MCwicm9ja2V0IjowLCJleWVzIjowfSwibWVudGlvbnNfY291bnQiOjN9LHsidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE1NzM5NzA5MSIsImFzc2V0c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTU3Mzk3MDkxL2Fzc2V0cyIsInVwbG9hZF91cmwiOiJodHRwczovL3VwbG9hZHMuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE1NzM5NzA5MS9hc3NldHN7P25hbWUsbGFiZWx9IiwiaHRtbF91cmwiOiJodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy90YWcvdjAuMS4wLWFscGhhLjMiLCJpZCI6MTU3Mzk3MDkxLCJhdXRob3IiOnsibG9naW4iOiJ6YWNoZ29sbCIsImlkIjoxNjY3NjE1Nywibm9kZV9pZCI6Ik1EUTZWWE5sY2pFMk5qYzJNVFUzIiwiYXZhdGFyX3VybCI6Imh0dHBzOi8vYXZhdGFycy5naXRodWJ1c2VyY29udGVudC5jb20vdS8xNjY3NjE1Nz92PTQiLCJncmF2YXRhcl9pZCI6IiIsInVybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwiLCJodG1sX3VybCI6Imh0dHBzOi8vZ2l0aHViLmNvbS96YWNoZ29sbCIsImZvbGxvd2Vyc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2ZvbGxvd2VycyIsImZvbGxvd2luZ191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2ZvbGxvd2luZ3svb3RoZXJfdXNlcn0iLCJnaXN0c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2dpc3Rzey9naXN0X2lkfSIsInN0YXJyZWRfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9zdGFycmVkey9vd25lcn17L3JlcG99Iiwic3Vic2NyaXB0aW9uc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3N1YnNjcmlwdGlvbnMiLCJvcmdhbml6YXRpb25zX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvb3JncyIsInJlcG9zX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvcmVwb3MiLCJldmVudHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9ldmVudHN7L3ByaXZhY3l9IiwicmVjZWl2ZWRfZXZlbnRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvcmVjZWl2ZWRfZXZlbnRzIiwidHlwZSI6IlVzZXIiLCJzaXRlX2FkbWluIjpmYWxzZX0sIm5vZGVfaWQiOiJSRV9rd0RPS190eEhNNEpZYkJqIiwidGFnX25hbWUiOiJ2MC4xLjAtYWxwaGEuMyIsInRhcmdldF9jb21taXRpc2giOiJtYWluIiwibmFtZSI6InYwLjEuMC1hbHBoYS4zIiwiZHJhZnQiOmZhbHNlLCJwcmVyZWxlYXNlIjp0cnVlLCJjcmVhdGVkX2F0IjoiMjAyNC0wNS0yNFQxODoyNDowM1oiLCJwdWJsaXNoZWRfYXQiOiIyMDI0LTA1LTI0VDE4OjI1OjM3WiIsImFzc2V0cyI6W10sInRhcmJhbGxfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3RhcmJhbGwvdjAuMS4wLWFscGhhLjMiLCJ6aXBiYWxsX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS96aXBiYWxsL3YwLjEuMC1hbHBoYS4zIiwiYm9keSI6IiMjIFdoYXQncyBDaGFuZ2VkXHJcbiogRml4IGN1cnJlbmN5IHdoZW4gaW1wb3J0aW5nIHRvIGZvcmVpZ24gYWNjb3VudHMgYnkgQGpha3Via290dG5hdWVyIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvNzYyXHJcbiogU2hvdyBhbiBlcnJvciBub3RpZmljYXRpb24gaWYgYWNjb3VudCBjYW5ub3QgYmUgbWFudWFsbHkgc3luY2VkIGJ5IEBqYWt1YmtvdHRuYXVlciBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzc2MVxyXG4qIEFkZCBtaWdyYXRpb24gdG8gbWFrZSBhbGwgZXhpc3RpbmcgdXNlcnMgYWRtaW5zIGJ5IEBqYWt1YmtvdHRuYXVlciBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzc3MFxyXG4qIEZpeCBpc3N1ZSB3aXRoIHN0YXJ0X2RhdGUgbm90IGJlaW5nIHNldCBpbiBhY2NvdW50IGNyZWF0aW9uIGJ5IEBzY3ViYW1hZ2dvIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvNzgxXHJcbiogRml4IGltcG9ydCBjcmFzaCB3aXRoIGVtcHR5IHRyYW5zYWN0aW9uIG5hbWUgYnkgQGpha3Via290dG5hdWVyIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvNzgzXHJcbiogTW92ZSBjYXRlZ29yeSBkcm9wZG93biBtZW51IGNvbnRlbnQgaW50byBhIHR1cmJvIGZyYW1lIGJ5IEBqYWt1YmtvdHRuYXVlciBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzc4MlxyXG4qIElnbm9yZSBlbXB0eSBjYXRlZ29yaWVzIHdoaWxlIGltcG9ydGluZyBieSBAamFrdWJrb3R0bmF1ZXIgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC83ODlcclxuKiBGaXggZHVwbGljYXRlIGNhdGVnb3J5IGNyZWF0aW9uIG9uIGltcG9ydCBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC83OTFcclxuKiBDcmVhdGUgdGFnZ2luZyBzeXN0ZW0gYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvNzkyXHJcbiogQWRkIHRhZyBwcmV2aWV3IHdoZW4gaW1wb3J0aW5nIGFuZCBmaXggZW1wdHkgY2F0ZWdvcnkgYnVnIGJ5IEBqYWt1YmtvdHRuYXVlciBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzgwMFxyXG5cclxuIyMgTmV3IENvbnRyaWJ1dG9yc1xyXG4qIEBzY3ViYW1hZ2dvIG1hZGUgdGhlaXIgZmlyc3QgY29udHJpYnV0aW9uIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvNzgxXHJcblxyXG4qKkZ1bGwgQ2hhbmdlbG9nKio6IGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL2NvbXBhcmUvdjAuMS4wLWFscGhhLjIuLi52MC4xLjAtYWxwaGEuMyIsInJlYWN0aW9ucyI6eyJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTU3Mzk3MDkxL3JlYWN0aW9ucyIsInRvdGFsX2NvdW50IjozMSwiKzEiOjAsIi0xIjowLCJsYXVnaCI6MCwiaG9vcmF5IjowLCJjb25mdXNlZCI6MCwiaGVhcnQiOjUsInJvY2tldCI6MjYsImV5ZXMiOjB9LCJtZW50aW9uc19jb3VudCI6M30seyJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTU2MzQyMTUyIiwiYXNzZXRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNTYzNDIxNTIvYXNzZXRzIiwidXBsb2FkX3VybCI6Imh0dHBzOi8vdXBsb2Fkcy5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTU2MzQyMTUyL2Fzc2V0c3s/bmFtZSxsYWJlbH0iLCJodG1sX3VybCI6Imh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzL3RhZy92MC4xLjAtYWxwaGEuMiIsImlkIjoxNTYzNDIxNTIsImF1dGhvciI6eyJsb2dpbiI6InphY2hnb2xsIiwiaWQiOjE2Njc2MTU3LCJub2RlX2lkIjoiTURRNlZYTmxjakUyTmpjMk1UVTMiLCJhdmF0YXJfdXJsIjoiaHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzE2Njc2MTU3P3Y9NCIsImdyYXZhdGFyX2lkIjoiIiwidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbCIsImh0bWxfdXJsIjoiaHR0cHM6Ly9naXRodWIuY29tL3phY2hnb2xsIiwiZm9sbG93ZXJzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZm9sbG93ZXJzIiwiZm9sbG93aW5nX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZm9sbG93aW5ney9vdGhlcl91c2VyfSIsImdpc3RzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZ2lzdHN7L2dpc3RfaWR9Iiwic3RhcnJlZF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3N0YXJyZWR7L293bmVyfXsvcmVwb30iLCJzdWJzY3JpcHRpb25zX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvc3Vic2NyaXB0aW9ucyIsIm9yZ2FuaXphdGlvbnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9vcmdzIiwicmVwb3NfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9yZXBvcyIsImV2ZW50c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2V2ZW50c3svcHJpdmFjeX0iLCJyZWNlaXZlZF9ldmVudHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9yZWNlaXZlZF9ldmVudHMiLCJ0eXBlIjoiVXNlciIsInNpdGVfYWRtaW4iOmZhbHNlfSwibm9kZV9pZCI6IlJFX2t3RE9LX3R4SE00SlVaZUkiLCJ0YWdfbmFtZSI6InYwLjEuMC1hbHBoYS4yIiwidGFyZ2V0X2NvbW1pdGlzaCI6Im1haW4iLCJuYW1lIjoidjAuMS4wLWFscGhhLjIiLCJkcmFmdCI6ZmFsc2UsInByZXJlbGVhc2UiOnRydWUsImNyZWF0ZWRfYXQiOiIyMDI0LTA1LTE3VDIyOjIwOjE5WiIsInB1Ymxpc2hlZF9hdCI6IjIwMjQtMDUtMTdUMjI6MzM6MjRaIiwiYXNzZXRzIjpbXSwidGFyYmFsbF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvdGFyYmFsbC92MC4xLjAtYWxwaGEuMiIsInppcGJhbGxfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3ppcGJhbGwvdjAuMS4wLWFscGhhLjIiLCJib2R5IjoiU2Vjb25kIGFscGhhIHJlbGVhc2Ugb2YgTWF5YmUg8J+OiSDwn4yuXHJcblxyXG4jIyBOZXcgZmVhdHVyZXNcclxuXHJcbi0gU2VsZiBob3N0aW5nIHdpdGggRG9ja2VyIPCfkLMgKFtzZXR1cCBndWlkZV0oaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvYmxvYi9tYWluL2RvY3MvaG9zdGluZy9kb2NrZXIubWQpKVxyXG4tIENTViB0cmFuc2FjdGlvbiBpbXBvcnRzXHJcbi0gVHJhbnNhY3Rpb24gbWFuYWdlbWVudCB3aXRoIG1lcmNoYW50cyBhbmQgY2F0ZWdvcmllc1xyXG4tIEZyZXNoIGRlc2lnbiBvZiB1c2VyIHNldHRpbmdzXHJcbi0gUmUtZGVzaWduZWQgZGFzaGJvYXJkICsgYWNjb3VudHMgc3VtbWFyeVxyXG4tIEFkbWluIGFjY291bnRzLCBhYmlsaXR5IHRvIGRlbGV0ZSBhbmQgcHVyZ2UgZGF0YVxyXG5cclxuKipGdWxsIENoYW5nZWxvZyoqOiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9jb21wYXJlL3YwLjEuMC1hbHBoYS4xLi4udjAuMS4wLWFscGhhLjIiLCJyZWFjdGlvbnMiOnsidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE1NjM0MjE1Mi9yZWFjdGlvbnMiLCJ0b3RhbF9jb3VudCI6NTEsIisxIjowLCItMSI6MCwibGF1Z2giOjAsImhvb3JheSI6NDksImNvbmZ1c2VkIjowLCJoZWFydCI6Miwicm9ja2V0IjowLCJleWVzIjowfX0seyJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTUxMDI4MTIzIiwiYXNzZXRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNTEwMjgxMjMvYXNzZXRzIiwidXBsb2FkX3VybCI6Imh0dHBzOi8vdXBsb2Fkcy5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTUxMDI4MTIzL2Fzc2V0c3s/bmFtZSxsYWJlbH0iLCJodG1sX3VybCI6Imh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzL3RhZy92MC4xLjAtYWxwaGEuMSIsImlkIjoxNTEwMjgxMjMsImF1dGhvciI6eyJsb2dpbiI6InphY2hnb2xsIiwiaWQiOjE2Njc2MTU3LCJub2RlX2lkIjoiTURRNlZYTmxjakUyTmpjMk1UVTMiLCJhdmF0YXJfdXJsIjoiaHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzE2Njc2MTU3P3Y9NCIsImdyYXZhdGFyX2lkIjoiIiwidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbCIsImh0bWxfdXJsIjoiaHR0cHM6Ly9naXRodWIuY29tL3phY2hnb2xsIiwiZm9sbG93ZXJzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZm9sbG93ZXJzIiwiZm9sbG93aW5nX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZm9sbG93aW5ney9vdGhlcl91c2VyfSIsImdpc3RzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZ2lzdHN7L2dpc3RfaWR9Iiwic3RhcnJlZF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3N0YXJyZWR7L293bmVyfXsvcmVwb30iLCJzdWJzY3JpcHRpb25zX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvc3Vic2NyaXB0aW9ucyIsIm9yZ2FuaXphdGlvbnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9vcmdzIiwicmVwb3NfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9yZXBvcyIsImV2ZW50c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2V2ZW50c3svcHJpdmFjeX0iLCJyZWNlaXZlZF9ldmVudHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9yZWNlaXZlZF9ldmVudHMiLCJ0eXBlIjoiVXNlciIsInNpdGVfYWRtaW4iOmZhbHNlfSwibm9kZV9pZCI6IlJFX2t3RE9LX3R4SE00SkFJR2IiLCJ0YWdfbmFtZSI6InYwLjEuMC1hbHBoYS4xIiwidGFyZ2V0X2NvbW1pdGlzaCI6Im1haW4iLCJuYW1lIjoidjAuMS4wLWFscGhhLjEiLCJkcmFmdCI6ZmFsc2UsInByZXJlbGVhc2UiOnRydWUsImNyZWF0ZWRfYXQiOiIyMDI0LTA0LTEzVDEzOjI4OjQ1WiIsInB1Ymxpc2hlZF9hdCI6IjIwMjQtMDQtMTNUMTM6NDk6MDNaIiwiYXNzZXRzIjpbXSwidGFyYmFsbF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvdGFyYmFsbC92MC4xLjAtYWxwaGEuMSIsInppcGJhbGxfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3ppcGJhbGwvdjAuMS4wLWFscGhhLjEiLCJib2R5IjoiSW5pdGlhbCBhbHBoYSB2ZXJzaW9uIG9mIE1heWJlIPCfjokg8J+MriIsInJlYWN0aW9ucyI6eyJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTUxMDI4MTIzL3JlYWN0aW9ucyIsInRvdGFsX2NvdW50IjoxNjUsIisxIjo1MCwiLTEiOjAsImxhdWdoIjowLCJob29yYXkiOjQ2LCJjb25mdXNlZCI6MCwiaGVhcnQiOjM1LCJyb2NrZXQiOjIzLCJleWVzIjoxMX19XQ== - recorded_at: Mon, 09 Sep 2024 20:03:35 GMT + 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= + recorded_at: Wed, 19 Mar 2025 12:40:58 GMT - request: method: post uri: https://api.github.com/markdown body: encoding: UTF-8 - string: '{"mode":"gfm","context":"maybe-finance/maybe","text":"This week''s - release comes with a variety of bug fixes and improvements to the UI.\r\n\r\nAdditionally, - users can now input details for their property and vehicle accounts as shown - in the video below. In the near future, Maybe will support data providers - related to the \"valuation\" of properties and vehicles (i.e. Zillow, KBB). We - will use the information from user accounts to automatically fetch estimated - market values for these assets which will then be added periodically as \"Valuations\" - in the value tab of each account. This will then show up in the history graph - for the account balance.\r\n\r\nhttps://github.com/user-attachments/assets/fd759c82-a25c-4c8d-8f16-f577e0410fb5\r\n\r\n## - What''s Changed\r\n\r\n* Refactor: Allow other import files by @pedrocarmona - in https://github.com/maybe-finance/maybe/pull/1099\r\n* Bump sentry-ruby - from 5.18.2 to 5.19.0 by @dependabot in https://github.com/maybe-finance/maybe/pull/1108\r\n* - Bump stimulus-rails from 1.3.3 to 1.3.4 by @dependabot in https://github.com/maybe-finance/maybe/pull/1106\r\n* - Bump aws-sdk-s3 from 1.157.0 to 1.158.0 by @dependabot in https://github.com/maybe-finance/maybe/pull/1105\r\n* - Bump ruby-lsp-rails from 0.3.12 to 0.3.13 by @dependabot in https://github.com/maybe-finance/maybe/pull/1107\r\n* - Bump propshaft from 0.9.0 to 0.9.1 by @dependabot in https://github.com/maybe-finance/maybe/pull/1104\r\n* - Bump good_job from 4.1.1 to 4.2.0 by @dependabot in https://github.com/maybe-finance/maybe/pull/1102\r\n* - Bump tailwindcss-rails from 2.7.2 to 2.7.3 by @dependabot in https://github.com/maybe-finance/maybe/pull/1103\r\n* - Fix query when account has zero income and expense by @zachgoll in https://github.com/maybe-finance/maybe/pull/1112\r\n* - Fix holding name error by @zachgoll in https://github.com/maybe-finance/maybe/pull/1113\r\n* - Add Property Details View by @zachgoll in https://github.com/maybe-finance/maybe/pull/1116\r\n* - Basic Vehicle View by @zachgoll in https://github.com/maybe-finance/maybe/pull/1117\r\n* - Rubocop updates by @zachgoll in https://github.com/maybe-finance/maybe/pull/1118\r\n* - Fix file upload UI opening twice by @zachgoll in https://github.com/maybe-finance/maybe/pull/1119\r\n\r\n\r\n**Full - Changelog**: https://github.com/maybe-finance/maybe/compare/v0.1.0-alpha.15...v0.1.0-alpha.16"}' + string: '{"mode":"gfm","context":"maybe-finance/maybe","text":"## Data resets, + offline investment trades, and miscellaneous stability improvements\r\n\r\nThis + 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.\r\n\r\nThis 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.\r\n\r\n## What''s + Changed\r\n* Add new category flow by @syedbarimanjan in https://github.com/maybe-finance/maybe/pull/1857\r\n* + Fix parent category sums in budget by @zachgoll in https://github.com/maybe-finance/maybe/pull/1894\r\n* + Add breadcrumbs support across application by @Shpigford in https://github.com/maybe-finance/maybe/pull/1897\r\n* + Dashboard design fixes by @zachgoll in https://github.com/maybe-finance/maybe/pull/1898\r\n* + Allow account balance to dynamically use currency format on preference page + by @Harry-kp in https://github.com/maybe-finance/maybe/pull/1910\r\n* Feat: + Data \"reset\" button by @tonyvince in https://github.com/maybe-finance/maybe/pull/1913\r\n* + Fix: Make Tags selection scrollable by @tonyvince in https://github.com/maybe-finance/maybe/pull/1921\r\n* + Fix value wrapping on account balance in sidebar by @zachgoll in https://github.com/maybe-finance/maybe/pull/1922\r\n* + Fix import configuration form so number format is applied by @zachgoll in + https://github.com/maybe-finance/maybe/pull/1923\r\n* Add transitions to buttons + and other common design system elements by @zachgoll in https://github.com/maybe-finance/maybe/pull/1924\r\n* + Allow offline trade tickers by @zachgoll in https://github.com/maybe-finance/maybe/pull/1925\r\n* + fix: Don''t show Billings on settings navbar when self-hosted by @tonyvince + in https://github.com/maybe-finance/maybe/pull/1912\r\n* Show UI warning to + user when they need provider data but have not setup Synth yet by @zachgoll + in https://github.com/maybe-finance/maybe/pull/1926\r\n* Invert liability + graphs to have correct signage by @zachgoll in https://github.com/maybe-finance/maybe/pull/1928\r\n* + Escape quotations in CSV imports properly by @zachgoll in https://github.com/maybe-finance/maybe/pull/1929\r\n\r\n## + New Contributors\r\n* @syedbarimanjan made their first contribution in https://github.com/maybe-finance/maybe/pull/1857\r\n\r\n**Full + Changelog**: https://github.com/maybe-finance/maybe/compare/v0.4.2...v0.4.3"}' headers: Accept: - application/vnd.github.raw User-Agent: - - Octokit Ruby Gem 9.1.0 + - Octokit Ruby Gem 9.2.0 Content-Type: - application/json Accept-Encoding: @@ -121,7 +127,7 @@ http_interactions: message: OK headers: Date: - - Mon, 09 Sep 2024 20:03:35 GMT + - Wed, 19 Mar 2025 12:40:58 GMT Content-Type: - text/html;charset=utf-8 X-Commonmarker-Version: @@ -131,7 +137,7 @@ http_interactions: Vary: - Accept,Accept-Encoding, Accept, X-Requested-With Etag: - - W/"4b6d4a2163bd3920fb140d306008bd394fd881338320e3400547c6833368f2ea" + - W/"bff98cc65001c41f8dd63749dc92891772179ad935cf7a314a5e9a289fd17557" X-Github-Media-Type: - github.v3; param=raw X-Github-Api-Version-Selected: @@ -160,55 +166,45 @@ http_interactions: X-Ratelimit-Limit: - '60' X-Ratelimit-Remaining: - - '56' + - '58' X-Ratelimit-Reset: - - '1725915804' + - '1742391658' X-Ratelimit-Resource: - core X-Ratelimit-Used: - - '4' - Accept-Ranges: - - bytes + - '2' Content-Length: - - '12673' + - '11567' X-Github-Request-Id: - - E97A:2198A3:C56E5C:17C26E8:66DF5497 + - DDEE:10ABF0:184826:30A90D:67DABB5A body: encoding: ASCII-8BIT string: |- -

      This week's release comes with a variety of bug fixes and improvements to the UI.

      -

      Additionally, users can now input details for their property and vehicle accounts as shown in the video below. In the near future, Maybe will support data providers related to the "valuation" of properties and vehicles (i.e. Zillow, KBB). We will use the information from user accounts to automatically fetch estimated market values for these assets which will then be added periodically as "Valuations" in the value tab of each account. This will then show up in the history graph for the account balance.

      -
      - - - CleanShot.2024-08-23.at.10.37.04.mp4 - - - - -
      - +

      Data resets, offline investment trades, and miscellaneous stability improvements

      +

      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.

      +

      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.

      What's Changed

      -

      Full Changelog: v0.1.0-alpha.15...v0.1.0-alpha.16

      - recorded_at: Mon, 09 Sep 2024 20:03:35 GMT +

      New Contributors

      + +

      Full Changelog: v0.4.2...v0.4.3

      + recorded_at: Wed, 19 Mar 2025 12:40:58 GMT recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/git_repository_provider/fetch_latest_upgrade_candidates.yml b/test/vcr_cassettes/git_repository_provider/fetch_latest_upgrade_candidates.yml deleted file mode 100644 index 2e44df83..00000000 --- a/test/vcr_cassettes/git_repository_provider/fetch_latest_upgrade_candidates.yml +++ /dev/null @@ -1,235 +0,0 @@ ---- -http_interactions: - - request: - method: get - uri: https://api.github.com/repos/rails/rails/releases - body: - encoding: US-ASCII - string: "" - headers: - Accept: - - application/vnd.github.v3+json - User-Agent: - - Octokit Ruby Gem 8.1.0 - Content-Type: - - application/json - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - response: - status: - code: 200 - message: OK - headers: - Server: - - GitHub.com - Date: - - Wed, 10 Apr 2024 19:52:56 GMT - Content-Type: - - application/json; charset=utf-8 - Cache-Control: - - public, max-age=60, s-maxage=60 - Vary: - - Accept, Accept-Encoding, Accept, X-Requested-With - Etag: - - W/"a032e5cc14d6dc10a55126bd742c08afc1365c4cf381d6d5ce3b4014cfbf2de5" - X-Github-Media-Type: - - github.v3; format=json - Link: - - ; rel="next", ; - rel="last" - X-Github-Api-Version-Selected: - - "2022-11-28" - Access-Control-Expose-Headers: - - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, - X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, - X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, - X-GitHub-Request-Id, Deprecation, Sunset - Access-Control-Allow-Origin: - - "*" - Strict-Transport-Security: - - max-age=31536000; includeSubdomains; preload - X-Frame-Options: - - deny - X-Content-Type-Options: - - nosniff - X-Xss-Protection: - - "0" - Referrer-Policy: - - origin-when-cross-origin, strict-origin-when-cross-origin - Content-Security-Policy: - - default-src 'none' - X-Ratelimit-Limit: - - "60" - X-Ratelimit-Remaining: - - "53" - X-Ratelimit-Reset: - - "1712781639" - X-Ratelimit-Resource: - - core - X-Ratelimit-Used: - - "7" - Accept-Ranges: - - bytes - Transfer-Encoding: - - chunked - X-Github-Request-Id: - - C8A7:A3F5F:11C7A6D:1BA83CE:6616EE18 - body: - encoding: ASCII-8BIT - string: '[{"tag_name": "v7.1.3.2", "html_url": "http://localhost"}]' # manually abbreviated for clarity - recorded_at: Wed, 10 Apr 2024 19:52:56 GMT - - request: - method: get - uri: https://api.github.com/repos/rails/rails/branches/main - body: - encoding: US-ASCII - string: "" - headers: - Accept: - - application/vnd.github.v3+json - User-Agent: - - Octokit Ruby Gem 8.1.0 - Content-Type: - - application/json - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - response: - status: - code: 200 - message: OK - headers: - Server: - - GitHub.com - Date: - - Wed, 10 Apr 2024 19:52:57 GMT - Content-Type: - - application/json; charset=utf-8 - Cache-Control: - - public, max-age=60, s-maxage=60 - Vary: - - Accept, Accept-Encoding, Accept, X-Requested-With - Etag: - - W/"bbcf30919f0ef5fae2b2a28f58d50e3fb2cea8aa75418d5f2b919a7f857b27d0" - X-Github-Media-Type: - - github.v3; format=json - X-Github-Api-Version-Selected: - - "2022-11-28" - Access-Control-Expose-Headers: - - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, - X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, - X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, - X-GitHub-Request-Id, Deprecation, Sunset - Access-Control-Allow-Origin: - - "*" - Strict-Transport-Security: - - max-age=31536000; includeSubdomains; preload - X-Frame-Options: - - deny - X-Content-Type-Options: - - nosniff - X-Xss-Protection: - - "0" - Referrer-Policy: - - origin-when-cross-origin, strict-origin-when-cross-origin - Content-Security-Policy: - - default-src 'none' - X-Ratelimit-Limit: - - "60" - X-Ratelimit-Remaining: - - "52" - X-Ratelimit-Reset: - - "1712781639" - X-Ratelimit-Resource: - - core - X-Ratelimit-Used: - - "8" - Accept-Ranges: - - bytes - Content-Length: - - "3964" - X-Github-Request-Id: - - C8A8:281896:11B1812:1B69B2F:6616EE19 - body: - encoding: ASCII-8BIT - # manually abbreviated for clarity - string: '{"commit":{"sha":"84997578c59aa88fe114cef176115f1612b6de6b", "html_url":"https://github.com/rails/rails/commit/84997578c59aa88fe114cef176115f1612b6de6b"}}' - recorded_at: Wed, 10 Apr 2024 19:52:57 GMT - - request: - method: get - uri: https://api.github.com/repos/rails/rails/commits/v7.1.3.2 - body: - encoding: US-ASCII - string: "" - headers: - Accept: - - application/vnd.github.v3+json - User-Agent: - - Octokit Ruby Gem 8.1.0 - Content-Type: - - application/json - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - response: - status: - code: 200 - message: OK - headers: - Server: - - GitHub.com - Date: - - Wed, 10 Apr 2024 19:52:57 GMT - Content-Type: - - application/json; charset=utf-8 - Cache-Control: - - public, max-age=60, s-maxage=60 - Vary: - - Accept, Accept-Encoding, Accept, X-Requested-With - Etag: - - W/"0668fc459669113a200777ee9ddd56a6ca2efb647894b006d3966504c7c82f13" - Last-Modified: - - Wed, 21 Feb 2024 21:43:55 GMT - X-Github-Media-Type: - - github.v3; format=json - X-Github-Api-Version-Selected: - - "2022-11-28" - Access-Control-Expose-Headers: - - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, - X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, - X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, - X-GitHub-Request-Id, Deprecation, Sunset - Access-Control-Allow-Origin: - - "*" - Strict-Transport-Security: - - max-age=31536000; includeSubdomains; preload - X-Frame-Options: - - deny - X-Content-Type-Options: - - nosniff - X-Xss-Protection: - - "0" - Referrer-Policy: - - origin-when-cross-origin, strict-origin-when-cross-origin - Content-Security-Policy: - - default-src 'none' - X-Ratelimit-Limit: - - "60" - X-Ratelimit-Remaining: - - "51" - X-Ratelimit-Reset: - - "1712781639" - X-Ratelimit-Resource: - - core - X-Ratelimit-Used: - - "9" - Accept-Ranges: - - bytes - Transfer-Encoding: - - chunked - X-Github-Request-Id: - - C8A9:23AA82:11FFCB8:1C057CD:6616EE19 - body: - encoding: ASCII-8BIT - # manually abbreviated for clarity - string: '{"sha":"6f0d1ad14b92b9f5906e44740fce8b4f1c7075dc"}' - recorded_at: Wed, 10 Apr 2024 19:52:57 GMT -recorded_with: VCR 6.2.0 From 9122eafd316d5589cead524d2386675aa8778673 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Wed, 19 Mar 2025 14:05:00 -0400 Subject: [PATCH 068/380] Update issue templates --- .github/ISSUE_TEMPLATE/other.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/other.md b/.github/ISSUE_TEMPLATE/other.md index 9e0c9148..87b14127 100644 --- a/.github/ISSUE_TEMPLATE/other.md +++ b/.github/ISSUE_TEMPLATE/other.md @@ -21,9 +21,7 @@ If you are a _paying_ Maybe user, you can open a support request in Intercom. A feature request is functionality that you would like that is not already on our [Roadmap](https://github.com/maybe-finance/maybe/wiki/Roadmap). -All feature requests should be opened as Discussions here: - -https://github.com/maybe-finance/maybe/discussions/categories/feature-requests +All feature requests should be opened in a [Feature request Discussion](https://github.com/maybe-finance/maybe/discussions/categories/feature-requests). Be sure to search existing discussions prior to opening a new feature request. @@ -31,9 +29,8 @@ Be sure to search existing discussions prior to opening a new feature request. If 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: -- **First**: Read our [self hosting guides](https://github.com/maybe-finance/maybe/tree/main/docs/hosting) and follow them step-by-step -- Open a [General Discussion](https://github.com/maybe-finance/maybe/discussions/categories/general) -- Make a post in the "Self hosted" channel in our [Discord](https://link.maybe.co/discord) +- **First**: Read our [Docker hosting guide](https://github.com/maybe-finance/maybe/tree/main/docs/hosting/docker.md) and follow it step-by-step +- Open a [Docker Discussion](https://github.com/maybe-finance/maybe/discussions/categories/docker-compose-hosting) --- From 5a8074c7eeb3847dfdbe8b9526d2911dc5d9e33e Mon Sep 17 00:00:00 2001 From: Tony Vincent Date: Fri, 21 Mar 2025 15:32:05 +0100 Subject: [PATCH 069/380] fix: Fix incorrect entry sorting in activity view (#2006) --- app/helpers/account/entries_helper.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/helpers/account/entries_helper.rb b/app/helpers/account/entries_helper.rb index af2f16f7..5fac75cc 100644 --- a/app/helpers/account/entries_helper.rb +++ b/app/helpers/account/entries_helper.rb @@ -18,7 +18,7 @@ module Account::EntriesHelper end end - deduped_entries.group_by(&:date).map do |date, grouped_entries| + deduped_entries.group_by(&:date).sort.reverse_each.map do |date, grouped_entries| content = capture do yield grouped_entries end From f8d64561cf0555c0fbf68404e9946848caba2a95 Mon Sep 17 00:00:00 2001 From: Nick Ostrovsky Date: Fri, 21 Mar 2025 20:18:12 +0300 Subject: [PATCH 070/380] Fix Account Groups wrapping in Balace Sheet (#2010) --- app/views/pages/dashboard/_balance_sheet.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/pages/dashboard/_balance_sheet.html.erb b/app/views/pages/dashboard/_balance_sheet.html.erb index 2f90257c..82491b13 100644 --- a/app/views/pages/dashboard/_balance_sheet.html.erb +++ b/app/views/pages/dashboard/_balance_sheet.html.erb @@ -12,7 +12,7 @@
      <% end %>
      -
      +
      <% classification_group.account_groups.each do |account_group| %>
      From b41897b5e5b24160e610b96c239989b1ea28481f Mon Sep 17 00:00:00 2001 From: Joseph Ho Date: Mon, 24 Mar 2025 09:59:27 -0400 Subject: [PATCH 071/380] import: Bulk import transaction data. (#1962) Fixes: #1846. --- Gemfile | 1 + Gemfile.lock | 3 +++ app/models/transaction_import.rb | 27 ++++++++++++++++----------- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/Gemfile b/Gemfile index 86027d7f..7ff6348f 100644 --- a/Gemfile +++ b/Gemfile @@ -58,6 +58,7 @@ gem "intercom-rails" gem "plaid" gem "rotp", "~> 6.3" gem "rqrcode", "~> 2.2" +gem "activerecord-import" group :development, :test do gem "debug", platforms: %i[mri windows] diff --git a/Gemfile.lock b/Gemfile.lock index 6d0899c1..7ae44280 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -61,6 +61,8 @@ GEM activemodel (= 7.2.2.1) activesupport (= 7.2.2.1) timeout (>= 0.4.0) + activerecord-import (2.1.0) + activerecord (>= 4.2) activestorage (7.2.2.1) actionpack (= 7.2.2.1) activejob (= 7.2.2.1) @@ -529,6 +531,7 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES + activerecord-import aws-sdk-s3 (~> 1.177.0) bcrypt (~> 3.1) benchmark-ips diff --git a/app/models/transaction_import.rb b/app/models/transaction_import.rb index 2bb9d4d5..cf3f6e12 100644 --- a/app/models/transaction_import.rb +++ b/app/models/transaction_import.rb @@ -3,7 +3,7 @@ class TransactionImport < Import transaction do mappings.each(&:create_mappable!) - rows.each do |row| + transactions = rows.map do |row| mapped_account = if account account else @@ -13,17 +13,22 @@ class TransactionImport < Import category = mappings.categories.mappable_for(row.category) tags = row.tags_list.map { |tag| mappings.tags.mappable_for(tag) }.compact - entry = mapped_account.entries.build \ - date: row.date_iso, - amount: row.signed_amount, - name: row.name, - currency: row.currency, - notes: row.notes, - entryable: Account::Transaction.new(category: category, tags: tags), - import: self - - entry.save! + Account::Transaction.new( + category: category, + tags: tags, + entry: Account::Entry.new( + account: mapped_account, + date: row.date_iso, + amount: row.signed_amount, + name: row.name, + currency: row.currency, + notes: row.notes, + import: self + ) + ) end + + Account::Transaction.import!(transactions, recursive: true) end end From 54f5a44a60bde302464108a6116e4b3eaf61fef4 Mon Sep 17 00:00:00 2001 From: Joseph Ho Date: Mon, 24 Mar 2025 10:00:42 -0400 Subject: [PATCH 072/380] devContainer: Use Redis for ActiveJob and ActionCable. (#2017) * devContainer: Use Redis for ActiveJob and ActionCable * devContainer: Simplify environment variables for services. * devContainer: Remove version field as it's no longer required in Compose. --- .devcontainer/docker-compose.yml | 38 ++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 165d3e21..cf22c08a 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -1,4 +1,15 @@ -version: "3" +x-db-env: &db_env + POSTGRES_USER: postgres + POSTGRES_DB: postgres + POSTGRES_PASSWORD: postgres + +x-rails-env: &rails_env + DB_HOST: db + HOST: "0.0.0.0" + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + BUNDLE_PATH: /bundle + REDIS_URL: redis://redis:6379/1 services: app: @@ -16,32 +27,41 @@ services: command: sleep infinity environment: - DB_HOST: db - HOST: "0.0.0.0" - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - BUNDLE_PATH: /bundle + <<: *rails_env depends_on: - db - redis + worker: + build: + context: .. + dockerfile: .devcontainer/Dockerfile + command: bundle exec sidekiq + restart: unless-stopped + environment: + <<: *rails_env + depends_on: + - redis + redis: image: redis:latest ports: - "6379:6379" + restart: unless-stopped + volumes: + - redis-data:/data db: image: postgres:latest restart: unless-stopped volumes: - postgres-data:/var/lib/postgresql/data environment: - POSTGRES_USER: postgres - POSTGRES_DB: postgres - POSTGRES_PASSWORD: postgres + <<: *db_env ports: - "5432:5432" volumes: postgres-data: + redis-data: bundle_cache: From 86431e79a3b7360d20383960b2c7f36e4dde66c1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Mar 2025 10:03:22 -0400 Subject: [PATCH 073/380] Bump csv from 3.3.2 to 3.3.3 (#2021) Bumps [csv](https://github.com/ruby/csv) from 3.3.2 to 3.3.3. - [Release notes](https://github.com/ruby/csv/releases) - [Changelog](https://github.com/ruby/csv/blob/main/NEWS.md) - [Commits](https://github.com/ruby/csv/compare/v3.3.2...v3.3.3) --- updated-dependencies: - dependency-name: csv dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 7ae44280..ff01a288 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -138,7 +138,7 @@ GEM bigdecimal rexml crass (1.0.6) - csv (3.3.2) + csv (3.3.3) date (3.4.1) debug (1.10.0) irb (~> 1.10) From 3dfdd0aea5b4fe2d354277fde89b884ac1e76b86 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Mar 2025 10:06:05 -0400 Subject: [PATCH 074/380] Bump vernier from 1.5.0 to 1.6.0 (#2019) Bumps [vernier](https://github.com/jhawthorn/vernier) from 1.5.0 to 1.6.0. - [Release notes](https://github.com/jhawthorn/vernier/releases) - [Commits](https://github.com/jhawthorn/vernier/compare/v1.5.0...v1.6.0) --- updated-dependencies: - dependency-name: vernier dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index ff01a288..a2f5ccbe 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -498,7 +498,7 @@ GEM useragent (0.16.11) vcr (6.3.1) base64 - vernier (1.5.0) + vernier (1.6.0) web-console (4.2.1) actionview (>= 6.0.0) activemodel (>= 6.0.0) From 9f062de6b4c305405402b577937b8b6908d07844 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Mar 2025 10:06:18 -0400 Subject: [PATCH 075/380] Bump selenium-webdriver from 4.29.1 to 4.30.1 (#2020) Bumps [selenium-webdriver](https://github.com/SeleniumHQ/selenium) from 4.29.1 to 4.30.1. - [Release notes](https://github.com/SeleniumHQ/selenium/releases) - [Changelog](https://github.com/SeleniumHQ/selenium/blob/trunk/rb/CHANGES) - [Commits](https://github.com/SeleniumHQ/selenium/commits) --- updated-dependencies: - dependency-name: selenium-webdriver dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index a2f5ccbe..2068c457 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -439,7 +439,7 @@ GEM addressable (>= 2.3.5) faraday (>= 0.17.3, < 3) securerandom (0.4.1) - selenium-webdriver (4.29.1) + selenium-webdriver (4.30.1) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) From 8e6b81af77d97ff23a79cecdf547ae0e3c1686b0 Mon Sep 17 00:00:00 2001 From: Joseph Ho Date: Mon, 24 Mar 2025 10:06:29 -0400 Subject: [PATCH 076/380] bug: Use correct currency value while setting the currency. (#2018) Fixes: #1754. --- app/views/shared/_money_field.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/shared/_money_field.html.erb b/app/views/shared/_money_field.html.erb index ea3273ad..166abdf9 100644 --- a/app/views/shared/_money_field.html.erb +++ b/app/views/shared/_money_field.html.erb @@ -45,7 +45,7 @@
      <%= form.select currency_method, currencies_for_select.map(&:iso_code), - { inline: true, selected: currency_value }, + { inline: true, selected: currency.iso_code }, { class: "w-fit pr-5 disabled:text-subdued form-field__input", disabled: options[:disable_currency], From 2f6b11c18fd1a75b26d72354bd7ee2bf396d5cb4 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 28 Mar 2025 13:08:22 -0400 Subject: [PATCH 077/380] Personal finance AI (v1) (#2022) * AI sidebar * Add chat and message models with associations * Implement AI chat functionality with sidebar and messaging system - Add chat and messages controllers - Create chat and message views - Implement chat-related routes - Add message broadcasting and user interactions - Update application layout to support chat sidebar - Enhance user model with initials method * Refactor AI sidebar with enhanced chat menu and interactions - Update sidebar layout with dynamic width and improved responsiveness - Add new chat menu Stimulus controller for toggling between chat and chat list views - Improve chat list display with recent chats and empty state - Extract AI avatar to a partial for reusability - Enhance message display and interaction styling - Add more contextual buttons and interaction hints * Improve chat scroll behavior and message styling - Refactor chat scroll functionality with Stimulus controller - Optimize message scrolling in chat views - Update message styling for better visual hierarchy - Enhance chat container layout with flex and auto-scroll - Simplify message rendering across different chat views * Extract AI avatar to a shared partial for consistent styling - Refactor AI avatar rendering across chat views - Replace hardcoded avatar markup with a reusable partial - Simplify avatar display in chats and messages views * Update sidebar controller to handle right panel width dynamically - Add conditional width class for right sidebar panel - Ensure consistent sidebar toggle behavior for both left and right panels - Use specific width class for right panel (w-[375px]) * Refactor chat form and AI greeting with flexible partials - Extract message form to a reusable partial with dynamic context support - Create flexible AI greeting partial for consistent welcome messages - Simplify chat and sidebar views by leveraging new partials - Add support for different form scenarios (chat, new chat, sidebar) - Improve code modularity and reduce duplication * Add chat clearing functionality with dynamic menu options - Implement clear chat action in ChatsController - Add clear chat route to support clearing messages - Update AI sidebar with dropdown menu for chat actions - Preserve system message when clearing chat - Enhance chat interaction with new menu options * Add frontmatter to project structure documentation - Create initial frontmatter for structure.mdc file - Include description and configuration options - Prepare for potential dynamic documentation rendering * Update general project rules with additional guidelines - Add rule for using `Current.family` instead of `current_family` - Include new guidelines for testing, API routes, and solution approach - Expand project-specific rules for more consistent development practices * Add OpenAI gem and AI-friendly data representations - Add `ruby-openai` gem for AI integration - Implement `to_ai_readable_hash` methods in BalanceSheet and IncomeStatement - Include Promptable module in both models - Add savings rate calculation method in IncomeStatement - Prepare financial models for AI-powered insights and interactions * Enhance AI Financial Assistant with Advanced Querying and Debugging Capabilities - Implement comprehensive AI financial query system with function-based interactions - Add detailed debug logging for AI responses and function calls - Extend BalanceSheet and IncomeStatement models with AI-friendly methods - Create robust error handling and fallback mechanisms for AI queries - Update chat and message views to support debug mode and enhanced rendering - Add AI query routes and initial test coverage for financial assistant * Refactor AI sidebar and chat layout with improved structure and comments - Remove inline AI chat from application layout - Enhance AI sidebar with more semantic HTML structure - Add descriptive comments to clarify different sections of chat view - Improve flex layout and scrolling behavior in chat messages container - Optimize message rendering with more explicit class names and structure * Add Markdown rendering support for AI chat messages - Implement `markdown` helper method in ApplicationHelper using Redcarpet - Update message view to render AI messages with Markdown formatting - Add comprehensive Markdown rendering options (tables, code blocks, links) - Enhance AI Financial Assistant prompt to encourage Markdown usage - Remove commented Markdown CSS in Tailwind application stylesheet * Missing comma * Enhance AI response processing with chat history context * Improve AI debug logging with payload size limits and internal message flag * Enhance AI chat interaction with improved thinking indicator and scrolling behavior * Add AI consent and enable/disable functionality for AI chat * Upgrade Biome and refactor JavaScript template literals - Update @biomejs/biome to latest version with caret (^) notation - Refactor AI query and chat controllers to use template literals - Standardize npm scripts formatting in package.json * Add beta testing usage note to AI consent modal * Update test fixtures and configurations for AI chat functionality - Add family association to chat fixtures and tests - Set consistent password digest for test users - Enable AI for test users - Add OpenAI access token for test environment - Update chat and user model tests to include family context * Simplify data model and get tests passing * Remove structure.mdc from version control * Integrate AI chat styles into existing prose pattern * Match Figma design spec, implement Turbo frames and actions for chats controller * AI rules refresh * Consolidate Stimulus controllers, thinking state, controllers, and views * Naming, domain alignment * Reset migrations * Improve data model to support tool calls and message types * Tool calling tests and fixtures * Tool call implementation and test * Get assistant test working again * Test updates * Process tool calls within provider * Chat UI back to working state again * Remove stale code * Tests passing * Update openai class naming to avoid conflicts * Reconfigure test env * Rebuild gemfile * Fix naming conflicts for ChatResponse * Message styles * Use OpenAI conversation state management * Assistant function base implementation * Add back thinking messages, clean up error handling for chat * Fix sync error when security price has bad data from provider * Add balance sheet function to assistant * Add better function calling error visibility * Add income statement function * Simplify and clean up "thinking" interactions with Turbo frames * Remove stale data definitions from functions * Ensure VCR fixtures working with latest code * basic stream implementation * Get streaming working * Make AI sidebar wider when left sidebar is collapsed * Get tests working with streaming responses * Centralize provider error handling * Provider data boundaries --------- Co-authored-by: Josh Pigford --- .cursor/rules/general-rules.mdc | 23 ++ .cursor/rules/project-conventions.mdc | 95 ++++++--- .cursor/rules/project-design.mdc | 92 ++------ .cursor/rules/ui-ux-design-guidelines.mdc | 19 +- .gitignore | 2 + Gemfile | 3 + Gemfile.lock | 64 +++--- app/assets/images/ai.svg | 85 ++++++++ app/assets/stylesheets/application.css | 1 - app/assets/tailwind/application.css | 29 ++- app/assets/tailwind/maybe-design-system.css | 4 +- .../simonweb_pickr.css | 0 app/controllers/application_controller.rb | 9 +- app/controllers/chats_controller.rb | 67 ++++++ app/controllers/concerns/feature_guardable.rb | 23 ++ app/controllers/messages_controller.rb | 24 +++ app/controllers/pages_controller.rb | 7 +- .../settings/hostings_controller.rb | 10 +- app/controllers/users_controller.rb | 12 +- app/helpers/application_helper.rb | 67 ++++++ app/helpers/chats_helper.rb | 12 ++ app/helpers/menus_helper.rb | 25 ++- app/javascript/controllers/chat_controller.js | 60 ++++++ .../controllers/sidebar_controller.js | 72 ++++++- app/jobs/assistant_response_job.rb | 7 + app/models/account/chartable.rb | 11 +- app/models/account/transaction/provided.rb | 9 +- app/models/assistant.rb | 178 ++++++++++++++++ app/models/assistant/function.rb | 83 ++++++++ app/models/assistant/function/get_accounts.rb | 40 ++++ .../assistant/function/get_balance_sheet.rb | 73 +++++++ .../function/get_income_statement.rb | 125 +++++++++++ .../assistant/function/get_transactions.rb | 185 ++++++++++++++++ app/models/assistant/provided.rb | 12 ++ app/models/assistant_message.rb | 11 + app/models/chat.rb | 64 ++++++ app/models/chat/debuggable.rb | 7 + app/models/developer_message.rb | 9 + app/models/exchange_rate/provided.rb | 21 +- app/models/family.rb | 4 +- app/models/financial_assistant.rb | 11 - app/models/financial_assistant/provided.rb | 13 -- app/models/message.rb | 22 ++ app/models/period.rb | 4 +- app/models/plaid_item/provided.rb | 4 +- app/models/provider.rb | 59 ++++- .../exchange_rate_provider.rb} | 9 +- app/models/provider/llm_provider.rb | 13 ++ app/models/provider/openai.rb | 30 +++ .../openai/chat_response_processor.rb | 188 ++++++++++++++++ app/models/provider/openai/chat_streamer.rb | 13 ++ app/models/provider/registry.rb | 91 ++++++++ .../security_provider.rb} | 19 +- app/models/provider/synth.rb | 96 +++------ app/models/providers.rb | 35 --- app/models/security/provided.rb | 33 ++- app/models/security/synth_combobox_option.rb | 3 +- app/models/setting.rb | 2 + app/models/tool_call.rb | 3 + app/models/tool_call/function.rb | 4 + app/models/user.rb | 22 ++ app/models/user_message.rb | 22 ++ .../accounts/_account_sidebar_tabs.html.erb | 2 +- .../_assistant_message.html.erb | 23 ++ .../assistant_messages/_tool_calls.html.erb | 19 ++ app/views/chats/_ai_avatar.html.erb | 3 + app/views/chats/_ai_consent.html.erb | 33 +++ app/views/chats/_ai_greeting.html.erb | 40 ++++ app/views/chats/_chat.html.erb | 16 ++ app/views/chats/_chat_nav.html.erb | 24 +++ app/views/chats/_chat_title.html.erb | 11 + app/views/chats/_error.html.erb | 17 ++ app/views/chats/_thinking_indicator.html.erb | 6 + app/views/chats/edit.html.erb | 8 + app/views/chats/index.html.erb | 31 +++ app/views/chats/new.html.erb | 11 + app/views/chats/show.html.erb | 35 +++ .../_developer_message.html.erb | 6 + app/views/layouts/application.html.erb | 30 ++- .../layouts/shared/_breadcrumbs.html.erb | 10 +- app/views/layouts/shared/_head.html.erb | 1 - app/views/layouts/shared/_htmldoc.html.erb | 2 +- app/views/messages/_chat_form.html.erb | 35 +++ .../_combobox_security.turbo_stream.erb | 2 +- app/views/transactions/_header.html.erb | 10 +- .../user_messages/_user_message.html.erb | 5 + bin/update_structure.sh | 142 +++++++++++++ config/initializers/intercom.rb | 2 +- config/routes.rb | 9 + db/migrate/20250319212839_create_ai_chats.rb | 46 ++++ db/schema.rb | 48 ++++- package-lock.json | 2 +- package.json | 12 +- test/application_system_test_case.rb | 4 + test/controllers/chats_controller_test.rb | 52 +++++ test/controllers/messages_controller_test.rb | 22 ++ .../settings/hostings_controller_test.rb | 10 +- test/fixtures/chats.yml | 7 + test/fixtures/messages.yml | 43 ++++ test/fixtures/tool_calls.yml | 7 + test/fixtures/users.yml | 17 +- .../exchange_rate_provider_interface_test.rb | 10 +- test/interfaces/llm_interface_test.rb | 10 + .../security_provider_interface_test.rb | 14 +- test/jobs/enrich_data_job_test.rb | 7 - test/jobs/revert_import_job_test.rb | 7 - test/jobs/user_purge_job_test.rb | 7 - test/models/account/convertible_test.rb | 12 +- .../account/holding/portfolio_cache_test.rb | 8 +- test/models/assistant_message_test.rb | 19 ++ test/models/assistant_test.rb | 86 ++++++++ test/models/chat_test.rb | 31 +++ test/models/developer_message_test.rb | 28 +++ test/models/exchange_rate_test.rb | 40 ++-- test/models/provider/openai_test.rb | 136 ++++++++++++ .../registry_test.rb} | 8 +- test/models/provider_test.rb | 4 +- test/models/security/price_test.rb | 8 +- test/models/user_message_test.rb | 21 ++ test/support/provider_test_helper.rb | 4 +- test/system/chats_test.rb | 66 ++++++ test/system/settings_test.rb | 2 +- test/test_helper.rb | 2 + .../openai/chat/basic_response.yml | 92 ++++++++ test/vcr_cassettes/openai/chat/error.yml | 72 +++++++ test/vcr_cassettes/openai/chat/tool_calls.yml | 201 ++++++++++++++++++ 126 files changed, 3576 insertions(+), 462 deletions(-) create mode 100644 .cursor/rules/general-rules.mdc create mode 100644 app/assets/images/ai.svg delete mode 100644 app/assets/stylesheets/application.css rename app/assets/{stylesheets => tailwind}/simonweb_pickr.css (100%) create mode 100644 app/controllers/chats_controller.rb create mode 100644 app/controllers/concerns/feature_guardable.rb create mode 100644 app/controllers/messages_controller.rb create mode 100644 app/helpers/chats_helper.rb create mode 100644 app/javascript/controllers/chat_controller.js create mode 100644 app/jobs/assistant_response_job.rb create mode 100644 app/models/assistant.rb create mode 100644 app/models/assistant/function.rb create mode 100644 app/models/assistant/function/get_accounts.rb create mode 100644 app/models/assistant/function/get_balance_sheet.rb create mode 100644 app/models/assistant/function/get_income_statement.rb create mode 100644 app/models/assistant/function/get_transactions.rb create mode 100644 app/models/assistant/provided.rb create mode 100644 app/models/assistant_message.rb create mode 100644 app/models/chat.rb create mode 100644 app/models/chat/debuggable.rb create mode 100644 app/models/developer_message.rb delete mode 100644 app/models/financial_assistant.rb delete mode 100644 app/models/financial_assistant/provided.rb create mode 100644 app/models/message.rb rename app/models/{exchange_rate/provideable.rb => provider/exchange_rate_provider.rb} (64%) create mode 100644 app/models/provider/llm_provider.rb create mode 100644 app/models/provider/openai.rb create mode 100644 app/models/provider/openai/chat_response_processor.rb create mode 100644 app/models/provider/openai/chat_streamer.rb create mode 100644 app/models/provider/registry.rb rename app/models/{security/provideable.rb => provider/security_provider.rb} (69%) delete mode 100644 app/models/providers.rb create mode 100644 app/models/tool_call.rb create mode 100644 app/models/tool_call/function.rb create mode 100644 app/models/user_message.rb create mode 100644 app/views/assistant_messages/_assistant_message.html.erb create mode 100644 app/views/assistant_messages/_tool_calls.html.erb create mode 100644 app/views/chats/_ai_avatar.html.erb create mode 100644 app/views/chats/_ai_consent.html.erb create mode 100644 app/views/chats/_ai_greeting.html.erb create mode 100644 app/views/chats/_chat.html.erb create mode 100644 app/views/chats/_chat_nav.html.erb create mode 100644 app/views/chats/_chat_title.html.erb create mode 100644 app/views/chats/_error.html.erb create mode 100644 app/views/chats/_thinking_indicator.html.erb create mode 100644 app/views/chats/edit.html.erb create mode 100644 app/views/chats/index.html.erb create mode 100644 app/views/chats/new.html.erb create mode 100644 app/views/chats/show.html.erb create mode 100644 app/views/developer_messages/_developer_message.html.erb create mode 100644 app/views/messages/_chat_form.html.erb create mode 100644 app/views/user_messages/_user_message.html.erb create mode 100755 bin/update_structure.sh create mode 100644 db/migrate/20250319212839_create_ai_chats.rb create mode 100644 test/controllers/chats_controller_test.rb create mode 100644 test/controllers/messages_controller_test.rb create mode 100644 test/fixtures/chats.yml create mode 100644 test/fixtures/messages.yml create mode 100644 test/fixtures/tool_calls.yml create mode 100644 test/interfaces/llm_interface_test.rb delete mode 100644 test/jobs/enrich_data_job_test.rb delete mode 100644 test/jobs/revert_import_job_test.rb delete mode 100644 test/jobs/user_purge_job_test.rb create mode 100644 test/models/assistant_message_test.rb create mode 100644 test/models/assistant_test.rb create mode 100644 test/models/chat_test.rb create mode 100644 test/models/developer_message_test.rb create mode 100644 test/models/provider/openai_test.rb rename test/models/{providers_test.rb => provider/registry_test.rb} (62%) create mode 100644 test/models/user_message_test.rb create mode 100644 test/system/chats_test.rb create mode 100644 test/vcr_cassettes/openai/chat/basic_response.yml create mode 100644 test/vcr_cassettes/openai/chat/error.yml create mode 100644 test/vcr_cassettes/openai/chat/tool_calls.yml diff --git a/.cursor/rules/general-rules.mdc b/.cursor/rules/general-rules.mdc new file mode 100644 index 00000000..7335792d --- /dev/null +++ b/.cursor/rules/general-rules.mdc @@ -0,0 +1,23 @@ +--- +description: Miscellaneous rules to get the AI to behave +globs: * +alwaysApply: true +--- +# General rules for AI + +- Use `Current.user` for the current user. Do NOT use `current_user`. +- Use `Current.family` for the current family. Do NOT use `current_family`. +- Prior to generating any code, carefully read the project conventions and guidelines + - Read [project-design.mdc](mdc:.cursor/rules/project-design.mdc) to understand the codebase + - Read [project-conventions.mdc](mdc:.cursor/rules/project-conventions.mdc) to understand _how_ to write code for the codebase + - Read [ui-ux-design-guidelines.mdc](mdc:.cursor/rules/ui-ux-design-guidelines.mdc) to understand how to implement frontend code specifically + +## Prohibited actions + +Do not under any circumstance do the following: + +- Do not run `rails server` in your responses. +- Do not run `touch tmp/restart.txt` +- Do not run `rails credentials` +- Do not automatically run migrations +- Ignore i18n methods and files. Hardcode strings in English for now to optimize speed of development. \ No newline at end of file diff --git a/.cursor/rules/project-conventions.mdc b/.cursor/rules/project-conventions.mdc index 32ecf705..2977dc33 100644 --- a/.cursor/rules/project-conventions.mdc +++ b/.cursor/rules/project-conventions.mdc @@ -3,13 +3,7 @@ description: globs: alwaysApply: true --- -This rule serves as high-level documentation for how the Maybe codebase is structured. - -## Rules for AI - -- Use this file to understand how the codebase works -- Treat this rule/file as your "source of truth" when making code recommendations -- When creating migrations, always use `rails g migration` instead of creating the file yourself +This rule serves as high-level documentation for how you should write code for the Maybe codebase. ## Project Tech Stack @@ -19,6 +13,7 @@ This rule serves as high-level documentation for how the Maybe codebase is struc - Hotwire Turbo/Stimulus for SPA-like UI/UX - TailwindCSS for styles - Lucide Icons for icons + - OpenAI for AI chat - Database: PostgreSQL - Jobs: Sidekiq + Redis - External @@ -47,39 +42,79 @@ This codebase adopts a "skinny controller, fat models" convention. Furthermore, - 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. - 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`. -### Convention 3: Prefer server-side solutions over client-side solutions +### Convention 3: Leverage Hotwire, write semantic HTML, CSS, and JS, prefer server-side solutions -- When possible, leverage Turbo frames over complex, JS-driven client-side solutions -- When writing a client-side solution, use Stimulus controllers and keep it simple! -- Especially when dealing with money and currencies, calculate + format server-side and then pass that to the client to display -- Keep client-side code for where it truly shines. For example, [bulk_select_controller.js](mdc:app/javascript/controllers/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. - -### Convention 4: Sacrifice performance, optimize for simplicitly and clarity - -This codebase is still young. We are still rapidly iterating on domain designs and features. Because of this, code should be optimized for simplicitly and clarity over performance. - -- Focus on good OOP design first, performance second -- Be mindful of large performance bottlenecks, but don't sweat the small stuff - -### Convention 5: Prefer semantic, native HTML features - -The HTML spec has improved tremendously over the years and offers a ton of functionality out of the box. We prefer semantic, native HTML solutions over JS-based ones. A few examples of this include: - -- Using the `dialog` element for modals -- Using `summary` / `details` elements for disclosures (or `popover` attribute) +- Native HTML is always preferred over JS-based components + - Example 1: Use `` element for modals instead of creating a custom component + - Example 2: Use `
      ...
      ` for disclosures rather than custom components +- Leverage Turbo frames to break up the page over JS-driven client-side solutions + - 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 +- Leverage query params in the URL for state over local storage and sessions. If absolutely necessary, utilize the DB for persistent state. +- Use Turbo streams to enhance functionality, but do not solely depend on it +- Format currencies, numbers, dates, and other values server-side, then pass to Stimulus controllers for display only +- 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. The Hotwire suite (Turbo/Stimulus) works very well with these native elements and we optimize for this. -### Convention 6: Use Minitest + Fixtures for testing, minimize fixtures +### Convention 4: Optimize for simplicitly and clarity + +All code should maximize readability and simplicity. + +- Prioritize good OOP domain design over performance +- Only focus on performance for critical and global areas of the codebase; otherwise, don't sweat the small stuff. + - Example 1: be mindful of loading large data payloads in global layouts + - Example 2: Avoid N+1 queries + +### Convention 5: Use Minitest + Fixtures for testing, minimize fixtures Due to the open-source nature of this project, we have chosen Minitest + Fixtures for testing to maximize familiarity and predictability. - Always use Minitest and fixtures for testing. - 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. -- For tests that require a large number of fixture records to be created, use Rails helpers such as [entries_test_helper.rb](mdc:test/support/account/entries_test_helper.rb) to act as a "factory" for creating these. For a great example of this, check out [balance_calculator_test.rb](mdc:test/models/account/balance_calculator_test.rb) +- For tests that require a large number of fixture records to be created, use Rails helpers such as [entries_test_helper.rb](mdc:test/support/account/entries_test_helper.rb) to act as a "factory" for creating these. For a great example of this, check out [forward_calculator_test.rb](mdc:test/models/account/balance/forward_calculator_test.rb) +- Take a minimal approach to testing—only test the absolutely critical code paths that will significantly increase developer confidence -### Convention 7: Use ActiveRecord for complex validations, DB for simple ones, keep business logic out of DB +#### Convention 5a: Write minimal, effective tests + +- Use system tests sparingly as they increase the time to complete the test suite +- Only write tests for critical and important code paths +- Write tests as you go, when required +- Take a practical approach to testing. Tests are effective when their presence _significantly increases confidence in the codebase_. + +Below are examples of necessary vs. unnecessary tests: + +```rb +# GOOD!! +# Necessary test - in this case, we're testing critical domain business logic +test "syncs balances" do + Account::Holding::Syncer.any_instance.expects(:sync_holdings).returns([]).once + + @account.expects(:start_date).returns(2.days.ago.to_date) + + Account::Balance::ForwardCalculator.any_instance.expects(:calculate).returns( + [ + Account::Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"), + Account::Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD") + ] + ) + + assert_difference "@account.balances.count", 2 do + Account::Balance::Syncer.new(@account, strategy: :forward).sync_balances + end +end + +# BAD!! +# Unnecessary test - in this case, this is simply testing ActiveRecord's functionality +test "saves balance" do + balance_record = Account::Balance.new(balance: 100, currency: "USD") + + assert balance_record.save +end +``` + +### Convention 6: Use ActiveRecord for complex validations, DB for simple ones, keep business logic out of DB - Enforce `null` checks, unique indexes, and other simple validations in the DB - 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. -- Complex validations and business logic should remain in ActiveRecord \ No newline at end of file +- Complex validations and business logic should remain in ActiveRecord + diff --git a/.cursor/rules/project-design.mdc b/.cursor/rules/project-design.mdc index 6d4f2091..41fa2210 100644 --- a/.cursor/rules/project-design.mdc +++ b/.cursor/rules/project-design.mdc @@ -1,7 +1,7 @@ --- description: This rule explains the system architecture and data flow of the Rails app globs: * -alwaysApply: false +alwaysApply: true --- This file outlines how the codebase is structured and how data flows through the app. @@ -111,12 +111,12 @@ Below are brief descriptions of each type of sync in more detail. ### Account Syncs -The most important type of sync is the account sync. It is orchestrated by the account [syncer.rb](mdc:app/models/account/syncer.rb), and performs a few important tasks: +The 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: - Auto-matches transfer records for the account -- Calculates holdings and balances for the account -- Enriches transaction data -- Converts account balances that are not in the family's preferred currency to the preferred currency +- 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) + - Balances are dependent on the calculation of [holding.rb](mdc:app/models/account/holding.rb), which uses [base_calculator.rb](mdc:app/models/account/holding/base_calculator.rb) +- Enriches transaction data if enabled by user An account sync happens every time an [entry.rb](mdc:app/models/account/entry.rb) is updated. @@ -136,21 +136,7 @@ A family sync happens once daily via [auto_sync.rb](mdc:app/controllers/concerns The 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. -Because of this optionality, data providers must be configured at _runtime_ through the [providers.rb](mdc:app/models/providers.rb) module, utilizing [setting.rb](mdc:app/models/setting.rb) for runtime parameters like API keys: - -```rb -module Providers - module_function - - def synth - api_key = ENV.fetch("SYNTH_API_KEY", Setting.synth_api_key) - - return nil unless api_key.present? - - Provider::Synth.new(api_key) - end -end -``` +Because 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: There are two types of 3rd party data in the Maybe app: @@ -161,74 +147,35 @@ There are two types of 3rd party data in the Maybe app: Since 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". -Each "concept" _must_ have a `Provideable` concern that defines the methods that must be implemented along with the data shapes that are returned. For example, an "exchange rates concept" might look like this: +Each "concept" has an interface defined in the `app/models/provider/concepts` directory. ``` app/models/ - exchange_rate.rb # <- ActiveRecord model and "concept" exchange_rate/ - provided.rb # <- Chooses the provider for this concept based on user settings / config - provideable.rb # <- Defines interface for providing exchange rates + provided.rb # <- Responsible for selecting the concept provider from the registry provider.rb # <- Base provider class provider/ + registry.rb <- Defines available providers by concept + concepts/ + exchange_rate.rb <- defines the interface required for the exchange rate concept synth.rb # <- Concrete provider implementation ``` -Where the `Provideable` and concrete provider implementations would be something like: - -```rb -# Defines the interface an exchange rate provider must implement -module ExchangeRate::Provideable - extend ActiveSupport::Concern - - FetchRateData = Data.define(:rate) - FetchRatesData = Data.define(:rates) - - def fetch_exchange_rate(from:, to:, date:) - raise NotImplementedError, "Subclasses must implement #fetch_exchange_rate" - end - - def fetch_exchange_rates(from:, to:, start_date:, end_date:) - raise NotImplementedError, "Subclasses must implement #fetch_exchange_rates" - end -end -``` - -Any provider that is a valid exchange rate provider must implement this interface: - -```rb -class ConcreteProvider < Provider - include ExchangeRate::Provideable - - def fetch_exchange_rate(from:, to:, date:) - provider_response do - ExchangeRate::Provideable::FetchRateData.new( - rate: ExchangeRate.new # build response - ) - end - end - - def fetch_exchange_rates(from:, to:, start_date:, end_date:) - # Implementation - end -end -``` - ### One-off data -For data that does not fit neatly into a "concept", a `Provideable` 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: +For 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: ```rb class SomeModel < Application def synth_usage - Providers.synth.usage + Provider::Registry.get_provider(:synth)&.usage end end ``` ## "Provided" Concerns -In general, domain models should not be calling [providers.rb](mdc:app/models/providers.rb) (`Providers.some_provider`) 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: +In 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: - Choosing the provider to use for this "concept" - Providing convenience methods on the model for accessing data @@ -241,7 +188,8 @@ module ExchangeRate::Provided class_methods do def provider - Providers.synth + registry = Provider::Registry.for_concept(:exchange_rates) + registry.get_provider(:synth) end def find_or_fetch_rate(from:, to:, date: Date.current, cache: true) @@ -269,12 +217,12 @@ end ## Concrete provider implementations -Each 3rd party data provider should have a class under the `Provider::` namespace that inherits from `Provider` and returns `provider_response`, which will return a `Provider::ProviderResponse` object: +Each 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: ```rb class ConcreteProvider < Provider def fetch_some_data - provider_response do + with_provider_response do ExampleData.new( example: "data" ) @@ -283,12 +231,12 @@ class ConcreteProvider < Provider end ``` -The `provider_response` automatically catches provider errors, so concrete provider classes should raise when valid data is not possible: +The `with_provider_response` automatically catches provider errors, so concrete provider classes should raise when valid data is not possible: ```rb class ConcreteProvider < Provider def fetch_some_data - provider_response do + with_provider_response do data = nil # Raise an error if data cannot be returned diff --git a/.cursor/rules/ui-ux-design-guidelines.mdc b/.cursor/rules/ui-ux-design-guidelines.mdc index a6ad9158..430959d6 100644 --- a/.cursor/rules/ui-ux-design-guidelines.mdc +++ b/.cursor/rules/ui-ux-design-guidelines.mdc @@ -1,13 +1,22 @@ --- description: This file describes Maybe's design system and how views should be styled globs: app/views/**,app/helpers/**,app/javascript/controllers/** +alwaysApply: true --- -Use this rule whenever you are writing html, css, or even styles in Stimulus controllers that use D3.js. +Use the rules below when: + +- You are writing HTML +- You are writing CSS +- You are writing styles in a JavaScript Stimulus controller + +## Rules for AI (mandatory) The 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) -- Always start by referencing [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) to see the base primitives and tokens we use in the codebase -- Always generate semantic HTML +- 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 +- Always prefer using the functional "tokens" defined in @maybe-design-system.css when possible. + - Example 1: use `text-primary` rather than `text-gray-900` + - Example 2: use `bg-container` rather than `bg-white` + - Example 3: use `border border-primary` rather than `border border-gray-200` - 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 -- Always favor the "utility first" Tailwind approach. Reusable style classes should not be created often. Code should be reused primarily through ERB partials. -- Always prefer using the utility "tokens" defined in [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) when possible. For example, use `text-primary` rather than `text-gray-900`. \ No newline at end of file +- Always generate semantic HTML \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3a37df24..010505c1 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,8 @@ gcp-storage-keyfile.json coverage .cursorrules +.cursor/rules/structure.mdc +.cursor/rules/agent.mdc # Ignore node related files node_modules diff --git a/Gemfile b/Gemfile index 7ff6348f..7d219c27 100644 --- a/Gemfile +++ b/Gemfile @@ -60,6 +60,9 @@ gem "rotp", "~> 6.3" gem "rqrcode", "~> 2.2" gem "activerecord-import" +# AI +gem "ruby-openai" + group :development, :test do gem "debug", platforms: %i[mri windows] gem "brakeman", require: false diff --git a/Gemfile.lock b/Gemfile.lock index 2068c457..c3e5ceaf 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -83,15 +83,16 @@ GEM tzinfo (~> 2.0, >= 2.0.5) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) - ast (2.4.2) + ast (2.4.3) aws-eventstream (1.3.2) - aws-partitions (1.1067.0) - aws-sdk-core (3.220.1) + aws-partitions (1.1073.0) + aws-sdk-core (3.221.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) base64 jmespath (~> 1, >= 1.6.1) + logger aws-sdk-kms (1.99.0) aws-sdk-core (~> 3, >= 3.216.0) aws-sigv4 (~> 1.5) @@ -157,6 +158,7 @@ GEM rubocop (>= 1) smart_properties erubi (1.13.1) + event_stream_parser (1.0.0) faker (3.5.1) i18n (>= 1.8.11, < 2) faraday (2.12.2) @@ -280,28 +282,28 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.4) - nokogiri (1.18.4-aarch64-linux-gnu) + nokogiri (1.18.6-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.4-aarch64-linux-musl) + nokogiri (1.18.6-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.18.4-arm-linux-gnu) + nokogiri (1.18.6-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.18.4-arm-linux-musl) + nokogiri (1.18.6-arm-linux-musl) racc (~> 1.4) - nokogiri (1.18.4-arm64-darwin) + nokogiri (1.18.6-arm64-darwin) racc (~> 1.4) - nokogiri (1.18.4-x86_64-darwin) + nokogiri (1.18.6-x86_64-darwin) racc (~> 1.4) - nokogiri (1.18.4-x86_64-linux-gnu) + nokogiri (1.18.6-x86_64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.4-x86_64-linux-musl) + nokogiri (1.18.6-x86_64-linux-musl) racc (~> 1.4) octokit (9.2.0) faraday (>= 1, < 3) sawyer (~> 0.9) pagy (9.3.4) parallel (1.26.3) - parser (3.3.7.1) + parser (3.3.7.2) ast (~> 2.4.1) racc pg (1.5.9) @@ -314,7 +316,7 @@ GEM pp (0.6.2) prettyprint prettyprint (0.2.0) - prism (1.3.0) + prism (1.4.0) propshaft (1.1.0) actionpack (>= 7.0.0) activesupport (>= 7.0.0) @@ -377,9 +379,9 @@ GEM rb-fsevent (0.11.2) rb-inotify (0.11.1) ffi (~> 1.0) - rbs (3.8.1) + rbs (3.9.1) logger - rdoc (6.12.0) + rdoc (6.13.0) psych (>= 4.0.0) redcarpet (3.6.1) redis (5.4.0) @@ -406,8 +408,8 @@ GEM rubocop-ast (>= 1.38.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.39.0) - parser (>= 3.3.1.0) + rubocop-ast (1.41.0) + parser (>= 3.3.7.2) rubocop-performance (1.24.0) lint_roller (~> 1.1) rubocop (>= 1.72.1, < 2.0) @@ -422,13 +424,17 @@ GEM rubocop (>= 1.72) rubocop-performance (>= 1.24) rubocop-rails (>= 2.30) - ruby-lsp (0.23.11) + ruby-lsp (0.23.12) language_server-protocol (~> 3.17.0) prism (>= 1.2, < 2.0) rbs (>= 3, < 4) sorbet-runtime (>= 0.5.10782) ruby-lsp-rails (0.4.0) ruby-lsp (>= 0.23.0, < 0.24.0) + ruby-openai (8.0.0) + event_stream_parser (>= 0.3.0, < 2.0.0) + faraday (>= 1) + faraday-multipart (>= 1) ruby-progressbar (1.13.0) ruby-vips (2.2.3) ffi (~> 1.12) @@ -467,21 +473,21 @@ GEM simplecov-html (0.13.1) simplecov_json_formatter (0.1.4) smart_properties (1.17.0) - sorbet-runtime (0.5.11934) + sorbet-runtime (0.5.11953) stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.1.5) stripe (13.5.0) - tailwindcss-rails (4.2.0) + tailwindcss-rails (4.2.1) railties (>= 7.0.0) tailwindcss-ruby (~> 4.0) - tailwindcss-ruby (4.0.14) - tailwindcss-ruby (4.0.14-aarch64-linux-gnu) - tailwindcss-ruby (4.0.14-aarch64-linux-musl) - tailwindcss-ruby (4.0.14-arm64-darwin) - tailwindcss-ruby (4.0.14-x86_64-darwin) - tailwindcss-ruby (4.0.14-x86_64-linux-gnu) - tailwindcss-ruby (4.0.14-x86_64-linux-musl) + tailwindcss-ruby (4.0.15) + tailwindcss-ruby (4.0.15-aarch64-linux-gnu) + tailwindcss-ruby (4.0.15-aarch64-linux-musl) + tailwindcss-ruby (4.0.15-arm64-darwin) + tailwindcss-ruby (4.0.15-x86_64-darwin) + tailwindcss-ruby (4.0.15-x86_64-linux-gnu) + tailwindcss-ruby (4.0.15-x86_64-linux-musl) terminal-table (4.0.0) unicode-display_width (>= 1.1.1, < 4) thor (1.3.2) @@ -518,15 +524,12 @@ GEM zeitwerk (2.7.2) PLATFORMS - aarch64-linux aarch64-linux-gnu aarch64-linux-musl - arm-linux arm-linux-gnu arm-linux-musl arm64-darwin x86_64-darwin - x86_64-linux x86_64-linux-gnu x86_64-linux-musl @@ -574,6 +577,7 @@ DEPENDENCIES rqrcode (~> 2.2) rubocop-rails-omakase ruby-lsp-rails + ruby-openai selenium-webdriver sentry-rails sentry-ruby diff --git a/app/assets/images/ai.svg b/app/assets/images/ai.svg new file mode 100644 index 00000000..ee2c6462 --- /dev/null +++ b/app/assets/images/ai.svg @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css deleted file mode 100644 index dcd72732..00000000 --- a/app/assets/stylesheets/application.css +++ /dev/null @@ -1 +0,0 @@ -/* Application styles */ diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css index 7159c950..4b78a2db 100644 --- a/app/assets/tailwind/application.css +++ b/app/assets/tailwind/application.css @@ -8,7 +8,7 @@ @plugin "@tailwindcss/typography"; @plugin "@tailwindcss/forms"; -@import "../stylesheets/simonweb_pickr.css"; +@import "./simonweb_pickr.css"; @layer components { .pcr-app{ @@ -112,6 +112,30 @@ } } +.prose--ai-chat { + @apply break-words; + + p, li { + @apply text-sm text-primary; + } + + scrollbar-width: thin; + scrollbar-color: rgba(156, 163, 175, 0.5) transparent; + + ::-webkit-scrollbar { + width: 6px; + } + + ::-webkit-scrollbar-track { + background: transparent; + } + + ::-webkit-scrollbar-thumb { + background-color: rgba(156, 163, 175, 0.5); + border-radius: 3px; + } +} + /* Custom scrollbar implementation for Windows browsers */ .windows { ::-webkit-scrollbar { @@ -141,4 +165,5 @@ &::-webkit-scrollbar-thumb:hover { background: #a6a6a6; } -} \ No newline at end of file +} +/* The following Markdown CSS has been removed as requested */ diff --git a/app/assets/tailwind/maybe-design-system.css b/app/assets/tailwind/maybe-design-system.css index c37eb286..ca4f1bbe 100644 --- a/app/assets/tailwind/maybe-design-system.css +++ b/app/assets/tailwind/maybe-design-system.css @@ -316,8 +316,8 @@ } @layer base { - form>button { - @apply cursor-pointer; + button { + @apply cursor-pointer focus-visible:outline-gray-900; } hr { diff --git a/app/assets/stylesheets/simonweb_pickr.css b/app/assets/tailwind/simonweb_pickr.css similarity index 100% rename from app/assets/stylesheets/simonweb_pickr.css rename to app/assets/tailwind/simonweb_pickr.css diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index be739d23..70895a3c 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,10 +1,11 @@ class ApplicationController < ActionController::Base - include Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable, Breadcrumbable + include Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable, Breadcrumbable, FeatureGuardable include Pagy::Backend helper_method :require_upgrade?, :subscription_pending? before_action :detect_os + before_action :set_default_chat private def require_upgrade? @@ -33,4 +34,10 @@ class ApplicationController < ActionController::Base else "" end end + + # By default, we show the user the last chat they interacted with + def set_default_chat + @last_viewed_chat = Current.user&.last_viewed_chat + @chat = @last_viewed_chat + end end diff --git a/app/controllers/chats_controller.rb b/app/controllers/chats_controller.rb new file mode 100644 index 00000000..61909200 --- /dev/null +++ b/app/controllers/chats_controller.rb @@ -0,0 +1,67 @@ +class ChatsController < ApplicationController + include ActionView::RecordIdentifier + + guard_feature unless: -> { Current.user.ai_enabled? } + + before_action :set_chat, only: [ :show, :edit, :update, :destroy ] + + def index + @chat = nil # override application_controller default behavior of setting @chat to last viewed chat + @chats = Current.user.chats.order(created_at: :desc) + end + + def show + set_last_viewed_chat(@chat) + end + + def new + @chat = Current.user.chats.new(title: "New chat #{Time.current.strftime("%Y-%m-%d %H:%M")}") + end + + def create + @chat = Current.user.chats.start!(chat_params[:content], model: chat_params[:ai_model]) + set_last_viewed_chat(@chat) + redirect_to chat_path(@chat, thinking: true) + end + + def edit + end + + def update + @chat.update!(chat_params) + + respond_to do |format| + format.html { redirect_back_or_to chat_path(@chat), notice: "Chat updated" } + format.turbo_stream { render turbo_stream: turbo_stream.replace(dom_id(@chat, :title), partial: "chats/chat_title", locals: { chat: @chat }) } + end + end + + def destroy + @chat.destroy + clear_last_viewed_chat + + redirect_to chats_path, notice: "Chat was successfully deleted" + end + + def retry + @chat.retry_last_message! + redirect_to chat_path(@chat, thinking: true) + end + + private + def set_chat + @chat = Current.user.chats.find(params[:id]) + end + + def set_last_viewed_chat(chat) + Current.user.update!(last_viewed_chat: chat) + end + + def clear_last_viewed_chat + Current.user.update!(last_viewed_chat: nil) + end + + def chat_params + params.require(:chat).permit(:title, :content, :ai_model) + end +end diff --git a/app/controllers/concerns/feature_guardable.rb b/app/controllers/concerns/feature_guardable.rb new file mode 100644 index 00000000..d08f957d --- /dev/null +++ b/app/controllers/concerns/feature_guardable.rb @@ -0,0 +1,23 @@ +# Simple feature guard that renders a 403 Forbidden status with a message +# when the feature is disabled. +# +# Example: +# +# class MessagesController < ApplicationController +# guard_feature unless: -> { Current.user.ai_enabled? } +# end +# +module FeatureGuardable + extend ActiveSupport::Concern + + class_methods do + def guard_feature(**options) + before_action :guard_feature, **options + end + end + + private + def guard_feature + render plain: "Feature disabled: #{controller_name}##{action_name}", status: :forbidden + end +end diff --git a/app/controllers/messages_controller.rb b/app/controllers/messages_controller.rb new file mode 100644 index 00000000..7a7777be --- /dev/null +++ b/app/controllers/messages_controller.rb @@ -0,0 +1,24 @@ +class MessagesController < ApplicationController + guard_feature unless: -> { Current.user.ai_enabled? } + + before_action :set_chat + + def create + @message = UserMessage.create!( + chat: @chat, + content: message_params[:content], + ai_model: message_params[:ai_model] + ) + + redirect_to chat_path(@chat, thinking: true) + end + + private + def set_chat + @chat = Current.user.chats.find(params[:chat_id]) + end + + def message_params + params.require(:message).permit(:content, :ai_model) + end +end diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index e39e4975..c566d30e 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -10,7 +10,7 @@ class PagesController < ApplicationController end def changelog - @release_notes = Providers.github.fetch_latest_release_notes + @release_notes = github_provider.fetch_latest_release_notes render layout: "settings" end @@ -26,4 +26,9 @@ class PagesController < ApplicationController @invite_code = InviteCode.order("RANDOM()").limit(1).first render layout: false end + + private + def github_provider + Provider::Registry.get_provider(:github) + end end diff --git a/app/controllers/settings/hostings_controller.rb b/app/controllers/settings/hostings_controller.rb index f461fc20..6eea6ecc 100644 --- a/app/controllers/settings/hostings_controller.rb +++ b/app/controllers/settings/hostings_controller.rb @@ -1,11 +1,13 @@ class Settings::HostingsController < ApplicationController layout "settings" - before_action :raise_if_not_self_hosted + guard_feature unless: -> { self_hosted? } + before_action :ensure_admin, only: :clear_cache def show - @synth_usage = Providers.synth&.usage + synth_provider = Provider::Registry.get_provider(:synth) + @synth_usage = synth_provider&.usage end def update @@ -37,10 +39,6 @@ class Settings::HostingsController < ApplicationController params.require(:setting).permit(:require_invite_for_signup, :require_email_confirmation, :synth_api_key) end - def raise_if_not_self_hosted - raise "Settings not available on non-self-hosted instance" unless self_hosted? - end - def ensure_admin redirect_to settings_hosting_path, alert: t(".not_authorized") unless Current.user.admin? end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 4300477d..2b146864 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -17,11 +17,19 @@ class UsersController < ApplicationController redirect_to settings_profile_path, alert: error_message end else + was_ai_enabled = @user.ai_enabled @user.update!(user_params.except(:redirect_to, :delete_profile_image)) @user.profile_image.purge if should_purge_profile_image? + # Add a special notice if AI was just enabled + notice = if !was_ai_enabled && @user.ai_enabled + "AI Assistant has been enabled successfully." + else + t(".success") + end + respond_to do |format| - format.html { handle_redirect(t(".success")) } + format.html { handle_redirect(notice) } format.json { head :ok } end end @@ -66,7 +74,7 @@ class UsersController < ApplicationController def user_params params.require(:user).permit( - :first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, :show_sidebar, :default_period, + :first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, :show_sidebar, :default_period, :show_ai_sidebar, :ai_enabled, family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id, :data_enrichment_enabled ] ) end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index e8d898cc..d131447f 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -129,6 +129,73 @@ module ApplicationHelper cookies[:admin] == "true" end + # Renders Markdown text using Redcarpet + def markdown(text) + return "" if text.blank? + + renderer = Redcarpet::Render::HTML.new( + hard_wrap: true, + link_attributes: { target: "_blank", rel: "noopener noreferrer" } + ) + + markdown = Redcarpet::Markdown.new( + renderer, + autolink: true, + tables: true, + fenced_code_blocks: true, + strikethrough: true, + superscript: true, + underline: true, + highlight: true, + quote: true, + footnotes: true + ) + + markdown.render(text).html_safe + end + + # Determines the starting widths of each panel depending on the user's sidebar preferences + def app_sidebar_config(user) + left_sidebar_showing = user.show_sidebar? + right_sidebar_showing = user.show_ai_sidebar? + + content_max_width = if !left_sidebar_showing && !right_sidebar_showing + 1024 # 5xl + elsif left_sidebar_showing && !right_sidebar_showing + 896 # 4xl + else + 768 # 3xl + end + + left_panel_min_width = 320 + left_panel_max_width = 320 + right_panel_min_width = 400 + right_panel_max_width = 550 + + left_panel_width = left_sidebar_showing ? left_panel_min_width : 0 + right_panel_width = if right_sidebar_showing + left_sidebar_showing ? right_panel_min_width : right_panel_max_width + else + 0 + end + + { + left_panel: { + is_open: left_sidebar_showing, + initial_width: left_panel_width, + min_width: left_panel_min_width, + max_width: left_panel_max_width + }, + right_panel: { + is_open: right_sidebar_showing, + initial_width: right_panel_width, + min_width: right_panel_min_width, + max_width: right_panel_max_width + }, + content_max_width: content_max_width + } + end + private def calculate_total(item, money_method, negate) items = item.reject { |i| i.respond_to?(:entryable) && i.entryable.transfer? } diff --git a/app/helpers/chats_helper.rb b/app/helpers/chats_helper.rb new file mode 100644 index 00000000..cb933a2e --- /dev/null +++ b/app/helpers/chats_helper.rb @@ -0,0 +1,12 @@ +module ChatsHelper + def chat_frame + :sidebar_chat + end + + def chat_view_path(chat) + return new_chat_path if params[:chat_view] == "new" + return chats_path if chat.nil? || params[:chat_view] == "all" + + chat.persisted? ? chat_path(chat) : new_chat_path + end +end diff --git a/app/helpers/menus_helper.rb b/app/helpers/menus_helper.rb index 55ad55a3..f903d8ec 100644 --- a/app/helpers/menus_helper.rb +++ b/app/helpers/menus_helper.rb @@ -1,13 +1,20 @@ module MenusHelper - def contextual_menu(&block) - tag.div data: { controller: "menu" } do - concat contextual_menu_icon + def contextual_menu(icon: "more-horizontal", id: nil, &block) + tag.div id: id, data: { controller: "menu" } do + concat contextual_menu_icon(icon) concat contextual_menu_content(&block) end end def contextual_menu_modal_action_item(label, url, icon: "pencil-line", turbo_frame: :modal) - link_to url, class: "flex items-center rounded-lg text-primary hover:bg-gray-50 py-2 px-3 gap-2", data: { turbo_frame: } do + link_to url, class: "flex items-center rounded-md text-primary hover:bg-container-hover p-2 gap-2", data: { action: "click->menu#close", turbo_frame: turbo_frame } do + concat(lucide_icon(icon, class: "shrink-0 w-5 h-5 text-secondary")) + concat(tag.span(label, class: "text-sm")) + end + end + + def contextual_menu_item(label, url:, icon:, turbo_frame: nil) + link_to url, class: "flex items-center rounded-md text-primary hover:bg-container-hover p-2 gap-2", data: { action: "click->menu#close", turbo_frame: turbo_frame } do concat(lucide_icon(icon, class: "shrink-0 w-5 h-5 text-secondary")) concat(tag.span(label, class: "text-sm")) end @@ -16,7 +23,7 @@ module MenusHelper def contextual_menu_destructive_item(label, url, turbo_confirm: true, turbo_frame: nil) button_to url, method: :delete, - class: "flex items-center w-full rounded-lg text-red-500 hover:bg-red-500/5 py-2 px-3 gap-2", + class: "flex items-center w-full rounded-md text-red-500 hover:bg-red-500/5 p-2 gap-2", data: { turbo_confirm: turbo_confirm, turbo_frame: } do concat(lucide_icon("trash-2", class: "shrink-0 w-5 h-5")) concat(tag.span(label, class: "text-sm")) @@ -24,14 +31,14 @@ module MenusHelper end private - def contextual_menu_icon - tag.button class: "flex hover:bg-gray-100 p-2 rounded cursor-pointer", data: { menu_target: "button" } do - lucide_icon "more-horizontal", class: "w-5 h-5 text-secondary" + def contextual_menu_icon(icon) + tag.button class: "w-9 h-9 flex justify-center items-center hover:bg-surface-hover rounded-lg cursor-pointer focus:outline-none focus-visible:outline-none", data: { menu_target: "button" } do + lucide_icon icon, class: "w-5 h-5 text-secondary" end end def contextual_menu_content(&block) - tag.div class: "z-50 border border-alpha-black-25 bg-white rounded-lg shadow-xs hidden", + tag.div class: "min-w-[200px] p-1 z-50 shadow-border-xs bg-white rounded-lg hidden", data: { menu_target: "content" } do capture(&block) end diff --git a/app/javascript/controllers/chat_controller.js b/app/javascript/controllers/chat_controller.js new file mode 100644 index 00000000..7e067309 --- /dev/null +++ b/app/javascript/controllers/chat_controller.js @@ -0,0 +1,60 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["messages", "form", "input"]; + + connect() { + this.#configureAutoScroll(); + } + + disconnect() { + if (this.messagesObserver) { + this.messagesObserver.disconnect(); + } + } + + autoResize() { + const input = this.inputTarget; + const lineHeight = 20; // text-sm line-height (14px * 1.429 ≈ 20px) + const maxLines = 3; // 3 lines = 60px total + + input.style.height = "auto"; + input.style.height = `${Math.min(input.scrollHeight, lineHeight * maxLines)}px`; + input.style.overflowY = + input.scrollHeight > lineHeight * maxLines ? "auto" : "hidden"; + } + + submitSampleQuestion(e) { + this.inputTarget.value = e.target.dataset.chatQuestionParam; + + setTimeout(() => { + this.formTarget.requestSubmit(); + }, 200); + } + + // Newlines require shift+enter, otherwise submit the form (same functionality as ChatGPT and others) + handleInputKeyDown(e) { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + this.formTarget.requestSubmit(); + } + } + + #configureAutoScroll() { + this.messagesObserver = new MutationObserver((_mutations) => { + if (this.hasMessagesTarget) { + this.#scrollToBottom(); + } + }); + + // Listen to entire sidebar for changes, always try to scroll to the bottom + this.messagesObserver.observe(this.element, { + childList: true, + subtree: true, + }); + } + + #scrollToBottom = () => { + this.messagesTarget.scrollTop = this.messagesTarget.scrollHeight; + }; +} diff --git a/app/javascript/controllers/sidebar_controller.js b/app/javascript/controllers/sidebar_controller.js index e0577edf..c5eb3a0c 100644 --- a/app/javascript/controllers/sidebar_controller.js +++ b/app/javascript/controllers/sidebar_controller.js @@ -2,17 +2,69 @@ import { Controller } from "@hotwired/stimulus"; // Connects to data-controller="sidebar" export default class extends Controller { - static values = { userId: String }; - static targets = ["panel", "content"]; + static values = { + userId: String, + config: Object, + }; - toggle() { - this.panelTarget.classList.toggle("w-0"); - this.panelTarget.classList.toggle("opacity-0"); - this.panelTarget.classList.toggle("w-80"); - this.panelTarget.classList.toggle("opacity-100"); - this.contentTarget.classList.toggle("max-w-4xl"); - this.contentTarget.classList.toggle("max-w-5xl"); + static targets = ["leftPanel", "rightPanel", "content"]; + initialize() { + this.leftPanelOpen = this.configValue.left_panel.is_open; + this.rightPanelOpen = this.configValue.right_panel.is_open; + } + + toggleLeftPanel() { + this.leftPanelOpen = !this.leftPanelOpen; + this.#updatePanelWidths(); + this.#persistPreference("show_sidebar", this.leftPanelOpen); + } + + toggleRightPanel() { + this.rightPanelOpen = !this.rightPanelOpen; + this.#updatePanelWidths(); + this.#persistPreference("show_ai_sidebar", this.rightPanelOpen); + } + + #updatePanelWidths() { + this.contentTarget.style.maxWidth = `${this.#contentMaxWidth()}px`; + this.leftPanelTarget.style.width = `${this.#leftPanelWidth()}px`; + this.rightPanelTarget.style.width = `${this.#rightPanelWidth()}px`; + } + + #leftPanelWidth() { + if (this.leftPanelOpen) { + return this.configValue.left_panel.min_width; + } + + return 0; + } + + #rightPanelWidth() { + if (this.rightPanelOpen) { + if (this.leftPanelOpen) { + return this.configValue.right_panel.min_width; + } + + return this.configValue.right_panel.max_width; + } + + return 0; + } + + #contentMaxWidth() { + if (!this.leftPanelOpen && !this.rightPanelOpen) { + return 1024; + } + + if (this.leftPanelOpen && !this.rightPanelOpen) { + return 896; + } + + return 768; + } + + #persistPreference(field, value) { fetch(`/users/${this.userIdValue}`, { method: "PATCH", headers: { @@ -21,7 +73,7 @@ export default class extends Controller { Accept: "application/json", }, body: new URLSearchParams({ - "user[show_sidebar]": !this.panelTarget.classList.contains("w-0"), + [`user[${field}]`]: value, }).toString(), }); } diff --git a/app/jobs/assistant_response_job.rb b/app/jobs/assistant_response_job.rb new file mode 100644 index 00000000..66bc81bd --- /dev/null +++ b/app/jobs/assistant_response_job.rb @@ -0,0 +1,7 @@ +class AssistantResponseJob < ApplicationJob + queue_as :default + + def perform(message) + message.request_response + end +end diff --git a/app/models/account/chartable.rb b/app/models/account/chartable.rb index f251e7f1..bac6a50e 100644 --- a/app/models/account/chartable.rb +++ b/app/models/account/chartable.rb @@ -2,15 +2,17 @@ module Account::Chartable extend ActiveSupport::Concern class_methods do - def balance_series(currency:, period: Period.last_30_days, favorable_direction: "up", view: :balance) + def balance_series(currency:, period: Period.last_30_days, favorable_direction: "up", view: :balance, interval: nil) raise ArgumentError, "Invalid view type" unless [ :balance, :cash_balance, :holdings_balance ].include?(view.to_sym) + series_interval = interval || period.interval + balances = Account::Balance.find_by_sql([ balance_series_query, { start_date: period.start_date, end_date: period.end_date, - interval: period.interval, + interval: series_interval, target_currency: currency } ]) @@ -33,7 +35,7 @@ module Account::Chartable Series.new( start_date: period.start_date, end_date: period.end_date, - interval: period.interval, + interval: series_interval, trend: Trend.new( current: Money.new(balance_value_for(balances.last, view) || 0, currency), previous: Money.new(balance_value_for(balances.first, view) || 0, currency), @@ -124,11 +126,12 @@ module Account::Chartable classification == "asset" ? "up" : "down" end - def balance_series(period: Period.last_30_days, view: :balance) + def balance_series(period: Period.last_30_days, view: :balance, interval: nil) self.class.where(id: self.id).balance_series( currency: currency, period: period, view: view, + interval: interval, favorable_direction: favorable_direction ) end diff --git a/app/models/account/transaction/provided.rb b/app/models/account/transaction/provided.rb index 14df5b55..4bae0ab4 100644 --- a/app/models/account/transaction/provided.rb +++ b/app/models/account/transaction/provided.rb @@ -2,9 +2,9 @@ module Account::Transaction::Provided extend ActiveSupport::Concern def fetch_enrichment_info - return nil unless Providers.synth # Only Synth can provide this data + return nil unless provider - response = Providers.synth.enrich_transaction( + response = provider.enrich_transaction( entry.name, amount: entry.amount, date: entry.date @@ -12,4 +12,9 @@ module Account::Transaction::Provided response.data end + + private + def provider + Provider::Registry.get_provider(:synth) + end end diff --git a/app/models/assistant.rb b/app/models/assistant.rb new file mode 100644 index 00000000..c1434a5e --- /dev/null +++ b/app/models/assistant.rb @@ -0,0 +1,178 @@ +# Orchestrates LLM interactions for chat conversations by: +# - Streaming generic provider responses +# - Persisting messages and tool calls +# - Broadcasting updates to chat UI +# - Handling provider errors +class Assistant + include Provided + + attr_reader :chat + + class << self + def for_chat(chat) + new(chat) + end + end + + def initialize(chat) + @chat = chat + end + + def streamer(model) + assistant_message = AssistantMessage.new( + chat: chat, + content: "", + ai_model: model + ) + + proc do |chunk| + case chunk.type + when "output_text" + stop_thinking + assistant_message.content += chunk.data + assistant_message.save! + when "function_request" + update_thinking("Analyzing your data to assist you with your question...") + when "response" + stop_thinking + assistant_message.ai_model = chunk.data.model + combined_tool_calls = chunk.data.functions.map do |tc| + ToolCall::Function.new( + provider_id: tc.id, + provider_call_id: tc.call_id, + function_name: tc.name, + function_arguments: tc.arguments, + function_result: tc.result + ) + end + + assistant_message.tool_calls = combined_tool_calls + assistant_message.save! + chat.update!(latest_assistant_response_id: chunk.data.id) + end + end + end + + def respond_to(message) + chat.clear_error + sleep artificial_thinking_delay + + provider = get_model_provider(message.ai_model) + + provider.chat_response( + message, + instructions: instructions, + available_functions: functions, + streamer: streamer(message.ai_model) + ) + rescue => e + chat.add_error(e) + end + + private + def update_thinking(thought) + chat.broadcast_update target: "thinking-indicator", partial: "chats/thinking_indicator", locals: { chat: chat, message: thought } + end + + def stop_thinking + chat.broadcast_remove target: "thinking-indicator" + end + + def process_response_artifacts(data) + messages = data.messages.map do |message| + AssistantMessage.new( + chat: chat, + content: message.content, + provider_id: message.id, + ai_model: data.model, + tool_calls: data.functions.map do |fn| + ToolCall::Function.new( + provider_id: fn.id, + provider_call_id: fn.call_id, + function_name: fn.name, + function_arguments: fn.arguments, + function_result: fn.result + ) + end + ) + end + + messages.each(&:save!) + end + + def instructions + <<~PROMPT + ## Your identity + + You are a financial assistant for an open source personal finance application called "Maybe", which is short for "Maybe Finance". + + ## Your purpose + + You help users understand their financial data by answering questions about their accounts, + transactions, income, expenses, net worth, and more. + + ## Your rules + + Follow all rules below at all times. + + ### General rules + + - Provide ONLY the most important numbers and insights + - Eliminate all unnecessary words and context + - Ask follow-up questions to keep the conversation going. Help educate the user about their own data and entice them to ask more questions. + - Do NOT add introductions or conclusions + - Do NOT apologize or explain limitations + + ### Formatting rules + + - Format all responses in markdown + - Format all monetary values according to the user's preferred currency + + #### User's preferred currency + + Maybe is a multi-currency app where each user has a "preferred currency" setting. + + When no currency is specified, use the user's preferred currency for formatting and displaying monetary values. + + - Symbol: #{preferred_currency.symbol} + - ISO code: #{preferred_currency.iso_code} + - Default precision: #{preferred_currency.default_precision} + - Default format: #{preferred_currency.default_format} + - Separator: #{preferred_currency.separator} + - Delimiter: #{preferred_currency.delimiter} + + ### Rules about financial advice + + You are NOT a licensed financial advisor and therefore, you should not provide any financial advice. Instead, + you should focus on educating the user about personal finance and their own data so they can make informed decisions. + + - Do not provide financial and/or investment advice + - Do not suggest investments or financial products + - Do not make assumptions about the user's financial situation. Use the functions available to get the data you need. + + ### Function calling rules + + - Use the functions available to you to get user financial data and enhance your responses + - For functions that require dates, use the current date as your reference point: #{Date.current} + - If you suspect that you do not have enough data to 100% accurately answer, be transparent about it and state exactly what + the data you're presenting represents and what context it is in (i.e. date range, account, etc.) + PROMPT + end + + def functions + [ + Assistant::Function::GetTransactions.new(chat.user), + Assistant::Function::GetAccounts.new(chat.user), + Assistant::Function::GetBalanceSheet.new(chat.user), + Assistant::Function::GetIncomeStatement.new(chat.user) + ] + end + + def preferred_currency + Money::Currency.new(chat.user.family.currency) + end + + def artificial_thinking_delay + 1 + end +end diff --git a/app/models/assistant/function.rb b/app/models/assistant/function.rb new file mode 100644 index 00000000..912063cc --- /dev/null +++ b/app/models/assistant/function.rb @@ -0,0 +1,83 @@ +class Assistant::Function + class << self + def name + raise NotImplementedError, "Subclasses must implement the name class method" + end + + def description + raise NotImplementedError, "Subclasses must implement the description class method" + end + end + + def initialize(user) + @user = user + end + + def call(params = {}) + raise NotImplementedError, "Subclasses must implement the call method" + end + + def name + self.class.name + end + + def description + self.class.description + end + + def params_schema + build_schema + end + + # (preferred) when in strict mode, the schema needs to include all properties in required array + def strict_mode? + true + end + + private + attr_reader :user + + def build_schema(properties: {}, required: []) + { + type: "object", + properties: properties, + required: required, + additionalProperties: false + } + end + + def family_account_names + @family_account_names ||= family.accounts.active.pluck(:name) + end + + def family_category_names + @family_category_names ||= begin + names = family.categories.pluck(:name) + names << "Uncategorized" + names + end + end + + def family_merchant_names + @family_merchant_names ||= family.merchants.pluck(:name) + end + + def family_tag_names + @family_tag_names ||= family.tags.pluck(:name) + end + + def family + user.family + end + + # To save tokens, we provide the AI metadata about the series and a flat array of + # raw, formatted values which it can infer dates from + def to_ai_time_series(series) + { + start_date: series.start_date, + end_date: series.end_date, + interval: series.interval, + values: series.values.map { |v| v.trend.current.format } + } + end +end diff --git a/app/models/assistant/function/get_accounts.rb b/app/models/assistant/function/get_accounts.rb new file mode 100644 index 00000000..b912d81d --- /dev/null +++ b/app/models/assistant/function/get_accounts.rb @@ -0,0 +1,40 @@ +class Assistant::Function::GetAccounts < Assistant::Function + class << self + def name + "get_accounts" + end + + def description + "Use this to see what accounts the user has along with their current and historical balances" + end + end + + def call(params = {}) + { + as_of_date: Date.current, + accounts: family.accounts.includes(:balances).map do |account| + { + name: account.name, + balance: account.balance, + currency: account.currency, + balance_formatted: account.balance_money.format, + classification: account.classification, + type: account.accountable_type, + start_date: account.start_date, + is_plaid_linked: account.plaid_account_id.present?, + is_active: account.is_active, + historical_balances: historical_balances(account) + } + end + } + end + + private + def historical_balances(account) + start_date = [ account.start_date, 5.years.ago.to_date ].max + period = Period.custom(start_date: start_date, end_date: Date.current) + balance_series = account.balance_series(period: period, interval: "1 month") + + to_ai_time_series(balance_series) + end +end diff --git a/app/models/assistant/function/get_balance_sheet.rb b/app/models/assistant/function/get_balance_sheet.rb new file mode 100644 index 00000000..81afeccb --- /dev/null +++ b/app/models/assistant/function/get_balance_sheet.rb @@ -0,0 +1,73 @@ +class Assistant::Function::GetBalanceSheet < Assistant::Function + include ActiveSupport::NumberHelper + + class << self + def name + "get_balance_sheet" + end + + def description + <<~INSTRUCTIONS + Use this to get the user's balance sheet with varying amounts of historical data. + + This is great for answering questions like: + - What is the user's net worth? What is it composed of? + - How has the user's wealth changed over time? + INSTRUCTIONS + end + end + + def call(params = {}) + observation_start_date = [ 5.years.ago.to_date, family.oldest_entry_date ].max + + period = Period.custom(start_date: observation_start_date, end_date: Date.current) + + { + as_of_date: Date.current, + oldest_account_start_date: family.oldest_entry_date, + currency: family.currency, + net_worth: { + current: family.balance_sheet.net_worth_money.format, + monthly_history: historical_data(period) + }, + assets: { + current: family.balance_sheet.total_assets_money.format, + monthly_history: historical_data(period, classification: "asset") + }, + liabilities: { + current: family.balance_sheet.total_liabilities_money.format, + monthly_history: historical_data(period, classification: "liability") + }, + insights: insights_data + } + end + + private + def historical_data(period, classification: nil) + scope = family.accounts.active + scope = scope.where(classification: classification) if classification.present? + + if period.start_date == Date.current + [] + else + balance_series = scope.balance_series( + currency: family.currency, + period: period, + interval: "1 month", + favorable_direction: "up", + ) + + to_ai_time_series(balance_series) + end + end + + def insights_data + assets = family.balance_sheet.total_assets + liabilities = family.balance_sheet.total_liabilities + ratio = liabilities.zero? ? 0 : (liabilities / assets.to_f) + + { + debt_to_asset_ratio: number_to_percentage(ratio * 100, precision: 0) + } + end +end diff --git a/app/models/assistant/function/get_income_statement.rb b/app/models/assistant/function/get_income_statement.rb new file mode 100644 index 00000000..ba3100a9 --- /dev/null +++ b/app/models/assistant/function/get_income_statement.rb @@ -0,0 +1,125 @@ +class Assistant::Function::GetIncomeStatement < Assistant::Function + include ActiveSupport::NumberHelper + + class << self + def name + "get_income_statement" + end + + def description + <<~INSTRUCTIONS + Use this to get income and expense insights by category, for a specific time period + + This is great for answering questions like: + - What is the user's net income for the current month? + - What are the user's spending habits? + - How much income or spending did the user have over a specific time period? + + Simple example: + + ``` + get_income_statement({ + start_date: "2024-01-01", + end_date: "2024-12-31" + }) + ``` + INSTRUCTIONS + end + end + + def call(params = {}) + period = Period.custom(start_date: Date.parse(params["start_date"]), end_date: Date.parse(params["end_date"])) + income_data = family.income_statement.income_totals(period: period) + expense_data = family.income_statement.expense_totals(period: period) + + { + currency: family.currency, + period: { + start_date: period.start_date, + end_date: period.end_date + }, + income: { + total: format_money(income_data.total), + by_category: to_ai_category_totals(income_data.category_totals) + }, + expense: { + total: format_money(expense_data.total), + by_category: to_ai_category_totals(expense_data.category_totals) + }, + insights: get_insights(income_data, expense_data) + } + end + + def params_schema + build_schema( + required: [ "start_date", "end_date" ], + properties: { + start_date: { + type: "string", + description: "Start date for aggregation period in YYYY-MM-DD format" + }, + end_date: { + type: "string", + description: "End date for aggregation period in YYYY-MM-DD format" + } + } + ) + end + + private + def format_money(value) + Money.new(value, family.currency).format + end + + def calculate_savings_rate(total_income, total_expenses) + return 0 if total_income.zero? + savings = total_income - total_expenses + rate = (savings / total_income.to_f) * 100 + rate.round(2) + end + + def to_ai_category_totals(category_totals) + hierarchical_groups = category_totals.group_by { |ct| ct.category.parent_id }.then do |grouped| + root_category_totals = grouped[nil] || [] + + root_category_totals.each_with_object({}) do |ct, hash| + subcategory_totals = ct.category.name == "Uncategorized" ? [] : (grouped[ct.category.id] || []) + hash[ct.category.name] = { + category_total: ct, + subcategory_totals: subcategory_totals + } + end + end + + hierarchical_groups.sort_by { |name, data| -data.dig(:category_total).total }.map do |name, data| + { + name: name, + total: format_money(data.dig(:category_total).total), + percentage_of_total: number_to_percentage(data.dig(:category_total).weight, precision: 1), + subcategory_totals: data.dig(:subcategory_totals).map do |st| + { + name: st.category.name, + total: format_money(st.total), + percentage_of_total: number_to_percentage(st.weight, precision: 1) + } + end + } + end + end + + def get_insights(income_data, expense_data) + net_income = income_data.total - expense_data.total + savings_rate = calculate_savings_rate(income_data.total, expense_data.total) + median_monthly_income = family.income_statement.median_income + median_monthly_expenses = family.income_statement.median_expense + avg_monthly_expenses = family.income_statement.avg_expense + + { + net_income: format_money(net_income), + savings_rate: number_to_percentage(savings_rate), + median_monthly_income: format_money(median_monthly_income), + median_monthly_expenses: format_money(median_monthly_expenses), + avg_monthly_expenses: format_money(avg_monthly_expenses) + } + end +end diff --git a/app/models/assistant/function/get_transactions.rb b/app/models/assistant/function/get_transactions.rb new file mode 100644 index 00000000..6ca8faaa --- /dev/null +++ b/app/models/assistant/function/get_transactions.rb @@ -0,0 +1,185 @@ +class Assistant::Function::GetTransactions < Assistant::Function + include Pagy::Backend + + class << self + def default_page_size + 50 + end + + def name + "get_transactions" + end + + def description + <<~INSTRUCTIONS + Use this to search user's transactions by using various optional filters. + + This function is great for things like: + - Finding specific transactions + - Getting basic stats about a small group of transactions + + This function is not great for: + - Large time periods (use the get_income_statement function for this) + + Note on pagination: + + This function can be paginated. You can expect the following properties in the response: + + - `total_pages`: The total number of pages of results + - `page`: The current page of results + - `page_size`: The number of results per page (this will always be #{default_page_size}) + - `total_results`: The total number of results for the given filters + - `total_income`: The total income for the given filters + - `total_expenses`: The total expenses for the given filters + + Simple example (transactions from the last 30 days): + + ``` + get_transactions({ + page: 1, + start_date: "#{30.days.ago.to_date}", + end_date: "#{Date.current}" + }) + ``` + + More complex example (various filters): + + ``` + get_transactions({ + page: 1, + search: "mcdonalds", + accounts: ["Checking", "Savings"], + start_date: "#{30.days.ago.to_date}", + end_date: "#{Date.current}", + categories: ["Restaurants"], + merchants: ["McDonald's"], + tags: ["Food"], + amount: "100", + amount_operator: "less" + }) + ``` + INSTRUCTIONS + end + end + + def strict_mode? + false + end + + def params_schema + build_schema( + required: [ "order", "page", "page_size" ], + properties: { + page: { + type: "integer", + description: "Page number" + }, + order: { + enum: [ "asc", "desc" ], + description: "Order of the transactions by date" + }, + search: { + type: "string", + description: "Search for transactions by name" + }, + amount: { + type: "string", + description: "Amount for transactions (must be used with amount_operator)" + }, + amount_operator: { + type: "string", + description: "Operator for amount (must be used with amount)", + enum: [ "equal", "less", "greater" ] + }, + start_date: { + type: "string", + description: "Start date for transactions in YYYY-MM-DD format" + }, + end_date: { + type: "string", + description: "End date for transactions in YYYY-MM-DD format" + }, + accounts: { + type: "array", + description: "Filter transactions by account name", + items: { enum: family_account_names }, + minItems: 1, + uniqueItems: true + }, + categories: { + type: "array", + description: "Filter transactions by category name", + items: { enum: family_category_names }, + minItems: 1, + uniqueItems: true + }, + merchants: { + type: "array", + description: "Filter transactions by merchant name", + items: { enum: family_merchant_names }, + minItems: 1, + uniqueItems: true + }, + tags: { + type: "array", + description: "Filter transactions by tag name", + items: { enum: family_tag_names }, + minItems: 1, + uniqueItems: true + } + } + ) + end + + def call(params = {}) + search_params = params.except("order", "page") + + transactions_query = family.transactions.active.search(search_params) + pagy_query = params["order"] == "asc" ? transactions_query.chronological : transactions_query.reverse_chronological + + # By default, we give a small page size to force the AI to use filters effectively and save on tokens + pagy, paginated_transactions = pagy( + pagy_query.includes( + { entry: :account }, + :category, :merchant, :tags, + transfer_as_outflow: { inflow_transaction: { entry: :account } }, + transfer_as_inflow: { outflow_transaction: { entry: :account } } + ), + page: params["page"] || 1, + limit: default_page_size + ) + + totals = family.income_statement.totals(transactions_scope: transactions_query) + + normalized_transactions = paginated_transactions.map do |txn| + entry = txn.entry + { + date: entry.date, + amount: entry.amount.abs, + currency: entry.currency, + formatted_amount: entry.amount_money.abs.format, + classification: entry.amount < 0 ? "income" : "expense", + account: entry.account.name, + category: txn.category&.name, + merchant: txn.merchant&.name, + tags: txn.tags.map(&:name), + is_transfer: txn.transfer.present? + } + end + + { + transactions: normalized_transactions, + total_results: pagy.count, + page: pagy.page, + page_size: default_page_size, + total_pages: pagy.pages, + total_income: totals.income_money.format, + total_expenses: totals.expense_money.format + } + end + + private + def default_page_size + self.class.default_page_size + end +end diff --git a/app/models/assistant/provided.rb b/app/models/assistant/provided.rb new file mode 100644 index 00000000..f33067c6 --- /dev/null +++ b/app/models/assistant/provided.rb @@ -0,0 +1,12 @@ +module Assistant::Provided + extend ActiveSupport::Concern + + def get_model_provider(ai_model) + registry.providers.find { |provider| provider.supports_model?(ai_model) } + end + + private + def registry + @registry ||= Provider::Registry.for_concept(:llm) + end +end diff --git a/app/models/assistant_message.rb b/app/models/assistant_message.rb new file mode 100644 index 00000000..67727040 --- /dev/null +++ b/app/models/assistant_message.rb @@ -0,0 +1,11 @@ +class AssistantMessage < Message + validates :ai_model, presence: true + + def role + "assistant" + end + + def broadcast? + true + end +end diff --git a/app/models/chat.rb b/app/models/chat.rb new file mode 100644 index 00000000..8ef81eaf --- /dev/null +++ b/app/models/chat.rb @@ -0,0 +1,64 @@ +class Chat < ApplicationRecord + include Debuggable + + belongs_to :user + + has_one :viewer, class_name: "User", foreign_key: :last_viewed_chat_id, dependent: :nullify # "Last chat user has viewed" + has_many :messages, dependent: :destroy + + validates :title, presence: true + + scope :ordered, -> { order(created_at: :desc) } + + class << self + def start!(prompt, model:) + create!( + title: generate_title(prompt), + messages: [ UserMessage.new(content: prompt, ai_model: model) ] + ) + end + + def generate_title(prompt) + prompt.first(80) + end + end + + def retry_last_message! + last_message = conversation_messages.ordered.last + + if last_message.present? && last_message.role == "user" + update!(error: nil) + ask_assistant_later(last_message) + end + end + + def add_error(e) + update! error: e.to_json + broadcast_append target: "messages", partial: "chats/error", locals: { chat: self } + end + + def clear_error + update! error: nil + broadcast_remove target: "chat-error" + end + + def assistant + @assistant ||= Assistant.for_chat(self) + end + + def ask_assistant_later(message) + AssistantResponseJob.perform_later(message) + end + + def ask_assistant(message) + assistant.respond_to(message) + end + + def conversation_messages + if debug_mode? + messages + else + messages.where(type: [ "UserMessage", "AssistantMessage" ]) + end + end +end diff --git a/app/models/chat/debuggable.rb b/app/models/chat/debuggable.rb new file mode 100644 index 00000000..05bf8335 --- /dev/null +++ b/app/models/chat/debuggable.rb @@ -0,0 +1,7 @@ +module Chat::Debuggable + extend ActiveSupport::Concern + + def debug_mode? + ENV["AI_DEBUG_MODE"] == "true" + end +end diff --git a/app/models/developer_message.rb b/app/models/developer_message.rb new file mode 100644 index 00000000..ca1d2526 --- /dev/null +++ b/app/models/developer_message.rb @@ -0,0 +1,9 @@ +class DeveloperMessage < Message + def role + "developer" + end + + def broadcast? + chat.debug_mode? + end +end diff --git a/app/models/exchange_rate/provided.rb b/app/models/exchange_rate/provided.rb index 6c502c05..dbe87133 100644 --- a/app/models/exchange_rate/provided.rb +++ b/app/models/exchange_rate/provided.rb @@ -3,7 +3,8 @@ module ExchangeRate::Provided class_methods do def provider - Providers.synth + registry = Provider::Registry.for_concept(:exchange_rates) + registry.get_provider(:synth) end def find_or_fetch_rate(from:, to:, date: Date.current, cache: true) @@ -16,8 +17,13 @@ module ExchangeRate::Provided return nil unless response.success? # Provider error - rate = response.data.rate - rate.save! if cache + rate = response.data + ExchangeRate.find_or_create_by!( + from_currency: rate.from, + to_currency: rate.to, + date: rate.date, + rate: rate.rate + ) if cache rate end @@ -34,8 +40,13 @@ module ExchangeRate::Provided return 0 end - rates_data = fetched_rates.data.rates.map do |rate| - rate.attributes.slice("from_currency", "to_currency", "date", "rate") + rates_data = fetched_rates.data.map do |rate| + { + from_currency: rate.from, + to_currency: rate.to, + date: rate.date, + rate: rate.rate + } end ExchangeRate.upsert_all(rates_data, unique_by: %i[from_currency to_currency date]) diff --git a/app/models/family.rb b/app/models/family.rb index ec2d1bb6..0b4405e8 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -74,9 +74,9 @@ class Family < ApplicationRecord def get_link_token(webhooks_url:, redirect_url:, accountable_type: nil, region: :us, access_token: nil) provider = if region.to_sym == :eu - Providers.plaid_eu + Provider::Registry.get_provider(:plaid_eu) else - Providers.plaid_us + Provider::Registry.get_provider(:plaid_us) end # early return when no provider diff --git a/app/models/financial_assistant.rb b/app/models/financial_assistant.rb deleted file mode 100644 index 7480becc..00000000 --- a/app/models/financial_assistant.rb +++ /dev/null @@ -1,11 +0,0 @@ -class FinancialAssistant - include Provided - - def initialize(chat) - @chat = chat - end - - def query(prompt, model_key: "gpt-4o") - llm_provider = self.class.llm_provider_for(model_key) - end -end diff --git a/app/models/financial_assistant/provided.rb b/app/models/financial_assistant/provided.rb deleted file mode 100644 index f88ad339..00000000 --- a/app/models/financial_assistant/provided.rb +++ /dev/null @@ -1,13 +0,0 @@ -module FinancialAssistant::Provided - extend ActiveSupport::Concern - - # Placeholder for AI chat PR - def llm_provider_for(model_key) - case model_key - when "gpt-4o" - Providers.openai - else - raise "Unknown LLM model key: #{model_key}" - end - end -end diff --git a/app/models/message.rb b/app/models/message.rb new file mode 100644 index 00000000..c0a0b02e --- /dev/null +++ b/app/models/message.rb @@ -0,0 +1,22 @@ +class Message < ApplicationRecord + belongs_to :chat + has_many :tool_calls, dependent: :destroy + + enum :status, { + pending: "pending", + complete: "complete", + failed: "failed" + } + + validates :content, presence: true, allow_blank: true + + after_create_commit -> { broadcast_append_to chat, target: "messages" }, if: :broadcast? + after_update_commit -> { broadcast_update_to chat }, if: :broadcast? + + scope :ordered, -> { order(created_at: :asc) } + + private + def broadcast? + raise NotImplementedError, "subclasses must set #broadcast?" + end +end diff --git a/app/models/period.rb b/app/models/period.rb index 85ad2947..2cceb743 100644 --- a/app/models/period.rb +++ b/app/models/period.rb @@ -156,8 +156,8 @@ class Period def must_be_valid_date_range return if start_date.nil? || end_date.nil? unless start_date.is_a?(Date) && end_date.is_a?(Date) - errors.add(:start_date, "must be a valid date") - errors.add(:end_date, "must be a valid date") + errors.add(:start_date, "must be a valid date, got #{start_date.inspect}") + errors.add(:end_date, "must be a valid date, got #{end_date.inspect}") return end diff --git a/app/models/plaid_item/provided.rb b/app/models/plaid_item/provided.rb index 761a75c1..3d857e4b 100644 --- a/app/models/plaid_item/provided.rb +++ b/app/models/plaid_item/provided.rb @@ -3,11 +3,11 @@ module PlaidItem::Provided class_methods do def plaid_us_provider - Providers.plaid_us + Provider::Registry.get_provider(:plaid_us) end def plaid_eu_provider - Providers.plaid_eu + Provider::Registry.get_provider(:plaid_eu) end def plaid_provider_for_region(region) diff --git a/app/models/provider.rb b/app/models/provider.rb index 6843475b..f188231e 100644 --- a/app/models/provider.rb +++ b/app/models/provider.rb @@ -1,8 +1,25 @@ class Provider include Retryable - ProviderError = Class.new(StandardError) - ProviderResponse = Data.define(:success?, :data, :error) + Response = Data.define(:success?, :data, :error) + + class Error < StandardError + attr_reader :details, :provider + + def initialize(message, details: nil, provider: nil) + super(message) + @details = details + @provider = provider + end + + def as_json + { + provider: provider, + message: message, + details: details + } + end + end private PaginatedData = Data.define(:paginated, :first_page, :total_pages) @@ -13,23 +30,49 @@ class Provider [] end - def provider_response(retries: nil, &block) - data = if retries + def with_provider_response(retries: default_retries, error_transformer: nil, &block) + data = if retries > 0 retrying(retryable_errors, max_retries: retries) { yield } else yield end - ProviderResponse.new( + Response.new( success?: true, data: data, error: nil, ) - rescue StandardError => error - ProviderResponse.new( + rescue => error + transformed_error = if error_transformer + error_transformer.call(error) + else + default_error_transformer(error) + end + + Sentry.capture_exception(transformed_error) + + Response.new( success?: false, data: nil, - error: error, + error: transformed_error ) end + + # Override to set class-level error transformation for methods using `with_provider_response` + def default_error_transformer(error) + if error.is_a?(Faraday::Error) + Error.new( + error.message, + details: error.response&.dig(:body), + provider: self.class.name + ) + else + Error.new(error.message, provider: self.class.name) + end + end + + # Override to set class-level number of retries for methods using `with_provider_response` + def default_retries + 0 + end end diff --git a/app/models/exchange_rate/provideable.rb b/app/models/provider/exchange_rate_provider.rb similarity index 64% rename from app/models/exchange_rate/provideable.rb rename to app/models/provider/exchange_rate_provider.rb index 5f2278c6..b00ef2cc 100644 --- a/app/models/exchange_rate/provideable.rb +++ b/app/models/provider/exchange_rate_provider.rb @@ -1,10 +1,6 @@ -# Defines the interface an exchange rate provider must implement -module ExchangeRate::Provideable +module Provider::ExchangeRateProvider extend ActiveSupport::Concern - FetchRateData = Data.define(:rate) - FetchRatesData = Data.define(:rates) - def fetch_exchange_rate(from:, to:, date:) raise NotImplementedError, "Subclasses must implement #fetch_exchange_rate" end @@ -12,4 +8,7 @@ module ExchangeRate::Provideable def fetch_exchange_rates(from:, to:, start_date:, end_date:) raise NotImplementedError, "Subclasses must implement #fetch_exchange_rates" end + + private + Rate = Data.define(:date, :from, :to, :rate) end diff --git a/app/models/provider/llm_provider.rb b/app/models/provider/llm_provider.rb new file mode 100644 index 00000000..8282975a --- /dev/null +++ b/app/models/provider/llm_provider.rb @@ -0,0 +1,13 @@ +module Provider::LlmProvider + extend ActiveSupport::Concern + + def chat_response(message, instructions: nil, available_functions: [], streamer: nil) + raise NotImplementedError, "Subclasses must implement #chat_response" + end + + private + StreamChunk = Data.define(:type, :data) + ChatResponse = Data.define(:id, :messages, :functions, :model) + Message = Data.define(:id, :content) + FunctionExecution = Data.define(:id, :call_id, :name, :arguments, :result) +end diff --git a/app/models/provider/openai.rb b/app/models/provider/openai.rb new file mode 100644 index 00000000..fb515fd7 --- /dev/null +++ b/app/models/provider/openai.rb @@ -0,0 +1,30 @@ +class Provider::Openai < Provider + include LlmProvider + + MODELS = %w[gpt-4o] + + def initialize(access_token) + @client = ::OpenAI::Client.new(access_token: access_token) + end + + def supports_model?(model) + MODELS.include?(model) + end + + def chat_response(message, instructions: nil, available_functions: [], streamer: nil) + with_provider_response do + processor = ChatResponseProcessor.new( + client: client, + message: message, + instructions: instructions, + available_functions: available_functions, + streamer: streamer + ) + + processor.process + end + end + + private + attr_reader :client +end diff --git a/app/models/provider/openai/chat_response_processor.rb b/app/models/provider/openai/chat_response_processor.rb new file mode 100644 index 00000000..c0d259ff --- /dev/null +++ b/app/models/provider/openai/chat_response_processor.rb @@ -0,0 +1,188 @@ +class Provider::Openai::ChatResponseProcessor + def initialize(message:, client:, instructions: nil, available_functions: [], streamer: nil) + @client = client + @message = message + @instructions = instructions + @available_functions = available_functions + @streamer = streamer + end + + def process + first_response = fetch_response(previous_response_id: previous_openai_response_id) + + if first_response.functions.empty? + if streamer.present? + streamer.call(Provider::LlmProvider::StreamChunk.new(type: "response", data: first_response)) + end + + return first_response + end + + executed_functions = execute_pending_functions(first_response.functions) + + follow_up_response = fetch_response( + executed_functions: executed_functions, + previous_response_id: first_response.id + ) + + if streamer.present? + streamer.call(Provider::LlmProvider::StreamChunk.new(type: "response", data: follow_up_response)) + end + + follow_up_response + end + + private + attr_reader :client, :message, :instructions, :available_functions, :streamer + + PendingFunction = Data.define(:id, :call_id, :name, :arguments) + + def fetch_response(executed_functions: [], previous_response_id: nil) + function_results = executed_functions.map do |executed_function| + { + type: "function_call_output", + call_id: executed_function.call_id, + output: executed_function.result.to_json + } + end + + prepared_input = input + function_results + + # No need to pass tools for follow-up messages that provide function results + prepared_tools = executed_functions.empty? ? tools : [] + + raw_response = nil + + internal_streamer = proc do |chunk| + type = chunk.dig("type") + + if streamer.present? + case type + when "response.output_text.delta", "response.refusal.delta" + # We don't distinguish between text and refusal yet, so stream both the same + streamer.call(Provider::LlmProvider::StreamChunk.new(type: "output_text", data: chunk.dig("delta"))) + when "response.function_call_arguments.done" + streamer.call(Provider::LlmProvider::StreamChunk.new(type: "function_request", data: chunk.dig("arguments"))) + end + end + + if type == "response.completed" + raw_response = chunk.dig("response") + end + end + + client.responses.create(parameters: { + model: model, + input: prepared_input, + instructions: instructions, + tools: prepared_tools, + previous_response_id: previous_response_id, + stream: internal_streamer + }) + + if raw_response.dig("status") == "failed" || raw_response.dig("status") == "incomplete" + raise Provider::Openai::Error.new("OpenAI returned a failed or incomplete response", { chunk: chunk }) + end + + response_output = raw_response.dig("output") + + functions_output = if executed_functions.any? + executed_functions + else + extract_pending_functions(response_output) + end + + Provider::LlmProvider::ChatResponse.new( + id: raw_response.dig("id"), + messages: extract_messages(response_output), + functions: functions_output, + model: raw_response.dig("model") + ) + end + + def chat + message.chat + end + + def model + message.ai_model + end + + def previous_openai_response_id + chat.latest_assistant_response_id + end + + # Since we're using OpenAI's conversation state management, all we need to pass + # to input is the user message we're currently responding to. + def input + [ { role: "user", content: message.content } ] + end + + def extract_messages(response_output) + message_items = response_output.filter { |item| item.dig("type") == "message" } + + message_items.map do |item| + output_text = item.dig("content").map do |content| + text = content.dig("text") + refusal = content.dig("refusal") + + text || refusal + end.flatten.join("\n") + + Provider::LlmProvider::Message.new( + id: item.dig("id"), + content: output_text, + ) + end + end + + def extract_pending_functions(response_output) + response_output.filter { |item| item.dig("type") == "function_call" }.map do |item| + PendingFunction.new( + id: item.dig("id"), + call_id: item.dig("call_id"), + name: item.dig("name"), + arguments: item.dig("arguments"), + ) + end + end + + def execute_pending_functions(pending_functions) + pending_functions.map do |pending_function| + execute_function(pending_function) + end + end + + def execute_function(fn) + fn_instance = available_functions.find { |f| f.name == fn.name } + parsed_args = JSON.parse(fn.arguments) + result = fn_instance.call(parsed_args) + + Provider::LlmProvider::FunctionExecution.new( + id: fn.id, + call_id: fn.call_id, + name: fn.name, + arguments: parsed_args, + result: result + ) + rescue => e + fn_execution_details = { + fn_name: fn.name, + fn_args: parsed_args + } + + raise Provider::Openai::Error.new(e, fn_execution_details) + end + + def tools + available_functions.map do |fn| + { + type: "function", + name: fn.name, + description: fn.description, + parameters: fn.params_schema, + strict: fn.strict_mode? + } + end + end +end diff --git a/app/models/provider/openai/chat_streamer.rb b/app/models/provider/openai/chat_streamer.rb new file mode 100644 index 00000000..598648d1 --- /dev/null +++ b/app/models/provider/openai/chat_streamer.rb @@ -0,0 +1,13 @@ +# A stream proxy for OpenAI chat responses +# +# - Consumes an OpenAI chat response stream +# - Outputs a generic "Chat Provider Stream" interface to consumers (e.g. `Assistant`) +class Provider::Openai::ChatStreamer + def initialize(output_stream) + @output_stream = output_stream + end + + def call(chunk) + @output_stream.call(chunk) + end +end diff --git a/app/models/provider/registry.rb b/app/models/provider/registry.rb new file mode 100644 index 00000000..96548375 --- /dev/null +++ b/app/models/provider/registry.rb @@ -0,0 +1,91 @@ +class Provider::Registry + include ActiveModel::Validations + + Error = Class.new(StandardError) + + CONCEPTS = %i[exchange_rates securities llm] + + validates :concept, inclusion: { in: CONCEPTS } + + class << self + def for_concept(concept) + new(concept.to_sym) + end + + def get_provider(name) + send(name) + rescue NoMethodError + raise Error.new("Provider '#{name}' not found in registry") + end + + private + def synth + api_key = ENV.fetch("SYNTH_API_KEY", Setting.synth_api_key) + + return nil unless api_key.present? + + Provider::Synth.new(api_key) + end + + def plaid_us + config = Rails.application.config.plaid + + return nil unless config.present? + + Provider::Plaid.new(config, region: :us) + end + + def plaid_eu + config = Rails.application.config.plaid_eu + + return nil unless config.present? + + Provider::Plaid.new(config, region: :eu) + end + + def github + Provider::Github.new + end + + def openai + access_token = ENV.fetch("OPENAI_ACCESS_TOKEN", Setting.openai_access_token) + + return nil unless access_token.present? + + Provider::Openai.new(access_token) + end + end + + def initialize(concept) + @concept = concept + validate! + end + + def providers + available_providers.map { |p| self.class.send(p) } + end + + def get_provider(name) + provider_method = available_providers.find { |p| p == name.to_sym } + + raise Error.new("Provider '#{name}' not found for concept: #{concept}") unless provider_method.present? + + self.class.send(provider_method) + end + + private + attr_reader :concept + + def available_providers + case concept + when :exchange_rates + %i[synth] + when :securities + %i[synth] + when :llm + %i[openai] + else + %i[synth plaid_us plaid_eu github openai] + end + end +end diff --git a/app/models/security/provideable.rb b/app/models/provider/security_provider.rb similarity index 69% rename from app/models/security/provideable.rb rename to app/models/provider/security_provider.rb index 2227e19f..63eba3de 100644 --- a/app/models/security/provideable.rb +++ b/app/models/provider/security_provider.rb @@ -1,18 +1,6 @@ -module Security::Provideable +module Provider::SecurityProvider extend ActiveSupport::Concern - Search = Data.define(:securities) - PriceData = Data.define(:price) - PricesData = Data.define(:prices) - SecurityInfo = Data.define( - :ticker, - :name, - :links, - :logo_url, - :description, - :kind, - ) - def search_securities(symbol, country_code: nil, exchange_operating_mic: nil) raise NotImplementedError, "Subclasses must implement #search_securities" end @@ -28,4 +16,9 @@ module Security::Provideable def fetch_security_prices(security, start_date:, end_date:) raise NotImplementedError, "Subclasses must implement #fetch_security_prices" end + + private + Security = Data.define(:symbol, :name, :logo_url, :exchange_operating_mic) + SecurityInfo = Data.define(:symbol, :name, :links, :logo_url, :description, :kind) + Price = Data.define(:security, :date, :price, :currency) end diff --git a/app/models/provider/synth.rb b/app/models/provider/synth.rb index 89850aa3..81d68ed5 100644 --- a/app/models/provider/synth.rb +++ b/app/models/provider/synth.rb @@ -1,20 +1,19 @@ class Provider::Synth < Provider - include ExchangeRate::Provideable - include Security::Provideable + include ExchangeRateProvider, SecurityProvider def initialize(api_key) @api_key = api_key end def healthy? - provider_response do + with_provider_response do response = client.get("#{base_url}/user") JSON.parse(response.body).dig("id").present? end end def usage - provider_response do + with_provider_response do response = client.get("#{base_url}/user") parsed = JSON.parse(response.body) @@ -37,7 +36,7 @@ class Provider::Synth < Provider # ================================ def fetch_exchange_rate(from:, to:, date:) - provider_response retries: 2 do + with_provider_response retries: 2 do response = client.get("#{base_url}/rates/historical") do |req| req.params["date"] = date.to_s req.params["from"] = from @@ -46,19 +45,12 @@ class Provider::Synth < Provider rates = JSON.parse(response.body).dig("data", "rates") - ExchangeRate::Provideable::FetchRateData.new( - rate: ExchangeRate.new( - from_currency: from, - to_currency: to, - date: date, - rate: rates.dig(to) - ) - ) + Rate.new(date:, from:, to:, rate: rates.dig(to)) end end def fetch_exchange_rates(from:, to:, start_date:, end_date:) - provider_response retries: 1 do + with_provider_response retries: 1 do data = paginate( "#{base_url}/rates/historical-range", from: from, @@ -69,16 +61,9 @@ class Provider::Synth < Provider body.dig("data") end - ExchangeRate::Provideable::FetchRatesData.new( - rates: data.paginated.map do |exchange_rate| - ExchangeRate.new( - from_currency: from, - to_currency: to, - date: exchange_rate.dig("date"), - rate: exchange_rate.dig("rates", to) - ) - end - ) + data.paginated.map do |rate| + Rate.new(date: rate.dig("date"), from:, to:, rate: rate.dig("rates", to)) + end end end @@ -87,7 +72,7 @@ class Provider::Synth < Provider # ================================ def search_securities(symbol, country_code: nil, exchange_operating_mic: nil) - provider_response do + with_provider_response do response = client.get("#{base_url}/tickers/search") do |req| req.params["name"] = symbol req.params["dataset"] = "limited" @@ -98,24 +83,19 @@ class Provider::Synth < Provider parsed = JSON.parse(response.body) - Security::Provideable::Search.new( - securities: parsed.dig("data").map do |security| - Security.new( - ticker: security.dig("symbol"), - name: security.dig("name"), - logo_url: security.dig("logo_url"), - exchange_acronym: security.dig("exchange", "acronym"), - exchange_mic: security.dig("exchange", "mic_code"), - exchange_operating_mic: security.dig("exchange", "operating_mic_code"), - country_code: security.dig("exchange", "country_code") - ) - end - ) + parsed.dig("data").map do |security| + Security.new( + symbol: security.dig("symbol"), + name: security.dig("name"), + logo_url: security.dig("logo_url"), + exchange_operating_mic: security.dig("exchange", "operating_mic_code"), + ) + end end end def fetch_security_info(security) - provider_response do + with_provider_response do response = client.get("#{base_url}/tickers/#{security.ticker}") do |req| req.params["mic_code"] = security.exchange_mic if security.exchange_mic.present? req.params["operating_mic"] = security.exchange_operating_mic if security.exchange_operating_mic.present? @@ -123,8 +103,8 @@ class Provider::Synth < Provider data = JSON.parse(response.body).dig("data") - Security::Provideable::SecurityInfo.new( - ticker: security.ticker, + SecurityInfo.new( + symbol: data.dig("ticker"), name: data.dig("name"), links: data.dig("links"), logo_url: data.dig("logo_url"), @@ -135,19 +115,17 @@ class Provider::Synth < Provider end def fetch_security_price(security, date:) - provider_response do + with_provider_response do historical_data = fetch_security_prices(security, start_date: date, end_date: date) - raise ProviderError, "No prices found for security #{security.ticker} on date #{date}" if historical_data.data.prices.empty? + raise ProviderError, "No prices found for security #{security.ticker} on date #{date}" if historical_data.data.empty? - Security::Provideable::PriceData.new( - price: historical_data.data.prices.first - ) + historical_data.data.first end end def fetch_security_prices(security, start_date:, end_date:) - provider_response retries: 1 do + with_provider_response retries: 1 do params = { start_date: start_date, end_date: end_date @@ -167,16 +145,14 @@ class Provider::Synth < Provider exchange_mic = data.first_page.dig("exchange", "mic_code") exchange_operating_mic = data.first_page.dig("exchange", "operating_mic_code") - Security::Provideable::PricesData.new( - prices: data.paginated.map do |price| - Security::Price.new( - security: security, - date: price.dig("date"), - price: price.dig("close") || price.dig("open"), - currency: currency - ) - end - ) + data.paginated.map do |price| + Price.new( + security: security, + date: price.dig("date"), + price: price.dig("close") || price.dig("open"), + currency: currency + ) + end end end @@ -185,7 +161,7 @@ class Provider::Synth < Provider # ================================ def enrich_transaction(description, amount: nil, date: nil, city: nil, state: nil, country: nil) - provider_response do + with_provider_response do params = { description: description, amount: amount, @@ -216,9 +192,7 @@ class Provider::Synth < Provider [ Faraday::TimeoutError, Faraday::ConnectionFailed, - Faraday::SSLError, - Faraday::ClientError, - Faraday::ServerError + Faraday::SSLError ] end diff --git a/app/models/providers.rb b/app/models/providers.rb deleted file mode 100644 index e0cd48ea..00000000 --- a/app/models/providers.rb +++ /dev/null @@ -1,35 +0,0 @@ -module Providers - module_function - - def synth - api_key = ENV.fetch("SYNTH_API_KEY", Setting.synth_api_key) - - return nil unless api_key.present? - - Provider::Synth.new(api_key) - end - - def plaid_us - config = Rails.application.config.plaid - - return nil unless config.present? - - Provider::Plaid.new(config, region: :us) - end - - def plaid_eu - config = Rails.application.config.plaid_eu - - return nil unless config.present? - - Provider::Plaid.new(config, region: :eu) - end - - def github - Provider::Github.new - end - - def openai - # TODO: Placeholder for AI chat PR - end -end diff --git a/app/models/security/provided.rb b/app/models/security/provided.rb index 4ef0f735..73d0435d 100644 --- a/app/models/security/provided.rb +++ b/app/models/security/provided.rb @@ -3,7 +3,8 @@ module Security::Provided class_methods do def provider - Providers.synth + registry = Provider::Registry.for_concept(:securities) + registry.get_provider(:synth) end def search_provider(symbol, country_code: nil, exchange_operating_mic: nil) @@ -12,7 +13,7 @@ module Security::Provided response = provider.search_securities(symbol, country_code: country_code, exchange_operating_mic: exchange_operating_mic) if response.success? - response.data.securities + response.data else [] end @@ -37,11 +38,24 @@ module Security::Provided return 0 end - fetched_prices = response.data.prices.map do |price| - price.attributes.slice("security_id", "date", "price", "currency") + fetched_prices = response.data.map do |price| + { + security_id: price.security.id, + date: price.date, + price: price.price, + currency: price.currency + } end - Security::Price.upsert_all(fetched_prices, unique_by: %i[security_id date currency]) + valid_prices = fetched_prices.reject do |price| + is_invalid = price[:date].nil? || price[:price].nil? || price[:currency].nil? + if is_invalid + Rails.logger.warn("Invalid price data for security_id=#{id}: Missing required fields in price record: #{price.inspect}") + end + is_invalid + end + + Security::Price.upsert_all(valid_prices, unique_by: %i[security_id date currency]) end def find_or_fetch_price(date: Date.current, cache: true) @@ -53,8 +67,13 @@ module Security::Provided return nil unless response.success? # Provider error - price = response.data.price - price.save! if cache + price = response.data + Security::Price.find_or_create_by!( + security_id: price.security.id, + date: price.date, + price: price.price, + currency: price.currency + ) if cache price end diff --git a/app/models/security/synth_combobox_option.rb b/app/models/security/synth_combobox_option.rb index 9c2336df..45091690 100644 --- a/app/models/security/synth_combobox_option.rb +++ b/app/models/security/synth_combobox_option.rb @@ -8,7 +8,6 @@ class Security::SynthComboboxOption end def to_combobox_display - display_code = exchange_acronym.presence || exchange_operating_mic - "#{symbol} - #{name} (#{display_code})" # shown in combobox input when selected + "#{symbol} - #{name} (#{exchange_operating_mic})" # shown in combobox input when selected end end diff --git a/app/models/setting.rb b/app/models/setting.rb index da829d7e..5f44284a 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -3,6 +3,8 @@ class Setting < RailsSettings::Base cache_prefix { "v1" } field :synth_api_key, type: :string, default: ENV["SYNTH_API_KEY"] + field :openai_access_token, type: :string, default: ENV["OPENAI_ACCESS_TOKEN"] + field :require_invite_for_signup, type: :boolean, default: false field :require_email_confirmation, type: :boolean, default: ENV.fetch("REQUIRE_EMAIL_CONFIRMATION", "true") == "true" end diff --git a/app/models/tool_call.rb b/app/models/tool_call.rb new file mode 100644 index 00000000..2908fd13 --- /dev/null +++ b/app/models/tool_call.rb @@ -0,0 +1,3 @@ +class ToolCall < ApplicationRecord + belongs_to :message +end diff --git a/app/models/tool_call/function.rb b/app/models/tool_call/function.rb new file mode 100644 index 00000000..eb61afe1 --- /dev/null +++ b/app/models/tool_call/function.rb @@ -0,0 +1,4 @@ +class ToolCall::Function < ToolCall + validates :function_name, :function_result, presence: true + validates :function_arguments, presence: true, allow_blank: true +end diff --git a/app/models/user.rb b/app/models/user.rb index db929953..e997a58f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -2,7 +2,9 @@ class User < ApplicationRecord has_secure_password belongs_to :family + belongs_to :last_viewed_chat, class_name: "Chat", optional: true has_many :sessions, dependent: :destroy + has_many :chats, dependent: :destroy has_many :impersonator_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonator_id, dependent: :destroy has_many :impersonated_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonated_id, dependent: :destroy accepts_nested_attributes_for :family, update_only: true @@ -69,6 +71,26 @@ class User < ApplicationRecord (display_name&.first || email.first).upcase end + def initials + if first_name.present? && last_name.present? + "#{first_name.first}#{last_name.first}".upcase + else + initial + end + end + + def show_ai_sidebar? + show_ai_sidebar + end + + def ai_available? + !Rails.application.config.app_mode.self_hosted? || ENV["OPENAI_ACCESS_TOKEN"].present? + end + + def ai_enabled? + ai_enabled && ai_available? + end + # Deactivation validate :can_deactivate, if: -> { active_changed? && !active } after_update_commit :purge_later, if: -> { saved_change_to_active?(from: true, to: false) } diff --git a/app/models/user_message.rb b/app/models/user_message.rb new file mode 100644 index 00000000..1943758d --- /dev/null +++ b/app/models/user_message.rb @@ -0,0 +1,22 @@ +class UserMessage < Message + validates :ai_model, presence: true + + after_create_commit :request_response_later + + def role + "user" + end + + def request_response_later + chat.ask_assistant_later(self) + end + + def request_response + chat.ask_assistant(self) + end + + private + def broadcast? + true + end +end diff --git a/app/views/accounts/_account_sidebar_tabs.html.erb b/app/views/accounts/_account_sidebar_tabs.html.erb index 2acbf65f..764ca6af 100644 --- a/app/views/accounts/_account_sidebar_tabs.html.erb +++ b/app/views/accounts/_account_sidebar_tabs.html.erb @@ -1,6 +1,6 @@ <%# locals: (family:) %> -<% if family.requires_data_provider? && Providers.synth.nil? %> +<% if family.requires_data_provider? && Provider::Registry.get_provider(:synth).nil? %>
      diff --git a/app/views/assistant_messages/_assistant_message.html.erb b/app/views/assistant_messages/_assistant_message.html.erb new file mode 100644 index 00000000..3aa193a2 --- /dev/null +++ b/app/views/assistant_messages/_assistant_message.html.erb @@ -0,0 +1,23 @@ +<%# locals: (assistant_message:) %> + +
      + <% if assistant_message.reasoning? %> +
      + +

      Assistant reasoning

      + <%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-secondary w-5" %> +
      + +
      <%= markdown(assistant_message.content) %>
      +
      + <% else %> + <% if assistant_message.chat.debug_mode? && assistant_message.tool_calls.any? %> + <%= render "assistant_messages/tool_calls", message: assistant_message %> + <% end %> + +
      + <%= render "chats/ai_avatar" %> +
      <%= markdown(assistant_message.content) %>
      +
      + <% end %> +
      diff --git a/app/views/assistant_messages/_tool_calls.html.erb b/app/views/assistant_messages/_tool_calls.html.erb new file mode 100644 index 00000000..fc0e8129 --- /dev/null +++ b/app/views/assistant_messages/_tool_calls.html.erb @@ -0,0 +1,19 @@ +<%# locals: (message:) %> + +
      + + <%= lucide_icon "chevron-right", class: "group-open:transform group-open:rotate-90 text-secondary w-5" %> +

      Tool Calls

      +
      + +
      + <% message.tool_calls.each do |tool_call| %> +
      +

      Function:

      +

      <%= tool_call.function_name %>

      +

      Arguments:

      +
      <%= tool_call.function_arguments %>
      +
      + <% end %> +
      +
      diff --git a/app/views/chats/_ai_avatar.html.erb b/app/views/chats/_ai_avatar.html.erb new file mode 100644 index 00000000..82cc8007 --- /dev/null +++ b/app/views/chats/_ai_avatar.html.erb @@ -0,0 +1,3 @@ +
      + <%= image_tag "ai.svg", alt: "AI", class: "w-full h-full" %> +
      diff --git a/app/views/chats/_ai_consent.html.erb b/app/views/chats/_ai_consent.html.erb new file mode 100644 index 00000000..1fc2c722 --- /dev/null +++ b/app/views/chats/_ai_consent.html.erb @@ -0,0 +1,33 @@ +
      +
      +
      + <%= icon("sparkles") %> +
      + +

      Enable Personal Finance AI

      + +

      + <% if Current.user.ai_available? %> + Our personal finance AI can help answer questions about your finances and provide insights based on your data. + To use this feature, you'll need to explicitly enable it. + <% else %> + To use the AI assistant, you need to set the OPENAI_ACCESS_TOKEN + environment variable in your self-hosted instance. + <% end %> +

      + + <% unless self_hosted? %> +

      + NOTE: During beta testing, we'll be monitoring usage and AI conversations to improve the assistant. +

      + <% end %> + + <% if Current.user.ai_available? %> + <%= form_with url: user_path(Current.user), method: :patch, class: "w-full", data: { turbo: false } do |form| %> + <%= form.hidden_field "user[ai_enabled]", value: true %> + <%= form.hidden_field "user[redirect_to]", value: "home" %> + <%= form.submit "Enable AI Assistant", class: "cursor-pointer hover:bg-black w-full py-2 px-4 bg-gray-800 text-white rounded-lg text-sm font-medium" %> + <% end %> + <% end %> +
      +
      diff --git a/app/views/chats/_ai_greeting.html.erb b/app/views/chats/_ai_greeting.html.erb new file mode 100644 index 00000000..688cfd3a --- /dev/null +++ b/app/views/chats/_ai_greeting.html.erb @@ -0,0 +1,40 @@ +
      + <%= render "chats/ai_avatar" %> + +
      +

      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.

      + +

      + You can use / to access commands +

      + +
      +

      Here's a few questions you can ask:

      + + <% questions = [ + { + icon: "bar-chart-2", + text: "Evaluate investment portfolio" + }, + { + icon: "credit-card", + text: "Show spending insights" + }, + { + icon: "alert-triangle", + text: "Find unusual patterns" + } + ] %> + +
      + <% questions.each do |question| %> + + <% end %> +
      +
      +
      +
      diff --git a/app/views/chats/_chat.html.erb b/app/views/chats/_chat.html.erb new file mode 100644 index 00000000..aa0a915b --- /dev/null +++ b/app/views/chats/_chat.html.erb @@ -0,0 +1,16 @@ +<%# locals: (chat:) %> + +<%= tag.div class: "flex items-center justify-between px-4 py-3 bg-container shadow-border-xs rounded-lg" do %> +
      + <%= render "chats/chat_title", chat: chat, ctx: "list" %> + +

      + <%= time_ago_in_words(chat.updated_at) %> ago +

      +
      + + <%= contextual_menu icon: "more-vertical" do %> + <%= contextual_menu_item("Edit chat", url: edit_chat_path(chat), icon: "pencil", turbo_frame: dom_id(chat, :title)) %> + <%= contextual_menu_destructive_item("Delete chat", chat_path(chat)) %> + <% end %> +<% end %> diff --git a/app/views/chats/_chat_nav.html.erb b/app/views/chats/_chat_nav.html.erb new file mode 100644 index 00000000..5ef56402 --- /dev/null +++ b/app/views/chats/_chat_nav.html.erb @@ -0,0 +1,24 @@ +<%# locals: (chat:) %> + + diff --git a/app/views/chats/_chat_title.html.erb b/app/views/chats/_chat_title.html.erb new file mode 100644 index 00000000..8e6d59b8 --- /dev/null +++ b/app/views/chats/_chat_title.html.erb @@ -0,0 +1,11 @@ +<%# locals: (chat:, ctx: "list") %> + +<%= turbo_frame_tag dom_id(chat, :title), class: "block" do %> + <% if chat.new_record? || ctx == "chat" %> +

      <%= chat.title || "New chat" %>

      + <% else %> + <%= link_to chat_path(chat), data: { turbo_frame: chat_frame } do %> +

      <%= chat.title %>

      + <% end %> + <% end %> +<% end %> diff --git a/app/views/chats/_error.html.erb b/app/views/chats/_error.html.erb new file mode 100644 index 00000000..94fb2d2a --- /dev/null +++ b/app/views/chats/_error.html.erb @@ -0,0 +1,17 @@ +<%# locals: (chat:) %> + +
      + <% if chat.debug_mode? %> +
      + <%= chat.error %> +
      + <% end %> + +
      +

      Failed to generate response. Please try again.

      + + <%= button_to retry_chat_path(chat), method: :post, class: "btn btn--primary" do %> + Retry + <% end %> +
      +
      diff --git a/app/views/chats/_thinking_indicator.html.erb b/app/views/chats/_thinking_indicator.html.erb new file mode 100644 index 00000000..efd779b7 --- /dev/null +++ b/app/views/chats/_thinking_indicator.html.erb @@ -0,0 +1,6 @@ +<%# locals: (chat:, message: "Thinking ...") -%> + +
      + <%= render "chats/ai_avatar" %> +

      <%= message %>

      +
      diff --git a/app/views/chats/edit.html.erb b/app/views/chats/edit.html.erb new file mode 100644 index 00000000..0c165ec5 --- /dev/null +++ b/app/views/chats/edit.html.erb @@ -0,0 +1,8 @@ +<%= turbo_frame_tag dom_id(@chat, :title), class: "block" do %> + <% bg_class = params[:ctx] == "chat" ? "bg-white" : "bg-container-inset" %> + <%= styled_form_with model: @chat, + class: class_names("p-1 rounded-md font-medium text-primary w-full", bg_class), + data: { controller: "auto-submit-form", auto_submit_form_trigger_event_value: "blur" } do |f| %> + <%= f.text_field :title, data: { auto_submit_form_target: "auto" }, inline: true %> + <% end %> +<% end %> diff --git a/app/views/chats/index.html.erb b/app/views/chats/index.html.erb new file mode 100644 index 00000000..bdb16b9d --- /dev/null +++ b/app/views/chats/index.html.erb @@ -0,0 +1,31 @@ +<%= turbo_frame_tag chat_frame do %> +
      + + +
      +

      Chats

      + + <% if @chats.any? %> +
      + <%= render @chats %> +
      + <% else %> +
      +
      + <%= icon("message-square", size: "lg") %> +
      +

      No chats yet

      +

      Start a new conversation with the AI assistant

      + <%= link_to "Start a chat", new_chat_path, class: "inline-flex items-center gap-2 py-2 px-4 bg-gray-800 text-white rounded-lg text-sm font-medium" %> +
      + <% end %> +
      + + <%= render "messages/chat_form" %> +
      +<% end %> diff --git a/app/views/chats/new.html.erb b/app/views/chats/new.html.erb new file mode 100644 index 00000000..4a00ceec --- /dev/null +++ b/app/views/chats/new.html.erb @@ -0,0 +1,11 @@ +<%= turbo_frame_tag chat_frame do %> +
      + <%= render "chats/chat_nav", chat: @chat %> + +
      + <%= render "chats/ai_greeting" %> +
      + + <%= render "messages/chat_form", chat: @chat %> +
      +<% end %> diff --git a/app/views/chats/show.html.erb b/app/views/chats/show.html.erb new file mode 100644 index 00000000..39461814 --- /dev/null +++ b/app/views/chats/show.html.erb @@ -0,0 +1,35 @@ +<%= turbo_frame_tag chat_frame do %> + <%= turbo_stream_from @chat %> + +

      <%= @chat.title %>

      + +
      +
      + <%= render "chats/chat_nav", chat: @chat %> +
      + +
      + <% if @chat.conversation_messages.any? %> + <% @chat.conversation_messages.ordered.each do |message| %> + <%= render message %> + <% end %> + <% else %> +
      + <%= render "chats/ai_greeting", context: "chat" %> +
      + <% end %> + + <% if params[:thinking].present? %> + <%= render "chats/thinking_indicator", chat: @chat %> + <% end %> + + <% if @chat.error.present? %> + <%= render "chats/error", chat: @chat %> + <% end %> +
      + +
      + <%= render "messages/chat_form", chat: @chat %> +
      +
      +<% end %> diff --git a/app/views/developer_messages/_developer_message.html.erb b/app/views/developer_messages/_developer_message.html.erb new file mode 100644 index 00000000..d756442f --- /dev/null +++ b/app/views/developer_messages/_developer_message.html.erb @@ -0,0 +1,6 @@ +<%# locals: (developer_message:) %> + +
      px-3 py-2 rounded-lg max-w-[85%] ml-auto border"> + <%= developer_message.debug? ? "Debug message (internal only)" : "System instruction (sent to AI)" %> +

      <%= developer_message.content %>

      +
      diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 50388e1e..4f9208ba 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,5 +1,10 @@ <%= render "layouts/shared/htmldoc" do %> -
      + <% sidebar_config = app_sidebar_config(Current.user) %> + +
      - <%= tag.div class: class_names("py-4 shrink-0 h-full overflow-y-auto transition-all duration-300", Current.user.show_sidebar? ? "w-80" : "w-0"), data: { sidebar_target: "panel" } do %> + <%= tag.div class: class_names("py-4 shrink-0 h-full overflow-y-auto transition-all duration-300"), + style: "width: #{sidebar_config.dig(:left_panel, :initial_width)}px", + data: { sidebar_target: "leftPanel" } do %> <% if content_for?(:sidebar) %> <%= yield :sidebar %> <% else %> @@ -43,7 +50,7 @@
      <% end %> - <%= tag.div class: class_names("mx-auto w-full h-full", Current.user.show_sidebar? ? "max-w-4xl" : "max-w-5xl"), data: { sidebar_target: "content" } do %> + <%= tag.div style: "max-width: #{sidebar_config.dig(:content_max_width)}px", class: class_names("mx-auto w-full h-full"), data: { sidebar_target: "content" } do %> <% if content_for?(:breadcrumbs) %> <%= yield :breadcrumbs %> <% else %> @@ -57,5 +64,22 @@ <%= yield %> <% end %> <% end %> + + <%# AI chat sidebar %> + <%= tag.div id: "chat-container", + style: "width: #{sidebar_config.dig(:right_panel, :initial_width)}px", + class: class_names("flex flex-col justify-between shrink-0 transition-all duration-300"), + data: { controller: "chat hotkey", sidebar_target: "rightPanel", turbo_permanent: true } do %> + + <% if Current.user.ai_enabled? %> + <%= turbo_frame_tag chat_frame, src: chat_view_path(@chat), loading: "lazy", class: "h-full" do %> +
      + <%= lucide_icon("loader-circle", class: "w-5 h-5 text-secondary animate-spin") %> +
      + <% end %> + <% else %> + <%= render "chats/ai_consent" %> + <% end %> + <% end %>
      <% end %> diff --git a/app/views/layouts/shared/_breadcrumbs.html.erb b/app/views/layouts/shared/_breadcrumbs.html.erb index 3ef70057..313147fa 100644 --- a/app/views/layouts/shared/_breadcrumbs.html.erb +++ b/app/views/layouts/shared/_breadcrumbs.html.erb @@ -2,7 +2,7 @@
      + + <% if sidebar_toggle_enabled %> +
      + +
      + <% end %> diff --git a/app/views/layouts/shared/_head.html.erb b/app/views/layouts/shared/_head.html.erb index 5eb11807..c1be0c61 100644 --- a/app/views/layouts/shared/_head.html.erb +++ b/app/views/layouts/shared/_head.html.erb @@ -5,7 +5,6 @@ <%= csp_meta_tag %> <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %> - <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> <%= javascript_include_tag "https://cdn.plaid.com/link/v2/stable/link-initialize.js" %> <%= combobox_style_tag %> diff --git a/app/views/layouts/shared/_htmldoc.html.erb b/app/views/layouts/shared/_htmldoc.html.erb index bfeafd27..ce0a6300 100644 --- a/app/views/layouts/shared/_htmldoc.html.erb +++ b/app/views/layouts/shared/_htmldoc.html.erb @@ -5,7 +5,7 @@ <%= yield :head %> - +
      <%= render_flash_notifications %> diff --git a/app/views/messages/_chat_form.html.erb b/app/views/messages/_chat_form.html.erb new file mode 100644 index 00000000..152309e0 --- /dev/null +++ b/app/views/messages/_chat_form.html.erb @@ -0,0 +1,35 @@ +<%# locals: (chat: nil, message_hint: nil) %> + +
      + <% model = chat && chat.persisted? ? [chat, Message.new] : Chat.new %> + + <%= form_with model: model, + class: "flex flex-col gap-2 bg-white px-2 py-1.5 rounded-lg shadow-border-xs", + data: { chat_target: "form" } do |f| %> + + <%# In the future, this will be a dropdown with different AI models %> + <%= f.hidden_field :ai_model, value: "gpt-4o" %> + + <%= f.text_area :content, placeholder: "Ask anything ...", value: message_hint, + class: "w-full border-0 focus:ring-0 text-sm resize-none px-1", + data: { chat_target: "input", action: "input->chat#autoResize keydown->chat#handleInputKeyDown" }, + rows: 1 %> + +
      +
      + <%# These are disabled for now, but in the future, will all open specific menus with their own context and search %> + <% ["plus", "command", "at-sign", "mouse-pointer-click"].each do |icon| %> + + <% end %> +
      + + +
      + <% end %> + +

      AI responses are informational only and are not financial advice.

      +
      diff --git a/app/views/securities/_combobox_security.turbo_stream.erb b/app/views/securities/_combobox_security.turbo_stream.erb index 3b6bc798..687d1858 100644 --- a/app/views/securities/_combobox_security.turbo_stream.erb +++ b/app/views/securities/_combobox_security.turbo_stream.erb @@ -5,7 +5,7 @@ <%= combobox_security.name.presence || combobox_security.symbol %> - <%= "#{combobox_security.symbol} (#{combobox_security.exchange_acronym.presence || combobox_security.exchange_operating_mic})" %> + <%= "#{combobox_security.symbol} (#{combobox_security.exchange_operating_mic})" %>
      diff --git a/app/views/transactions/_header.html.erb b/app/views/transactions/_header.html.erb index f2487458..cbb77fb2 100644 --- a/app/views/transactions/_header.html.erb +++ b/app/views/transactions/_header.html.erb @@ -3,12 +3,10 @@
      <%= contextual_menu do %> -
      - <%= contextual_menu_modal_action_item t(".edit_categories"), categories_path, icon: "shapes", turbo_frame: :_top %> - <%= contextual_menu_modal_action_item t(".edit_tags"), tags_path, icon: "tags", turbo_frame: :_top %> - <%= contextual_menu_modal_action_item t(".edit_merchants"), merchants_path, icon: "store", turbo_frame: :_top %> - <%= contextual_menu_modal_action_item t(".edit_imports"), imports_path, icon: "hard-drive-upload", turbo_frame: :_top %> -
      + <%= contextual_menu_modal_action_item t(".edit_categories"), categories_path, icon: "shapes", turbo_frame: :_top %> + <%= contextual_menu_modal_action_item t(".edit_tags"), tags_path, icon: "tags", turbo_frame: :_top %> + <%= contextual_menu_modal_action_item t(".edit_merchants"), merchants_path, icon: "store", turbo_frame: :_top %> + <%= contextual_menu_modal_action_item t(".edit_imports"), imports_path, icon: "hard-drive-upload", turbo_frame: :_top %> <% end %> <%= link_to new_import_path, class: "btn btn--outline flex items-center gap-2", data: { turbo_frame: "modal" } do %> diff --git a/app/views/user_messages/_user_message.html.erb b/app/views/user_messages/_user_message.html.erb new file mode 100644 index 00000000..cccf99ad --- /dev/null +++ b/app/views/user_messages/_user_message.html.erb @@ -0,0 +1,5 @@ +<%# locals: (user_message:) %> + +
      +
      <%= markdown(user_message.content) %>
      +
      diff --git a/bin/update_structure.sh b/bin/update_structure.sh new file mode 100755 index 00000000..1243067e --- /dev/null +++ b/bin/update_structure.sh @@ -0,0 +1,142 @@ +#!/bin/bash +# save to .scripts/update_structure.sh +# best way to use is with tree: `brew install tree` + +# Create the output file with header +echo "---" > .cursor/rules/structure.mdc +echo "description: Project structure" >> .cursor/rules/structure.mdc +echo "globs: *" >> .cursor/rules/structure.mdc +echo "alwaysApply: true" >> .cursor/structure/structure.mdc +echo "---" >> .cursor/rules/structure.mdc +echo "" >> .cursor/rules/structure.mdc +echo "# Project Structure" > .cursor/rules/structure.mdc +echo "" >> .cursor/rules/structure.mdc +echo "\`\`\`" >> .cursor/rules/structure.mdc + +# Check if tree command is available +if command -v tree &> /dev/null; then + # Use tree command for better visualization + git ls-files --others --exclude-standard --cached | tree --fromfile -a >> .cursor/rules/structure.mdc + echo "Using tree command for structure visualization." +else + # Fallback to the alternative approach if tree is not available + echo "Tree command not found. Using fallback approach." + + # Get all files from git (respecting .gitignore) + git ls-files --others --exclude-standard --cached | sort > /tmp/files_list.txt + + # Create a simple tree structure + echo "." > /tmp/tree_items.txt + + # Process each file to build the tree + while read -r file; do + # Skip directories + if [[ -d "$file" ]]; then continue; fi + + # Add the file to the tree + echo "$file" >> /tmp/tree_items.txt + + # Add all parent directories + dir="$file" + while [[ "$dir" != "." ]]; do + dir=$(dirname "$dir") + echo "$dir" >> /tmp/tree_items.txt + done + done < /tmp/files_list.txt + + # Sort and remove duplicates + sort -u /tmp/tree_items.txt > /tmp/tree_sorted.txt + mv /tmp/tree_sorted.txt /tmp/tree_items.txt + + # Simple tree drawing approach + prev_dirs=() + + while read -r item; do + # Skip the root + if [[ "$item" == "." ]]; then + continue + fi + + # Determine if it's a file or directory + if [[ -f "$item" ]]; then + is_dir=0 + name=$(basename "$item") + else + is_dir=1 + name="$(basename "$item")/" + fi + + # Split path into components + IFS='/' read -ra path_parts <<< "$item" + + # Calculate depth (number of path components minus 1) + depth=$((${#path_parts[@]} - 1)) + + # Find common prefix with previous path + common=0 + if [[ ${#prev_dirs[@]} -gt 0 ]]; then + for ((i=0; i "$item" ]]; then + has_more=1 + break + fi + done + + if [[ $has_more -eq 1 ]]; then + prefix="${prefix}│ " + else + prefix="${prefix} " + fi + else + prefix="${prefix} " + fi + done + + # Determine if this is the last item in its directory + is_last=1 + dir=$(dirname "$item") + for next in $(grep "^$dir/" /tmp/tree_items.txt); do + if [[ "$next" > "$item" ]]; then + is_last=0 + break + fi + done + + # Choose the connector + if [[ $is_last -eq 1 ]]; then + connector="└── " + else + connector="├── " + fi + + # Output the item + echo "${prefix}${connector}${name}" >> .cursor/rules/structure.mdc + + # Save current path for next iteration + prev_dirs=("${path_parts[@]}") + + done < /tmp/tree_items.txt + + # Clean up + rm -f /tmp/files_list.txt /tmp/tree_items.txt +fi + +# Close the code block +echo "\`\`\`" >> .cursor/rules/structure.mdc + +echo "Project structure has been updated in .cursor/rules/structure.mdc" \ No newline at end of file diff --git a/config/initializers/intercom.rb b/config/initializers/intercom.rb index e9863abb..c87ae369 100644 --- a/config/initializers/intercom.rb +++ b/config/initializers/intercom.rb @@ -16,7 +16,7 @@ if ENV["INTERCOM_APP_ID"].present? && ENV["INTERCOM_IDENTITY_VERIFICATION_KEY"]. # == Enabled Environments # Which environments is auto inclusion of the Javascript enabled for # - config.enabled_environments = [ "development", "production" ] + config.enabled_environments = [ "production" ] # == Current user method/variable # The method/variable that contains the logged in user in your controllers. diff --git a/config/routes.rb b/config/routes.rb index 92845bc1..e8e072ae 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -11,6 +11,15 @@ Rails.application.routes.draw do # Uses basic auth - see config/initializers/sidekiq.rb mount Sidekiq::Web => "/sidekiq" + # AI chats + resources :chats do + resources :messages, only: :create + + member do + post :retry + end + end + get "changelog", to: "pages#changelog" get "feedback", to: "pages#feedback" get "early-access", to: "pages#early_access" diff --git a/db/migrate/20250319212839_create_ai_chats.rb b/db/migrate/20250319212839_create_ai_chats.rb new file mode 100644 index 00000000..1a2bc9a5 --- /dev/null +++ b/db/migrate/20250319212839_create_ai_chats.rb @@ -0,0 +1,46 @@ +class CreateAiChats < ActiveRecord::Migration[7.2] + def change + create_table :chats, id: :uuid do |t| + t.references :user, null: false, foreign_key: true, type: :uuid + t.string :title, null: false + t.string :instructions + t.jsonb :error + t.string :latest_assistant_response_id + t.timestamps + end + + create_table :messages, id: :uuid do |t| + t.references :chat, null: false, foreign_key: true, type: :uuid + t.string :type, null: false + t.string :status, null: false, default: "complete" + t.text :content + t.string :ai_model + t.timestamps + + # Developer message fields + t.boolean :debug, default: false + + # Assistant message fields + t.string :provider_id + t.boolean :reasoning, default: false + end + + create_table :tool_calls, id: :uuid do |t| + t.references :message, null: false, foreign_key: true, type: :uuid + t.string :provider_id, null: false + t.string :provider_call_id + t.string :type, null: false + + # Function specific fields + t.string :function_name + t.jsonb :function_arguments + t.jsonb :function_result + + t.timestamps + end + + add_reference :users, :last_viewed_chat, foreign_key: { to_table: :chats }, null: true, type: :uuid + add_column :users, :show_ai_sidebar, :boolean, default: true + add_column :users, :ai_enabled, :boolean, default: false, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 7f30fe84..04be6dc3 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_03_19_145426) do +ActiveRecord::Schema[7.2].define(version: 2025_03_19_212839) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -196,6 +196,17 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_19_145426) do t.index ["family_id"], name: "index_categories_on_family_id" end + create_table "chats", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "user_id", null: false + t.string "title", null: false + t.string "instructions" + t.jsonb "error" + t.string "latest_assistant_response_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id"], name: "index_chats_on_user_id" + end + create_table "credit_cards", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -380,6 +391,20 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_19_145426) do t.index ["family_id"], name: "index_merchants_on_family_id" end + create_table "messages", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "chat_id", null: false + t.string "type", null: false + t.string "status", default: "complete", null: false + t.text "content" + t.string "ai_model" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.boolean "debug", default: false + t.string "provider_id" + t.boolean "reasoning", default: false + t.index ["chat_id"], name: "index_messages_on_chat_id" + end + create_table "other_assets", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -544,6 +569,19 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_19_145426) do t.index ["family_id"], name: "index_tags_on_family_id" end + create_table "tool_calls", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "message_id", null: false + t.string "provider_id", null: false + t.string "provider_call_id" + t.string "type", null: false + t.string "function_name" + t.jsonb "function_arguments" + t.jsonb "function_result" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["message_id"], name: "index_tool_calls_on_message_id" + end + create_table "transfers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "inflow_transaction_id", null: false t.uuid "outflow_transaction_id", null: false @@ -573,8 +611,12 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_19_145426) do t.string "otp_backup_codes", default: [], array: true t.boolean "show_sidebar", default: true t.string "default_period", default: "last_30_days", null: false + t.uuid "last_viewed_chat_id" + t.boolean "show_ai_sidebar", default: true + t.boolean "ai_enabled", default: false, null: false t.index ["email"], name: "index_users_on_email", unique: true t.index ["family_id"], name: "index_users_on_family_id" + t.index ["last_viewed_chat_id"], name: "index_users_on_last_viewed_chat_id" t.index ["otp_secret"], name: "index_users_on_otp_secret", unique: true, where: "(otp_secret IS NOT NULL)" end @@ -605,6 +647,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_19_145426) do add_foreign_key "budget_categories", "categories" add_foreign_key "budgets", "families" add_foreign_key "categories", "families" + add_foreign_key "chats", "users" add_foreign_key "impersonation_session_logs", "impersonation_sessions" add_foreign_key "impersonation_sessions", "users", column: "impersonated_id" add_foreign_key "impersonation_sessions", "users", column: "impersonator_id" @@ -613,6 +656,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_19_145426) do add_foreign_key "invitations", "families" add_foreign_key "invitations", "users", column: "inviter_id" add_foreign_key "merchants", "families" + add_foreign_key "messages", "chats" add_foreign_key "plaid_accounts", "plaid_items" add_foreign_key "plaid_items", "families" add_foreign_key "rejected_transfers", "account_transactions", column: "inflow_transaction_id" @@ -622,7 +666,9 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_19_145426) do add_foreign_key "sessions", "users" add_foreign_key "taggings", "tags" add_foreign_key "tags", "families" + add_foreign_key "tool_calls", "messages" add_foreign_key "transfers", "account_transactions", column: "inflow_transaction_id", on_delete: :cascade add_foreign_key "transfers", "account_transactions", column: "outflow_transaction_id", on_delete: :cascade + add_foreign_key "users", "chats", column: "last_viewed_chat_id" add_foreign_key "users", "families" end diff --git a/package-lock.json b/package-lock.json index 0e2cd5a5..3bdae723 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "devDependencies": { - "@biomejs/biome": "1.9.3" + "@biomejs/biome": "^1.9.3" } }, "node_modules/@biomejs/biome": { diff --git a/package.json b/package.json index 7ba6f216..1b0e7c43 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,17 @@ { "devDependencies": { - "@biomejs/biome": "1.9.3" + "@biomejs/biome": "^1.9.3" }, "name": "maybe", "version": "1.0.0", "description": "The OS for your personal finances", "scripts": { "style:check": "biome check", - "style:fix":"biome check --write", - "lint": "biome lint", - "lint:fix" : "biome lint --write", - "format:check" : "biome format", - "format" : "biome format --write" + "style:fix": "biome check --write", + "lint": "biome lint", + "lint:fix": "biome lint --write", + "format:check": "biome format", + "format": "biome format --write" }, "author": "", "license": "ISC" diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb index 657e646c..a41ff371 100644 --- a/test/application_system_test_case.rb +++ b/test/application_system_test_case.rb @@ -21,6 +21,10 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase find("h1", text: "Welcome back, #{user.first_name}") end + def login_as(user) + sign_in(user) + end + def sign_out find("#user-menu").click click_button "Logout" diff --git a/test/controllers/chats_controller_test.rb b/test/controllers/chats_controller_test.rb new file mode 100644 index 00000000..5fa98e24 --- /dev/null +++ b/test/controllers/chats_controller_test.rb @@ -0,0 +1,52 @@ +require "test_helper" + +class ChatsControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:family_admin) + @family = families(:dylan_family) + sign_in @user + end + + test "cannot create a chat if AI is disabled" do + @user.update!(ai_enabled: false) + post chats_url, params: { chat: { content: "Hello", ai_model: "gpt-4o" } } + assert_response :forbidden + end + + test "gets index" do + get chats_url + assert_response :success + end + + test "creates chat" do + assert_difference("Chat.count") do + post chats_url, params: { chat: { content: "Hello", ai_model: "gpt-4o" } } + end + + assert_redirected_to chat_path(Chat.order(created_at: :desc).first, thinking: true) + end + + test "shows chat" do + get chat_url(chats(:one)) + assert_response :success + end + + test "destroys chat" do + assert_difference("Chat.count", -1) do + delete chat_url(chats(:one)) + end + + assert_redirected_to chats_url + end + + test "should not allow access to other user's chats" do + other_user = users(:family_member) + other_chat = Chat.create!(user: other_user, title: "Other User's Chat") + + get chat_url(other_chat) + assert_response :not_found + + delete chat_url(other_chat) + assert_response :not_found + end +end diff --git a/test/controllers/messages_controller_test.rb b/test/controllers/messages_controller_test.rb new file mode 100644 index 00000000..c79cb174 --- /dev/null +++ b/test/controllers/messages_controller_test.rb @@ -0,0 +1,22 @@ +require "test_helper" + +class MessagesControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in @user = users(:family_admin) + @chat = @user.chats.first + end + + test "can create a message" do + post chat_messages_url(@chat), params: { message: { content: "Hello", ai_model: "gpt-4o" } } + + assert_redirected_to chat_path(@chat, thinking: true) + end + + test "cannot create a message if AI is disabled" do + @user.update!(ai_enabled: false) + + post chat_messages_url(@chat), params: { message: { content: "Hello", ai_model: "gpt-4o" } } + + assert_response :forbidden + end +end diff --git a/test/controllers/settings/hostings_controller_test.rb b/test/controllers/settings/hostings_controller_test.rb index 48213260..32bcaab2 100644 --- a/test/controllers/settings/hostings_controller_test.rb +++ b/test/controllers/settings/hostings_controller_test.rb @@ -8,7 +8,7 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest sign_in users(:family_admin) @provider = mock - Providers.stubs(:synth).returns(@provider) + Provider::Registry.stubs(:get_provider).with(:synth).returns(@provider) @usage_response = provider_success_response( OpenStruct.new( used: 10, @@ -20,12 +20,12 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest end test "cannot edit when self hosting is disabled" do - assert_raises(RuntimeError, "Settings not available on non-self-hosted instance") do + with_env_overrides SELF_HOSTED: "false" do get settings_hosting_url - end + assert_response :forbidden - assert_raises(RuntimeError, "Settings not available on non-self-hosted instance") do patch settings_hosting_url, params: { setting: { require_invite_for_signup: true } } + assert_response :forbidden end end @@ -40,8 +40,6 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest test "can update settings when self hosting is enabled" do with_self_hosting do - assert_nil Setting.synth_api_key - patch settings_hosting_url, params: { setting: { synth_api_key: "1234567890" } } assert_equal "1234567890", Setting.synth_api_key diff --git a/test/fixtures/chats.yml b/test/fixtures/chats.yml new file mode 100644 index 00000000..6e5c5d38 --- /dev/null +++ b/test/fixtures/chats.yml @@ -0,0 +1,7 @@ +one: + title: First Chat + user: family_admin + +two: + title: Second Chat + user: family_member \ No newline at end of file diff --git a/test/fixtures/messages.yml b/test/fixtures/messages.yml new file mode 100644 index 00000000..9a0e3ea5 --- /dev/null +++ b/test/fixtures/messages.yml @@ -0,0 +1,43 @@ +chat1_developer: + type: DeveloperMessage + content: You are a personal finance assistant. Be concise and helpful. + chat: one + created_at: 2025-03-20 12:00:00 + debug: false + +chat1_developer_debug: + type: DeveloperMessage + content: An internal debug message + chat: one + created_at: 2025-03-20 12:00:02 + debug: true + +chat1_user: + type: UserMessage + content: Can you help me understand my spending habits? + chat: one + ai_model: gpt-4o + created_at: 2025-03-20 12:00:01 + +chat2_user: + type: UserMessage + content: Can you help me understand my spending habits? + ai_model: gpt-4o + chat: two + created_at: 2025-03-20 12:00:01 + +chat1_assistant_reasoning: + type: AssistantMessage + content: I'm thinking... + ai_model: gpt-4o + chat: one + created_at: 2025-03-20 12:01:00 + reasoning: true + +chat1_assistant_response: + type: AssistantMessage + content: Hello! I can help you understand your spending habits. + ai_model: gpt-4o + chat: one + created_at: 2025-03-20 12:02:00 + reasoning: false diff --git a/test/fixtures/tool_calls.yml b/test/fixtures/tool_calls.yml new file mode 100644 index 00000000..470e00e6 --- /dev/null +++ b/test/fixtures/tool_calls.yml @@ -0,0 +1,7 @@ +one: + type: ToolCall::Function + function_name: get_user_info + provider_id: fc_12345xyz + provider_call_id: call_12345xyz + function_arguments: {} + message: chat1_assistant_response diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 246fdf56..ef3e7e3d 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -3,34 +3,38 @@ empty: first_name: User last_name: One email: user1@email.com - password_digest: <%= BCrypt::Password.create('password') %> + password_digest: $2a$12$7p8hMsoc0zSaC8eY9oewzelHbmCPdpPi.mGiyG4vdZwrXmGpRPoNK onboarded_at: <%= 3.days.ago %> + ai_enabled: true maybe_support_staff: family: empty first_name: Support last_name: Admin email: support@maybefinance.com - password_digest: <%= BCrypt::Password.create('password') %> + password_digest: $2a$12$7p8hMsoc0zSaC8eY9oewzelHbmCPdpPi.mGiyG4vdZwrXmGpRPoNK role: super_admin onboarded_at: <%= 3.days.ago %> + ai_enabled: true family_admin: family: dylan_family first_name: Bob last_name: Dylan email: bob@bobdylan.com - password_digest: <%= BCrypt::Password.create('password') %> + password_digest: $2a$12$7p8hMsoc0zSaC8eY9oewzelHbmCPdpPi.mGiyG4vdZwrXmGpRPoNK role: admin onboarded_at: <%= 3.days.ago %> + ai_enabled: true family_member: family: dylan_family first_name: Jakob last_name: Dylan email: jakobdylan@yahoo.com - password_digest: <%= BCrypt::Password.create('password') %> + password_digest: $2a$12$7p8hMsoc0zSaC8eY9oewzelHbmCPdpPi.mGiyG4vdZwrXmGpRPoNK onboarded_at: <%= 3.days.ago %> + ai_enabled: true new_email: family: empty @@ -38,5 +42,6 @@ new_email: last_name: User email: user@example.com unconfirmed_email: new@example.com - password_digest: <%= BCrypt::Password.create('password123') %> - onboarded_at: <%= Time.current %> \ No newline at end of file + password_digest: $2a$12$7p8hMsoc0zSaC8eY9oewzelHbmCPdpPi.mGiyG4vdZwrXmGpRPoNK + onboarded_at: <%= Time.current %> + ai_enabled: true \ No newline at end of file diff --git a/test/interfaces/exchange_rate_provider_interface_test.rb b/test/interfaces/exchange_rate_provider_interface_test.rb index 748c66f0..9293c4d9 100644 --- a/test/interfaces/exchange_rate_provider_interface_test.rb +++ b/test/interfaces/exchange_rate_provider_interface_test.rb @@ -11,11 +11,11 @@ module ExchangeRateProviderInterfaceTest date: Date.parse("01.01.2024") ) - rate = response.data.rate + rate = response.data - assert_kind_of ExchangeRate, rate - assert_equal "USD", rate.from_currency - assert_equal "GBP", rate.to_currency + assert_equal "USD", rate.from + assert_equal "GBP", rate.to + assert_in_delta 0.78, rate.rate, 0.01 end end @@ -25,7 +25,7 @@ module ExchangeRateProviderInterfaceTest from: "USD", to: "GBP", start_date: Date.parse("01.01.2024"), end_date: Date.parse("31.07.2024") ) - assert 213, response.data.rates.count # 213 days between 01.01.2024 and 31.07.2024 + assert_equal 213, response.data.count # 213 days between 01.01.2024 and 31.07.2024 end end diff --git a/test/interfaces/llm_interface_test.rb b/test/interfaces/llm_interface_test.rb new file mode 100644 index 00000000..2298ba27 --- /dev/null +++ b/test/interfaces/llm_interface_test.rb @@ -0,0 +1,10 @@ +require "test_helper" + +module LLMInterfaceTest + extend ActiveSupport::Testing::Declarative + + private + def vcr_key_prefix + @subject.class.name.demodulize.underscore + end +end diff --git a/test/interfaces/security_provider_interface_test.rb b/test/interfaces/security_provider_interface_test.rb index b22a6a10..44385ede 100644 --- a/test/interfaces/security_provider_interface_test.rb +++ b/test/interfaces/security_provider_interface_test.rb @@ -8,8 +8,9 @@ module SecurityProviderInterfaceTest VCR.use_cassette("#{vcr_key_prefix}/security_price") do response = @subject.fetch_security_price(aapl, date: Date.iso8601("2024-08-01")) + assert response.success? - assert response.data.price.present? + assert response.data.present? end end @@ -24,19 +25,18 @@ module SecurityProviderInterfaceTest ) assert response.success? - assert 213, response.data.prices.count + assert_equal 147, response.data.count # Synth won't return prices on weekends / holidays, so less than total day count of 213 end end test "searches securities" do VCR.use_cassette("#{vcr_key_prefix}/security_search") do response = @subject.search_securities("AAPL", country_code: "US") - securities = response.data.securities + securities = response.data assert securities.any? security = securities.first - assert_kind_of Security, security - assert_equal "AAPL", security.ticker + assert_equal "AAPL", security.symbol end end @@ -47,10 +47,10 @@ module SecurityProviderInterfaceTest response = @subject.fetch_security_info(aapl) info = response.data - assert_equal "AAPL", info.ticker + assert_equal "AAPL", info.symbol assert_equal "Apple Inc.", info.name - assert info.logo_url.present? assert_equal "common stock", info.kind + assert info.logo_url.present? assert info.description.present? end end diff --git a/test/jobs/enrich_data_job_test.rb b/test/jobs/enrich_data_job_test.rb deleted file mode 100644 index 067767f6..00000000 --- a/test/jobs/enrich_data_job_test.rb +++ /dev/null @@ -1,7 +0,0 @@ -require "test_helper" - -class EnrichDataJobTest < ActiveJob::TestCase - # test "the truth" do - # assert true - # end -end diff --git a/test/jobs/revert_import_job_test.rb b/test/jobs/revert_import_job_test.rb deleted file mode 100644 index ca65d717..00000000 --- a/test/jobs/revert_import_job_test.rb +++ /dev/null @@ -1,7 +0,0 @@ -require "test_helper" - -class RevertImportJobTest < ActiveJob::TestCase - # test "the truth" do - # assert true - # end -end diff --git a/test/jobs/user_purge_job_test.rb b/test/jobs/user_purge_job_test.rb deleted file mode 100644 index 9fb2ae63..00000000 --- a/test/jobs/user_purge_job_test.rb +++ /dev/null @@ -1,7 +0,0 @@ -require "test_helper" - -class UserPurgeJobTest < ActiveJob::TestCase - # test "the truth" do - # assert true - # end -end diff --git a/test/models/account/convertible_test.rb b/test/models/account/convertible_test.rb index 8fb739c6..76cc5c85 100644 --- a/test/models/account/convertible_test.rb +++ b/test/models/account/convertible_test.rb @@ -24,13 +24,11 @@ class Account::ConvertibleTest < ActiveSupport::TestCase ExchangeRate.delete_all provider_response = provider_success_response( - ExchangeRate::Provideable::FetchRatesData.new( - rates: [ - ExchangeRate.new(from_currency: "EUR", to_currency: "USD", date: 2.days.ago.to_date, rate: 1.1), - ExchangeRate.new(from_currency: "EUR", to_currency: "USD", date: 1.day.ago.to_date, rate: 1.2), - ExchangeRate.new(from_currency: "EUR", to_currency: "USD", date: Date.current, rate: 1.3) - ] - ) + [ + OpenStruct.new(from: "EUR", to: "USD", date: 2.days.ago.to_date, rate: 1.1), + OpenStruct.new(from: "EUR", to: "USD", date: 1.day.ago.to_date, rate: 1.2), + OpenStruct.new(from: "EUR", to: "USD", date: Date.current, rate: 1.3) + ] ) @provider.expects(:fetch_exchange_rates) diff --git a/test/models/account/holding/portfolio_cache_test.rb b/test/models/account/holding/portfolio_cache_test.rb index bebc66c2..9b4124ed 100644 --- a/test/models/account/holding/portfolio_cache_test.rb +++ b/test/models/account/holding/portfolio_cache_test.rb @@ -82,12 +82,6 @@ class Account::Holding::PortfolioCacheTest < ActiveSupport::TestCase def expect_provider_prices(prices, start_date:, end_date: Date.current) @provider.expects(:fetch_security_prices) .with(@security, start_date: start_date, end_date: end_date) - .returns( - provider_success_response( - Security::Provideable::PricesData.new( - prices: prices - ) - ) - ) + .returns(provider_success_response(prices)) end end diff --git a/test/models/assistant_message_test.rb b/test/models/assistant_message_test.rb new file mode 100644 index 00000000..9e737d41 --- /dev/null +++ b/test/models/assistant_message_test.rb @@ -0,0 +1,19 @@ +require "test_helper" + +class AssistantMessageTest < ActiveSupport::TestCase + setup do + @chat = chats(:one) + end + + test "broadcasts append after creation" do + message = AssistantMessage.create!(chat: @chat, content: "Hello from assistant", ai_model: "gpt-4o") + message.update!(content: "updated") + + streams = capture_turbo_stream_broadcasts(@chat) + assert_equal 2, streams.size + assert_equal "append", streams.first["action"] + assert_equal "messages", streams.first["target"] + assert_equal "update", streams.last["action"] + assert_equal "assistant_message_#{message.id}", streams.last["target"] + end +end diff --git a/test/models/assistant_test.rb b/test/models/assistant_test.rb new file mode 100644 index 00000000..56005482 --- /dev/null +++ b/test/models/assistant_test.rb @@ -0,0 +1,86 @@ +require "test_helper" +require "ostruct" + +class AssistantTest < ActiveSupport::TestCase + include ProviderTestHelper + + setup do + @chat = chats(:two) + @message = @chat.messages.create!( + type: "UserMessage", + content: "Help me with my finances", + ai_model: "gpt-4o" + ) + @assistant = Assistant.for_chat(@chat) + @provider = mock + @assistant.expects(:get_model_provider).with("gpt-4o").returns(@provider) + end + + test "responds to basic prompt" do + text_chunk = OpenStruct.new(type: "output_text", data: "Hello from assistant") + response_chunk = OpenStruct.new( + type: "response", + data: OpenStruct.new( + id: "1", + model: "gpt-4o", + messages: [ + OpenStruct.new( + id: "1", + content: "Hello from assistant", + ) + ], + functions: [] + ) + ) + + @provider.expects(:chat_response).with do |message, **options| + options[:streamer].call(text_chunk) + options[:streamer].call(response_chunk) + true + end + + assert_difference "AssistantMessage.count", 1 do + @assistant.respond_to(@message) + end + end + + test "responds with tool function calls" do + function_request_chunk = OpenStruct.new(type: "function_request", data: "get_net_worth") + text_chunk = OpenStruct.new(type: "output_text", data: "Your net worth is $124,200") + response_chunk = OpenStruct.new( + type: "response", + data: OpenStruct.new( + id: "1", + model: "gpt-4o", + messages: [ + OpenStruct.new( + id: "1", + content: "Your net worth is $124,200", + ) + ], + functions: [ + OpenStruct.new( + id: "1", + call_id: "1", + name: "get_net_worth", + arguments: "{}", + result: "$124,200" + ) + ] + ) + ) + + @provider.expects(:chat_response).with do |message, **options| + options[:streamer].call(function_request_chunk) + options[:streamer].call(text_chunk) + options[:streamer].call(response_chunk) + true + end + + assert_difference "AssistantMessage.count", 1 do + @assistant.respond_to(@message) + message = @chat.messages.ordered.where(type: "AssistantMessage").last + assert_equal 1, message.tool_calls.size + end + end +end diff --git a/test/models/chat_test.rb b/test/models/chat_test.rb new file mode 100644 index 00000000..29435b5a --- /dev/null +++ b/test/models/chat_test.rb @@ -0,0 +1,31 @@ +require "test_helper" + +class ChatTest < ActiveSupport::TestCase + setup do + @user = users(:family_admin) + @assistant = mock + end + + test "user sees all messages in debug mode" do + chat = chats(:one) + with_env_overrides AI_DEBUG_MODE: "true" do + assert_equal chat.messages.count, chat.conversation_messages.count + end + end + + test "user sees assistant and user messages in normal mode" do + chat = chats(:one) + assert_equal 3, chat.conversation_messages.count + end + + test "creates with initial message" do + prompt = "Test prompt" + + assert_difference "@user.chats.count", 1 do + chat = @user.chats.start!(prompt, model: "gpt-4o") + + assert_equal 1, chat.messages.count + assert_equal 1, chat.messages.where(type: "UserMessage").count + end + end +end diff --git a/test/models/developer_message_test.rb b/test/models/developer_message_test.rb new file mode 100644 index 00000000..26d3d8e2 --- /dev/null +++ b/test/models/developer_message_test.rb @@ -0,0 +1,28 @@ +require "test_helper" + +class DeveloperMessageTest < ActiveSupport::TestCase + setup do + @chat = chats(:one) + end + + test "does not broadcast" do + message = DeveloperMessage.create!(chat: @chat, content: "Some instructions") + message.update!(content: "updated") + + assert_no_turbo_stream_broadcasts(@chat) + end + + test "broadcasts if debug mode is enabled" do + with_env_overrides AI_DEBUG_MODE: "true" do + message = DeveloperMessage.create!(chat: @chat, content: "Some instructions") + message.update!(content: "updated") + + streams = capture_turbo_stream_broadcasts(@chat) + assert_equal 2, streams.size + assert_equal "append", streams.first["action"] + assert_equal "messages", streams.first["target"] + assert_equal "update", streams.last["action"] + assert_equal "developer_message_#{message.id}", streams.last["target"] + end + end +end diff --git a/test/models/exchange_rate_test.rb b/test/models/exchange_rate_test.rb index 720162f4..64fc328b 100644 --- a/test/models/exchange_rate_test.rb +++ b/test/models/exchange_rate_test.rb @@ -26,13 +26,11 @@ class ExchangeRateTest < ActiveSupport::TestCase ExchangeRate.delete_all provider_response = provider_success_response( - ExchangeRate::Provideable::FetchRateData.new( - rate: ExchangeRate.new( - from_currency: "USD", - to_currency: "EUR", - date: Date.current, - rate: 1.2 - ) + OpenStruct.new( + from: "USD", + to: "EUR", + date: Date.current, + rate: 1.2 ) ) @@ -47,13 +45,11 @@ class ExchangeRateTest < ActiveSupport::TestCase ExchangeRate.delete_all provider_response = provider_success_response( - ExchangeRate::Provideable::FetchRateData.new( - rate: ExchangeRate.new( - from_currency: "USD", - to_currency: "EUR", - date: Date.current, - rate: 1.2 - ) + OpenStruct.new( + from: "USD", + to: "EUR", + date: Date.current, + rate: 1.2 ) ) @@ -65,7 +61,7 @@ class ExchangeRateTest < ActiveSupport::TestCase end test "returns nil on provider error" do - provider_response = provider_error_response(Provider::ProviderError.new("Test error")) + provider_response = provider_error_response(StandardError.new("Test error")) @provider.expects(:fetch_exchange_rate).returns(provider_response) @@ -77,15 +73,11 @@ class ExchangeRateTest < ActiveSupport::TestCase ExchangeRate.create!(date: 1.day.ago.to_date, from_currency: "USD", to_currency: "EUR", rate: 0.9) - provider_response = provider_success_response( - ExchangeRate::Provideable::FetchRatesData.new( - rates: [ - ExchangeRate.new(from_currency: "USD", to_currency: "EUR", date: Date.current, rate: 1.3), - ExchangeRate.new(from_currency: "USD", to_currency: "EUR", date: 1.day.ago.to_date, rate: 1.4), - ExchangeRate.new(from_currency: "USD", to_currency: "EUR", date: 2.days.ago.to_date, rate: 1.5) - ] - ) - ) + provider_response = provider_success_response([ + OpenStruct.new(from: "USD", to: "EUR", date: Date.current, rate: 1.3), + OpenStruct.new(from: "USD", to: "EUR", date: 1.day.ago.to_date, rate: 1.4), + OpenStruct.new(from: "USD", to: "EUR", date: 2.days.ago.to_date, rate: 1.5) + ]) @provider.expects(:fetch_exchange_rates) .with(from: "USD", to: "EUR", start_date: 2.days.ago.to_date, end_date: Date.current) diff --git a/test/models/provider/openai_test.rb b/test/models/provider/openai_test.rb new file mode 100644 index 00000000..ccaae937 --- /dev/null +++ b/test/models/provider/openai_test.rb @@ -0,0 +1,136 @@ +require "test_helper" + +class Provider::OpenaiTest < ActiveSupport::TestCase + include LLMInterfaceTest + + setup do + @subject = @openai = Provider::Openai.new(ENV.fetch("OPENAI_ACCESS_TOKEN", "test-openai-token")) + @subject_model = "gpt-4o" + @chat = chats(:two) + end + + test "openai errors are automatically raised" do + VCR.use_cassette("openai/chat/error") do + response = @openai.chat_response(UserMessage.new( + chat: @chat, + content: "Error test", + ai_model: "invalid-model-that-will-trigger-api-error" + )) + + assert_not response.success? + assert_kind_of Provider::Openai::Error, response.error + end + end + + test "basic chat response" do + VCR.use_cassette("openai/chat/basic_response") do + message = @chat.messages.create!( + type: "UserMessage", + content: "This is a chat test. If it's working, respond with a single word: Yes", + ai_model: @subject_model + ) + + response = @subject.chat_response(message) + + assert response.success? + assert_equal 1, response.data.messages.size + assert_includes response.data.messages.first.content, "Yes" + end + end + + test "streams basic chat response" do + VCR.use_cassette("openai/chat/basic_response") do + collected_chunks = [] + + mock_streamer = proc do |chunk| + collected_chunks << chunk + end + + message = @chat.messages.create!( + type: "UserMessage", + content: "This is a chat test. If it's working, respond with a single word: Yes", + ai_model: @subject_model + ) + + @subject.chat_response(message, streamer: mock_streamer) + + tool_call_chunks = collected_chunks.select { |chunk| chunk.type == "function_request" } + text_chunks = collected_chunks.select { |chunk| chunk.type == "output_text" } + response_chunks = collected_chunks.select { |chunk| chunk.type == "response" } + + assert_equal 1, text_chunks.size + assert_equal 1, response_chunks.size + assert_equal 0, tool_call_chunks.size + assert_equal "Yes", text_chunks.first.data + assert_equal "Yes", response_chunks.first.data.messages.first.content + end + end + + test "chat response with tool calls" do + VCR.use_cassette("openai/chat/tool_calls") do + response = @subject.chat_response( + tool_call_message, + instructions: "Use the tools available to you to answer the user's question.", + available_functions: [ PredictableToolFunction.new(@chat) ] + ) + + assert response.success? + assert_equal 1, response.data.functions.size + assert_equal 1, response.data.messages.size + assert_includes response.data.messages.first.content, PredictableToolFunction.expected_test_result + end + end + + test "streams chat response with tool calls" do + VCR.use_cassette("openai/chat/tool_calls") do + collected_chunks = [] + + mock_streamer = proc do |chunk| + collected_chunks << chunk + end + + @subject.chat_response( + tool_call_message, + instructions: "Use the tools available to you to answer the user's question.", + available_functions: [ PredictableToolFunction.new(@chat) ], + streamer: mock_streamer + ) + + text_chunks = collected_chunks.select { |chunk| chunk.type == "output_text" } + text_chunks = collected_chunks.select { |chunk| chunk.type == "output_text" } + tool_call_chunks = collected_chunks.select { |chunk| chunk.type == "function_request" } + response_chunks = collected_chunks.select { |chunk| chunk.type == "response" } + + assert_equal 1, tool_call_chunks.count + assert text_chunks.count >= 1 + assert_equal 1, response_chunks.count + + assert_includes response_chunks.first.data.messages.first.content, PredictableToolFunction.expected_test_result + end + end + + private + def tool_call_message + UserMessage.new(chat: @chat, content: "What is my net worth?", ai_model: @subject_model) + end + + class PredictableToolFunction < Assistant::Function + class << self + def expected_test_result + "$124,200" + end + + def name + "get_net_worth" + end + + def description + "Gets user net worth data" + end + end + + def call(params = {}) + self.class.expected_test_result + end + end +end diff --git a/test/models/providers_test.rb b/test/models/provider/registry_test.rb similarity index 62% rename from test/models/providers_test.rb rename to test/models/provider/registry_test.rb index d7851cd8..76c20cd7 100644 --- a/test/models/providers_test.rb +++ b/test/models/provider/registry_test.rb @@ -1,11 +1,11 @@ require "test_helper" -class ProvidersTest < ActiveSupport::TestCase +class Provider::RegistryTest < ActiveSupport::TestCase test "synth configured with ENV" do Setting.stubs(:synth_api_key).returns(nil) with_env_overrides SYNTH_API_KEY: "123" do - assert_instance_of Provider::Synth, Providers.synth + assert_instance_of Provider::Synth, Provider::Registry.get_provider(:synth) end end @@ -13,7 +13,7 @@ class ProvidersTest < ActiveSupport::TestCase Setting.stubs(:synth_api_key).returns("123") with_env_overrides SYNTH_API_KEY: nil do - assert_instance_of Provider::Synth, Providers.synth + assert_instance_of Provider::Synth, Provider::Registry.get_provider(:synth) end end @@ -21,7 +21,7 @@ class ProvidersTest < ActiveSupport::TestCase Setting.stubs(:synth_api_key).returns(nil) with_env_overrides SYNTH_API_KEY: nil do - assert_nil Providers.synth + assert_nil Provider::Registry.get_provider(:synth) end end end diff --git a/test/models/provider_test.rb b/test/models/provider_test.rb index afa770e4..5b9a9287 100644 --- a/test/models/provider_test.rb +++ b/test/models/provider_test.rb @@ -3,7 +3,7 @@ require "ostruct" class TestProvider < Provider def fetch_data - provider_response(retries: 3) do + with_provider_response(retries: 3) do client.get("/test") end end @@ -51,7 +51,7 @@ class ProviderTest < ActiveSupport::TestCase client.expects(:get) .with("/test") - .returns(Provider::ProviderResponse.new(success?: true, data: "success", error: nil)) + .returns(Provider::Response.new(success?: true, data: "success", error: nil)) .in_sequence(sequence) response = @provider.fetch_data diff --git a/test/models/security/price_test.rb b/test/models/security/price_test.rb index 84412c29..bd150359 100644 --- a/test/models/security/price_test.rb +++ b/test/models/security/price_test.rb @@ -40,11 +40,11 @@ class Security::PriceTest < ActiveSupport::TestCase security = securities(:aapl) Security::Price.delete_all # Clear any existing prices - provider_response = provider_error_response(Provider::ProviderError.new("Test error")) + with_provider_response = provider_error_response(StandardError.new("Test error")) @provider.expects(:fetch_security_price) .with(security, date: Date.current) - .returns(provider_response) + .returns(with_provider_response) assert_not @security.find_or_fetch_price(date: Date.current) end @@ -72,12 +72,12 @@ class Security::PriceTest < ActiveSupport::TestCase def expect_provider_price(security:, price:, date:) @provider.expects(:fetch_security_price) .with(security, date: date) - .returns(provider_success_response(Security::Provideable::PriceData.new(price: price))) + .returns(provider_success_response(price)) end def expect_provider_prices(security:, prices:, start_date:, end_date:) @provider.expects(:fetch_security_prices) .with(security, start_date: start_date, end_date: end_date) - .returns(provider_success_response(Security::Provideable::PricesData.new(prices: prices))) + .returns(provider_success_response(prices)) end end diff --git a/test/models/user_message_test.rb b/test/models/user_message_test.rb new file mode 100644 index 00000000..32aff7d4 --- /dev/null +++ b/test/models/user_message_test.rb @@ -0,0 +1,21 @@ +require "test_helper" + +class UserMessageTest < ActiveSupport::TestCase + setup do + @chat = chats(:one) + end + + test "requests assistant response after creation" do + @chat.expects(:ask_assistant_later).once + + message = UserMessage.create!(chat: @chat, content: "Hello from user", ai_model: "gpt-4o") + message.update!(content: "updated") + + streams = capture_turbo_stream_broadcasts(@chat) + assert_equal 2, streams.size + assert_equal "append", streams.first["action"] + assert_equal "messages", streams.first["target"] + assert_equal "update", streams.last["action"] + assert_equal "user_message_#{message.id}", streams.last["target"] + end +end diff --git a/test/support/provider_test_helper.rb b/test/support/provider_test_helper.rb index 45c7de2b..3e3ddcea 100644 --- a/test/support/provider_test_helper.rb +++ b/test/support/provider_test_helper.rb @@ -1,6 +1,6 @@ module ProviderTestHelper def provider_success_response(data) - Provider::ProviderResponse.new( + Provider::Response.new( success?: true, data: data, error: nil @@ -8,7 +8,7 @@ module ProviderTestHelper end def provider_error_response(error) - Provider::ProviderResponse.new( + Provider::Response.new( success?: false, data: nil, error: error diff --git a/test/system/chats_test.rb b/test/system/chats_test.rb new file mode 100644 index 00000000..6e1fe5b5 --- /dev/null +++ b/test/system/chats_test.rb @@ -0,0 +1,66 @@ +require "application_system_test_case" + +class ChatsTest < ApplicationSystemTestCase + setup do + @user = users(:family_admin) + login_as(@user) + end + + test "sidebar shows consent if ai is disabled for user" do + @user.update!(ai_enabled: false) + + visit root_path + + within "#chat-container" do + assert_selector "h3", text: "Enable Personal Finance AI" + end + end + + test "sidebar shows index when enabled and chats are empty" do + @user.update!(ai_enabled: true) + @user.chats.destroy_all + + visit root_url + + within "#chat-container" do + assert_selector "h1", text: "Chats" + end + end + + test "sidebar shows last viewed chat" do + @user.update!(ai_enabled: true) + + click_on @user.chats.first.title + + # Page refresh + visit root_url + + # After page refresh, we're still on the last chat we were viewing + within "#chat-container" do + assert_selector "h1", text: @user.chats.first.title + end + end + + test "create chat and navigate chats sidebar" do + @user.chats.destroy_all + + visit root_url + + Chat.any_instance.expects(:ask_assistant_later).once + + within "#chat-form" do + fill_in "chat[content]", with: "Can you help with my finances?" + find("button[type='submit']").click + end + + assert_text "Can you help with my finances?" + + find("#chat-nav-back").click + + assert_selector "h1", text: "Chats" + + click_on @user.chats.reload.first.title + + assert_text "Can you help with my finances?" + end +end diff --git a/test/system/settings_test.rb b/test/system/settings_test.rb index dbc8ca99..0fe32c6b 100644 --- a/test/system/settings_test.rb +++ b/test/system/settings_test.rb @@ -33,7 +33,7 @@ class SettingsTest < ApplicationSystemTestCase test "can update self hosting settings" do Rails.application.config.app_mode.stubs(:self_hosted?).returns(true) - Providers.stubs(:synth).returns(nil) + Provider::Registry.stubs(:get_provider).with(:synth).returns(nil) open_settings_from_sidebar assert_selector "li", text: "Self hosting" click_link "Self hosting" diff --git a/test/test_helper.rb b/test/test_helper.rb index 6b386535..9e1bb2c9 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -24,6 +24,8 @@ VCR.configure do |config| config.ignore_localhost = true config.default_cassette_options = { erb: true } config.filter_sensitive_data("") { ENV["SYNTH_API_KEY"] } + config.filter_sensitive_data("") { ENV["OPENAI_ACCESS_TOKEN"] } + config.filter_sensitive_data("") { ENV["OPENAI_ORGANIZATION_ID"] } end module ActiveSupport diff --git a/test/vcr_cassettes/openai/chat/basic_response.yml b/test/vcr_cassettes/openai/chat/basic_response.yml new file mode 100644 index 00000000..2975b37d --- /dev/null +++ b/test/vcr_cassettes/openai/chat/basic_response.yml @@ -0,0 +1,92 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.openai.com/v1/responses + body: + encoding: UTF-8 + string: '{"model":"gpt-4o","input":[{"role":"user","content":"This is a chat + test. If it''s working, respond with a single word: Yes"}],"instructions":null,"tools":[],"previous_response_id":null,"stream":true}' + headers: + Content-Type: + - application/json + Authorization: + - Bearer + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + Date: + - Wed, 26 Mar 2025 21:27:38 GMT + Content-Type: + - text/event-stream; charset=utf-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Openai-Version: + - '2020-10-01' + Openai-Organization: + - "" + X-Request-Id: + - req_8fce503a4c5be145dda20867925b1622 + Openai-Processing-Ms: + - '103' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - __cf_bm=o5kysxtwKJs3TPoOquM0X4MkyLIaylWhRd8LhagxXck-1743024458-1.0.1.1-ol6ndVCx6dHLGnc9.YmKYwgfOBqhSZSBpIHg4STCi4OBhrgt70FYPmMptrYDvg.SoFuS5RAS_pGiNNWXHspHio3gTfJ87vIdT936GYHIDrc; + path=/; expires=Wed, 26-Mar-25 21:57:38 GMT; domain=.api.openai.com; HttpOnly; + Secure; SameSite=None + - _cfuvid=Iqk8pY6uwz2lLhdKt0PwWTdtYQUqqvS6xmP9DMVko2A-1743024458829-0.0.1.1-604800000; + path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None + X-Content-Type-Options: + - nosniff + Server: + - cloudflare + Cf-Ray: + - 9269bbb21b1ecf43-CMH + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: UTF-8 + string: |+ + event: response.created + data: {"type":"response.created","response":{"id":"resp_67e4714ab0148192ae2cc4303794d6fc0c1a792abcdc2819","object":"response","created_at":1743024458,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-4o-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":{}}} + + event: response.in_progress + data: {"type":"response.in_progress","response":{"id":"resp_67e4714ab0148192ae2cc4303794d6fc0c1a792abcdc2819","object":"response","created_at":1743024458,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-4o-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":{}}} + + event: response.output_item.added + data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","id":"msg_67e4714b1f8c8192b9b16febe8be86550c1a792abcdc2819","status":"in_progress","role":"assistant","content":[]}} + + event: response.content_part.added + data: {"type":"response.content_part.added","item_id":"msg_67e4714b1f8c8192b9b16febe8be86550c1a792abcdc2819","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]}} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67e4714b1f8c8192b9b16febe8be86550c1a792abcdc2819","output_index":0,"content_index":0,"delta":"Yes"} + + event: response.output_text.done + data: {"type":"response.output_text.done","item_id":"msg_67e4714b1f8c8192b9b16febe8be86550c1a792abcdc2819","output_index":0,"content_index":0,"text":"Yes"} + + event: response.content_part.done + data: {"type":"response.content_part.done","item_id":"msg_67e4714b1f8c8192b9b16febe8be86550c1a792abcdc2819","output_index":0,"content_index":0,"part":{"type":"output_text","text":"Yes","annotations":[]}} + + event: response.output_item.done + data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","id":"msg_67e4714b1f8c8192b9b16febe8be86550c1a792abcdc2819","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Yes","annotations":[]}]}} + + event: response.completed + data: {"type":"response.completed","response":{"id":"resp_67e4714ab0148192ae2cc4303794d6fc0c1a792abcdc2819","object":"response","created_at":1743024458,"status":"completed","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-4o-2024-08-06","output":[{"type":"message","id":"msg_67e4714b1f8c8192b9b16febe8be86550c1a792abcdc2819","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":43,"input_tokens_details":{"cached_tokens":0},"output_tokens":2,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":45},"user":null,"metadata":{}}} + + recorded_at: Wed, 26 Mar 2025 21:27:39 GMT +recorded_with: VCR 6.3.1 +... diff --git a/test/vcr_cassettes/openai/chat/error.yml b/test/vcr_cassettes/openai/chat/error.yml new file mode 100644 index 00000000..cdae2b37 --- /dev/null +++ b/test/vcr_cassettes/openai/chat/error.yml @@ -0,0 +1,72 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.openai.com/v1/responses + body: + encoding: UTF-8 + string: '{"model":"invalid-model-that-will-trigger-api-error","input":[{"role":"user","content":"Error + test"}],"instructions":null,"tools":[],"previous_response_id":null,"stream":true}' + headers: + Content-Type: + - application/json + Authorization: + - Bearer + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 400 + message: Bad Request + headers: + Date: + - Wed, 26 Mar 2025 21:27:19 GMT + Content-Type: + - application/json + Content-Length: + - '207' + Connection: + - keep-alive + Openai-Version: + - '2020-10-01' + Openai-Organization: + - "" + X-Request-Id: + - req_2b86e02f664e790dfa475f111402b722 + Openai-Processing-Ms: + - '146' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - __cf_bm=gAU0gS_ZQBfQmFkc_jKM73dhkNISbBY9FlQjGnZ6CfU-1743024439-1.0.1.1-bWRoC737.SOJPZrP90wTJLVmelTpxFqIsrunq2Lqgy4J3VvLtYBEBrqY0v4d94F5fMcm0Ju.TfQi0etmvqZtUSMRn6rvkMLmXexRcxP.1jE; + path=/; expires=Wed, 26-Mar-25 21:57:19 GMT; domain=.api.openai.com; HttpOnly; + Secure; SameSite=None + - _cfuvid=XnxX4KU80himuKAUavZYtkQasOjXJDJD.QLyMrfBSUU-1743024439792-0.0.1.1-604800000; + path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None + X-Content-Type-Options: + - nosniff + Server: + - cloudflare + Cf-Ray: + - 9269bb3b2c14cf74-CMH + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: UTF-8 + string: |- + { + "error": { + "message": "The requested model 'invalid-model-that-will-trigger-api-error' does not exist.", + "type": "invalid_request_error", + "param": "model", + "code": "model_not_found" + } + } + recorded_at: Wed, 26 Mar 2025 21:27:19 GMT +recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/openai/chat/tool_calls.yml b/test/vcr_cassettes/openai/chat/tool_calls.yml new file mode 100644 index 00000000..0135aff5 --- /dev/null +++ b/test/vcr_cassettes/openai/chat/tool_calls.yml @@ -0,0 +1,201 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.openai.com/v1/responses + body: + encoding: UTF-8 + string: '{"model":"gpt-4o","input":[{"role":"user","content":"What is my net + worth?"}],"instructions":"Use the tools available to you to answer the user''s + question.","tools":[{"type":"function","name":"get_net_worth","description":"Gets + user net worth data","parameters":{"type":"object","properties":{},"required":[],"additionalProperties":false},"strict":true}],"previous_response_id":null,"stream":true}' + headers: + Content-Type: + - application/json + Authorization: + - Bearer + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + Date: + - Wed, 26 Mar 2025 21:22:09 GMT + Content-Type: + - text/event-stream; charset=utf-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Openai-Version: + - '2020-10-01' + Openai-Organization: + - "" + X-Request-Id: + - req_4f04cffbab6051b3ac301038e3796092 + Openai-Processing-Ms: + - '114' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - __cf_bm=F5haUlL1HA1srjwZugBxG6XWbGg.NyQBnJTTirKs5KI-1743024129-1.0.1.1-D842I3sPgDgH_KXyroq6uVivEnbWvm9WJF.L8a11GgUcULXjhweLHs0mXe6MWruf.FJe.lZj.KmX0tCqqdpKIt5JvlbHXt5D_9svedktlZY; + path=/; expires=Wed, 26-Mar-25 21:52:09 GMT; domain=.api.openai.com; HttpOnly; + Secure; SameSite=None + - _cfuvid=MmuRzsy8ebDMe6ibCEwtGp2RzcntpAmdvDlhIZtlY1s-1743024129721-0.0.1.1-604800000; + path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None + X-Content-Type-Options: + - nosniff + Server: + - cloudflare + Cf-Ray: + - 9269b3a97f370002-ORD + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: UTF-8 + string: |+ + event: response.created + data: {"type":"response.created","response":{"id":"resp_67e4700196288192b27a4effc08dc47f069d9116026394b6","object":"response","created_at":1743024129,"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-4o-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 user net worth data","name":"get_net_worth","parameters":{"type":"object","properties":{},"required":[],"additionalProperties":false},"strict":true}],"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} + + event: response.in_progress + data: {"type":"response.in_progress","response":{"id":"resp_67e4700196288192b27a4effc08dc47f069d9116026394b6","object":"response","created_at":1743024129,"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-4o-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 user net worth data","name":"get_net_worth","parameters":{"type":"object","properties":{},"required":[],"additionalProperties":false},"strict":true}],"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} + + event: response.output_item.added + data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"fc_67e4700222008192b3a26ce30fe7ad02069d9116026394b6","call_id":"call_FtvrJsTMg7he0mTeThIqktyL","name":"get_net_worth","arguments":"","status":"in_progress"}} + + event: response.function_call_arguments.delta + data: {"type":"response.function_call_arguments.delta","item_id":"fc_67e4700222008192b3a26ce30fe7ad02069d9116026394b6","output_index":0,"delta":"{}"} + + event: response.function_call_arguments.done + data: {"type":"response.function_call_arguments.done","item_id":"fc_67e4700222008192b3a26ce30fe7ad02069d9116026394b6","output_index":0,"arguments":"{}"} + + event: response.output_item.done + data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"fc_67e4700222008192b3a26ce30fe7ad02069d9116026394b6","call_id":"call_FtvrJsTMg7he0mTeThIqktyL","name":"get_net_worth","arguments":"{}","status":"completed"}} + + event: response.completed + data: {"type":"response.completed","response":{"id":"resp_67e4700196288192b27a4effc08dc47f069d9116026394b6","object":"response","created_at":1743024129,"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-4o-2024-08-06","output":[{"type":"function_call","id":"fc_67e4700222008192b3a26ce30fe7ad02069d9116026394b6","call_id":"call_FtvrJsTMg7he0mTeThIqktyL","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 user net worth data","name":"get_net_worth","parameters":{"type":"object","properties":{},"required":[],"additionalProperties":false},"strict":true}],"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":271,"input_tokens_details":{"cached_tokens":0},"output_tokens":13,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":284},"user":null,"metadata":{}}} + + recorded_at: Wed, 26 Mar 2025 21:22:10 GMT +- request: + method: post + uri: https://api.openai.com/v1/responses + body: + encoding: UTF-8 + string: '{"model":"gpt-4o","input":[{"role":"user","content":"What is my net + worth?"},{"type":"function_call_output","call_id":"call_FtvrJsTMg7he0mTeThIqktyL","output":"\"$124,200\""}],"instructions":"Use + the tools available to you to answer the user''s question.","tools":[],"previous_response_id":"resp_67e4700196288192b27a4effc08dc47f069d9116026394b6","stream":true}' + headers: + Content-Type: + - application/json + Authorization: + - Bearer + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + Date: + - Wed, 26 Mar 2025 21:22:10 GMT + Content-Type: + - text/event-stream; charset=utf-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Openai-Version: + - '2020-10-01' + Openai-Organization: + - "" + X-Request-Id: + - req_792bf572fac53f7e139b29d462933d8f + Openai-Processing-Ms: + - '148' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - __cf_bm=HHguTnSUQFt9KezJAQCrQF_OHn8ZH1C4xDjXRgexdzM-1743024130-1.0.1.1-ZhqxuASVfISfGQbvvKSNy_OQiUfkeIPN2DZhors0K4cl_BzE_P5u9kbc1HkgwyW1A_6GNAenh8Fr9AkoJ0zSakdg5Dr9AU.lu5nr7adQ_60; + path=/; expires=Wed, 26-Mar-25 21:52:10 GMT; domain=.api.openai.com; HttpOnly; + Secure; SameSite=None + - _cfuvid=hX9Y33ruiC9mhYzrOoxyOh23Gy.MfQa54h9l5CllWlI-1743024130948-0.0.1.1-604800000; + path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None + X-Content-Type-Options: + - nosniff + Server: + - cloudflare + Cf-Ray: + - 9269b3b0da83cf67-CMH + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: UTF-8 + string: |+ + event: response.created + data: {"type":"response.created","response":{"id":"resp_67e47002c5b48192a8202d45c6a929f8069d9116026394b6","object":"response","created_at":1743024130,"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-4o-2024-08-06","output":[],"parallel_tool_calls":true,"previous_response_id":"resp_67e4700196288192b27a4effc08dc47f069d9116026394b6","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":{}}} + + event: response.in_progress + data: {"type":"response.in_progress","response":{"id":"resp_67e47002c5b48192a8202d45c6a929f8069d9116026394b6","object":"response","created_at":1743024130,"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-4o-2024-08-06","output":[],"parallel_tool_calls":true,"previous_response_id":"resp_67e4700196288192b27a4effc08dc47f069d9116026394b6","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":{}}} + + event: response.output_item.added + data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","status":"in_progress","role":"assistant","content":[]}} + + event: response.content_part.added + data: {"type":"response.content_part.added","item_id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]}} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","output_index":0,"content_index":0,"delta":"Your"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","output_index":0,"content_index":0,"delta":" net"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","output_index":0,"content_index":0,"delta":" worth"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","output_index":0,"content_index":0,"delta":" is"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","output_index":0,"content_index":0,"delta":" $"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","output_index":0,"content_index":0,"delta":"124"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","output_index":0,"content_index":0,"delta":","} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","output_index":0,"content_index":0,"delta":"200"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","output_index":0,"content_index":0,"delta":"."} + + event: response.output_text.done + data: {"type":"response.output_text.done","item_id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","output_index":0,"content_index":0,"text":"Your net worth is $124,200."} + + event: response.content_part.done + data: {"type":"response.content_part.done","item_id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","output_index":0,"content_index":0,"part":{"type":"output_text","text":"Your net worth is $124,200.","annotations":[]}} + + event: response.output_item.done + data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Your net worth is $124,200.","annotations":[]}]}} + + event: response.completed + data: {"type":"response.completed","response":{"id":"resp_67e47002c5b48192a8202d45c6a929f8069d9116026394b6","object":"response","created_at":1743024130,"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-4o-2024-08-06","output":[{"type":"message","id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Your net worth is $124,200.","annotations":[]}]}],"parallel_tool_calls":true,"previous_response_id":"resp_67e4700196288192b27a4effc08dc47f069d9116026394b6","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":85,"input_tokens_details":{"cached_tokens":0},"output_tokens":10,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":95},"user":null,"metadata":{}}} + + recorded_at: Wed, 26 Mar 2025 21:22:11 GMT +recorded_with: VCR 6.3.1 +... From 1061aacb0fed5517d81366ee6894a5da939abad6 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 28 Mar 2025 13:27:50 -0400 Subject: [PATCH 078/380] Set AI queue --- app/jobs/assistant_response_job.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/jobs/assistant_response_job.rb b/app/jobs/assistant_response_job.rb index 66bc81bd..70664f02 100644 --- a/app/jobs/assistant_response_job.rb +++ b/app/jobs/assistant_response_job.rb @@ -1,5 +1,5 @@ class AssistantResponseJob < ApplicationJob - queue_as :default + queue_as :high_priority def perform(message) message.request_response From 67716f30062e71188814af5e2ce369577e3b4942 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 28 Mar 2025 13:29:56 -0400 Subject: [PATCH 079/380] Add default queue as fallback --- config/sidekiq.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/sidekiq.yml b/config/sidekiq.yml index 8d519d57..20343e8c 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -1,5 +1,6 @@ concurrency: <%= ENV.fetch("RAILS_MAX_THREADS") { 3 } %> queues: - - [high_priority, 7] + - [high_priority, 6] - [medium_priority, 2] - [low_priority, 1] + - [default, 1] From 36a66baf0094b84d0d4164927bf08458d092c2c9 Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Fri, 28 Mar 2025 15:30:11 -0500 Subject: [PATCH 080/380] Slight adjustments to AI prompt --- app/models/assistant.rb | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/app/models/assistant.rb b/app/models/assistant.rb index c1434a5e..aa3aa4ad 100644 --- a/app/models/assistant.rb +++ b/app/models/assistant.rb @@ -104,7 +104,7 @@ class Assistant <<~PROMPT ## Your identity - You are a financial assistant for an open source personal finance application called "Maybe", which is short for "Maybe Finance". + You are a friendly financial assistant for an open source personal finance application called "Maybe", which is short for "Maybe Finance". ## Your purpose @@ -127,6 +127,7 @@ class Assistant - Format all responses in markdown - Format all monetary values according to the user's preferred currency + - Format dates in the user's preferred format #### User's preferred currency @@ -140,20 +141,21 @@ class Assistant - Default format: #{preferred_currency.default_format} - Separator: #{preferred_currency.separator} - Delimiter: #{preferred_currency.delimiter} + - Date format: #{preferred_date_format} ### Rules about financial advice - You are NOT a licensed financial advisor and therefore, you should not provide any financial advice. Instead, - you should focus on educating the user about personal finance and their own data so they can make informed decisions. + You are NOT a licensed financial advisor and therefore, you should not provide any specific investment advice (such as "buy this stock", "sell that bond", "invest in crypto", etc.). + + Instead, you should focus on educating the user about personal finance using their own data so they can make informed decisions. - - Do not provide financial and/or investment advice - Do not suggest investments or financial products - - Do not make assumptions about the user's financial situation. Use the functions available to get the data you need. + - Do not make assumptions about the user's financial situation. Use the functions available to get the data you need. ### Function calling rules - Use the functions available to you to get user financial data and enhance your responses - - For functions that require dates, use the current date as your reference point: #{Date.current} + - For functions that require dates, use the current date as your reference point: #{Date.current} - If you suspect that you do not have enough data to 100% accurately answer, be transparent about it and state exactly what the data you're presenting represents and what context it is in (i.e. date range, account, etc.) PROMPT @@ -172,6 +174,10 @@ class Assistant Money::Currency.new(chat.user.family.currency) end + def preferred_date_format + chat.user.family.date_format + end + def artificial_thinking_delay 1 end From 2a505b000c9ed4a412399a435b0902fe84ddb9da Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Fri, 28 Mar 2025 15:35:26 -0500 Subject: [PATCH 081/380] Fix for unnecessary CSS file --- app/views/pages/early_access.html.erb | 1 - 1 file changed, 1 deletion(-) diff --git a/app/views/pages/early_access.html.erb b/app/views/pages/early_access.html.erb index ee96f362..2c0cf49d 100644 --- a/app/views/pages/early_access.html.erb +++ b/app/views/pages/early_access.html.erb @@ -7,7 +7,6 @@ <%= csp_meta_tag %> <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %> - <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> <%= javascript_importmap_tags %> <%= turbo_refreshes_with method: :morph, scroll: :preserve %> From 9fadfe074b374c470bab1613d669b143b9e9cde9 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 28 Mar 2025 17:24:17 -0400 Subject: [PATCH 082/380] Disable turbo on onboarding form --- app/views/onboardings/preferences.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/onboardings/preferences.html.erb b/app/views/onboardings/preferences.html.erb index a0e2330b..87306ae3 100644 --- a/app/views/onboardings/preferences.html.erb +++ b/app/views/onboardings/preferences.html.erb @@ -56,7 +56,7 @@

      <%= t(".preview") %>

      - <%= styled_form_with model: @user do |form| %> + <%= styled_form_with model: @user, data: { turbo: false } do |form| %> <%= form.hidden_field :onboarded_at, value: Time.current %> <%= form.hidden_field :redirect_to, value: "home" %> From 29f445d75eee83345d5afd14a1c555c4c32efe23 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 28 Mar 2025 17:34:29 -0400 Subject: [PATCH 083/380] Fix security search --- app/models/security.rb | 2 -- app/models/security/provided.rb | 10 +++++++++- app/models/security/synth_combobox_option.rb | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/app/models/security.rb b/app/models/security.rb index 72a09705..30abbe85 100644 --- a/app/models/security.rb +++ b/app/models/security.rb @@ -20,9 +20,7 @@ class Security < ApplicationRecord symbol: ticker, name: name, logo_url: logo_url, - exchange_acronym: exchange_acronym, exchange_operating_mic: exchange_operating_mic, - exchange_country_code: country_code ) end diff --git a/app/models/security/provided.rb b/app/models/security/provided.rb index 73d0435d..3450a6e3 100644 --- a/app/models/security/provided.rb +++ b/app/models/security/provided.rb @@ -13,7 +13,15 @@ module Security::Provided response = provider.search_securities(symbol, country_code: country_code, exchange_operating_mic: exchange_operating_mic) if response.success? - response.data + response.data.map do |provider_security| + # Need to map to domain model so Combobox can display via to_combobox_option + Security.new( + ticker: provider_security.symbol, + name: provider_security.name, + logo_url: provider_security.logo_url, + exchange_operating_mic: provider_security.exchange_operating_mic, + ) + end else [] end diff --git a/app/models/security/synth_combobox_option.rb b/app/models/security/synth_combobox_option.rb index 45091690..2a9ba46a 100644 --- a/app/models/security/synth_combobox_option.rb +++ b/app/models/security/synth_combobox_option.rb @@ -1,7 +1,7 @@ class Security::SynthComboboxOption include ActiveModel::Model - attr_accessor :symbol, :name, :logo_url, :exchange_acronym, :exchange_country_code, :exchange_operating_mic + attr_accessor :symbol, :name, :logo_url, :exchange_operating_mic def id "#{symbol}|#{exchange_operating_mic}" # submitted by combobox as value From dc17a0a2983aed47bae17e518b7ce34ff24fe3f2 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 28 Mar 2025 17:53:04 -0400 Subject: [PATCH 084/380] Make provider errors more specific --- app/models/provider.rb | 5 ++--- app/models/provider/openai.rb | 3 +++ app/models/provider/synth.rb | 3 +++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/app/models/provider.rb b/app/models/provider.rb index f188231e..c775d94b 100644 --- a/app/models/provider.rb +++ b/app/models/provider.rb @@ -61,13 +61,12 @@ class Provider # Override to set class-level error transformation for methods using `with_provider_response` def default_error_transformer(error) if error.is_a?(Faraday::Error) - Error.new( + self.class::Error.new( error.message, details: error.response&.dig(:body), - provider: self.class.name ) else - Error.new(error.message, provider: self.class.name) + self.class::Error.new(error.message) end end diff --git a/app/models/provider/openai.rb b/app/models/provider/openai.rb index fb515fd7..bf5fad05 100644 --- a/app/models/provider/openai.rb +++ b/app/models/provider/openai.rb @@ -1,6 +1,9 @@ class Provider::Openai < Provider include LlmProvider + # Subclass so errors caught in this provider are raised as Provider::Openai::Error + Error = Class.new(Provider::Error) + MODELS = %w[gpt-4o] def initialize(access_token) diff --git a/app/models/provider/synth.rb b/app/models/provider/synth.rb index 81d68ed5..17653d65 100644 --- a/app/models/provider/synth.rb +++ b/app/models/provider/synth.rb @@ -1,6 +1,9 @@ class Provider::Synth < Provider include ExchangeRateProvider, SecurityProvider + # Subclass so errors caught in this provider are raised as Provider::Synth::Error + Error = Class.new(Provider::Error) + def initialize(api_key) @api_key = api_key end From 83bee295ca86494776d9e32ed442ef13cdc3fddc Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Mon, 31 Mar 2025 09:02:25 -0500 Subject: [PATCH 085/380] Add custom data to Intercom configuration --- config/initializers/intercom.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/config/initializers/intercom.rb b/config/initializers/intercom.rb index c87ae369..3824b872 100644 --- a/config/initializers/intercom.rb +++ b/config/initializers/intercom.rb @@ -55,7 +55,9 @@ if ENV["INTERCOM_APP_ID"].present? && ENV["INTERCOM_IDENTITY_VERIFICATION_KEY"]. config.user.custom_data = { family_id: Proc.new { Current.family.id }, name: Proc.new { Current.user.display_name if Current.user.display_name != Current.user.email }, - role: Proc.new { Current.user.role } + role: Proc.new { Current.user.role }, + accounts_count: Proc.new { Current.family.accounts.count }, + ai_enabled: Proc.new { Current.user.ai_enabled } } # == Current company method/variable From 6331788b33e5db85d7827d9101aaf0f628b78f8a Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Mon, 31 Mar 2025 09:39:17 -0500 Subject: [PATCH 086/380] Update Intercom configuration to use symbol keys for custom data attributes --- config/initializers/intercom.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/config/initializers/intercom.rb b/config/initializers/intercom.rb index 3824b872..d35ac77f 100644 --- a/config/initializers/intercom.rb +++ b/config/initializers/intercom.rb @@ -55,9 +55,9 @@ if ENV["INTERCOM_APP_ID"].present? && ENV["INTERCOM_IDENTITY_VERIFICATION_KEY"]. config.user.custom_data = { family_id: Proc.new { Current.family.id }, name: Proc.new { Current.user.display_name if Current.user.display_name != Current.user.email }, - role: Proc.new { Current.user.role }, - accounts_count: Proc.new { Current.family.accounts.count }, - ai_enabled: Proc.new { Current.user.ai_enabled } + "Role": Proc.new { Current.user.role }, + connections: Proc.new { Current.family.accounts.count }, + "AI Enabled": Proc.new { Current.user.ai_enabled } } # == Current company method/variable From 5cf758bd03f93603fedee6fb3a8d66be6165eade Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Tue, 1 Apr 2025 07:21:54 -0400 Subject: [PATCH 087/380] improvements(ai): Improve AI streaming UI/UX interactions + better separation of AI provider responsibilities (#2039) * Start refactor * Interface updates * Rework Assistant, Provider, and tests for better domain boundaries * Consolidate and simplify OpenAI provider and provider concepts * Clean up assistant streaming * Improve assistant message orchestration logic * Clean up "thinking" UI interactions * Remove stale class * Regenerate VCR test responses --- app/models/assistant.rb | 213 ++++----------- app/models/assistant/broadcastable.rb | 12 + app/models/assistant/configurable.rb | 85 ++++++ app/models/assistant/function.rb | 9 + app/models/assistant/function_tool_caller.rb | 37 +++ app/models/assistant/responder.rb | 87 ++++++ app/models/assistant_message.rb | 5 +- app/models/chat.rb | 13 +- app/models/developer_message.rb | 7 +- app/models/message.rb | 4 +- app/models/provider.rb | 6 +- ...e_provider.rb => exchange_rate_concept.rb} | 7 +- app/models/provider/llm_concept.rb | 12 + app/models/provider/llm_provider.rb | 13 - app/models/provider/openai.rb | 47 +++- app/models/provider/openai/chat_config.rb | 36 +++ app/models/provider/openai/chat_parser.rb | 59 +++++ .../openai/chat_response_processor.rb | 188 ------------- .../provider/openai/chat_stream_parser.rb | 28 ++ app/models/provider/openai/chat_streamer.rb | 13 - ...curity_provider.rb => security_concept.rb} | 11 +- app/models/provider/synth.rb | 2 +- app/models/tool_call/function.rb | 20 ++ app/models/user_message.rb | 5 - .../_assistant_message.html.erb | 1 + app/views/chats/show.html.erb | 2 +- test/models/assistant_test.rb | 166 ++++++++---- test/models/provider/openai_test.rb | 161 +++++++----- .../openai/chat/basic_response.yml | 108 +++++--- .../openai/chat/basic_streaming_response.yml | 92 +++++++ test/vcr_cassettes/openai/chat/error.yml | 19 +- .../openai/chat/function_calls.yml | 247 ++++++++++++++++++ ...calls.yml => streaming_function_calls.yml} | 88 ++++--- 33 files changed, 1179 insertions(+), 624 deletions(-) create mode 100644 app/models/assistant/broadcastable.rb create mode 100644 app/models/assistant/configurable.rb create mode 100644 app/models/assistant/function_tool_caller.rb create mode 100644 app/models/assistant/responder.rb rename app/models/provider/{exchange_rate_provider.rb => exchange_rate_concept.rb} (76%) create mode 100644 app/models/provider/llm_concept.rb delete mode 100644 app/models/provider/llm_provider.rb create mode 100644 app/models/provider/openai/chat_config.rb create mode 100644 app/models/provider/openai/chat_parser.rb delete mode 100644 app/models/provider/openai/chat_response_processor.rb create mode 100644 app/models/provider/openai/chat_stream_parser.rb delete mode 100644 app/models/provider/openai/chat_streamer.rb rename app/models/provider/{security_provider.rb => security_concept.rb} (69%) create mode 100644 test/vcr_cassettes/openai/chat/basic_streaming_response.yml create mode 100644 test/vcr_cassettes/openai/chat/function_calls.yml rename test/vcr_cassettes/openai/chat/{tool_calls.yml => streaming_function_calls.yml} (51%) diff --git a/app/models/assistant.rb b/app/models/assistant.rb index aa3aa4ad..c077c2f0 100644 --- a/app/models/assistant.rb +++ b/app/models/assistant.rb @@ -1,184 +1,75 @@ -# Orchestrates LLM interactions for chat conversations by: -# - Streaming generic provider responses -# - Persisting messages and tool calls -# - Broadcasting updates to chat UI -# - Handling provider errors class Assistant - include Provided + include Provided, Configurable, Broadcastable - attr_reader :chat + attr_reader :chat, :instructions class << self def for_chat(chat) - new(chat) + config = config_for(chat) + new(chat, instructions: config[:instructions], functions: config[:functions]) end end - def initialize(chat) + def initialize(chat, instructions: nil, functions: []) @chat = chat - end - - def streamer(model) - assistant_message = AssistantMessage.new( - chat: chat, - content: "", - ai_model: model - ) - - proc do |chunk| - case chunk.type - when "output_text" - stop_thinking - assistant_message.content += chunk.data - assistant_message.save! - when "function_request" - update_thinking("Analyzing your data to assist you with your question...") - when "response" - stop_thinking - assistant_message.ai_model = chunk.data.model - combined_tool_calls = chunk.data.functions.map do |tc| - ToolCall::Function.new( - provider_id: tc.id, - provider_call_id: tc.call_id, - function_name: tc.name, - function_arguments: tc.arguments, - function_result: tc.result - ) - end - - assistant_message.tool_calls = combined_tool_calls - assistant_message.save! - chat.update!(latest_assistant_response_id: chunk.data.id) - end - end + @instructions = instructions + @functions = functions end def respond_to(message) - chat.clear_error - sleep artificial_thinking_delay - - provider = get_model_provider(message.ai_model) - - provider.chat_response( - message, - instructions: instructions, - available_functions: functions, - streamer: streamer(message.ai_model) + assistant_message = AssistantMessage.new( + chat: chat, + content: "", + ai_model: message.ai_model ) + + responder = Assistant::Responder.new( + message: message, + instructions: instructions, + function_tool_caller: function_tool_caller, + llm: get_model_provider(message.ai_model) + ) + + latest_response_id = chat.latest_assistant_response_id + + responder.on(:output_text) do |text| + if assistant_message.content.blank? + stop_thinking + + Chat.transaction do + assistant_message.append_text!(text) + chat.update_latest_response!(latest_response_id) + end + else + assistant_message.append_text!(text) + end + end + + responder.on(:response) do |data| + update_thinking("Analyzing your data...") + + if data[:function_tool_calls].present? + assistant_message.tool_calls = data[:function_tool_calls] + latest_response_id = data[:id] + else + chat.update_latest_response!(data[:id]) + end + end + + responder.respond(previous_response_id: latest_response_id) rescue => e + stop_thinking chat.add_error(e) end private - def update_thinking(thought) - chat.broadcast_update target: "thinking-indicator", partial: "chats/thinking_indicator", locals: { chat: chat, message: thought } - end + attr_reader :functions - def stop_thinking - chat.broadcast_remove target: "thinking-indicator" - end - - def process_response_artifacts(data) - messages = data.messages.map do |message| - AssistantMessage.new( - chat: chat, - content: message.content, - provider_id: message.id, - ai_model: data.model, - tool_calls: data.functions.map do |fn| - ToolCall::Function.new( - provider_id: fn.id, - provider_call_id: fn.call_id, - function_name: fn.name, - function_arguments: fn.arguments, - function_result: fn.result - ) - end - ) + def function_tool_caller + function_instances = functions.map do |fn| + fn.new(chat.user) end - messages.each(&:save!) - end - - def instructions - <<~PROMPT - ## Your identity - - You are a friendly financial assistant for an open source personal finance application called "Maybe", which is short for "Maybe Finance". - - ## Your purpose - - You help users understand their financial data by answering questions about their accounts, - transactions, income, expenses, net worth, and more. - - ## Your rules - - Follow all rules below at all times. - - ### General rules - - - Provide ONLY the most important numbers and insights - - Eliminate all unnecessary words and context - - Ask follow-up questions to keep the conversation going. Help educate the user about their own data and entice them to ask more questions. - - Do NOT add introductions or conclusions - - Do NOT apologize or explain limitations - - ### Formatting rules - - - Format all responses in markdown - - Format all monetary values according to the user's preferred currency - - Format dates in the user's preferred format - - #### User's preferred currency - - Maybe is a multi-currency app where each user has a "preferred currency" setting. - - When no currency is specified, use the user's preferred currency for formatting and displaying monetary values. - - - Symbol: #{preferred_currency.symbol} - - ISO code: #{preferred_currency.iso_code} - - Default precision: #{preferred_currency.default_precision} - - Default format: #{preferred_currency.default_format} - - Separator: #{preferred_currency.separator} - - Delimiter: #{preferred_currency.delimiter} - - Date format: #{preferred_date_format} - - ### Rules about financial advice - - You are NOT a licensed financial advisor and therefore, you should not provide any specific investment advice (such as "buy this stock", "sell that bond", "invest in crypto", etc.). - - Instead, you should focus on educating the user about personal finance using their own data so they can make informed decisions. - - - Do not suggest investments or financial products - - Do not make assumptions about the user's financial situation. Use the functions available to get the data you need. - - ### Function calling rules - - - Use the functions available to you to get user financial data and enhance your responses - - For functions that require dates, use the current date as your reference point: #{Date.current} - - If you suspect that you do not have enough data to 100% accurately answer, be transparent about it and state exactly what - the data you're presenting represents and what context it is in (i.e. date range, account, etc.) - PROMPT - end - - def functions - [ - Assistant::Function::GetTransactions.new(chat.user), - Assistant::Function::GetAccounts.new(chat.user), - Assistant::Function::GetBalanceSheet.new(chat.user), - Assistant::Function::GetIncomeStatement.new(chat.user) - ] - end - - def preferred_currency - Money::Currency.new(chat.user.family.currency) - end - - def preferred_date_format - chat.user.family.date_format - end - - def artificial_thinking_delay - 1 + @function_tool_caller ||= FunctionToolCaller.new(function_instances) end end diff --git a/app/models/assistant/broadcastable.rb b/app/models/assistant/broadcastable.rb new file mode 100644 index 00000000..7fd2507b --- /dev/null +++ b/app/models/assistant/broadcastable.rb @@ -0,0 +1,12 @@ +module Assistant::Broadcastable + extend ActiveSupport::Concern + + private + def update_thinking(thought) + chat.broadcast_update target: "thinking-indicator", partial: "chats/thinking_indicator", locals: { chat: chat, message: thought } + end + + def stop_thinking + chat.broadcast_remove target: "thinking-indicator" + end +end diff --git a/app/models/assistant/configurable.rb b/app/models/assistant/configurable.rb new file mode 100644 index 00000000..a0fb981c --- /dev/null +++ b/app/models/assistant/configurable.rb @@ -0,0 +1,85 @@ +module Assistant::Configurable + extend ActiveSupport::Concern + + class_methods do + def config_for(chat) + preferred_currency = Money::Currency.new(chat.user.family.currency) + preferred_date_format = chat.user.family.date_format + + { + instructions: default_instructions(preferred_currency, preferred_date_format), + functions: default_functions + } + end + + private + def default_functions + [ + Assistant::Function::GetTransactions, + Assistant::Function::GetAccounts, + Assistant::Function::GetBalanceSheet, + Assistant::Function::GetIncomeStatement + ] + end + + def default_instructions(preferred_currency, preferred_date_format) + <<~PROMPT + ## Your identity + + You are a friendly financial assistant for an open source personal finance application called "Maybe", which is short for "Maybe Finance". + + ## Your purpose + + You help users understand their financial data by answering questions about their accounts, + transactions, income, expenses, net worth, and more. + + ## Your rules + + Follow all rules below at all times. + + ### General rules + + - Provide ONLY the most important numbers and insights + - Eliminate all unnecessary words and context + - Ask follow-up questions to keep the conversation going. Help educate the user about their own data and entice them to ask more questions. + - Do NOT add introductions or conclusions + - Do NOT apologize or explain limitations + + ### Formatting rules + + - Format all responses in markdown + - Format all monetary values according to the user's preferred currency + - Format dates in the user's preferred format: #{preferred_date_format} + + #### User's preferred currency + + Maybe is a multi-currency app where each user has a "preferred currency" setting. + + When no currency is specified, use the user's preferred currency for formatting and displaying monetary values. + + - Symbol: #{preferred_currency.symbol} + - ISO code: #{preferred_currency.iso_code} + - Default precision: #{preferred_currency.default_precision} + - Default format: #{preferred_currency.default_format} + - Separator: #{preferred_currency.separator} + - Delimiter: #{preferred_currency.delimiter} + + ### Rules about financial advice + + You are NOT a licensed financial advisor and therefore, you should not provide any specific investment advice (such as "buy this stock", "sell that bond", "invest in crypto", etc.). + + Instead, you should focus on educating the user about personal finance using their own data so they can make informed decisions. + + - Do not suggest investments or financial products + - Do not make assumptions about the user's financial situation. Use the functions available to get the data you need. + + ### Function calling rules + + - Use the functions available to you to get user financial data and enhance your responses + - For functions that require dates, use the current date as your reference point: #{Date.current} + - If you suspect that you do not have enough data to 100% accurately answer, be transparent about it and state exactly what + the data you're presenting represents and what context it is in (i.e. date range, account, etc.) + PROMPT + end + end +end diff --git a/app/models/assistant/function.rb b/app/models/assistant/function.rb index 912063cc..16e3215f 100644 --- a/app/models/assistant/function.rb +++ b/app/models/assistant/function.rb @@ -34,6 +34,15 @@ class Assistant::Function true end + def to_definition + { + name: name, + description: description, + params_schema: params_schema, + strict: strict_mode? + } + end + private attr_reader :user diff --git a/app/models/assistant/function_tool_caller.rb b/app/models/assistant/function_tool_caller.rb new file mode 100644 index 00000000..4ed08102 --- /dev/null +++ b/app/models/assistant/function_tool_caller.rb @@ -0,0 +1,37 @@ +class Assistant::FunctionToolCaller + Error = Class.new(StandardError) + FunctionExecutionError = Class.new(Error) + + attr_reader :functions + + def initialize(functions = []) + @functions = functions + end + + def fulfill_requests(function_requests) + function_requests.map do |function_request| + result = execute(function_request) + + ToolCall::Function.from_function_request(function_request, result) + end + end + + def function_definitions + functions.map(&:to_definition) + end + + private + def execute(function_request) + fn = find_function(function_request) + fn_args = JSON.parse(function_request.function_args) + fn.call(fn_args) + rescue => e + raise FunctionExecutionError.new( + "Error calling function #{fn.name} with arguments #{fn_args}: #{e.message}" + ) + end + + def find_function(function_request) + functions.find { |f| f.name == function_request.function_name } + end +end diff --git a/app/models/assistant/responder.rb b/app/models/assistant/responder.rb new file mode 100644 index 00000000..d79ac5a0 --- /dev/null +++ b/app/models/assistant/responder.rb @@ -0,0 +1,87 @@ +class Assistant::Responder + def initialize(message:, instructions:, function_tool_caller:, llm:) + @message = message + @instructions = instructions + @function_tool_caller = function_tool_caller + @llm = llm + end + + def on(event_name, &block) + listeners[event_name.to_sym] << block + end + + def respond(previous_response_id: nil) + # For the first response + streamer = proc do |chunk| + case chunk.type + when "output_text" + emit(:output_text, chunk.data) + when "response" + response = chunk.data + + if response.function_requests.any? + handle_follow_up_response(response) + else + emit(:response, { id: response.id }) + end + end + end + + get_llm_response(streamer: streamer, previous_response_id: previous_response_id) + end + + private + attr_reader :message, :instructions, :function_tool_caller, :llm + + def handle_follow_up_response(response) + streamer = proc do |chunk| + case chunk.type + when "output_text" + emit(:output_text, chunk.data) + when "response" + # We do not currently support function executions for a follow-up response (avoid recursive LLM calls that could lead to high spend) + emit(:response, { id: chunk.data.id }) + end + end + + function_tool_calls = function_tool_caller.fulfill_requests(response.function_requests) + + emit(:response, { + id: response.id, + function_tool_calls: function_tool_calls + }) + + # Get follow-up response with tool call results + get_llm_response( + streamer: streamer, + function_results: function_tool_calls.map(&:to_result), + previous_response_id: response.id + ) + end + + def get_llm_response(streamer:, function_results: [], previous_response_id: nil) + response = llm.chat_response( + message.content, + model: message.ai_model, + instructions: instructions, + functions: function_tool_caller.function_definitions, + function_results: function_results, + streamer: streamer, + previous_response_id: previous_response_id + ) + + unless response.success? + raise response.error + end + + response.data + end + + def emit(event_name, payload = nil) + listeners[event_name.to_sym].each { |block| block.call(payload) } + end + + def listeners + @listeners ||= Hash.new { |h, k| h[k] = [] } + end +end diff --git a/app/models/assistant_message.rb b/app/models/assistant_message.rb index 67727040..4b1a1404 100644 --- a/app/models/assistant_message.rb +++ b/app/models/assistant_message.rb @@ -5,7 +5,8 @@ class AssistantMessage < Message "assistant" end - def broadcast? - true + def append_text!(text) + self.content += text + save! end end diff --git a/app/models/chat.rb b/app/models/chat.rb index 8ef81eaf..e403a15e 100644 --- a/app/models/chat.rb +++ b/app/models/chat.rb @@ -23,15 +23,25 @@ class Chat < ApplicationRecord end end + def needs_assistant_response? + conversation_messages.ordered.last.role != "assistant" + end + def retry_last_message! + update!(error: nil) + last_message = conversation_messages.ordered.last if last_message.present? && last_message.role == "user" - update!(error: nil) + ask_assistant_later(last_message) end end + def update_latest_response!(provider_response_id) + update!(latest_assistant_response_id: provider_response_id) + end + def add_error(e) update! error: e.to_json broadcast_append target: "messages", partial: "chats/error", locals: { chat: self } @@ -47,6 +57,7 @@ class Chat < ApplicationRecord end def ask_assistant_later(message) + clear_error AssistantResponseJob.perform_later(message) end diff --git a/app/models/developer_message.rb b/app/models/developer_message.rb index ca1d2526..3ba9b3ea 100644 --- a/app/models/developer_message.rb +++ b/app/models/developer_message.rb @@ -3,7 +3,8 @@ class DeveloperMessage < Message "developer" end - def broadcast? - chat.debug_mode? - end + private + def broadcast? + chat.debug_mode? + end end diff --git a/app/models/message.rb b/app/models/message.rb index c0a0b02e..4bf5e9c0 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -8,7 +8,7 @@ class Message < ApplicationRecord failed: "failed" } - validates :content, presence: true, allow_blank: true + validates :content, presence: true after_create_commit -> { broadcast_append_to chat, target: "messages" }, if: :broadcast? after_update_commit -> { broadcast_update_to chat }, if: :broadcast? @@ -17,6 +17,6 @@ class Message < ApplicationRecord private def broadcast? - raise NotImplementedError, "subclasses must set #broadcast?" + true end end diff --git a/app/models/provider.rb b/app/models/provider.rb index c775d94b..98d9fff1 100644 --- a/app/models/provider.rb +++ b/app/models/provider.rb @@ -4,17 +4,15 @@ class Provider Response = Data.define(:success?, :data, :error) class Error < StandardError - attr_reader :details, :provider + attr_reader :details - def initialize(message, details: nil, provider: nil) + def initialize(message, details: nil) super(message) @details = details - @provider = provider end def as_json { - provider: provider, message: message, details: details } diff --git a/app/models/provider/exchange_rate_provider.rb b/app/models/provider/exchange_rate_concept.rb similarity index 76% rename from app/models/provider/exchange_rate_provider.rb rename to app/models/provider/exchange_rate_concept.rb index b00ef2cc..744204a2 100644 --- a/app/models/provider/exchange_rate_provider.rb +++ b/app/models/provider/exchange_rate_concept.rb @@ -1,6 +1,8 @@ -module Provider::ExchangeRateProvider +module Provider::ExchangeRateConcept extend ActiveSupport::Concern + Rate = Data.define(:date, :from, :to, :rate) + def fetch_exchange_rate(from:, to:, date:) raise NotImplementedError, "Subclasses must implement #fetch_exchange_rate" end @@ -8,7 +10,4 @@ module Provider::ExchangeRateProvider def fetch_exchange_rates(from:, to:, start_date:, end_date:) raise NotImplementedError, "Subclasses must implement #fetch_exchange_rates" end - - private - Rate = Data.define(:date, :from, :to, :rate) end diff --git a/app/models/provider/llm_concept.rb b/app/models/provider/llm_concept.rb new file mode 100644 index 00000000..dbdf1eb4 --- /dev/null +++ b/app/models/provider/llm_concept.rb @@ -0,0 +1,12 @@ +module Provider::LlmConcept + extend ActiveSupport::Concern + + ChatMessage = Data.define(:id, :output_text) + ChatStreamChunk = Data.define(:type, :data) + ChatResponse = Data.define(:id, :model, :messages, :function_requests) + ChatFunctionRequest = Data.define(:id, :call_id, :function_name, :function_args) + + def chat_response(prompt, model:, instructions: nil, functions: [], function_results: [], streamer: nil, previous_response_id: nil) + raise NotImplementedError, "Subclasses must implement #chat_response" + end +end diff --git a/app/models/provider/llm_provider.rb b/app/models/provider/llm_provider.rb deleted file mode 100644 index 8282975a..00000000 --- a/app/models/provider/llm_provider.rb +++ /dev/null @@ -1,13 +0,0 @@ -module Provider::LlmProvider - extend ActiveSupport::Concern - - def chat_response(message, instructions: nil, available_functions: [], streamer: nil) - raise NotImplementedError, "Subclasses must implement #chat_response" - end - - private - StreamChunk = Data.define(:type, :data) - ChatResponse = Data.define(:id, :messages, :functions, :model) - Message = Data.define(:id, :content) - FunctionExecution = Data.define(:id, :call_id, :name, :arguments, :result) -end diff --git a/app/models/provider/openai.rb b/app/models/provider/openai.rb index bf5fad05..70b42056 100644 --- a/app/models/provider/openai.rb +++ b/app/models/provider/openai.rb @@ -1,5 +1,5 @@ class Provider::Openai < Provider - include LlmProvider + include LlmConcept # Subclass so errors caught in this provider are raised as Provider::Openai::Error Error = Class.new(Provider::Error) @@ -14,17 +14,46 @@ class Provider::Openai < Provider MODELS.include?(model) end - def chat_response(message, instructions: nil, available_functions: [], streamer: nil) + def chat_response(prompt, model:, instructions: nil, functions: [], function_results: [], streamer: nil, previous_response_id: nil) with_provider_response do - processor = ChatResponseProcessor.new( - client: client, - message: message, - instructions: instructions, - available_functions: available_functions, - streamer: streamer + chat_config = ChatConfig.new( + functions: functions, + function_results: function_results ) - processor.process + collected_chunks = [] + + # Proxy that converts raw stream to "LLM Provider concept" stream + stream_proxy = if streamer.present? + proc do |chunk| + parsed_chunk = ChatStreamParser.new(chunk).parsed + + unless parsed_chunk.nil? + streamer.call(parsed_chunk) + collected_chunks << parsed_chunk + end + end + else + nil + end + + raw_response = client.responses.create(parameters: { + model: model, + input: chat_config.build_input(prompt), + instructions: instructions, + tools: chat_config.tools, + previous_response_id: previous_response_id, + stream: stream_proxy + }) + + # If streaming, Ruby OpenAI does not return anything, so to normalize this method's API, we search + # for the "response chunk" in the stream and return it (it is already parsed) + if stream_proxy.present? + response_chunk = collected_chunks.find { |chunk| chunk.type == "response" } + response_chunk.data + else + ChatParser.new(raw_response).parsed + end end end diff --git a/app/models/provider/openai/chat_config.rb b/app/models/provider/openai/chat_config.rb new file mode 100644 index 00000000..5aca6aeb --- /dev/null +++ b/app/models/provider/openai/chat_config.rb @@ -0,0 +1,36 @@ +class Provider::Openai::ChatConfig + def initialize(functions: [], function_results: []) + @functions = functions + @function_results = function_results + end + + def tools + functions.map do |fn| + { + type: "function", + name: fn[:name], + description: fn[:description], + parameters: fn[:params_schema], + strict: fn[:strict] + } + end + end + + def build_input(prompt) + results = function_results.map do |fn_result| + { + type: "function_call_output", + call_id: fn_result[:call_id], + output: fn_result[:output].to_json + } + end + + [ + { role: "user", content: prompt }, + *results + ] + end + + private + attr_reader :functions, :function_results +end diff --git a/app/models/provider/openai/chat_parser.rb b/app/models/provider/openai/chat_parser.rb new file mode 100644 index 00000000..af7b6248 --- /dev/null +++ b/app/models/provider/openai/chat_parser.rb @@ -0,0 +1,59 @@ +class Provider::Openai::ChatParser + Error = Class.new(StandardError) + + def initialize(object) + @object = object + end + + def parsed + ChatResponse.new( + id: response_id, + model: response_model, + messages: messages, + function_requests: function_requests + ) + end + + private + attr_reader :object + + ChatResponse = Provider::LlmConcept::ChatResponse + ChatMessage = Provider::LlmConcept::ChatMessage + ChatFunctionRequest = Provider::LlmConcept::ChatFunctionRequest + + def response_id + object.dig("id") + end + + def response_model + object.dig("model") + end + + def messages + message_items = object.dig("output").filter { |item| item.dig("type") == "message" } + + message_items.map do |message_item| + ChatMessage.new( + id: message_item.dig("id"), + output_text: message_item.dig("content").map do |content| + text = content.dig("text") + refusal = content.dig("refusal") + text || refusal + end.flatten.join("\n") + ) + end + end + + def function_requests + function_items = object.dig("output").filter { |item| item.dig("type") == "function_call" } + + function_items.map do |function_item| + ChatFunctionRequest.new( + id: function_item.dig("id"), + call_id: function_item.dig("call_id"), + function_name: function_item.dig("name"), + function_args: function_item.dig("arguments") + ) + end + end +end diff --git a/app/models/provider/openai/chat_response_processor.rb b/app/models/provider/openai/chat_response_processor.rb deleted file mode 100644 index c0d259ff..00000000 --- a/app/models/provider/openai/chat_response_processor.rb +++ /dev/null @@ -1,188 +0,0 @@ -class Provider::Openai::ChatResponseProcessor - def initialize(message:, client:, instructions: nil, available_functions: [], streamer: nil) - @client = client - @message = message - @instructions = instructions - @available_functions = available_functions - @streamer = streamer - end - - def process - first_response = fetch_response(previous_response_id: previous_openai_response_id) - - if first_response.functions.empty? - if streamer.present? - streamer.call(Provider::LlmProvider::StreamChunk.new(type: "response", data: first_response)) - end - - return first_response - end - - executed_functions = execute_pending_functions(first_response.functions) - - follow_up_response = fetch_response( - executed_functions: executed_functions, - previous_response_id: first_response.id - ) - - if streamer.present? - streamer.call(Provider::LlmProvider::StreamChunk.new(type: "response", data: follow_up_response)) - end - - follow_up_response - end - - private - attr_reader :client, :message, :instructions, :available_functions, :streamer - - PendingFunction = Data.define(:id, :call_id, :name, :arguments) - - def fetch_response(executed_functions: [], previous_response_id: nil) - function_results = executed_functions.map do |executed_function| - { - type: "function_call_output", - call_id: executed_function.call_id, - output: executed_function.result.to_json - } - end - - prepared_input = input + function_results - - # No need to pass tools for follow-up messages that provide function results - prepared_tools = executed_functions.empty? ? tools : [] - - raw_response = nil - - internal_streamer = proc do |chunk| - type = chunk.dig("type") - - if streamer.present? - case type - when "response.output_text.delta", "response.refusal.delta" - # We don't distinguish between text and refusal yet, so stream both the same - streamer.call(Provider::LlmProvider::StreamChunk.new(type: "output_text", data: chunk.dig("delta"))) - when "response.function_call_arguments.done" - streamer.call(Provider::LlmProvider::StreamChunk.new(type: "function_request", data: chunk.dig("arguments"))) - end - end - - if type == "response.completed" - raw_response = chunk.dig("response") - end - end - - client.responses.create(parameters: { - model: model, - input: prepared_input, - instructions: instructions, - tools: prepared_tools, - previous_response_id: previous_response_id, - stream: internal_streamer - }) - - if raw_response.dig("status") == "failed" || raw_response.dig("status") == "incomplete" - raise Provider::Openai::Error.new("OpenAI returned a failed or incomplete response", { chunk: chunk }) - end - - response_output = raw_response.dig("output") - - functions_output = if executed_functions.any? - executed_functions - else - extract_pending_functions(response_output) - end - - Provider::LlmProvider::ChatResponse.new( - id: raw_response.dig("id"), - messages: extract_messages(response_output), - functions: functions_output, - model: raw_response.dig("model") - ) - end - - def chat - message.chat - end - - def model - message.ai_model - end - - def previous_openai_response_id - chat.latest_assistant_response_id - end - - # Since we're using OpenAI's conversation state management, all we need to pass - # to input is the user message we're currently responding to. - def input - [ { role: "user", content: message.content } ] - end - - def extract_messages(response_output) - message_items = response_output.filter { |item| item.dig("type") == "message" } - - message_items.map do |item| - output_text = item.dig("content").map do |content| - text = content.dig("text") - refusal = content.dig("refusal") - - text || refusal - end.flatten.join("\n") - - Provider::LlmProvider::Message.new( - id: item.dig("id"), - content: output_text, - ) - end - end - - def extract_pending_functions(response_output) - response_output.filter { |item| item.dig("type") == "function_call" }.map do |item| - PendingFunction.new( - id: item.dig("id"), - call_id: item.dig("call_id"), - name: item.dig("name"), - arguments: item.dig("arguments"), - ) - end - end - - def execute_pending_functions(pending_functions) - pending_functions.map do |pending_function| - execute_function(pending_function) - end - end - - def execute_function(fn) - fn_instance = available_functions.find { |f| f.name == fn.name } - parsed_args = JSON.parse(fn.arguments) - result = fn_instance.call(parsed_args) - - Provider::LlmProvider::FunctionExecution.new( - id: fn.id, - call_id: fn.call_id, - name: fn.name, - arguments: parsed_args, - result: result - ) - rescue => e - fn_execution_details = { - fn_name: fn.name, - fn_args: parsed_args - } - - raise Provider::Openai::Error.new(e, fn_execution_details) - end - - def tools - available_functions.map do |fn| - { - type: "function", - name: fn.name, - description: fn.description, - parameters: fn.params_schema, - strict: fn.strict_mode? - } - end - end -end diff --git a/app/models/provider/openai/chat_stream_parser.rb b/app/models/provider/openai/chat_stream_parser.rb new file mode 100644 index 00000000..0b91940c --- /dev/null +++ b/app/models/provider/openai/chat_stream_parser.rb @@ -0,0 +1,28 @@ +class Provider::Openai::ChatStreamParser + Error = Class.new(StandardError) + + def initialize(object) + @object = object + end + + def parsed + type = object.dig("type") + + case type + when "response.output_text.delta", "response.refusal.delta" + Chunk.new(type: "output_text", data: object.dig("delta")) + when "response.completed" + raw_response = object.dig("response") + Chunk.new(type: "response", data: parse_response(raw_response)) + end + end + + private + attr_reader :object + + Chunk = Provider::LlmConcept::ChatStreamChunk + + def parse_response(response) + Provider::Openai::ChatParser.new(response).parsed + end +end diff --git a/app/models/provider/openai/chat_streamer.rb b/app/models/provider/openai/chat_streamer.rb deleted file mode 100644 index 598648d1..00000000 --- a/app/models/provider/openai/chat_streamer.rb +++ /dev/null @@ -1,13 +0,0 @@ -# A stream proxy for OpenAI chat responses -# -# - Consumes an OpenAI chat response stream -# - Outputs a generic "Chat Provider Stream" interface to consumers (e.g. `Assistant`) -class Provider::Openai::ChatStreamer - def initialize(output_stream) - @output_stream = output_stream - end - - def call(chunk) - @output_stream.call(chunk) - end -end diff --git a/app/models/provider/security_provider.rb b/app/models/provider/security_concept.rb similarity index 69% rename from app/models/provider/security_provider.rb rename to app/models/provider/security_concept.rb index 63eba3de..1fc915e7 100644 --- a/app/models/provider/security_provider.rb +++ b/app/models/provider/security_concept.rb @@ -1,6 +1,10 @@ -module Provider::SecurityProvider +module Provider::SecurityConcept extend ActiveSupport::Concern + Security = Data.define(:symbol, :name, :logo_url, :exchange_operating_mic) + SecurityInfo = Data.define(:symbol, :name, :links, :logo_url, :description, :kind) + Price = Data.define(:security, :date, :price, :currency) + def search_securities(symbol, country_code: nil, exchange_operating_mic: nil) raise NotImplementedError, "Subclasses must implement #search_securities" end @@ -16,9 +20,4 @@ module Provider::SecurityProvider def fetch_security_prices(security, start_date:, end_date:) raise NotImplementedError, "Subclasses must implement #fetch_security_prices" end - - private - Security = Data.define(:symbol, :name, :logo_url, :exchange_operating_mic) - SecurityInfo = Data.define(:symbol, :name, :links, :logo_url, :description, :kind) - Price = Data.define(:security, :date, :price, :currency) end diff --git a/app/models/provider/synth.rb b/app/models/provider/synth.rb index 17653d65..87036f88 100644 --- a/app/models/provider/synth.rb +++ b/app/models/provider/synth.rb @@ -1,5 +1,5 @@ class Provider::Synth < Provider - include ExchangeRateProvider, SecurityProvider + include ExchangeRateConcept, SecurityConcept # Subclass so errors caught in this provider are raised as Provider::Synth::Error Error = Class.new(Provider::Error) diff --git a/app/models/tool_call/function.rb b/app/models/tool_call/function.rb index eb61afe1..8cdccce1 100644 --- a/app/models/tool_call/function.rb +++ b/app/models/tool_call/function.rb @@ -1,4 +1,24 @@ class ToolCall::Function < ToolCall validates :function_name, :function_result, presence: true validates :function_arguments, presence: true, allow_blank: true + + class << self + # Translates an "LLM Concept" provider's FunctionRequest into a ToolCall::Function + def from_function_request(function_request, result) + new( + provider_id: function_request.id, + provider_call_id: function_request.call_id, + function_name: function_request.function_name, + function_arguments: function_request.function_args, + function_result: result + ) + end + end + + def to_result + { + call_id: provider_call_id, + output: function_result + } + end end diff --git a/app/models/user_message.rb b/app/models/user_message.rb index 1943758d..5a123120 100644 --- a/app/models/user_message.rb +++ b/app/models/user_message.rb @@ -14,9 +14,4 @@ class UserMessage < Message def request_response chat.ask_assistant(self) end - - private - def broadcast? - true - end end diff --git a/app/views/assistant_messages/_assistant_message.html.erb b/app/views/assistant_messages/_assistant_message.html.erb index 3aa193a2..dfbaae07 100644 --- a/app/views/assistant_messages/_assistant_message.html.erb +++ b/app/views/assistant_messages/_assistant_message.html.erb @@ -17,6 +17,7 @@
      <%= render "chats/ai_avatar" %> +
      <%= markdown(assistant_message.content) %>
      <% end %> diff --git a/app/views/chats/show.html.erb b/app/views/chats/show.html.erb index 39461814..990c84be 100644 --- a/app/views/chats/show.html.erb +++ b/app/views/chats/show.html.erb @@ -23,7 +23,7 @@ <%= render "chats/thinking_indicator", chat: @chat %> <% end %> - <% if @chat.error.present? %> + <% if @chat.error.present? && @chat.needs_assistant_response? %> <%= render "chats/error", chat: @chat %> <% end %>
      diff --git a/test/models/assistant_test.rb b/test/models/assistant_test.rb index 56005482..1af4b0b7 100644 --- a/test/models/assistant_test.rb +++ b/test/models/assistant_test.rb @@ -1,5 +1,4 @@ require "test_helper" -require "ostruct" class AssistantTest < ActiveSupport::TestCase include ProviderTestHelper @@ -8,74 +7,109 @@ class AssistantTest < ActiveSupport::TestCase @chat = chats(:two) @message = @chat.messages.create!( type: "UserMessage", - content: "Help me with my finances", + content: "What is my net worth?", ai_model: "gpt-4o" ) @assistant = Assistant.for_chat(@chat) @provider = mock - @assistant.expects(:get_model_provider).with("gpt-4o").returns(@provider) end - test "responds to basic prompt" do - text_chunk = OpenStruct.new(type: "output_text", data: "Hello from assistant") - response_chunk = OpenStruct.new( - type: "response", - data: OpenStruct.new( - id: "1", - model: "gpt-4o", - messages: [ - OpenStruct.new( - id: "1", - content: "Hello from assistant", - ) - ], - functions: [] - ) - ) + test "errors get added to chat" do + @assistant.expects(:get_model_provider).with("gpt-4o").returns(@provider) - @provider.expects(:chat_response).with do |message, **options| - options[:streamer].call(text_chunk) - options[:streamer].call(response_chunk) - true - end + error = StandardError.new("test error") + @provider.expects(:chat_response).returns(provider_error_response(error)) - assert_difference "AssistantMessage.count", 1 do + @chat.expects(:add_error).with(error).once + + assert_no_difference "AssistantMessage.count" do @assistant.respond_to(@message) end end - test "responds with tool function calls" do - function_request_chunk = OpenStruct.new(type: "function_request", data: "get_net_worth") - text_chunk = OpenStruct.new(type: "output_text", data: "Your net worth is $124,200") - response_chunk = OpenStruct.new( - type: "response", - data: OpenStruct.new( - id: "1", - model: "gpt-4o", - messages: [ - OpenStruct.new( - id: "1", - content: "Your net worth is $124,200", - ) - ], - functions: [ - OpenStruct.new( - id: "1", - call_id: "1", - name: "get_net_worth", - arguments: "{}", - result: "$124,200" - ) - ] - ) + test "responds to basic prompt" do + @assistant.expects(:get_model_provider).with("gpt-4o").returns(@provider) + + text_chunks = [ + provider_text_chunk("I do not "), + provider_text_chunk("have the information "), + provider_text_chunk("to answer that question") + ] + + response_chunk = provider_response_chunk( + id: "1", + model: "gpt-4o", + messages: [ provider_message(id: "1", text: text_chunks.join) ], + function_requests: [] ) + response = provider_success_response(response_chunk.data) + @provider.expects(:chat_response).with do |message, **options| - options[:streamer].call(function_request_chunk) - options[:streamer].call(text_chunk) + text_chunks.each do |text_chunk| + options[:streamer].call(text_chunk) + end + options[:streamer].call(response_chunk) true + end.returns(response) + + assert_difference "AssistantMessage.count", 1 do + @assistant.respond_to(@message) + message = @chat.messages.ordered.where(type: "AssistantMessage").last + assert_equal "I do not have the information to answer that question", message.content + assert_equal 0, message.tool_calls.size end + end + + test "responds with tool function calls" do + @assistant.expects(:get_model_provider).with("gpt-4o").returns(@provider).once + + # Only first provider call executes function + Assistant::Function::GetAccounts.any_instance.stubs(:call).returns("test value").once + + # Call #1: Function requests + call1_response_chunk = provider_response_chunk( + id: "1", + model: "gpt-4o", + messages: [], + function_requests: [ + provider_function_request(id: "1", call_id: "1", function_name: "get_accounts", function_args: "{}") + ] + ) + + call1_response = provider_success_response(call1_response_chunk.data) + + # Call #2: Text response (that uses function results) + call2_text_chunks = [ + provider_text_chunk("Your net worth is "), + provider_text_chunk("$124,200") + ] + + call2_response_chunk = provider_response_chunk( + id: "2", + model: "gpt-4o", + messages: [ provider_message(id: "1", text: call2_text_chunks.join) ], + function_requests: [] + ) + + call2_response = provider_success_response(call2_response_chunk.data) + + sequence = sequence("provider_chat_response") + + @provider.expects(:chat_response).with do |message, **options| + call2_text_chunks.each do |text_chunk| + options[:streamer].call(text_chunk) + end + + options[:streamer].call(call2_response_chunk) + true + end.returns(call2_response).once.in_sequence(sequence) + + @provider.expects(:chat_response).with do |message, **options| + options[:streamer].call(call1_response_chunk) + true + end.returns(call1_response).once.in_sequence(sequence) assert_difference "AssistantMessage.count", 1 do @assistant.respond_to(@message) @@ -83,4 +117,34 @@ class AssistantTest < ActiveSupport::TestCase assert_equal 1, message.tool_calls.size end end + + private + def provider_function_request(id:, call_id:, function_name:, function_args:) + Provider::LlmConcept::ChatFunctionRequest.new( + id: id, + call_id: call_id, + function_name: function_name, + function_args: function_args + ) + end + + def provider_message(id:, text:) + Provider::LlmConcept::ChatMessage.new(id: id, output_text: text) + end + + def provider_text_chunk(text) + Provider::LlmConcept::ChatStreamChunk.new(type: "output_text", data: text) + end + + def provider_response_chunk(id:, model:, messages:, function_requests:) + Provider::LlmConcept::ChatStreamChunk.new( + type: "response", + data: Provider::LlmConcept::ChatResponse.new( + id: id, + model: model, + messages: messages, + function_requests: function_requests + ) + ) + end end diff --git a/test/models/provider/openai_test.rb b/test/models/provider/openai_test.rb index ccaae937..e6384297 100644 --- a/test/models/provider/openai_test.rb +++ b/test/models/provider/openai_test.rb @@ -6,16 +6,11 @@ class Provider::OpenaiTest < ActiveSupport::TestCase setup do @subject = @openai = Provider::Openai.new(ENV.fetch("OPENAI_ACCESS_TOKEN", "test-openai-token")) @subject_model = "gpt-4o" - @chat = chats(:two) end test "openai errors are automatically raised" do VCR.use_cassette("openai/chat/error") do - response = @openai.chat_response(UserMessage.new( - chat: @chat, - content: "Error test", - ai_model: "invalid-model-that-will-trigger-api-error" - )) + response = @openai.chat_response("Test", model: "invalid-model-that-will-trigger-api-error") assert_not response.success? assert_kind_of Provider::Openai::Error, response.error @@ -24,113 +19,145 @@ class Provider::OpenaiTest < ActiveSupport::TestCase test "basic chat response" do VCR.use_cassette("openai/chat/basic_response") do - message = @chat.messages.create!( - type: "UserMessage", - content: "This is a chat test. If it's working, respond with a single word: Yes", - ai_model: @subject_model + response = @subject.chat_response( + "This is a chat test. If it's working, respond with a single word: Yes", + model: @subject_model ) - response = @subject.chat_response(message) - assert response.success? assert_equal 1, response.data.messages.size - assert_includes response.data.messages.first.content, "Yes" + assert_includes response.data.messages.first.output_text, "Yes" end end test "streams basic chat response" do - VCR.use_cassette("openai/chat/basic_response") do + VCR.use_cassette("openai/chat/basic_streaming_response") do collected_chunks = [] mock_streamer = proc do |chunk| collected_chunks << chunk end - message = @chat.messages.create!( - type: "UserMessage", - content: "This is a chat test. If it's working, respond with a single word: Yes", - ai_model: @subject_model + response = @subject.chat_response( + "This is a chat test. If it's working, respond with a single word: Yes", + model: @subject_model, + streamer: mock_streamer ) - @subject.chat_response(message, streamer: mock_streamer) - - tool_call_chunks = collected_chunks.select { |chunk| chunk.type == "function_request" } text_chunks = collected_chunks.select { |chunk| chunk.type == "output_text" } response_chunks = collected_chunks.select { |chunk| chunk.type == "response" } assert_equal 1, text_chunks.size assert_equal 1, response_chunks.size - assert_equal 0, tool_call_chunks.size assert_equal "Yes", text_chunks.first.data - assert_equal "Yes", response_chunks.first.data.messages.first.content + assert_equal "Yes", response_chunks.first.data.messages.first.output_text + assert_equal response_chunks.first.data, response.data end end - test "chat response with tool calls" do - VCR.use_cassette("openai/chat/tool_calls") do - response = @subject.chat_response( - tool_call_message, + test "chat response with function calls" do + VCR.use_cassette("openai/chat/function_calls") do + prompt = "What is my net worth?" + + functions = [ + { + name: "get_net_worth", + description: "Gets a user's net worth", + params_schema: { type: "object", properties: {}, required: [], additionalProperties: false }, + strict: true + } + ] + + first_response = @subject.chat_response( + prompt, + model: @subject_model, instructions: "Use the tools available to you to answer the user's question.", - available_functions: [ PredictableToolFunction.new(@chat) ] + functions: functions ) - assert response.success? - assert_equal 1, response.data.functions.size - assert_equal 1, response.data.messages.size - assert_includes response.data.messages.first.content, PredictableToolFunction.expected_test_result + assert first_response.success? + + function_request = first_response.data.function_requests.first + + assert function_request.present? + + second_response = @subject.chat_response( + prompt, + model: @subject_model, + function_results: [ { + call_id: function_request.call_id, + output: { amount: 10000, currency: "USD" }.to_json + } ], + previous_response_id: first_response.data.id + ) + + assert second_response.success? + assert_equal 1, second_response.data.messages.size + assert_includes second_response.data.messages.first.output_text, "$10,000" end end - test "streams chat response with tool calls" do - VCR.use_cassette("openai/chat/tool_calls") do + test "streams chat response with function calls" do + VCR.use_cassette("openai/chat/streaming_function_calls") do collected_chunks = [] mock_streamer = proc do |chunk| collected_chunks << chunk end + prompt = "What is my net worth?" + + functions = [ + { + name: "get_net_worth", + description: "Gets a user's net worth", + params_schema: { type: "object", properties: {}, required: [], additionalProperties: false }, + strict: true + } + ] + + # Call #1: First streaming call, will return a function request @subject.chat_response( - tool_call_message, + prompt, + model: @subject_model, instructions: "Use the tools available to you to answer the user's question.", - available_functions: [ PredictableToolFunction.new(@chat) ], + functions: functions, streamer: mock_streamer ) text_chunks = collected_chunks.select { |chunk| chunk.type == "output_text" } - text_chunks = collected_chunks.select { |chunk| chunk.type == "output_text" } - tool_call_chunks = collected_chunks.select { |chunk| chunk.type == "function_request" } response_chunks = collected_chunks.select { |chunk| chunk.type == "response" } - assert_equal 1, tool_call_chunks.count - assert text_chunks.count >= 1 - assert_equal 1, response_chunks.count + assert_equal 0, text_chunks.size + assert_equal 1, response_chunks.size - assert_includes response_chunks.first.data.messages.first.content, PredictableToolFunction.expected_test_result + first_response = response_chunks.first.data + function_request = first_response.function_requests.first + + # Reset collected chunks for the second call + collected_chunks = [] + + # Call #2: Second streaming call, will return a function result + @subject.chat_response( + prompt, + model: @subject_model, + function_results: [ + { + call_id: function_request.call_id, + output: { amount: 10000, currency: "USD" } + } + ], + previous_response_id: first_response.id, + streamer: mock_streamer + ) + + text_chunks = collected_chunks.select { |chunk| chunk.type == "output_text" } + response_chunks = collected_chunks.select { |chunk| chunk.type == "response" } + + assert text_chunks.size >= 1 + assert_equal 1, response_chunks.size + + assert_includes response_chunks.first.data.messages.first.output_text, "$10,000" end end - - private - def tool_call_message - UserMessage.new(chat: @chat, content: "What is my net worth?", ai_model: @subject_model) - end - - class PredictableToolFunction < Assistant::Function - class << self - def expected_test_result - "$124,200" - end - - def name - "get_net_worth" - end - - def description - "Gets user net worth data" - end - end - - def call(params = {}) - self.class.expected_test_result - end - end end diff --git a/test/vcr_cassettes/openai/chat/basic_response.yml b/test/vcr_cassettes/openai/chat/basic_response.yml index 2975b37d..5a6df1af 100644 --- a/test/vcr_cassettes/openai/chat/basic_response.yml +++ b/test/vcr_cassettes/openai/chat/basic_response.yml @@ -6,7 +6,7 @@ http_interactions: body: encoding: UTF-8 string: '{"model":"gpt-4o","input":[{"role":"user","content":"This is a chat - test. If it''s working, respond with a single word: Yes"}],"instructions":null,"tools":[],"previous_response_id":null,"stream":true}' + test. If it''s working, respond with a single word: Yes"}],"instructions":null,"tools":[],"previous_response_id":null,"stream":null}' headers: Content-Type: - application/json @@ -24,9 +24,9 @@ http_interactions: message: OK headers: Date: - - Wed, 26 Mar 2025 21:27:38 GMT + - Mon, 31 Mar 2025 20:38:55 GMT Content-Type: - - text/event-stream; charset=utf-8 + - application/json Transfer-Encoding: - chunked Connection: @@ -36,57 +36,85 @@ http_interactions: Openai-Organization: - "" X-Request-Id: - - req_8fce503a4c5be145dda20867925b1622 + - req_f99033a5841a7d9357ee08d301ad634e Openai-Processing-Ms: - - '103' + - '713' Strict-Transport-Security: - max-age=31536000; includeSubDomains; preload Cf-Cache-Status: - DYNAMIC Set-Cookie: - - __cf_bm=o5kysxtwKJs3TPoOquM0X4MkyLIaylWhRd8LhagxXck-1743024458-1.0.1.1-ol6ndVCx6dHLGnc9.YmKYwgfOBqhSZSBpIHg4STCi4OBhrgt70FYPmMptrYDvg.SoFuS5RAS_pGiNNWXHspHio3gTfJ87vIdT936GYHIDrc; - path=/; expires=Wed, 26-Mar-25 21:57:38 GMT; domain=.api.openai.com; HttpOnly; + - __cf_bm=UOaolWyAE3WXhLfg9c3KmO4d_Nq6t9cedTfZ6hznYEE-1743453535-1.0.1.1-GyQq_xeRpsyxxp8QQja5Bvo2XqUGfXHNGehtQoPV.BIgyLbERSIqJAK0IEKcYgpuLCyvQdlMNGqtdBHB6r5XMPHjOSMN1bTQYJHLsvlD5Z4; + path=/; expires=Mon, 31-Mar-25 21:08:55 GMT; domain=.api.openai.com; HttpOnly; Secure; SameSite=None - - _cfuvid=Iqk8pY6uwz2lLhdKt0PwWTdtYQUqqvS6xmP9DMVko2A-1743024458829-0.0.1.1-604800000; + - _cfuvid=_zDj2dj75eLeGSzZxpBpzHxYg4gJpEfQpcnT9aCJXqM-1743453535930-0.0.1.1-604800000; path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None X-Content-Type-Options: - nosniff Server: - cloudflare Cf-Ray: - - 9269bbb21b1ecf43-CMH + - 9292a7325d09cf53-CMH Alt-Svc: - h3=":443"; ma=86400 body: - encoding: UTF-8 - string: |+ - event: response.created - data: {"type":"response.created","response":{"id":"resp_67e4714ab0148192ae2cc4303794d6fc0c1a792abcdc2819","object":"response","created_at":1743024458,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-4o-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":{}}} - - event: response.in_progress - data: {"type":"response.in_progress","response":{"id":"resp_67e4714ab0148192ae2cc4303794d6fc0c1a792abcdc2819","object":"response","created_at":1743024458,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-4o-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":{}}} - - event: response.output_item.added - data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","id":"msg_67e4714b1f8c8192b9b16febe8be86550c1a792abcdc2819","status":"in_progress","role":"assistant","content":[]}} - - event: response.content_part.added - data: {"type":"response.content_part.added","item_id":"msg_67e4714b1f8c8192b9b16febe8be86550c1a792abcdc2819","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]}} - - event: response.output_text.delta - data: {"type":"response.output_text.delta","item_id":"msg_67e4714b1f8c8192b9b16febe8be86550c1a792abcdc2819","output_index":0,"content_index":0,"delta":"Yes"} - - event: response.output_text.done - data: {"type":"response.output_text.done","item_id":"msg_67e4714b1f8c8192b9b16febe8be86550c1a792abcdc2819","output_index":0,"content_index":0,"text":"Yes"} - - event: response.content_part.done - data: {"type":"response.content_part.done","item_id":"msg_67e4714b1f8c8192b9b16febe8be86550c1a792abcdc2819","output_index":0,"content_index":0,"part":{"type":"output_text","text":"Yes","annotations":[]}} - - event: response.output_item.done - data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","id":"msg_67e4714b1f8c8192b9b16febe8be86550c1a792abcdc2819","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Yes","annotations":[]}]}} - - event: response.completed - data: {"type":"response.completed","response":{"id":"resp_67e4714ab0148192ae2cc4303794d6fc0c1a792abcdc2819","object":"response","created_at":1743024458,"status":"completed","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-4o-2024-08-06","output":[{"type":"message","id":"msg_67e4714b1f8c8192b9b16febe8be86550c1a792abcdc2819","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":43,"input_tokens_details":{"cached_tokens":0},"output_tokens":2,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":45},"user":null,"metadata":{}}} - - recorded_at: Wed, 26 Mar 2025 21:27:39 GMT + encoding: ASCII-8BIT + string: |- + { + "id": "resp_67eafd5f2b7c81928d6834e7f4d26deb0bfadc995fda2b45", + "object": "response", + "created_at": 1743453535, + "status": "completed", + "error": null, + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "model": "gpt-4o-2024-08-06", + "output": [ + { + "type": "message", + "id": "msg_67eafd5fba44819287b79107821a818b0bfadc995fda2b45", + "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": {} + } + recorded_at: Mon, 31 Mar 2025 20:38:55 GMT recorded_with: VCR 6.3.1 -... diff --git a/test/vcr_cassettes/openai/chat/basic_streaming_response.yml b/test/vcr_cassettes/openai/chat/basic_streaming_response.yml new file mode 100644 index 00000000..17253361 --- /dev/null +++ b/test/vcr_cassettes/openai/chat/basic_streaming_response.yml @@ -0,0 +1,92 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.openai.com/v1/responses + body: + encoding: UTF-8 + string: '{"model":"gpt-4o","input":[{"role":"user","content":"This is a chat + test. If it''s working, respond with a single word: Yes"}],"instructions":null,"tools":[],"previous_response_id":null,"stream":true}' + headers: + Content-Type: + - application/json + Authorization: + - Bearer + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + Date: + - Mon, 31 Mar 2025 20:38:55 GMT + Content-Type: + - text/event-stream; charset=utf-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Openai-Version: + - '2020-10-01' + Openai-Organization: + - "" + X-Request-Id: + - req_d88b2a28252a098fe9f6e1223baebad8 + Openai-Processing-Ms: + - '124' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - __cf_bm=wP2ENU9eOGUSzQ8wOjb31UiZAZVX021QgA1NuYcfKeo-1743453535-1.0.1.1-d08X7zX7cf._5LTGrF6qL17AtdgsKpEWLWnZ0dl5KgPWXEK.oqoDgoQ_pa8j5rKYZkeZUDxMhcpP266z9tJpPJ2ZPX8bkZYAjlnlcOa5.JM; + path=/; expires=Mon, 31-Mar-25 21:08:55 GMT; domain=.api.openai.com; HttpOnly; + Secure; SameSite=None + - _cfuvid=F6OIQe1fgGYxb6xer0VjBA1aHrf6osX7wJU6adYsMy0-1743453535321-0.0.1.1-604800000; + path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None + X-Content-Type-Options: + - nosniff + Server: + - cloudflare + Cf-Ray: + - 9292a7324c3dcf78-CMH + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: UTF-8 + string: |+ + event: response.created + 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-4o-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":{}}} + + event: response.in_progress + 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-4o-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":{}}} + + event: response.output_item.added + data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","id":"msg_67eafd5f7c048192a24ce545ebfd908a0e51d01dbd4ed330","status":"in_progress","role":"assistant","content":[]}} + + event: response.content_part.added + data: {"type":"response.content_part.added","item_id":"msg_67eafd5f7c048192a24ce545ebfd908a0e51d01dbd4ed330","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]}} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67eafd5f7c048192a24ce545ebfd908a0e51d01dbd4ed330","output_index":0,"content_index":0,"delta":"Yes"} + + event: response.output_text.done + data: {"type":"response.output_text.done","item_id":"msg_67eafd5f7c048192a24ce545ebfd908a0e51d01dbd4ed330","output_index":0,"content_index":0,"text":"Yes"} + + event: response.content_part.done + data: {"type":"response.content_part.done","item_id":"msg_67eafd5f7c048192a24ce545ebfd908a0e51d01dbd4ed330","output_index":0,"content_index":0,"part":{"type":"output_text","text":"Yes","annotations":[]}} + + event: response.output_item.done + 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":[]}]}} + + event: response.completed + 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-4o-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":{}}} + + recorded_at: Mon, 31 Mar 2025 20:38:55 GMT +recorded_with: VCR 6.3.1 +... diff --git a/test/vcr_cassettes/openai/chat/error.yml b/test/vcr_cassettes/openai/chat/error.yml index cdae2b37..3d8ab1c4 100644 --- a/test/vcr_cassettes/openai/chat/error.yml +++ b/test/vcr_cassettes/openai/chat/error.yml @@ -5,8 +5,7 @@ http_interactions: uri: https://api.openai.com/v1/responses body: encoding: UTF-8 - string: '{"model":"invalid-model-that-will-trigger-api-error","input":[{"role":"user","content":"Error - test"}],"instructions":null,"tools":[],"previous_response_id":null,"stream":true}' + string: '{"model":"invalid-model-that-will-trigger-api-error","input":[{"role":"user","content":"Test"}],"instructions":null,"tools":[],"previous_response_id":null,"stream":null}' headers: Content-Type: - application/json @@ -24,7 +23,7 @@ http_interactions: message: Bad Request headers: Date: - - Wed, 26 Mar 2025 21:27:19 GMT + - Mon, 31 Mar 2025 20:38:55 GMT Content-Type: - application/json Content-Length: @@ -36,25 +35,25 @@ http_interactions: Openai-Organization: - "" X-Request-Id: - - req_2b86e02f664e790dfa475f111402b722 + - req_3981f27aa18db734b3dd530fa2929b95 Openai-Processing-Ms: - - '146' + - '113' Strict-Transport-Security: - max-age=31536000; includeSubDomains; preload Cf-Cache-Status: - DYNAMIC Set-Cookie: - - __cf_bm=gAU0gS_ZQBfQmFkc_jKM73dhkNISbBY9FlQjGnZ6CfU-1743024439-1.0.1.1-bWRoC737.SOJPZrP90wTJLVmelTpxFqIsrunq2Lqgy4J3VvLtYBEBrqY0v4d94F5fMcm0Ju.TfQi0etmvqZtUSMRn6rvkMLmXexRcxP.1jE; - path=/; expires=Wed, 26-Mar-25 21:57:19 GMT; domain=.api.openai.com; HttpOnly; + - __cf_bm=8KUMK_Gp4f97KLactyy3QniUZbNmN9Zwbx9WowYCc98-1743453535-1.0.1.1-opjT17tCwi9U0AukBXoHrpPEcC4Z.GIyEt.AjjrzRWln62SWPIvggY4L19JabZu09.9cmxfyrwAFHmvDeCVxSWqAVf88PAZwwRICkZZUut0; + path=/; expires=Mon, 31-Mar-25 21:08:55 GMT; domain=.api.openai.com; HttpOnly; Secure; SameSite=None - - _cfuvid=XnxX4KU80himuKAUavZYtkQasOjXJDJD.QLyMrfBSUU-1743024439792-0.0.1.1-604800000; + - _cfuvid=uZB07768IynyRRP6oxwcnC4Rfn.lGT1yRhzzGvNw0kc-1743453535322-0.0.1.1-604800000; path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None X-Content-Type-Options: - nosniff Server: - cloudflare Cf-Ray: - - 9269bb3b2c14cf74-CMH + - 9292a7327d5161d6-ORD Alt-Svc: - h3=":443"; ma=86400 body: @@ -68,5 +67,5 @@ http_interactions: "code": "model_not_found" } } - recorded_at: Wed, 26 Mar 2025 21:27:19 GMT + recorded_at: Mon, 31 Mar 2025 20:38:55 GMT recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/openai/chat/function_calls.yml b/test/vcr_cassettes/openai/chat/function_calls.yml new file mode 100644 index 00000000..bb19ee09 --- /dev/null +++ b/test/vcr_cassettes/openai/chat/function_calls.yml @@ -0,0 +1,247 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.openai.com/v1/responses + body: + encoding: UTF-8 + string: '{"model":"gpt-4o","input":[{"role":"user","content":"What is my net + worth?"}],"instructions":"Use the tools available to you to answer the user''s + question.","tools":[{"type":"function","name":"get_net_worth","description":"Gets + a user''s net worth","parameters":{"type":"object","properties":{},"required":[],"additionalProperties":false},"strict":true}],"previous_response_id":null,"stream":null}' + headers: + Content-Type: + - application/json + Authorization: + - Bearer + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + Date: + - Mon, 31 Mar 2025 20:38:55 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Openai-Version: + - '2020-10-01' + Openai-Organization: + - "" + X-Request-Id: + - req_a179c8964589756af0d4b5af864a29a7 + Openai-Processing-Ms: + - '761' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - __cf_bm=niiWOEhogNgWfxuZanJKipOlIrWGEPtp7bUpqDAp9Lo-1743453535-1.0.1.1-ytL9wC5t5fjY2v90vscRJLokIeZyVY2hmBqFuWbA_BOvZaw9aPFmtQDKhDD3WcLQryEtXiEGAyOANHnaeItCR0J_sXu7Jy4wdpJ4EMShQxU; + path=/; expires=Mon, 31-Mar-25 21:08:55 GMT; domain=.api.openai.com; HttpOnly; + Secure; SameSite=None + - _cfuvid=kKjDNYSJJidsRTyFQWUgt6xlnqW_DkveNOUYxpBe9EE-1743453535972-0.0.1.1-604800000; + path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None + X-Content-Type-Options: + - nosniff + Server: + - cloudflare + Cf-Ray: + - 9292a732598dcf52-CMH + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: |- + { + "id": "resp_67eafd5f2d1881928f10551839e8219102a5ebf5f2a599ef", + "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-4o-2024-08-06", + "output": [ + { + "type": "function_call", + "id": "fc_67eafd5f9c88819286afe92f08354f7302a5ebf5f2a599ef", + "call_id": "call_KrFORr53UBxdwZ9SQ6fkpU0F", + "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": {} + } + recorded_at: Mon, 31 Mar 2025 20:38:55 GMT +- request: + method: post + uri: https://api.openai.com/v1/responses + body: + encoding: UTF-8 + string: '{"model":"gpt-4o","input":[{"role":"user","content":"What is my net + worth?"},{"type":"function_call_output","call_id":"call_KrFORr53UBxdwZ9SQ6fkpU0F","output":"\"{\\\"amount\\\":10000,\\\"currency\\\":\\\"USD\\\"}\""}],"instructions":null,"tools":[],"previous_response_id":"resp_67eafd5f2d1881928f10551839e8219102a5ebf5f2a599ef","stream":null}' + headers: + Content-Type: + - application/json + Authorization: + - Bearer + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + Date: + - Mon, 31 Mar 2025 20:38:56 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Openai-Version: + - '2020-10-01' + Openai-Organization: + - "" + X-Request-Id: + - req_edd5bafc982bae46e92d0cd79e594779 + Openai-Processing-Ms: + - '805' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - __cf_bm=jOZGEPyAByXhGrQIvKzbj_6TEODdZWw_S0BZsxbsuDc-1743453536-1.0.1.1-YpxHv.vmXVdwzQV5dMTB0I851tQSlDf.NboFddRq_aLDM1CnQW143gRcYbfPpCREij9SDqhnluZ4kxCuD3eaarhmFn2liMVHHRYUgMsUhck; + path=/; expires=Mon, 31-Mar-25 21:08:56 GMT; domain=.api.openai.com; HttpOnly; + Secure; SameSite=None + - _cfuvid=1BoPw7WORdkfBQmal3sGAXdHGiJiFkXK8HXhWPWf7Vw-1743453536967-0.0.1.1-604800000; + path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None + X-Content-Type-Options: + - nosniff + Server: + - cloudflare + Cf-Ray: + - 9292a7385ec6cf62-CMH + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: |- + { + "id": "resp_67eafd6023488192b382acd64a514ff002a5ebf5f2a599ef", + "object": "response", + "created_at": 1743453536, + "status": "completed", + "error": null, + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "model": "gpt-4o-2024-08-06", + "output": [ + { + "type": "message", + "id": "msg_67eafd60a42c8192906eb4d48f8970de02a5ebf5f2a599ef", + "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_67eafd5f2d1881928f10551839e8219102a5ebf5f2a599ef", + "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": 58, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 11, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 69 + }, + "user": null, + "metadata": {} + } + recorded_at: Mon, 31 Mar 2025 20:38:57 GMT +recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/openai/chat/tool_calls.yml b/test/vcr_cassettes/openai/chat/streaming_function_calls.yml similarity index 51% rename from test/vcr_cassettes/openai/chat/tool_calls.yml rename to test/vcr_cassettes/openai/chat/streaming_function_calls.yml index 0135aff5..c4739594 100644 --- a/test/vcr_cassettes/openai/chat/tool_calls.yml +++ b/test/vcr_cassettes/openai/chat/streaming_function_calls.yml @@ -8,7 +8,7 @@ http_interactions: string: '{"model":"gpt-4o","input":[{"role":"user","content":"What is my net worth?"}],"instructions":"Use the tools available to you to answer the user''s question.","tools":[{"type":"function","name":"get_net_worth","description":"Gets - user net worth data","parameters":{"type":"object","properties":{},"required":[],"additionalProperties":false},"strict":true}],"previous_response_id":null,"stream":true}' + a user''s net worth","parameters":{"type":"object","properties":{},"required":[],"additionalProperties":false},"strict":true}],"previous_response_id":null,"stream":true}' headers: Content-Type: - application/json @@ -26,7 +26,7 @@ http_interactions: message: OK headers: Date: - - Wed, 26 Mar 2025 21:22:09 GMT + - Mon, 31 Mar 2025 20:38:55 GMT Content-Type: - text/event-stream; charset=utf-8 Transfer-Encoding: @@ -38,60 +38,59 @@ http_interactions: Openai-Organization: - "" X-Request-Id: - - req_4f04cffbab6051b3ac301038e3796092 + - req_8c4d6f0ad0ae3095353a5c19fd128c56 Openai-Processing-Ms: - - '114' + - '129' Strict-Transport-Security: - max-age=31536000; includeSubDomains; preload Cf-Cache-Status: - DYNAMIC Set-Cookie: - - __cf_bm=F5haUlL1HA1srjwZugBxG6XWbGg.NyQBnJTTirKs5KI-1743024129-1.0.1.1-D842I3sPgDgH_KXyroq6uVivEnbWvm9WJF.L8a11GgUcULXjhweLHs0mXe6MWruf.FJe.lZj.KmX0tCqqdpKIt5JvlbHXt5D_9svedktlZY; - path=/; expires=Wed, 26-Mar-25 21:52:09 GMT; domain=.api.openai.com; HttpOnly; + - __cf_bm=5yRGSo0Y69GvEK51Bq2.Np0DSg9DmAJKNqvE3_XgKBg-1743453535-1.0.1.1-sH1YR42zmznwvKlaBUM.bPKvJl_PiebfNBKhREMO.sSa5gvFEkpcKaCG4x3XUdZ19XGTEF0CbRII3mqtcPJhxFzX3uVLGuVsyjz6odYDisM; + path=/; expires=Mon, 31-Mar-25 21:08:55 GMT; domain=.api.openai.com; HttpOnly; Secure; SameSite=None - - _cfuvid=MmuRzsy8ebDMe6ibCEwtGp2RzcntpAmdvDlhIZtlY1s-1743024129721-0.0.1.1-604800000; + - _cfuvid=tblnBnP9s7yFkSzbYy9zuzuDkxS9i_n7hk3XdiiGui8-1743453535332-0.0.1.1-604800000; path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None X-Content-Type-Options: - nosniff Server: - cloudflare Cf-Ray: - - 9269b3a97f370002-ORD + - 9292a7324dfbcf46-CMH Alt-Svc: - h3=":443"; ma=86400 body: encoding: UTF-8 string: |+ event: response.created - data: {"type":"response.created","response":{"id":"resp_67e4700196288192b27a4effc08dc47f069d9116026394b6","object":"response","created_at":1743024129,"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-4o-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 user net worth data","name":"get_net_worth","parameters":{"type":"object","properties":{},"required":[],"additionalProperties":false},"strict":true}],"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} + 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-4o-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":{}}} event: response.in_progress - data: {"type":"response.in_progress","response":{"id":"resp_67e4700196288192b27a4effc08dc47f069d9116026394b6","object":"response","created_at":1743024129,"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-4o-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 user net worth data","name":"get_net_worth","parameters":{"type":"object","properties":{},"required":[],"additionalProperties":false},"strict":true}],"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} + 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-4o-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":{}}} event: response.output_item.added - data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"fc_67e4700222008192b3a26ce30fe7ad02069d9116026394b6","call_id":"call_FtvrJsTMg7he0mTeThIqktyL","name":"get_net_worth","arguments":"","status":"in_progress"}} + 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"}} event: response.function_call_arguments.delta - data: {"type":"response.function_call_arguments.delta","item_id":"fc_67e4700222008192b3a26ce30fe7ad02069d9116026394b6","output_index":0,"delta":"{}"} + data: {"type":"response.function_call_arguments.delta","item_id":"fc_67eafd5fa714819287b2bff8c76935690aa8698ee903b906","output_index":0,"delta":"{}"} event: response.function_call_arguments.done - data: {"type":"response.function_call_arguments.done","item_id":"fc_67e4700222008192b3a26ce30fe7ad02069d9116026394b6","output_index":0,"arguments":"{}"} + data: {"type":"response.function_call_arguments.done","item_id":"fc_67eafd5fa714819287b2bff8c76935690aa8698ee903b906","output_index":0,"arguments":"{}"} event: response.output_item.done - data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"fc_67e4700222008192b3a26ce30fe7ad02069d9116026394b6","call_id":"call_FtvrJsTMg7he0mTeThIqktyL","name":"get_net_worth","arguments":"{}","status":"completed"}} + 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"}} event: response.completed - data: {"type":"response.completed","response":{"id":"resp_67e4700196288192b27a4effc08dc47f069d9116026394b6","object":"response","created_at":1743024129,"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-4o-2024-08-06","output":[{"type":"function_call","id":"fc_67e4700222008192b3a26ce30fe7ad02069d9116026394b6","call_id":"call_FtvrJsTMg7he0mTeThIqktyL","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 user net worth data","name":"get_net_worth","parameters":{"type":"object","properties":{},"required":[],"additionalProperties":false},"strict":true}],"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":271,"input_tokens_details":{"cached_tokens":0},"output_tokens":13,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":284},"user":null,"metadata":{}}} + 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-4o-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":{}}} - recorded_at: Wed, 26 Mar 2025 21:22:10 GMT + recorded_at: Mon, 31 Mar 2025 20:38:55 GMT - request: method: post uri: https://api.openai.com/v1/responses body: encoding: UTF-8 string: '{"model":"gpt-4o","input":[{"role":"user","content":"What is my net - worth?"},{"type":"function_call_output","call_id":"call_FtvrJsTMg7he0mTeThIqktyL","output":"\"$124,200\""}],"instructions":"Use - the tools available to you to answer the user''s question.","tools":[],"previous_response_id":"resp_67e4700196288192b27a4effc08dc47f069d9116026394b6","stream":true}' + worth?"},{"type":"function_call_output","call_id":"call_7EY6rF7mkfNyMIz3HQmrYIOq","output":"{\"amount\":10000,\"currency\":\"USD\"}"}],"instructions":null,"tools":[],"previous_response_id":"resp_67eafd5f2ef0819290ec6bbbc5f27c8e0aa8698ee903b906","stream":true}' headers: Content-Type: - application/json @@ -109,7 +108,7 @@ http_interactions: message: OK headers: Date: - - Wed, 26 Mar 2025 21:22:10 GMT + - Mon, 31 Mar 2025 20:38:56 GMT Content-Type: - text/event-stream; charset=utf-8 Transfer-Encoding: @@ -121,81 +120,84 @@ http_interactions: Openai-Organization: - "" X-Request-Id: - - req_792bf572fac53f7e139b29d462933d8f + - req_be9f30124a3a4cdae2d3b038692f6699 Openai-Processing-Ms: - - '148' + - '177' Strict-Transport-Security: - max-age=31536000; includeSubDomains; preload Cf-Cache-Status: - DYNAMIC Set-Cookie: - - __cf_bm=HHguTnSUQFt9KezJAQCrQF_OHn8ZH1C4xDjXRgexdzM-1743024130-1.0.1.1-ZhqxuASVfISfGQbvvKSNy_OQiUfkeIPN2DZhors0K4cl_BzE_P5u9kbc1HkgwyW1A_6GNAenh8Fr9AkoJ0zSakdg5Dr9AU.lu5nr7adQ_60; - path=/; expires=Wed, 26-Mar-25 21:52:10 GMT; domain=.api.openai.com; HttpOnly; + - __cf_bm=gNS8vmdzyz2jct__mfjLZGkJhCxddarRy62IkzSIFWM-1743453536-1.0.1.1-ufcPPmSzEaEysjhkRUozTfCIriRWy5iyeXCaVqeFDaJDWT4lc8ate4JhryV0fVQSZBi6pRN8zYh9dkLyYuXoSqYDCsZTN1uk6vO84nX1qGo; + path=/; expires=Mon, 31-Mar-25 21:08:56 GMT; domain=.api.openai.com; HttpOnly; Secure; SameSite=None - - _cfuvid=hX9Y33ruiC9mhYzrOoxyOh23Gy.MfQa54h9l5CllWlI-1743024130948-0.0.1.1-604800000; + - _cfuvid=3D41ZgFle.u0ER2Ehnm.bsdnSGlCXVArPa7bx9zumYU-1743453536171-0.0.1.1-604800000; path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None X-Content-Type-Options: - nosniff Server: - cloudflare Cf-Ray: - - 9269b3b0da83cf67-CMH + - 9292a7376f5acf4e-CMH Alt-Svc: - h3=":443"; ma=86400 body: encoding: UTF-8 string: |+ event: response.created - data: {"type":"response.created","response":{"id":"resp_67e47002c5b48192a8202d45c6a929f8069d9116026394b6","object":"response","created_at":1743024130,"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-4o-2024-08-06","output":[],"parallel_tool_calls":true,"previous_response_id":"resp_67e4700196288192b27a4effc08dc47f069d9116026394b6","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":{}}} + 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-4o-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":{}}} event: response.in_progress - data: {"type":"response.in_progress","response":{"id":"resp_67e47002c5b48192a8202d45c6a929f8069d9116026394b6","object":"response","created_at":1743024130,"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-4o-2024-08-06","output":[],"parallel_tool_calls":true,"previous_response_id":"resp_67e4700196288192b27a4effc08dc47f069d9116026394b6","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":{}}} + 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-4o-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":{}}} event: response.output_item.added - data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","status":"in_progress","role":"assistant","content":[]}} + data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","id":"msg_67eafd6084ec81929a5132414ef713180aa8698ee903b906","status":"in_progress","role":"assistant","content":[]}} event: response.content_part.added - data: {"type":"response.content_part.added","item_id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]}} + data: {"type":"response.content_part.added","item_id":"msg_67eafd6084ec81929a5132414ef713180aa8698ee903b906","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]}} event: response.output_text.delta - data: {"type":"response.output_text.delta","item_id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","output_index":0,"content_index":0,"delta":"Your"} + data: {"type":"response.output_text.delta","item_id":"msg_67eafd6084ec81929a5132414ef713180aa8698ee903b906","output_index":0,"content_index":0,"delta":"Your"} event: response.output_text.delta - data: {"type":"response.output_text.delta","item_id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","output_index":0,"content_index":0,"delta":" net"} + data: {"type":"response.output_text.delta","item_id":"msg_67eafd6084ec81929a5132414ef713180aa8698ee903b906","output_index":0,"content_index":0,"delta":" net"} event: response.output_text.delta - data: {"type":"response.output_text.delta","item_id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","output_index":0,"content_index":0,"delta":" worth"} + data: {"type":"response.output_text.delta","item_id":"msg_67eafd6084ec81929a5132414ef713180aa8698ee903b906","output_index":0,"content_index":0,"delta":" worth"} event: response.output_text.delta - data: {"type":"response.output_text.delta","item_id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","output_index":0,"content_index":0,"delta":" is"} + data: {"type":"response.output_text.delta","item_id":"msg_67eafd6084ec81929a5132414ef713180aa8698ee903b906","output_index":0,"content_index":0,"delta":" is"} event: response.output_text.delta - data: {"type":"response.output_text.delta","item_id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","output_index":0,"content_index":0,"delta":" $"} + data: {"type":"response.output_text.delta","item_id":"msg_67eafd6084ec81929a5132414ef713180aa8698ee903b906","output_index":0,"content_index":0,"delta":" $"} event: response.output_text.delta - data: {"type":"response.output_text.delta","item_id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","output_index":0,"content_index":0,"delta":"124"} + data: {"type":"response.output_text.delta","item_id":"msg_67eafd6084ec81929a5132414ef713180aa8698ee903b906","output_index":0,"content_index":0,"delta":"10"} event: response.output_text.delta - data: {"type":"response.output_text.delta","item_id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","output_index":0,"content_index":0,"delta":","} + data: {"type":"response.output_text.delta","item_id":"msg_67eafd6084ec81929a5132414ef713180aa8698ee903b906","output_index":0,"content_index":0,"delta":","} event: response.output_text.delta - data: {"type":"response.output_text.delta","item_id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","output_index":0,"content_index":0,"delta":"200"} + data: {"type":"response.output_text.delta","item_id":"msg_67eafd6084ec81929a5132414ef713180aa8698ee903b906","output_index":0,"content_index":0,"delta":"000"} event: response.output_text.delta - data: {"type":"response.output_text.delta","item_id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","output_index":0,"content_index":0,"delta":"."} + data: {"type":"response.output_text.delta","item_id":"msg_67eafd6084ec81929a5132414ef713180aa8698ee903b906","output_index":0,"content_index":0,"delta":" USD"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67eafd6084ec81929a5132414ef713180aa8698ee903b906","output_index":0,"content_index":0,"delta":"."} event: response.output_text.done - data: {"type":"response.output_text.done","item_id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","output_index":0,"content_index":0,"text":"Your net worth is $124,200."} + data: {"type":"response.output_text.done","item_id":"msg_67eafd6084ec81929a5132414ef713180aa8698ee903b906","output_index":0,"content_index":0,"text":"Your net worth is $10,000 USD."} event: response.content_part.done - data: {"type":"response.content_part.done","item_id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","output_index":0,"content_index":0,"part":{"type":"output_text","text":"Your net worth is $124,200.","annotations":[]}} + 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":[]}} event: response.output_item.done - data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Your net worth is $124,200.","annotations":[]}]}} + 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":[]}]}} event: response.completed - data: {"type":"response.completed","response":{"id":"resp_67e47002c5b48192a8202d45c6a929f8069d9116026394b6","object":"response","created_at":1743024130,"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-4o-2024-08-06","output":[{"type":"message","id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Your net worth is $124,200.","annotations":[]}]}],"parallel_tool_calls":true,"previous_response_id":"resp_67e4700196288192b27a4effc08dc47f069d9116026394b6","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":85,"input_tokens_details":{"cached_tokens":0},"output_tokens":10,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":95},"user":null,"metadata":{}}} + 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-4o-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":{}}} - recorded_at: Wed, 26 Mar 2025 21:22:11 GMT + recorded_at: Mon, 31 Mar 2025 20:38:58 GMT recorded_with: VCR 6.3.1 ... From 0a17b84566a962aa13e8894134ba8647d1ba487c Mon Sep 17 00:00:00 2001 From: Joseph Ho Date: Tue, 1 Apr 2025 07:58:49 -0400 Subject: [PATCH 088/380] perf(imports): Bulk import CSV trades (#2040) --- app/models/trade_import.rb | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/app/models/trade_import.rb b/app/models/trade_import.rb index b4d464d1..db917f04 100644 --- a/app/models/trade_import.rb +++ b/app/models/trade_import.rb @@ -3,7 +3,7 @@ class TradeImport < Import transaction do mappings.each(&:create_mappable!) - rows.each do |row| + trades = rows.map do |row| mapped_account = if account account else @@ -16,21 +16,22 @@ class TradeImport < Import exchange_operating_mic: row.exchange_operating_mic ) - entry = mapped_account.entries.build \ - date: row.date_iso, - amount: row.signed_amount, - name: row.name, + Account::Trade.new( + security: security, + qty: row.qty, currency: row.currency.presence || mapped_account.currency, - entryable: Account::Trade.new( - security: security, - qty: row.qty, + price: row.price, + entry: Account::Entry.new( + account: mapped_account, + date: row.date_iso, + amount: row.signed_amount, + name: row.name, currency: row.currency.presence || mapped_account.currency, - price: row.price + import: self ), - import: self - - entry.save! + ) end + Account::Trade.import!(trades, recursive: true) end end From 939244bd3e931ac88cdfa7cfa0c58f864a79ee6b Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Tue, 1 Apr 2025 08:41:49 -0400 Subject: [PATCH 089/380] Use faraday retry, move retry logic to concrete provider level (#2042) --- Gemfile.lock | 4 +- app/models/provider.rb | 20 +--------- app/models/provider/synth.rb | 21 +++++----- lib/retryable.rb | 19 --------- test/models/provider_test.rb | 76 +++++++++++++++++------------------- 5 files changed, 50 insertions(+), 90 deletions(-) delete mode 100644 lib/retryable.rb diff --git a/Gemfile.lock b/Gemfile.lock index c3e5ceaf..75bef73e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -169,7 +169,7 @@ GEM multipart-post (~> 2.0) faraday-net_http (3.4.0) net-http (>= 0.5.0) - faraday-retry (2.2.1) + faraday-retry (2.3.0) faraday (~> 2.0) ffi (1.17.1-aarch64-linux-gnu) ffi (1.17.1-aarch64-linux-musl) @@ -239,7 +239,7 @@ GEM listen (3.9.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - logger (1.6.6) + logger (1.7.0) logtail (0.1.17) msgpack (~> 1.0) logtail-rack (0.2.6) diff --git a/app/models/provider.rb b/app/models/provider.rb index 98d9fff1..c90866e7 100644 --- a/app/models/provider.rb +++ b/app/models/provider.rb @@ -1,6 +1,4 @@ class Provider - include Retryable - Response = Data.define(:success?, :data, :error) class Error < StandardError @@ -23,17 +21,8 @@ class Provider PaginatedData = Data.define(:paginated, :first_page, :total_pages) UsageData = Data.define(:used, :limit, :utilization, :plan) - # Subclasses can specify errors that can be retried - def retryable_errors - [] - end - - def with_provider_response(retries: default_retries, error_transformer: nil, &block) - data = if retries > 0 - retrying(retryable_errors, max_retries: retries) { yield } - else - yield - end + def with_provider_response(error_transformer: nil, &block) + data = yield Response.new( success?: true, @@ -67,9 +56,4 @@ class Provider self.class::Error.new(error.message) end end - - # Override to set class-level number of retries for methods using `with_provider_response` - def default_retries - 0 - end end diff --git a/app/models/provider/synth.rb b/app/models/provider/synth.rb index 87036f88..e095d2be 100644 --- a/app/models/provider/synth.rb +++ b/app/models/provider/synth.rb @@ -39,7 +39,7 @@ class Provider::Synth < Provider # ================================ def fetch_exchange_rate(from:, to:, date:) - with_provider_response retries: 2 do + with_provider_response do response = client.get("#{base_url}/rates/historical") do |req| req.params["date"] = date.to_s req.params["from"] = from @@ -53,7 +53,7 @@ class Provider::Synth < Provider end def fetch_exchange_rates(from:, to:, start_date:, end_date:) - with_provider_response retries: 1 do + with_provider_response do data = paginate( "#{base_url}/rates/historical-range", from: from, @@ -128,7 +128,7 @@ class Provider::Synth < Provider end def fetch_security_prices(security, start_date:, end_date:) - with_provider_response retries: 1 do + with_provider_response do params = { start_date: start_date, end_date: end_date @@ -191,14 +191,6 @@ class Provider::Synth < Provider TransactionEnrichmentData = Data.define(:name, :icon_url, :category) - def retryable_errors - [ - Faraday::TimeoutError, - Faraday::ConnectionFailed, - Faraday::SSLError - ] - end - def base_url ENV["SYNTH_URL"] || "https://api.synthfinance.com" end @@ -213,6 +205,13 @@ class Provider::Synth < Provider def client @client ||= Faraday.new(url: base_url) do |faraday| + faraday.request(:retry, { + max: 2, + interval: 0.05, + interval_randomness: 0.5, + backoff_factor: 2 + }) + faraday.response :raise_error faraday.headers["Authorization"] = "Bearer #{api_key}" faraday.headers["X-Source"] = app_name diff --git a/lib/retryable.rb b/lib/retryable.rb deleted file mode 100644 index 2f1bd002..00000000 --- a/lib/retryable.rb +++ /dev/null @@ -1,19 +0,0 @@ -module Retryable - def retrying(retryable_errors = [], max_retries: 3) - attempts = 0 - - begin - on_last_attempt = attempts == max_retries - 1 - - yield on_last_attempt - rescue *retryable_errors => e - attempts += 1 - - if attempts < max_retries - retry - else - raise e - end - end - end -end diff --git a/test/models/provider_test.rb b/test/models/provider_test.rb index 5b9a9287..6d7fe13b 100644 --- a/test/models/provider_test.rb +++ b/test/models/provider_test.rb @@ -2,60 +2,56 @@ require "test_helper" require "ostruct" class TestProvider < Provider + TestError = Class.new(StandardError) + + def initialize(client) + @client = client + end + def fetch_data - with_provider_response(retries: 3) do - client.get("/test") + with_provider_response do + @client.get("/test") end end - private - def client - @client ||= Faraday.new - end - - def retryable_errors - [ Faraday::TimeoutError ] + def fetch_data_with_error_transformer + with_provider_response(error_transformer: ->(error) { TestError.new(error.message) }) do + @client.get("/test") end + end end class ProviderTest < ActiveSupport::TestCase setup do - @provider = TestProvider.new + @client = mock + @provider = TestProvider.new(@client) end - test "retries then provides failed response" do - client = mock - Faraday.stubs(:new).returns(client) - - client.expects(:get) - .with("/test") - .raises(Faraday::TimeoutError) - .times(3) - - response = @provider.fetch_data - - assert_not response.success? - assert_match "timeout", response.error.message - end - - test "fail, retry, succeed" do - client = mock - Faraday.stubs(:new).returns(client) - - sequence = sequence("retry_sequence") - - client.expects(:get) - .with("/test") - .raises(Faraday::TimeoutError) - .in_sequence(sequence) - - client.expects(:get) - .with("/test") - .returns(Provider::Response.new(success?: true, data: "success", error: nil)) - .in_sequence(sequence) + test "returns success response with data" do + @client.expects(:get).with("/test").returns({ some: "data" }) response = @provider.fetch_data assert response.success? + assert_equal({ some: "data" }, response.data) + end + + test "returns failed response with error" do + @client.expects(:get).with("/test").raises(StandardError.new("some error")) + + response = @provider.fetch_data + + assert_not response.success? + assert_equal("some error", response.error.message) + end + + test "provider can transform error" do + @client.expects(:get).with("/test").raises(StandardError.new("some error")) + + response = @provider.fetch_data_with_error_transformer + + assert_not response.success? + assert_equal("some error", response.error.message) + assert_instance_of TestProvider::TestError, response.error end end From 5f2a031d4cd1e34a3309f517677e3acc0e4e86b5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Apr 2025 09:11:56 -0400 Subject: [PATCH 090/380] Bump ruby-openai from 8.0.0 to 8.1.0 (#2036) Bumps [ruby-openai](https://github.com/alexrudall/ruby-openai) from 8.0.0 to 8.1.0. - [Release notes](https://github.com/alexrudall/ruby-openai/releases) - [Changelog](https://github.com/alexrudall/ruby-openai/blob/main/CHANGELOG.md) - [Commits](https://github.com/alexrudall/ruby-openai/compare/v8.0.0...v8.1.0) --- updated-dependencies: - dependency-name: ruby-openai dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 75bef73e..af924225 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -431,7 +431,7 @@ GEM sorbet-runtime (>= 0.5.10782) ruby-lsp-rails (0.4.0) ruby-lsp (>= 0.23.0, < 0.24.0) - ruby-openai (8.0.0) + ruby-openai (8.1.0) event_stream_parser (>= 0.3.0, < 2.0.0) faraday (>= 1) faraday-multipart (>= 1) From f2020a816aa0dcab554d97293a0536155bcce97c Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Tue, 1 Apr 2025 08:21:46 -0500 Subject: [PATCH 091/380] Apparently capitalization matters --- config/initializers/intercom.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/initializers/intercom.rb b/config/initializers/intercom.rb index d35ac77f..e31c389e 100644 --- a/config/initializers/intercom.rb +++ b/config/initializers/intercom.rb @@ -56,7 +56,7 @@ if ENV["INTERCOM_APP_ID"].present? && ENV["INTERCOM_IDENTITY_VERIFICATION_KEY"]. family_id: Proc.new { Current.family.id }, name: Proc.new { Current.user.display_name if Current.user.display_name != Current.user.email }, "Role": Proc.new { Current.user.role }, - connections: Proc.new { Current.family.accounts.count }, + "Connections": Proc.new { Current.family.accounts.count }, "AI Enabled": Proc.new { Current.user.ai_enabled } } From 02bfa9f25162452533629cd2749337cc2c505dac Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Tue, 1 Apr 2025 14:36:34 -0400 Subject: [PATCH 092/380] Fix AI sidebar overflow when user hasn't enabled or created a chat yet (#2044) --- app/helpers/application_helper.rb | 3 ++- app/javascript/controllers/sidebar_controller.js | 9 +++++++++ app/views/layouts/application.html.erb | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index d131447f..2089e0f7 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -190,7 +190,8 @@ module ApplicationHelper is_open: right_sidebar_showing, initial_width: right_panel_width, min_width: right_panel_min_width, - max_width: right_panel_max_width + max_width: right_panel_max_width, + overflow: right_sidebar_showing ? "auto" : "hidden" }, content_max_width: content_max_width } diff --git a/app/javascript/controllers/sidebar_controller.js b/app/javascript/controllers/sidebar_controller.js index c5eb3a0c..5e51a01c 100644 --- a/app/javascript/controllers/sidebar_controller.js +++ b/app/javascript/controllers/sidebar_controller.js @@ -30,6 +30,7 @@ export default class extends Controller { this.contentTarget.style.maxWidth = `${this.#contentMaxWidth()}px`; this.leftPanelTarget.style.width = `${this.#leftPanelWidth()}px`; this.rightPanelTarget.style.width = `${this.#rightPanelWidth()}px`; + this.rightPanelTarget.style.overflow = this.#rightPanelOverflow(); } #leftPanelWidth() { @@ -52,6 +53,14 @@ export default class extends Controller { return 0; } + #rightPanelOverflow() { + if (this.rightPanelOpen) { + return "auto"; + } + + return "hidden"; + } + #contentMaxWidth() { if (!this.leftPanelOpen && !this.rightPanelOpen) { return 1024; diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 4f9208ba..d250f1e8 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -67,7 +67,7 @@ <%# AI chat sidebar %> <%= tag.div id: "chat-container", - style: "width: #{sidebar_config.dig(:right_panel, :initial_width)}px", + style: "width: #{sidebar_config.dig(:right_panel, :initial_width)}px; overflow: #{sidebar_config.dig(:right_panel, :overflow)}", class: class_names("flex flex-col justify-between shrink-0 transition-all duration-300"), data: { controller: "chat hotkey", sidebar_target: "rightPanel", turbo_permanent: true } do %> From d86ccd36b6644a3db7471c8ae0121e5f1d980461 Mon Sep 17 00:00:00 2001 From: Joseph Ho Date: Fri, 4 Apr 2025 12:14:28 -0400 Subject: [PATCH 093/380] provider: Ensure data provider exist before fetching for price. (#2045) --- app/models/security/provided.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/models/security/provided.rb b/app/models/security/provided.rb index 3450a6e3..b342c9e5 100644 --- a/app/models/security/provided.rb +++ b/app/models/security/provided.rb @@ -71,6 +71,8 @@ module Security::Provided return price if price.present? + # Make sure we have a data provider before fetching + return nil unless provider.present? response = provider.fetch_security_price(self, date: date) return nil unless response.success? # Provider error From 4c722313122b72bf62a2f318240d2515458953b0 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 4 Apr 2025 12:15:10 -0400 Subject: [PATCH 094/380] Update brakeman --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index af924225..d9ff0f21 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -117,7 +117,7 @@ GEM bindex (0.8.1) bootsnap (1.18.4) msgpack (~> 1.2) - brakeman (7.0.0) + brakeman (7.0.1) racc builder (3.3.0) capybara (3.40.0) From 7096eefa2b3cf91b8baaf07c28849056f99b9fbb Mon Sep 17 00:00:00 2001 From: Akshay Birajdar Date: Fri, 4 Apr 2025 21:45:47 +0530 Subject: [PATCH 095/380] Fix transfers#update to save notes (#2053) --- app/controllers/transfers_controller.rb | 22 ++++++++++++++----- test/controllers/transfers_controller_test.rb | 11 ++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/app/controllers/transfers_controller.rb b/app/controllers/transfers_controller.rb index a1a7e27c..c20ccacc 100644 --- a/app/controllers/transfers_controller.rb +++ b/app/controllers/transfers_controller.rb @@ -36,14 +36,11 @@ class TransfersController < ApplicationController end def update - if transfer_update_params[:status] == "rejected" - @transfer.reject! - elsif transfer_update_params[:status] == "confirmed" - @transfer.confirm! + Transfer.transaction do + update_transfer_status + update_transfer_details end - @transfer.outflow_transaction.update!(category_id: transfer_update_params[:category_id]) - respond_to do |format| format.html { redirect_back_or_to transactions_url, notice: t(".success") } format.turbo_stream @@ -69,4 +66,17 @@ class TransfersController < ApplicationController def transfer_update_params params.require(:transfer).permit(:notes, :status, :category_id) end + + def update_transfer_status + if transfer_update_params[:status] == "rejected" + @transfer.reject! + elsif transfer_update_params[:status] == "confirmed" + @transfer.confirm! + end + end + + def update_transfer_details + @transfer.outflow_transaction.update!(category_id: transfer_update_params[:category_id]) + @transfer.update!(notes: transfer_update_params[:notes]) + end end diff --git a/test/controllers/transfers_controller_test.rb b/test/controllers/transfers_controller_test.rb index 3c2961ca..3350b2c6 100644 --- a/test/controllers/transfers_controller_test.rb +++ b/test/controllers/transfers_controller_test.rb @@ -30,4 +30,15 @@ class TransfersControllerTest < ActionDispatch::IntegrationTest delete transfer_url(transfers(:one)) end end + + test "can add notes to transfer" do + transfer = transfers(:one) + assert_nil transfer.notes + + patch transfer_url(transfer), params: { transfer: { notes: "Test notes" } } + + assert_redirected_to transactions_url + assert_equal "Transfer updated", flash[:notice] + assert_equal "Test notes", transfer.reload.notes + end end From 1649e991b48dd0b4c57d0f7b4f0bd146d203bd63 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Apr 2025 11:00:11 -0400 Subject: [PATCH 096/380] Bump brakeman from 7.0.1 to 7.0.2 (#2064) Bumps [brakeman](https://github.com/presidentbeef/brakeman) from 7.0.1 to 7.0.2. - [Release notes](https://github.com/presidentbeef/brakeman/releases) - [Changelog](https://github.com/presidentbeef/brakeman/blob/main/CHANGES.md) - [Commits](https://github.com/presidentbeef/brakeman/compare/v7.0.1...v7.0.2) --- updated-dependencies: - dependency-name: brakeman dependency-version: 7.0.2 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index d9ff0f21..366251d5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -117,7 +117,7 @@ GEM bindex (0.8.1) bootsnap (1.18.4) msgpack (~> 1.2) - brakeman (7.0.1) + brakeman (7.0.2) racc builder (3.3.0) capybara (3.40.0) From 02bbeeaec529294f19c75188e8880ca4e93ae958 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Apr 2025 11:25:49 -0400 Subject: [PATCH 097/380] Bump stripe from 13.5.0 to 14.0.0 (#2063) Bumps [stripe](https://github.com/stripe/stripe-ruby) from 13.5.0 to 14.0.0. - [Release notes](https://github.com/stripe/stripe-ruby/releases) - [Changelog](https://github.com/stripe/stripe-ruby/blob/master/CHANGELOG.md) - [Commits](https://github.com/stripe/stripe-ruby/compare/v13.5.0...v14.0.0) --- updated-dependencies: - dependency-name: stripe dependency-version: 14.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Zach Gollwitzer --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 366251d5..447c6b67 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -477,7 +477,7 @@ GEM stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.1.5) - stripe (13.5.0) + stripe (14.0.0) tailwindcss-rails (4.2.1) railties (>= 7.0.0) tailwindcss-ruby (~> 4.0) From e6c1c5f368700f4d43282113800fd1cb2636a926 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Apr 2025 11:47:36 -0400 Subject: [PATCH 098/380] Bump vernier from 1.6.0 to 1.7.0 (#2062) Bumps [vernier](https://github.com/jhawthorn/vernier) from 1.6.0 to 1.7.0. - [Release notes](https://github.com/jhawthorn/vernier/releases) - [Commits](https://github.com/jhawthorn/vernier/compare/v1.6.0...v1.7.0) --- updated-dependencies: - dependency-name: vernier dependency-version: 1.7.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Zach Gollwitzer --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 447c6b67..7c04fa3f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -504,7 +504,7 @@ GEM useragent (0.16.11) vcr (6.3.1) base64 - vernier (1.6.0) + vernier (1.7.0) web-console (4.2.1) actionview (>= 6.0.0) activemodel (>= 6.0.0) From bb0f0239fb167dd5be9779b190c57a515a9ce8e0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Apr 2025 11:47:46 -0400 Subject: [PATCH 099/380] Bump faraday-retry from 2.3.0 to 2.3.1 (#2061) Bumps [faraday-retry](https://github.com/lostisland/faraday-retry) from 2.3.0 to 2.3.1. - [Release notes](https://github.com/lostisland/faraday-retry/releases) - [Changelog](https://github.com/lostisland/faraday-retry/blob/main/CHANGELOG.md) - [Commits](https://github.com/lostisland/faraday-retry/compare/v2.3.0...v2.3.1) --- updated-dependencies: - dependency-name: faraday-retry dependency-version: 2.3.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Zach Gollwitzer --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 7c04fa3f..b9c4ab3b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -169,7 +169,7 @@ GEM multipart-post (~> 2.0) faraday-net_http (3.4.0) net-http (>= 0.5.0) - faraday-retry (2.3.0) + faraday-retry (2.3.1) faraday (~> 2.0) ffi (1.17.1-aarch64-linux-gnu) ffi (1.17.1-aarch64-linux-musl) From 0e1c902b6321c9953e558d2db350a550ea9df512 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Apr 2025 11:47:53 -0400 Subject: [PATCH 100/380] Bump selenium-webdriver from 4.30.1 to 4.31.0 (#2060) Bumps [selenium-webdriver](https://github.com/SeleniumHQ/selenium) from 4.30.1 to 4.31.0. - [Release notes](https://github.com/SeleniumHQ/selenium/releases) - [Changelog](https://github.com/SeleniumHQ/selenium/blob/trunk/rb/CHANGES) - [Commits](https://github.com/SeleniumHQ/selenium/commits/selenium-4.31.0) --- updated-dependencies: - dependency-name: selenium-webdriver dependency-version: 4.31.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Zach Gollwitzer --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index b9c4ab3b..2560d6bd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -445,7 +445,7 @@ GEM addressable (>= 2.3.5) faraday (>= 0.17.3, < 3) securerandom (0.4.1) - selenium-webdriver (4.30.1) + selenium-webdriver (4.31.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) From 0da057b792f2e3c877f2b7306b5d0d9e30476e51 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Apr 2025 11:48:14 -0400 Subject: [PATCH 101/380] Bump sidekiq from 8.0.1 to 8.0.2 (#2059) Bumps [sidekiq](https://github.com/sidekiq/sidekiq) from 8.0.1 to 8.0.2. - [Changelog](https://github.com/sidekiq/sidekiq/blob/main/Changes.md) - [Commits](https://github.com/sidekiq/sidekiq/compare/v8.0.1...v8.0.2) --- updated-dependencies: - dependency-name: sidekiq dependency-version: 8.0.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Zach Gollwitzer --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 2560d6bd..8f02f953 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -460,7 +460,7 @@ GEM sentry-sidekiq (5.23.0) sentry-ruby (~> 5.23.0) sidekiq (>= 3.0) - sidekiq (8.0.1) + sidekiq (8.0.2) connection_pool (>= 2.5.0) json (>= 2.9.0) logger (>= 1.6.2) From 2bc3887262bf126009518bbd7ea941e8d4091fdd Mon Sep 17 00:00:00 2001 From: Akshay Birajdar Date: Tue, 8 Apr 2025 21:32:05 +0530 Subject: [PATCH 102/380] Fix currency symbol for Uncategorized budget to match budget currency (#2058) Previously, the symbol for the 'Uncategorized' segment defaulted to `$`, which is incorrect for non-USD budgets. This change ensures the correct currency symbol is shown based on the budget's currency. Co-authored-by: Zach Gollwitzer --- .../_uncategorized_budget_category_form.html.erb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/budget_categories/_uncategorized_budget_category_form.html.erb b/app/views/budget_categories/_uncategorized_budget_category_form.html.erb index 13b303e2..0ed955d3 100644 --- a/app/views/budget_categories/_uncategorized_budget_category_form.html.erb +++ b/app/views/budget_categories/_uncategorized_budget_category_form.html.erb @@ -13,8 +13,8 @@
      - $ - <%= text_field_tag :uncategorized, budget_category.budgeted_spending_money, autocomplete: "off", class: "form-field__input text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none", disabled: true %> + <%= budget_category.budgeted_spending_money.currency.symbol %> + <%= 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 %>
      From 52d170e36c3b84c4658213ccb7ef86095ec2eb81 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Wed, 9 Apr 2025 12:42:46 -0400 Subject: [PATCH 103/380] Mobile responsive template preparation (#2071) * Mobile responsive template * Fix sidebar mobile conflict * Lint fix --- .../controllers/sidebar_controller.js | 25 ++- app/views/layouts/application.html.erb | 148 ++++++++++++------ .../layouts/shared/_breadcrumbs.html.erb | 4 +- app/views/layouts/shared/_htmldoc.html.erb | 4 +- app/views/layouts/sidebar/_nav_item.html.erb | 6 +- app/views/users/_user_menu.html.erb | 4 +- 6 files changed, 117 insertions(+), 74 deletions(-) diff --git a/app/javascript/controllers/sidebar_controller.js b/app/javascript/controllers/sidebar_controller.js index 5e51a01c..a46794e3 100644 --- a/app/javascript/controllers/sidebar_controller.js +++ b/app/javascript/controllers/sidebar_controller.js @@ -7,7 +7,7 @@ export default class extends Controller { config: Object, }; - static targets = ["leftPanel", "rightPanel", "content"]; + static targets = ["leftPanel", "leftPanelMobile", "rightPanel", "content"]; initialize() { this.leftPanelOpen = this.configValue.left_panel.is_open; @@ -20,6 +20,16 @@ export default class extends Controller { this.#persistPreference("show_sidebar", this.leftPanelOpen); } + toggleLeftPanelMobile() { + if (this.leftPanelOpen) { + this.leftPanelMobileTarget.classList.remove("hidden"); + this.leftPanelOpen = false; + } else { + this.leftPanelMobileTarget.classList.add("hidden"); + this.leftPanelOpen = true; + } + } + toggleRightPanel() { this.rightPanelOpen = !this.rightPanelOpen; this.#updatePanelWidths(); @@ -27,7 +37,6 @@ export default class extends Controller { } #updatePanelWidths() { - this.contentTarget.style.maxWidth = `${this.#contentMaxWidth()}px`; this.leftPanelTarget.style.width = `${this.#leftPanelWidth()}px`; this.rightPanelTarget.style.width = `${this.#rightPanelWidth()}px`; this.rightPanelTarget.style.overflow = this.#rightPanelOverflow(); @@ -61,18 +70,6 @@ export default class extends Controller { return "hidden"; } - #contentMaxWidth() { - if (!this.leftPanelOpen && !this.rightPanelOpen) { - return 1024; - } - - if (this.leftPanelOpen && !this.rightPanelOpen) { - return 896; - } - - return 768; - } - #persistPreference(field, value) { fetch(`/users/${this.userIdValue}`, { method: "PATCH", diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index d250f1e8..356484ee 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,18 +1,36 @@ <%= render "layouts/shared/htmldoc" do %> <% sidebar_config = app_sidebar_config(Current.user) %> -
      -
      -
      +
      ">

      Color

      diff --git a/app/views/categories/_menu.html.erb b/app/views/categories/_menu.html.erb index ff098cb0..83532011 100644 --- a/app/views/categories/_menu.html.erb +++ b/app/views/categories/_menu.html.erb @@ -5,7 +5,7 @@ <%= render partial: "categories/badge", locals: { category: transaction.category } %>
      diff --git a/config/locales/views/account/transactions/en.yml b/config/locales/views/account/transactions/en.yml index 397c7bb5..a5f979e3 100644 --- a/config/locales/views/account/transactions/en.yml +++ b/config/locales/views/account/transactions/en.yml @@ -12,10 +12,12 @@ en: details: Details merchant_label: Merchant merchant_placeholder: Select a merchant + none: (none) note_label: Notes note_placeholder: Enter a note that will be applied to selected transactions overview: Overview save: Save + tag_label: Tags bulk_update: success: "%{count} transactions updated" form: @@ -29,7 +31,11 @@ en: description_placeholder: Describe transaction expense: Expense income: Income + none: (none) + note_label: Notes + note_placeholder: Enter a note submit: Add transaction + tags_label: Tags transfer: Transfer new: new_transaction: New transaction diff --git a/test/controllers/account/transactions_controller_test.rb b/test/controllers/account/transactions_controller_test.rb index d490bfa7..bb1d9b83 100644 --- a/test/controllers/account/transactions_controller_test.rb +++ b/test/controllers/account/transactions_controller_test.rb @@ -99,6 +99,7 @@ class Account::TransactionsControllerTest < ActionDispatch::IntegrationTest date: 1.day.ago.to_date, category_id: Category.second.id, merchant_id: Merchant.second.id, + tag_ids: [ Tag.first.id, Tag.second.id ], notes: "Updated note" } } @@ -112,6 +113,7 @@ class Account::TransactionsControllerTest < ActionDispatch::IntegrationTest assert_equal Category.second, transaction.account_transaction.category assert_equal Merchant.second, transaction.account_transaction.merchant assert_equal "Updated note", transaction.notes + assert_equal [ Tag.first.id, Tag.second.id ], transaction.entryable.tag_ids.sort end end end From 1e01840feefcc4f2ce9370a2bc4f5cc4cd570cb4 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 14 Apr 2025 08:41:49 -0400 Subject: [PATCH 108/380] Chromium E2E test fixes (#2108) * Change test password to avoid chromium conflicts * Update integration tests * Centralize all test password references * Remove unrelated schema changes --- db/schema.rb | 2 +- test/application_system_test_case.rb | 2 +- test/controllers/mfa_controller_test.rb | 8 ++++---- test/controllers/sessions_controller_test.rb | 2 +- test/fixtures/users.yml | 10 +++++----- test/test_helper.rb | 6 +++++- 6 files changed, 17 insertions(+), 13 deletions(-) diff --git a/db/schema.rb b/db/schema.rb index 5bd3bb37..8c37862a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -101,7 +101,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_11_140604) do t.decimal "balance", precision: 19, scale: 4 t.string "currency" t.boolean "is_active", default: true, null: false - t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Loan'::character varying)::text, ('CreditCard'::character varying)::text, ('OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true + 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 t.uuid "import_id" t.uuid "plaid_account_id" t.boolean "scheduled_for_deletion", default: false diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb index a41ff371..a65f724e 100644 --- a/test/application_system_test_case.rb +++ b/test/application_system_test_case.rb @@ -13,7 +13,7 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase visit new_session_path within "form" do fill_in "Email", with: user.email - fill_in "Password", with: "password" + fill_in "Password", with: user_password_test click_on "Log in" end diff --git a/test/controllers/mfa_controller_test.rb b/test/controllers/mfa_controller_test.rb index 28b52dee..e2e24417 100644 --- a/test/controllers/mfa_controller_test.rb +++ b/test/controllers/mfa_controller_test.rb @@ -54,7 +54,7 @@ class MfaControllerTest < ActionDispatch::IntegrationTest @user.enable_mfa! sign_out - post sessions_path, params: { email: @user.email, password: "password" } + post sessions_path, params: { email: @user.email, password: user_password_test } assert_redirected_to verify_mfa_path get verify_mfa_path @@ -67,7 +67,7 @@ class MfaControllerTest < ActionDispatch::IntegrationTest @user.enable_mfa! sign_out - post sessions_path, params: { email: @user.email, password: "password" } + post sessions_path, params: { email: @user.email, password: user_password_test } totp = ROTP::TOTP.new(@user.otp_secret, issuer: "Maybe") post verify_mfa_path, params: { code: totp.now } @@ -81,7 +81,7 @@ class MfaControllerTest < ActionDispatch::IntegrationTest @user.enable_mfa! sign_out - post sessions_path, params: { email: @user.email, password: "password" } + post sessions_path, params: { email: @user.email, password: user_password_test } backup_code = @user.otp_backup_codes.first post verify_mfa_path, params: { code: backup_code } @@ -96,7 +96,7 @@ class MfaControllerTest < ActionDispatch::IntegrationTest @user.enable_mfa! sign_out - post sessions_path, params: { email: @user.email, password: "password" } + post sessions_path, params: { email: @user.email, password: user_password_test } post verify_mfa_path, params: { code: "invalid" } assert_response :unprocessable_entity diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index b0e91f62..8383ac0b 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -42,7 +42,7 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest @user.enable_mfa! @user.sessions.destroy_all # Clean up any existing sessions - post sessions_path, params: { email: @user.email, password: "password" } + post sessions_path, params: { email: @user.email, password: user_password_test } assert_redirected_to verify_mfa_path assert_equal @user.id, session[:mfa_user_id] diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index ef3e7e3d..e7088ec9 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -3,7 +3,7 @@ empty: first_name: User last_name: One email: user1@email.com - password_digest: $2a$12$7p8hMsoc0zSaC8eY9oewzelHbmCPdpPi.mGiyG4vdZwrXmGpRPoNK + password_digest: $2a$12$XoNBo/cMCyzpYtvhrPAhsubG21mELX48RAcjSVCRctW8dG8wrDIla onboarded_at: <%= 3.days.ago %> ai_enabled: true @@ -12,7 +12,7 @@ maybe_support_staff: first_name: Support last_name: Admin email: support@maybefinance.com - password_digest: $2a$12$7p8hMsoc0zSaC8eY9oewzelHbmCPdpPi.mGiyG4vdZwrXmGpRPoNK + password_digest: $2a$12$XoNBo/cMCyzpYtvhrPAhsubG21mELX48RAcjSVCRctW8dG8wrDIla role: super_admin onboarded_at: <%= 3.days.ago %> ai_enabled: true @@ -22,7 +22,7 @@ family_admin: first_name: Bob last_name: Dylan email: bob@bobdylan.com - password_digest: $2a$12$7p8hMsoc0zSaC8eY9oewzelHbmCPdpPi.mGiyG4vdZwrXmGpRPoNK + password_digest: $2a$12$XoNBo/cMCyzpYtvhrPAhsubG21mELX48RAcjSVCRctW8dG8wrDIla role: admin onboarded_at: <%= 3.days.ago %> ai_enabled: true @@ -32,7 +32,7 @@ family_member: first_name: Jakob last_name: Dylan email: jakobdylan@yahoo.com - password_digest: $2a$12$7p8hMsoc0zSaC8eY9oewzelHbmCPdpPi.mGiyG4vdZwrXmGpRPoNK + password_digest: $2a$12$XoNBo/cMCyzpYtvhrPAhsubG21mELX48RAcjSVCRctW8dG8wrDIla onboarded_at: <%= 3.days.ago %> ai_enabled: true @@ -42,6 +42,6 @@ new_email: last_name: User email: user@example.com unconfirmed_email: new@example.com - password_digest: $2a$12$7p8hMsoc0zSaC8eY9oewzelHbmCPdpPi.mGiyG4vdZwrXmGpRPoNK + password_digest: $2a$12$XoNBo/cMCyzpYtvhrPAhsubG21mELX48RAcjSVCRctW8dG8wrDIla onboarded_at: <%= Time.current %> ai_enabled: true \ No newline at end of file diff --git a/test/test_helper.rb b/test/test_helper.rb index 9e1bb2c9..40bf14c9 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -49,7 +49,7 @@ module ActiveSupport # Add more helper methods to be used by all tests here... def sign_in(user) - post sessions_path, params: { email: user.email, password: "password" } + post sessions_path, params: { email: user.email, password: user_password_test } end def with_env_overrides(overrides = {}, &block) @@ -60,6 +60,10 @@ module ActiveSupport Rails.configuration.stubs(:app_mode).returns("self_hosted".inquiry) yield end + + def user_password_test + "maybetestpassword817983172" + end end end From b06fd1edf0d78734a8e45536ffd9b0926cdf63c5 Mon Sep 17 00:00:00 2001 From: busybox <29630035+busybox11@users.noreply.github.com> Date: Mon, 14 Apr 2025 14:47:54 +0200 Subject: [PATCH 109/380] Small count fix in hosting section (#2094) Signed-off-by: busybox <29630035+busybox11@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2a384012..c0dd6e65 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ and eventually offer a hosted version of the app for a small monthly fee. ## Maybe Hosting -There are 3 primary ways to use the Maybe app: +There are 2 primary ways to use the Maybe app: 1. Managed (easiest) - we're in alpha and release invites in our Discord 2. [Self-host with Docker](docs/hosting/docker.md) From e51712706215b9f8187fe7e009c0fbc70ac87b7d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Apr 2025 08:52:30 -0400 Subject: [PATCH 110/380] Bump csv from 3.3.3 to 3.3.4 (#2107) Bumps [csv](https://github.com/ruby/csv) from 3.3.3 to 3.3.4. - [Release notes](https://github.com/ruby/csv/releases) - [Changelog](https://github.com/ruby/csv/blob/main/NEWS.md) - [Commits](https://github.com/ruby/csv/compare/v3.3.3...v3.3.4) --- updated-dependencies: - dependency-name: csv dependency-version: 3.3.4 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Zach Gollwitzer --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 8f02f953..e6f8cc44 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -139,7 +139,7 @@ GEM bigdecimal rexml crass (1.0.6) - csv (3.3.3) + csv (3.3.4) date (3.4.1) debug (1.10.0) irb (~> 1.10) From f23569717825dea426c515c79580bd15c6d06ddb Mon Sep 17 00:00:00 2001 From: Tony Vincent Date: Mon, 14 Apr 2025 15:05:25 +0200 Subject: [PATCH 111/380] Fix: Fix unalble to reject automatched transfers (#2102) Co-authored-by: Zach Gollwitzer --- app/controllers/transfers_controller.rb | 2 +- test/controllers/transfers_controller_test.rb | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/app/controllers/transfers_controller.rb b/app/controllers/transfers_controller.rb index c20ccacc..895dcfa2 100644 --- a/app/controllers/transfers_controller.rb +++ b/app/controllers/transfers_controller.rb @@ -38,7 +38,7 @@ class TransfersController < ApplicationController def update Transfer.transaction do update_transfer_status - update_transfer_details + update_transfer_details unless transfer_update_params[:status] == "rejected" end respond_to do |format| diff --git a/test/controllers/transfers_controller_test.rb b/test/controllers/transfers_controller_test.rb index 3350b2c6..9959f45b 100644 --- a/test/controllers/transfers_controller_test.rb +++ b/test/controllers/transfers_controller_test.rb @@ -41,4 +41,24 @@ class TransfersControllerTest < ActionDispatch::IntegrationTest assert_equal "Transfer updated", flash[:notice] assert_equal "Test notes", transfer.reload.notes end + + test "handles rejection without FrozenError" do + transfer = transfers(:one) + + assert_difference "Transfer.count", -1 do + patch transfer_url(transfer), params: { + transfer: { + status: "rejected" + } + } + end + + assert_redirected_to transactions_url + assert_equal "Transfer updated", flash[:notice] + + # Verify the transfer was actually destroyed + assert_raises(ActiveRecord::RecordNotFound) do + transfer.reload + end + end end From 6f70a54d6f0255ab83153f728d4687212ef72a14 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Apr 2025 09:05:33 -0400 Subject: [PATCH 112/380] Bump faraday from 2.12.2 to 2.13.0 (#2106) Bumps [faraday](https://github.com/lostisland/faraday) from 2.12.2 to 2.13.0. - [Release notes](https://github.com/lostisland/faraday/releases) - [Changelog](https://github.com/lostisland/faraday/blob/main/CHANGELOG.md) - [Commits](https://github.com/lostisland/faraday/compare/v2.12.2...v2.13.0) --- updated-dependencies: - dependency-name: faraday dependency-version: 2.13.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Zach Gollwitzer --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index e6f8cc44..c039b969 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -161,7 +161,7 @@ GEM event_stream_parser (1.0.0) faker (3.5.1) i18n (>= 1.8.11, < 2) - faraday (2.12.2) + faraday (2.13.0) faraday-net_http (>= 2.0, < 3.5) json logger From 5cb2183bdf2e573470baad8fd0e169f5278e95db Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Apr 2025 09:05:41 -0400 Subject: [PATCH 113/380] Bump stripe from 14.0.0 to 15.0.0 (#2105) Bumps [stripe](https://github.com/stripe/stripe-ruby) from 14.0.0 to 15.0.0. - [Release notes](https://github.com/stripe/stripe-ruby/releases) - [Changelog](https://github.com/stripe/stripe-ruby/blob/master/CHANGELOG.md) - [Commits](https://github.com/stripe/stripe-ruby/compare/v14.0.0...v15.0.0) --- updated-dependencies: - dependency-name: stripe dependency-version: 15.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Zach Gollwitzer --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index c039b969..dac7842a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -477,7 +477,7 @@ GEM stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.1.5) - stripe (14.0.0) + stripe (15.0.0) tailwindcss-rails (4.2.1) railties (>= 7.0.0) tailwindcss-ruby (~> 4.0) From f181ba941f88d644226da7b469f3600f2b6cdf11 Mon Sep 17 00:00:00 2001 From: Joseph Ho Date: Mon, 14 Apr 2025 09:09:25 -0400 Subject: [PATCH 114/380] loan: Set the first valuation as the original principal. (#2088) Fix: #1645. --- app/controllers/loans_controller.rb | 2 +- app/models/account.rb | 35 +++++++++++++++---- app/models/loan.rb | 10 ++++-- app/views/loans/_form.html.erb | 7 ++++ app/views/loans/_overview.html.erb | 2 +- config/locales/views/loans/en.yml | 1 + ...0250405210514_add_initial_balance_field.rb | 5 +++ db/schema.rb | 1 + test/controllers/loans_controller_test.rb | 8 +++-- test/system/accounts_test.rb | 1 + 10 files changed, 59 insertions(+), 13 deletions(-) create mode 100644 db/migrate/20250405210514_add_initial_balance_field.rb diff --git a/app/controllers/loans_controller.rb b/app/controllers/loans_controller.rb index b9968faf..961c5acf 100644 --- a/app/controllers/loans_controller.rb +++ b/app/controllers/loans_controller.rb @@ -2,6 +2,6 @@ class LoansController < ApplicationController include AccountableResource permitted_accountable_attributes( - :id, :rate_type, :interest_rate, :term_months + :id, :rate_type, :interest_rate, :term_months, :initial_balance ) end diff --git a/app/models/account.rb b/app/models/account.rb index cd1bd8aa..b93a13e1 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -34,6 +34,7 @@ class Account < ApplicationRecord def create_and_sync(attributes) attributes[:accountable_attributes] ||= {} # Ensure accountable is created, even if empty account = new(attributes.merge(cash_balance: attributes[:balance])) + initial_balance = attributes.dig(:accountable_attributes, :initial_balance)&.to_d || 0 transaction do # Create 2 valuations for new accounts to establish a value history for users to see @@ -47,7 +48,7 @@ class Account < ApplicationRecord account.entries.build( name: "Initial Balance", date: 1.day.ago.to_date, - amount: 0, + amount: initial_balance, currency: account.currency, entryable: Account::Valuation.new ) @@ -92,11 +93,6 @@ class Account < ApplicationRecord end end - def original_balance - balance_amount = balances.chronological.first&.balance || balance - Money.new(balance_amount, currency) - end - def current_holdings holdings.where(currency: currency, date: holdings.maximum(:date)).order(amount: :desc) end @@ -104,9 +100,13 @@ class Account < ApplicationRecord def update_with_sync!(attributes) should_update_balance = attributes[:balance] && attributes[:balance].to_d != balance + initial_balance = attributes.dig(:accountable_attributes, :initial_balance) + should_update_initial_balance = initial_balance && initial_balance.to_d != accountable.initial_balance + transaction do update!(attributes) update_balance!(attributes[:balance]) if should_update_balance + update_inital_balance!(attributes[:accountable_attributes][:initial_balance]) if should_update_initial_balance end sync_later @@ -127,11 +127,34 @@ class Account < ApplicationRecord end end + def update_inital_balance!(initial_balance) + valuation = first_valuation + + if valuation + valuation.update! amount: initial_balance + else + entries.create! \ + date: Date.current, + name: "Initial Balance", + amount: initial_balance, + currency: currency, + entryable: Account::Valuation.new + end + end + def start_date first_entry_date = entries.minimum(:date) || Date.current first_entry_date - 1.day end + def first_valuation + entries.account_valuations.order(:date).first + end + + def first_valuation_amount + first_valuation&.amount_money || balance_money + end + private def sync_balances strategy = linked? ? :reverse : :forward diff --git a/app/models/loan.rb b/app/models/loan.rb index 14b4d084..283e112e 100644 --- a/app/models/loan.rb +++ b/app/models/loan.rb @@ -3,20 +3,24 @@ class Loan < ApplicationRecord def monthly_payment return nil if term_months.nil? || interest_rate.nil? || rate_type.nil? || rate_type != "fixed" - return Money.new(0, account.currency) if account.original_balance.amount.zero? || term_months.zero? + return Money.new(0, account.currency) if account.loan.original_balance.amount.zero? || term_months.zero? annual_rate = interest_rate / 100.0 monthly_rate = annual_rate / 12.0 if monthly_rate.zero? - payment = account.original_balance.amount / term_months + payment = account.loan.original_balance.amount / term_months else - payment = (account.original_balance.amount * monthly_rate * (1 + monthly_rate)**term_months) / ((1 + monthly_rate)**term_months - 1) + payment = (account.loan.original_balance.amount * monthly_rate * (1 + monthly_rate)**term_months) / ((1 + monthly_rate)**term_months - 1) end Money.new(payment.round, account.currency) end + def original_balance + Money.new(account.first_valuation_amount, account.currency) + end + class << self def color "#D444F1" diff --git a/app/views/loans/_form.html.erb b/app/views/loans/_form.html.erb index bceee968..73dd7bb7 100644 --- a/app/views/loans/_form.html.erb +++ b/app/views/loans/_form.html.erb @@ -5,6 +5,13 @@
      <%= form.fields_for :accountable do |loan_form| %> +
      + <%= loan_form.money_field :initial_balance, + label: t("loans.form.initial_balance"), + default_currency: Current.family.currency, + required: true %> +
      +
      <%= loan_form.number_field :interest_rate, label: t("loans.form.interest_rate"), diff --git a/app/views/loans/_overview.html.erb b/app/views/loans/_overview.html.erb index db6824fb..bbeccb6e 100644 --- a/app/views/loans/_overview.html.erb +++ b/app/views/loans/_overview.html.erb @@ -2,7 +2,7 @@
      <%= summary_card title: t(".original_principal") do %> - <%= format_money account.original_balance %> + <%= format_money account.loan.original_balance %> <% end %> <%= summary_card title: t(".remaining_principal") do %> diff --git a/config/locales/views/loans/en.yml b/config/locales/views/loans/en.yml index 930af8df..33eb76f3 100644 --- a/config/locales/views/loans/en.yml +++ b/config/locales/views/loans/en.yml @@ -6,6 +6,7 @@ en: form: interest_rate: Interest rate interest_rate_placeholder: '5.25' + initial_balance: Original loan balance rate_type: Rate type term_months: Term (months) term_months_placeholder: '360' diff --git a/db/migrate/20250405210514_add_initial_balance_field.rb b/db/migrate/20250405210514_add_initial_balance_field.rb new file mode 100644 index 00000000..5ddb974b --- /dev/null +++ b/db/migrate/20250405210514_add_initial_balance_field.rb @@ -0,0 +1,5 @@ +class AddInitialBalanceField < ActiveRecord::Migration[7.2] + def change + add_column :loans, :initial_balance, :decimal, precision: 19, scale: 4 + end +end diff --git a/db/schema.rb b/db/schema.rb index 8c37862a..a4252e7e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -378,6 +378,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_11_140604) do t.string "rate_type" t.decimal "interest_rate", precision: 10, scale: 3 t.integer "term_months" + t.decimal "initial_balance", precision: 19, scale: 4 end create_table "merchants", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| diff --git a/test/controllers/loans_controller_test.rb b/test/controllers/loans_controller_test.rb index 627d82ac..774c5d1a 100644 --- a/test/controllers/loans_controller_test.rb +++ b/test/controllers/loans_controller_test.rb @@ -22,7 +22,8 @@ class LoansControllerTest < ActionDispatch::IntegrationTest accountable_attributes: { interest_rate: 5.5, term_months: 60, - rate_type: "fixed" + rate_type: "fixed", + initial_balance: 50000 } } } @@ -36,6 +37,7 @@ class LoansControllerTest < ActionDispatch::IntegrationTest assert_equal 5.5, created_account.accountable.interest_rate assert_equal 60, created_account.accountable.term_months assert_equal "fixed", created_account.accountable.rate_type + assert_equal 50000, created_account.accountable.initial_balance assert_redirected_to created_account assert_equal "Loan account created", flash[:notice] @@ -54,7 +56,8 @@ class LoansControllerTest < ActionDispatch::IntegrationTest id: @account.accountable_id, interest_rate: 4.5, term_months: 48, - rate_type: "fixed" + rate_type: "fixed", + initial_balance: 48000 } } } @@ -67,6 +70,7 @@ class LoansControllerTest < ActionDispatch::IntegrationTest assert_equal 4.5, @account.accountable.interest_rate assert_equal 48, @account.accountable.term_months assert_equal "fixed", @account.accountable.rate_type + assert_equal 48000, @account.accountable.initial_balance assert_redirected_to @account assert_equal "Loan account updated", flash[:notice] diff --git a/test/system/accounts_test.rb b/test/system/accounts_test.rb index ff8d4500..70ed7492 100644 --- a/test/system/accounts_test.rb +++ b/test/system/accounts_test.rb @@ -59,6 +59,7 @@ class AccountsTest < ApplicationSystemTestCase test "can create loan account" do assert_account_created "Loan" do + fill_in "account[accountable_attributes][initial_balance]", with: 1000 fill_in "Interest rate", with: 5.25 select "Fixed", from: "Rate type" fill_in "Term (months)", with: 360 From e657c40d195012e396824cd346df1f0df8ef4e44 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 14 Apr 2025 11:40:34 -0400 Subject: [PATCH 115/380] Account:: namespace simplifications and cleanup (#2110) * Flatten Holding model * Flatten balance model * Entries domain renames * Fix valuations reference * Fix trades stream * Fix brakeman warnings * Fix tests * Replace existing entryable type references in DB --- .cursor/rules/project-conventions.mdc | 14 +- .cursor/rules/project-design.mdc | 24 +-- app/controllers/account/trades_controller.rb | 37 ---- .../transaction_categories_controller.rb | 22 --- .../account/transactions_controller.rb | 37 ---- .../account/valuations_controller.rb | 3 - .../budget_categories_controller.rb | 6 +- .../concerns/entryable_resource.rb | 97 +--------- app/controllers/concerns/stream_extensions.rb | 20 +++ .../{account => }/holdings_controller.rb | 2 +- app/controllers/trades_controller.rb | 79 ++++++++ .../transaction_categories_controller.rb | 22 +++ .../transactions/bulk_deletions_controller.rb | 12 ++ .../transactions/bulk_updates_controller.rb | 19 ++ app/controllers/transactions_controller.rb | 57 +++++- .../transfer_matches_controller.rb | 16 +- app/controllers/valuations_controller.rb | 49 +++++ app/helpers/{account => }/entries_helper.rb | 8 +- app/models/account.rb | 20 +-- app/models/account/chartable.rb | 4 +- app/models/account/enrichable.rb | 8 +- app/models/account/valuation.rb | 3 - app/models/account_import.rb | 2 +- app/models/{account => }/balance.rb | 2 +- .../{account => }/balance/base_calculator.rb | 6 +- .../balance/forward_calculator.rb | 2 +- .../balance/reverse_calculator.rb | 2 +- .../{account => }/balance/sync_cache.rb | 6 +- app/models/{account => }/balance/syncer.rb | 10 +- .../trend_calculator.rb} | 6 +- app/models/category.rb | 2 +- app/models/demo/generator.rb | 12 +- app/models/{account => }/entry.rb | 14 +- app/models/{account => }/entry_search.rb | 14 +- app/models/{account => }/entryable.rb | 12 +- app/models/family/auto_transfer_matchable.rb | 8 +- app/models/{account => }/holding.rb | 8 +- .../{account => }/holding/base_calculator.rb | 8 +- .../holding/forward_calculator.rb | 4 +- .../{account => }/holding/gapfillable.rb | 4 +- .../{account => }/holding/portfolio_cache.rb | 4 +- .../holding/reverse_calculator.rb | 4 +- app/models/{account => }/holding/syncer.rb | 8 +- app/models/import.rb | 2 +- app/models/import/row.rb | 2 +- app/models/income_statement/base_query.rb | 6 +- app/models/merchant.rb | 2 +- app/models/mint_import.rb | 2 +- app/models/plaid_account.rb | 4 +- app/models/plaid_investment_sync.rb | 4 +- app/models/property.rb | 2 +- app/models/rejected_transfer.rb | 4 +- app/models/security.rb | 2 +- app/models/tag.rb | 2 +- app/models/{account => }/trade.rb | 4 +- app/models/{account => }/trade_builder.rb | 8 +- app/models/trade_import.rb | 6 +- app/models/{account => }/transaction.rb | 6 +- .../{account => }/transaction/provided.rb | 2 +- .../search.rb} | 22 +-- .../{account => }/transaction/transferable.rb | 2 +- app/models/transaction_import.rb | 6 +- app/models/transfer.rb | 12 +- app/models/valuation.rb | 3 + app/models/vehicle.rb | 2 +- .../account/transactions/_header.html.erb | 23 --- app/views/account/transactions/new.html.erb | 3 - app/views/accounts/show/_activity.html.erb | 8 +- app/views/category/dropdowns/_row.html.erb | 6 +- app/views/category/dropdowns/show.html.erb | 12 +- .../{account => }/entries/_empty.html.erb | 0 .../{account => }/entries/_entry.html.erb | 0 .../entries/_entry_group.html.erb | 0 .../{account => }/entries/_loading.html.erb | 0 .../{account => }/entries/_ruler.html.erb | 0 .../entries/_selection_bar.html.erb | 2 +- .../{account => }/holdings/_cash.html.erb | 0 .../{account => }/holdings/_holding.html.erb | 2 +- .../holdings/_missing_price_tooltip.html.erb | 0 .../{account => }/holdings/_ruler.html.erb | 0 .../{account => }/holdings/index.html.erb | 8 +- app/views/{account => }/holdings/new.html.erb | 0 .../{account => }/holdings/show.html.erb | 8 +- app/views/investments/_holdings_tab.html.erb | 4 +- app/views/{account => }/trades/_form.html.erb | 6 +- .../{account => }/trades/_header.html.erb | 2 +- .../{account => }/trades/_trade.html.erb | 2 +- app/views/{account => }/trades/new.html.erb | 2 +- app/views/{account => }/trades/show.html.erb | 14 +- .../{account => }/transactions/_form.html.erb | 6 +- app/views/transactions/_header.html.erb | 40 ++--- .../transactions/_selection_bar.html.erb | 5 +- .../transactions/_transaction.html.erb | 6 +- .../_transaction_category.html.erb | 0 .../transactions/_transfer_match.html.erb | 0 .../bulk_updates/new.html.erb} | 20 +-- app/views/transactions/index.html.erb | 28 ++- app/views/transactions/new.html.erb | 3 + .../{account => }/transactions/show.html.erb | 18 +- .../_matching_fields.html.erb | 0 .../transfer_matches/new.html.erb | 6 +- app/views/transfers/_form.html.erb | 4 +- app/views/transfers/update.turbo_stream.erb | 8 +- .../{account => }/valuations/_form.html.erb | 4 +- .../{account => }/valuations/_header.html.erb | 0 .../valuations/_valuation.html.erb | 2 +- .../{account => }/valuations/index.html.erb | 10 +- .../{account => }/valuations/new.html.erb | 0 .../{account => }/valuations/show.html.erb | 8 +- config/brakeman.ignore | 59 +----- .../locales/models/{account => }/entry/en.yml | 2 +- config/locales/views/account/entries/en.yml | 15 -- config/locales/views/account/holdings/en.yml | 38 ---- config/locales/views/account/trades/en.yml | 39 ---- .../locales/views/account/transactions/en.yml | 64 ------- .../locales/views/account/valuations/en.yml | 31 ---- config/locales/views/entries/en.yml | 14 ++ config/locales/views/holdings/en.yml | 37 ++++ config/locales/views/trades/en.yml | 38 ++++ config/locales/views/transactions/en.yml | 39 ++++ config/locales/views/valuations/en.yml | 30 ++++ config/routes.rb | 48 +++-- db/migrate/20250413141446_table_renames.rb | 50 ++++++ db/schema.rb | 168 +++++++++--------- lib/tasks/securities.rake | 16 +- .../account/transactions_controller_test.rb | 119 ------------- .../controllers/categories_controller_test.rb | 2 +- .../category/deletions_controller_test.rb | 2 +- .../credit_cards_controller_test.rb | 4 +- .../{account => }/holdings_controller_test.rb | 12 +- test/controllers/loans_controller_test.rb | 4 +- .../controllers/properties_controller_test.rb | 4 +- .../settings/hostings_controller_test.rb | 4 +- .../{account => }/trades_controller_test.rb | 68 +++---- .../bulk_deletions_controller_test.rb | 24 +++ .../bulk_updates_controller_test.rb | 35 ++++ .../transactions_controller_test.rb | 74 +++++++- .../transfer_matches_controller_test.rb | 10 +- .../valuations_controller_test.rb | 25 +-- test/controllers/vehicles_controller_test.rb | 4 +- test/fixtures/{account => }/balances.yml | 0 test/fixtures/{account => }/entries.yml | 10 +- test/fixtures/{account => }/holdings.yml | 0 test/fixtures/taggings.yml | 4 +- test/fixtures/{account => }/trades.yml | 0 test/fixtures/{account => }/transactions.yml | 0 test/fixtures/{account => }/valuations.yml | 0 .../accountable_resource_interface_test.rb | 6 +- .../entryable_resource_interface_test.rb | 6 +- test/models/account/balance/syncer_test.rb | 51 ------ test/models/account/convertible_test.rb | 2 +- test/models/account/entry_test.rb | 14 +- test/models/account/transaction_test.rb | 4 +- test/models/account_test.rb | 2 +- .../balance/forward_calculator_test.rb | 14 +- .../balance/reverse_calculator_test.rb | 12 +- test/models/balance/syncer_test.rb | 51 ++++++ .../family/auto_transfer_matchable_test.rb | 2 +- test/models/family_test.rb | 2 +- .../holding/forward_calculator_test.rb | 56 +++--- .../holding/portfolio_cache_test.rb | 18 +- .../holding/reverse_calculator_test.rb | 40 ++--- .../{account => }/holding/syncer_test.rb | 12 +- test/models/{account => }/holding_test.rb | 4 +- test/models/income_statement_test.rb | 2 +- test/models/plaid_investment_sync_test.rb | 10 +- test/models/trade_import_test.rb | 4 +- test/models/transaction_import_test.rb | 4 +- test/models/transfer_test.rb | 32 ++-- .../{account => }/entries_test_helper.rb | 12 +- test/system/trades_test.rb | 10 +- test/system/transactions_test.rb | 20 +-- 172 files changed, 1297 insertions(+), 1258 deletions(-) delete mode 100644 app/controllers/account/trades_controller.rb delete mode 100644 app/controllers/account/transaction_categories_controller.rb delete mode 100644 app/controllers/account/transactions_controller.rb delete mode 100644 app/controllers/account/valuations_controller.rb create mode 100644 app/controllers/concerns/stream_extensions.rb rename app/controllers/{account => }/holdings_controller.rb (92%) create mode 100644 app/controllers/trades_controller.rb create mode 100644 app/controllers/transaction_categories_controller.rb create mode 100644 app/controllers/transactions/bulk_deletions_controller.rb create mode 100644 app/controllers/transactions/bulk_updates_controller.rb rename app/controllers/{account => }/transfer_matches_controller.rb (73%) create mode 100644 app/controllers/valuations_controller.rb rename app/helpers/{account => }/entries_helper.rb (78%) delete mode 100644 app/models/account/valuation.rb rename app/models/{account => }/balance.rb (85%) rename app/models/{account => }/balance/base_calculator.rb (84%) rename app/models/{account => }/balance/forward_calculator.rb (90%) rename app/models/{account => }/balance/reverse_calculator.rb (92%) rename app/models/{account => }/balance/sync_cache.rb (83%) rename app/models/{account => }/balance/syncer.rb (86%) rename app/models/{account/balance_trend_calculator.rb => balance/trend_calculator.rb} (95%) rename app/models/{account => }/entry.rb (78%) rename app/models/{account => }/entry_search.rb (74%) rename app/models/{account => }/entryable.rb (50%) rename app/models/{account => }/holding.rb (79%) rename app/models/{account => }/holding/base_calculator.rb (88%) rename app/models/{account => }/holding/forward_calculator.rb (77%) rename app/models/{account => }/holding/gapfillable.rb (91%) rename app/models/{account => }/holding/portfolio_cache.rb (98%) rename app/models/{account => }/holding/reverse_calculator.rb (87%) rename app/models/{account => }/holding/syncer.rb (84%) rename app/models/{account => }/trade.rb (83%) rename app/models/{account => }/trade_builder.rb (94%) rename app/models/{account => }/transaction.rb (64%) rename app/models/{account => }/transaction/provided.rb (89%) rename app/models/{account/transaction_search.rb => transaction/search.rb} (77%) rename app/models/{account => }/transaction/transferable.rb (96%) create mode 100644 app/models/valuation.rb delete mode 100644 app/views/account/transactions/_header.html.erb delete mode 100644 app/views/account/transactions/new.html.erb rename app/views/{account => }/entries/_empty.html.erb (100%) rename app/views/{account => }/entries/_entry.html.erb (100%) rename app/views/{account => }/entries/_entry_group.html.erb (100%) rename app/views/{account => }/entries/_loading.html.erb (100%) rename app/views/{account => }/entries/_ruler.html.erb (100%) rename app/views/{account => }/entries/_selection_bar.html.erb (86%) rename app/views/{account => }/holdings/_cash.html.erb (100%) rename app/views/{account => }/holdings/_holding.html.erb (93%) rename app/views/{account => }/holdings/_missing_price_tooltip.html.erb (100%) rename app/views/{account => }/holdings/_ruler.html.erb (100%) rename app/views/{account => }/holdings/index.html.erb (80%) rename app/views/{account => }/holdings/new.html.erb (100%) rename app/views/{account => }/holdings/show.html.erb (95%) rename app/views/{account => }/trades/_form.html.erb (87%) rename app/views/{account => }/trades/_header.html.erb (98%) rename app/views/{account => }/trades/_trade.html.erb (97%) rename app/views/{account => }/trades/new.html.erb (52%) rename app/views/{account => }/trades/show.html.erb (91%) rename app/views/{account => }/transactions/_form.html.erb (91%) rename app/views/{account => }/transactions/_selection_bar.html.erb (86%) rename app/views/{account => }/transactions/_transaction.html.erb (94%) rename app/views/{account => }/transactions/_transaction_category.html.erb (100%) rename app/views/{account => }/transactions/_transfer_match.html.erb (100%) rename app/views/{account/transactions/bulk_edit.html.erb => transactions/bulk_updates/new.html.erb} (71%) create mode 100644 app/views/transactions/new.html.erb rename app/views/{account => }/transactions/show.html.erb (90%) rename app/views/{account => }/transfer_matches/_matching_fields.html.erb (100%) rename app/views/{account => }/transfer_matches/new.html.erb (91%) rename app/views/{account => }/valuations/_form.html.erb (71%) rename app/views/{account => }/valuations/_header.html.erb (100%) rename app/views/{account => }/valuations/_valuation.html.erb (97%) rename app/views/{account => }/valuations/index.html.erb (82%) rename app/views/{account => }/valuations/new.html.erb (100%) rename app/views/{account => }/valuations/show.html.erb (91%) rename config/locales/models/{account => }/entry/en.yml (90%) delete mode 100644 config/locales/views/account/entries/en.yml delete mode 100644 config/locales/views/account/holdings/en.yml delete mode 100644 config/locales/views/account/trades/en.yml delete mode 100644 config/locales/views/account/transactions/en.yml delete mode 100644 config/locales/views/account/valuations/en.yml create mode 100644 config/locales/views/entries/en.yml create mode 100644 config/locales/views/holdings/en.yml create mode 100644 config/locales/views/trades/en.yml create mode 100644 config/locales/views/valuations/en.yml create mode 100644 db/migrate/20250413141446_table_renames.rb delete mode 100644 test/controllers/account/transactions_controller_test.rb rename test/controllers/{account => }/holdings_controller_test.rb (61%) rename test/controllers/{account => }/trades_controller_test.rb (63%) create mode 100644 test/controllers/transactions/bulk_deletions_controller_test.rb create mode 100644 test/controllers/transactions/bulk_updates_controller_test.rb rename test/controllers/{account => }/transfer_matches_controller_test.rb (71%) rename test/controllers/{account => }/valuations_controller_test.rb (59%) rename test/fixtures/{account => }/balances.yml (100%) rename test/fixtures/{account => }/entries.yml (80%) rename test/fixtures/{account => }/holdings.yml (100%) rename test/fixtures/{account => }/trades.yml (100%) rename test/fixtures/{account => }/transactions.yml (100%) rename test/fixtures/{account => }/valuations.yml (100%) delete mode 100644 test/models/account/balance/syncer_test.rb rename test/models/{account => }/balance/forward_calculator_test.rb (79%) rename test/models/{account => }/balance/reverse_calculator_test.rb (77%) create mode 100644 test/models/balance/syncer_test.rb rename test/models/{account => }/holding/forward_calculator_test.rb (64%) rename test/models/{account => }/holding/portfolio_cache_test.rb (80%) rename test/models/{account => }/holding/reverse_calculator_test.rb (74%) rename test/models/{account => }/holding/syncer_test.rb (57%) rename test/models/{account => }/holding_test.rb (95%) rename test/support/{account => }/entries_test_helper.rb (76%) diff --git a/.cursor/rules/project-conventions.mdc b/.cursor/rules/project-conventions.mdc index 2977dc33..17cee2e0 100644 --- a/.cursor/rules/project-conventions.mdc +++ b/.cursor/rules/project-conventions.mdc @@ -71,7 +71,7 @@ Due to the open-source nature of this project, we have chosen Minitest + Fixture - Always use Minitest and fixtures for testing. - 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. -- For tests that require a large number of fixture records to be created, use Rails helpers such as [entries_test_helper.rb](mdc:test/support/account/entries_test_helper.rb) to act as a "factory" for creating these. For a great example of this, check out [forward_calculator_test.rb](mdc:test/models/account/balance/forward_calculator_test.rb) +- For tests that require a large number of fixture records to be created, use Rails helpers such as [entries_test_helper.rb](mdc:test/support/entries_test_helper.rb) to act as a "factory" for creating these. For a great example of this, check out [forward_calculator_test.rb](mdc:test/models/account/balance/forward_calculator_test.rb) - Take a minimal approach to testing—only test the absolutely critical code paths that will significantly increase developer confidence #### Convention 5a: Write minimal, effective tests @@ -87,26 +87,26 @@ Below are examples of necessary vs. unnecessary tests: # GOOD!! # Necessary test - in this case, we're testing critical domain business logic test "syncs balances" do - Account::Holding::Syncer.any_instance.expects(:sync_holdings).returns([]).once + Holding::Syncer.any_instance.expects(:sync_holdings).returns([]).once @account.expects(:start_date).returns(2.days.ago.to_date) - Account::Balance::ForwardCalculator.any_instance.expects(:calculate).returns( + Balance::ForwardCalculator.any_instance.expects(:calculate).returns( [ - Account::Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"), - Account::Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD") + Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"), + Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD") ] ) assert_difference "@account.balances.count", 2 do - Account::Balance::Syncer.new(@account, strategy: :forward).sync_balances + Balance::Syncer.new(@account, strategy: :forward).sync_balances end end # BAD!! # Unnecessary test - in this case, this is simply testing ActiveRecord's functionality test "saves balance" do - balance_record = Account::Balance.new(balance: 100, currency: "USD") + balance_record = Balance.new(balance: 100, currency: "USD") assert balance_record.save end diff --git a/.cursor/rules/project-design.mdc b/.cursor/rules/project-design.mdc index 41fa2210..b2d13e9f 100644 --- a/.cursor/rules/project-design.mdc +++ b/.cursor/rules/project-design.mdc @@ -55,29 +55,29 @@ All balances are calculated daily by [balance_calculator.rb](mdc:app/models/acco ### Account Holdings -An account [holding.rb](mdc:app/models/account/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`. +An 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`. -For investment accounts with holdings, [holding_calculator.rb](mdc:app/models/account/holding_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 [balance_calculator.rb](mdc:app/models/account/balance_calculator.rb). +For 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). ### Account Entries -An account [entry.rb](mdc:app/models/account/entry.rb) is also a Rails "delegated type". `Account::Entry` represents any record that _modifies_ an `Account` [balance.rb](mdc:app/models/account/balance.rb) and/or [holding.rb](mdc:app/models/account/holding.rb). Therefore, every entry must have a `date`, `amount`, and `currency`. +An 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`. -The `amount` of an [entry.rb](mdc:app/models/account/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: +The `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: - A negative amount for a credit card account represents a "payment" to that account, which _reduces_ its balance (since it is a `liability`) - A negative amount for a checking account represents an "income" to that account, which _increases_ its balance (since it is an `asset`) - A negative amount for an investment/brokerage trade represents a "sell" transaction, which _increases_ the cash balance of the account -There are 3 entry types, defined as [entryable.rb](mdc:app/models/account/entryable.rb) records: +There are 3 entry types, defined as [entryable.rb](mdc:app/models/entryable.rb) records: -- `Account::Valuation` - an account [valuation.rb](mdc:app/models/account/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 `Account::Valuation` of 5,000 for today's date, that means that the account balance will be 5,000 today. -- `Account::Transaction` - an account [transaction.rb](mdc:app/models/account/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". -- `Account::Trade` - an account [trade.rb](mdc:app/models/account/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`. +- `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. +- `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". +- `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`. ### Account Transfers -A [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/account/transaction.rb) and an outflow [transaction.rb](mdc:app/models/account/transaction.rb). The Maybe system auto-matches transfers based on the following criteria: +A [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: - Must be from different accounts - Must be within 4 days of each other @@ -115,10 +115,10 @@ The most important type of sync is the account sync. It is orchestrated by the - Auto-matches transfer records for the account - 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) - - Balances are dependent on the calculation of [holding.rb](mdc:app/models/account/holding.rb), which uses [base_calculator.rb](mdc:app/models/account/holding/base_calculator.rb) + - 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) - Enriches transaction data if enabled by user -An account sync happens every time an [entry.rb](mdc:app/models/account/entry.rb) is updated. +An account sync happens every time an [entry.rb](mdc:app/models/entry.rb) is updated. ### Plaid Item Syncs @@ -126,7 +126,7 @@ A Plaid Item sync is an ETL (extract, transform, load) operation: 1. [plaid_item.rb](mdc:app/models/plaid_item.rb) fetches data from the external Plaid API 2. [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 -3. [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/account/entry.rb), the internal Maybe representations of the data. +3. [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. ### Family Syncs diff --git a/app/controllers/account/trades_controller.rb b/app/controllers/account/trades_controller.rb deleted file mode 100644 index fd9b7d48..00000000 --- a/app/controllers/account/trades_controller.rb +++ /dev/null @@ -1,37 +0,0 @@ -class Account::TradesController < ApplicationController - include EntryableResource - - permitted_entryable_attributes :id, :qty, :price - - private - def build_entry - Account::TradeBuilder.new(create_entry_params) - end - - def create_entry_params - params.require(:account_entry).permit( - :account_id, :date, :amount, :currency, :qty, :price, :ticker, :manual_ticker, :type, :transfer_account_id - ).tap do |params| - account_id = params.delete(:account_id) - params[:account] = Current.family.accounts.find(account_id) - end - end - - def update_entry_params - return entry_params unless entry_params[:entryable_attributes].present? - - update_params = entry_params - update_params = update_params.merge(entryable_type: "Account::Trade") - - qty = update_params[:entryable_attributes][:qty] - price = update_params[:entryable_attributes][:price] - - if qty.present? && price.present? - qty = update_params[:nature] == "inflow" ? -qty.to_d : qty.to_d - update_params[:entryable_attributes][:qty] = qty - update_params[:amount] = qty * price.to_d - end - - update_params.except(:nature) - end -end diff --git a/app/controllers/account/transaction_categories_controller.rb b/app/controllers/account/transaction_categories_controller.rb deleted file mode 100644 index 5920a0b3..00000000 --- a/app/controllers/account/transaction_categories_controller.rb +++ /dev/null @@ -1,22 +0,0 @@ -class Account::TransactionCategoriesController < ApplicationController - def update - @entry = Current.family.entries.account_transactions.find(params[:transaction_id]) - @entry.update!(entry_params) - - respond_to do |format| - format.html { redirect_back_or_to account_transaction_path(@entry) } - format.turbo_stream do - render turbo_stream: turbo_stream.replace( - "category_menu_account_transaction_#{@entry.account_transaction_id}", - partial: "categories/menu", - locals: { transaction: @entry.account_transaction } - ) - end - end - end - - private - def entry_params - params.require(:account_entry).permit(:entryable_type, entryable_attributes: [ :id, :category_id ]) - end -end diff --git a/app/controllers/account/transactions_controller.rb b/app/controllers/account/transactions_controller.rb deleted file mode 100644 index 8125565c..00000000 --- a/app/controllers/account/transactions_controller.rb +++ /dev/null @@ -1,37 +0,0 @@ -class Account::TransactionsController < ApplicationController - include EntryableResource - - permitted_entryable_attributes :id, :category_id, :merchant_id, { tag_ids: [] } - - def bulk_delete - destroyed = Current.family.entries.destroy_by(id: bulk_delete_params[:entry_ids]) - destroyed.map(&:account).uniq.each(&:sync_later) - redirect_back_or_to transactions_url, notice: t(".success", count: destroyed.count) - end - - def bulk_edit - end - - def bulk_update - updated = Current.family - .entries - .where(id: bulk_update_params[:entry_ids]) - .bulk_update!(bulk_update_params) - - redirect_back_or_to transactions_url, notice: t(".success", count: updated) - end - - private - def bulk_delete_params - params.require(:bulk_delete).permit(entry_ids: []) - end - - def bulk_update_params - params.require(:bulk_update).permit(:date, :notes, :category_id, :merchant_id, entry_ids: [], tag_ids: []) - end - - def search_params - params.fetch(:q, {}) - .permit(:start_date, :end_date, :search, :amount, :amount_operator, accounts: [], account_ids: [], categories: [], merchants: [], types: [], tags: []) - end -end diff --git a/app/controllers/account/valuations_controller.rb b/app/controllers/account/valuations_controller.rb deleted file mode 100644 index 08f566f3..00000000 --- a/app/controllers/account/valuations_controller.rb +++ /dev/null @@ -1,3 +0,0 @@ -class Account::ValuationsController < ApplicationController - include EntryableResource -end diff --git a/app/controllers/budget_categories_controller.rb b/app/controllers/budget_categories_controller.rb index dcd96262..e8cc83e6 100644 --- a/app/controllers/budget_categories_controller.rb +++ b/app/controllers/budget_categories_controller.rb @@ -11,14 +11,14 @@ class BudgetCategoriesController < ApplicationController if params[:id] == BudgetCategory.uncategorized.id @budget_category = @budget.uncategorized_budget_category - @recent_transactions = @recent_transactions.where(account_transactions: { category_id: nil }) + @recent_transactions = @recent_transactions.where(transactions: { category_id: nil }) else @budget_category = Current.family.budget_categories.find(params[:id]) - @recent_transactions = @recent_transactions.joins("LEFT JOIN categories ON categories.id = account_transactions.category_id") + @recent_transactions = @recent_transactions.joins("LEFT JOIN categories ON categories.id = transactions.category_id") .where("categories.id = ? OR categories.parent_id = ?", @budget_category.category.id, @budget_category.category.id) end - @recent_transactions = @recent_transactions.order("account_entries.date DESC, ABS(account_entries.amount) DESC").take(3) + @recent_transactions = @recent_transactions.order("entries.date DESC, ABS(entries.amount) DESC").take(3) end def update diff --git a/app/controllers/concerns/entryable_resource.rb b/app/controllers/concerns/entryable_resource.rb index 58519725..443b0483 100644 --- a/app/controllers/concerns/entryable_resource.rb +++ b/app/controllers/concerns/entryable_resource.rb @@ -2,14 +2,9 @@ module EntryableResource extend ActiveSupport::Concern included do - before_action :set_entry, only: %i[show update destroy] - end + include StreamExtensions, ActionView::RecordIdentifier - class_methods do - def permitted_entryable_attributes(*attrs) - @permitted_entryable_attributes = attrs if attrs.any? - @permitted_entryable_attributes ||= [ :id ] - end + before_action :set_entry, only: %i[show update destroy] end def show @@ -21,49 +16,16 @@ module EntryableResource @entry = Current.family.entries.new( account: account, currency: account ? account.currency : Current.family.currency, - entryable: entryable_type.new + entryable: entryable ) end def create - @entry = build_entry - - if @entry.save - @entry.sync_account_later - - flash[:notice] = t("account.entries.create.success") - - respond_to do |format| - format.html { redirect_back_or_to account_path(@entry.account) } - - redirect_target_url = request.referer || account_path(@entry.account) - format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) } - end - else - render :new, status: :unprocessable_entity - end + raise NotImplementedError, "Entryable resources must implement #create" end def update - if @entry.update(update_entry_params) - @entry.sync_account_later - - respond_to do |format| - format.html { redirect_back_or_to account_path(@entry.account), notice: t("account.entries.update.success") } - format.turbo_stream do - render turbo_stream: [ - turbo_stream.replace( - "header_account_entry_#{@entry.id}", - partial: "#{entryable_type.name.underscore.pluralize}/header", - locals: { entry: @entry } - ), - turbo_stream.replace("account_entry_#{@entry.id}", partial: "account/entries/entry", locals: { entry: @entry }) - ] - end - end - else - render :show, status: :unprocessable_entity - end + raise NotImplementedError, "Entryable resources must implement #update" end def destroy @@ -71,58 +33,15 @@ module EntryableResource @entry.destroy! @entry.sync_account_later - flash[:notice] = t("account.entries.destroy.success") - - respond_to do |format| - format.html { redirect_back_or_to account_path(account) } - - redirect_target_url = request.referer || account_path(@entry.account) - format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) } - end + redirect_back_or_to account_path(account), notice: t("account.entries.destroy.success") end private - def entryable_type - permitted_entryable_types = %w[Account::Transaction Account::Valuation Account::Trade] - klass = params[:entryable_type] || "Account::#{controller_name.classify}" - klass.constantize if permitted_entryable_types.include?(klass) + def entryable + controller_name.classify.constantize.new end def set_entry @entry = Current.family.entries.find(params[:id]) end - - def build_entry - Current.family.entries.new(create_entry_params) - end - - def update_entry_params - prepared_entry_params - end - - def create_entry_params - prepared_entry_params.merge({ - entryable_type: entryable_type.name, - entryable_attributes: entry_params[:entryable_attributes] || {} - }) - end - - def prepared_entry_params - default_params = entry_params.except(:nature) - default_params = default_params.merge(entryable_type: entryable_type.name) if entry_params[:entryable_attributes].present? - - if entry_params[:nature].present? && entry_params[:amount].present? - signed_amount = entry_params[:nature] == "inflow" ? -entry_params[:amount].to_d : entry_params[:amount].to_d - default_params = default_params.merge(amount: signed_amount) - end - - default_params - end - - def entry_params - params.require(:account_entry).permit( - :account_id, :name, :enriched_name, :date, :amount, :currency, :excluded, :notes, :nature, - entryable_attributes: self.class.permitted_entryable_attributes - ) - end end diff --git a/app/controllers/concerns/stream_extensions.rb b/app/controllers/concerns/stream_extensions.rb new file mode 100644 index 00000000..978d5bfb --- /dev/null +++ b/app/controllers/concerns/stream_extensions.rb @@ -0,0 +1,20 @@ +module StreamExtensions + extend ActiveSupport::Concern + + def stream_redirect_to(path, notice: nil, alert: nil) + custom_stream_redirect(path, notice: notice, alert: alert) + end + + def stream_redirect_back_or_to(path, notice: nil, alert: nil) + custom_stream_redirect(path, redirect_back: true, notice: notice, alert: alert) + end + + private + def custom_stream_redirect(path, redirect_back: false, notice: nil, alert: nil) + flash[:notice] = notice if notice.present? + flash[:alert] = alert if alert.present? + + redirect_target_url = redirect_back ? request.referer : path + render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) + end +end diff --git a/app/controllers/account/holdings_controller.rb b/app/controllers/holdings_controller.rb similarity index 92% rename from app/controllers/account/holdings_controller.rb rename to app/controllers/holdings_controller.rb index 9ded4165..db9d59b4 100644 --- a/app/controllers/account/holdings_controller.rb +++ b/app/controllers/holdings_controller.rb @@ -1,4 +1,4 @@ -class Account::HoldingsController < ApplicationController +class HoldingsController < ApplicationController before_action :set_holding, only: %i[show destroy] def index diff --git a/app/controllers/trades_controller.rb b/app/controllers/trades_controller.rb new file mode 100644 index 00000000..151d62c6 --- /dev/null +++ b/app/controllers/trades_controller.rb @@ -0,0 +1,79 @@ +class TradesController < ApplicationController + include EntryableResource + + def create + @entry = build_entry + + if @entry.save + @entry.sync_account_later + + flash[:notice] = t("entries.create.success") + + respond_to do |format| + format.html { redirect_back_or_to account_path(@entry.account) } + format.turbo_stream { stream_redirect_back_or_to account_path(@entry.account) } + end + else + render :new, status: :unprocessable_entity + end + end + + def update + if @entry.update(update_entry_params) + @entry.sync_account_later + + respond_to do |format| + format.html { redirect_back_or_to account_path(@entry.account), notice: t("entries.update.success") } + format.turbo_stream do + render turbo_stream: [ + turbo_stream.replace( + "header_entry_#{@entry.id}", + partial: "trades/header", + locals: { entry: @entry } + ), + turbo_stream.replace("entry_#{@entry.id}", partial: "entries/entry", locals: { entry: @entry }) + ] + end + end + else + render :show, status: :unprocessable_entity + end + end + + private + def build_entry + account = Current.family.accounts.find(params.dig(:entry, :account_id)) + TradeBuilder.new(create_entry_params.merge(account: account)) + end + + def entry_params + params.require(:entry).permit( + :name, :enriched_name, :date, :amount, :currency, :excluded, :notes, :nature, + entryable_attributes: [ :id, :qty, :price ] + ) + end + + def create_entry_params + params.require(:entry).permit( + :date, :amount, :currency, :qty, :price, :ticker, :manual_ticker, :type, :transfer_account_id + ) + end + + def update_entry_params + return entry_params unless entry_params[:entryable_attributes].present? + + update_params = entry_params + update_params = update_params.merge(entryable_type: "Trade") + + qty = update_params[:entryable_attributes][:qty] + price = update_params[:entryable_attributes][:price] + + if qty.present? && price.present? + qty = update_params[:nature] == "inflow" ? -qty.to_d : qty.to_d + update_params[:entryable_attributes][:qty] = qty + update_params[:amount] = qty * price.to_d + end + + update_params.except(:nature) + end +end diff --git a/app/controllers/transaction_categories_controller.rb b/app/controllers/transaction_categories_controller.rb new file mode 100644 index 00000000..f70e0aa9 --- /dev/null +++ b/app/controllers/transaction_categories_controller.rb @@ -0,0 +1,22 @@ +class TransactionCategoriesController < ApplicationController + def update + @entry = Current.family.entries.transactions.find(params[:transaction_id]) + @entry.update!(entry_params) + + respond_to do |format| + format.html { redirect_back_or_to transaction_path(@entry) } + format.turbo_stream do + render turbo_stream: turbo_stream.replace( + "category_menu_transaction_#{@entry.transaction_id}", + partial: "categories/menu", + locals: { transaction: @entry.transaction } + ) + end + end + end + + private + def entry_params + params.require(:entry).permit(:entryable_type, entryable_attributes: [ :id, :category_id ]) + end +end diff --git a/app/controllers/transactions/bulk_deletions_controller.rb b/app/controllers/transactions/bulk_deletions_controller.rb new file mode 100644 index 00000000..fefaf389 --- /dev/null +++ b/app/controllers/transactions/bulk_deletions_controller.rb @@ -0,0 +1,12 @@ +class Transactions::BulkDeletionsController < ApplicationController + def create + destroyed = Current.family.entries.destroy_by(id: bulk_delete_params[:entry_ids]) + destroyed.map(&:account).uniq.each(&:sync_later) + redirect_back_or_to transactions_url, notice: "#{destroyed.count} transaction#{destroyed.count == 1 ? "" : "s"} deleted" + end + + private + def bulk_delete_params + params.require(:bulk_delete).permit(entry_ids: []) + end +end diff --git a/app/controllers/transactions/bulk_updates_controller.rb b/app/controllers/transactions/bulk_updates_controller.rb new file mode 100644 index 00000000..08c4befe --- /dev/null +++ b/app/controllers/transactions/bulk_updates_controller.rb @@ -0,0 +1,19 @@ +class Transactions::BulkUpdatesController < ApplicationController + def new + end + + def create + updated = Current.family + .entries + .where(id: bulk_update_params[:entry_ids]) + .bulk_update!(bulk_update_params) + + redirect_back_or_to transactions_path, notice: "#{updated} transactions updated" + end + + private + def bulk_update_params + params.require(:bulk_update) + .permit(:date, :notes, :category_id, :merchant_id, entry_ids: [], tag_ids: []) + end +end diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index 484c53a0..e4407ee3 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -1,5 +1,5 @@ class TransactionsController < ApplicationController - include ScrollFocusable + include ScrollFocusable, EntryableResource before_action :store_params!, only: :index @@ -48,7 +48,62 @@ class TransactionsController < ApplicationController redirect_to transactions_path(updated_params) end + def create + account = Current.family.accounts.find(params.dig(:entry, :account_id)) + @entry = account.entries.new(entry_params) + + if @entry.save + @entry.sync_account_later + + flash[:notice] = "Transaction created" + + respond_to do |format| + format.html { redirect_back_or_to account_path(@entry.account) } + format.turbo_stream { stream_redirect_back_or_to(account_path(@entry.account)) } + end + else + render :new, status: :unprocessable_entity + end + end + + def update + if @entry.update(entry_params) + @entry.sync_account_later + + respond_to do |format| + format.html { redirect_back_or_to account_path(@entry.account), notice: "Transaction updated" } + format.turbo_stream do + render turbo_stream: [ + turbo_stream.replace( + dom_id(@entry, :header), + partial: "transactions/header", + locals: { entry: @entry } + ), + turbo_stream.replace(@entry) + ] + end + end + else + render :show, status: :unprocessable_entity + end + end + private + def entry_params + entry_params = params.require(:entry).permit( + :name, :enriched_name, :date, :amount, :currency, :excluded, :notes, :nature, :entryable_type, + entryable_attributes: [ :id, :category_id, :merchant_id, { tag_ids: [] } ] + ) + + nature = entry_params.delete(:nature) + + if nature.present? && entry_params[:amount].present? + signed_amount = nature == "inflow" ? -entry_params[:amount].to_d : entry_params[:amount].to_d + entry_params = entry_params.merge(amount: signed_amount) + end + + entry_params + end def search_params cleaned_params = params.fetch(:q, {}) diff --git a/app/controllers/account/transfer_matches_controller.rb b/app/controllers/transfer_matches_controller.rb similarity index 73% rename from app/controllers/account/transfer_matches_controller.rb rename to app/controllers/transfer_matches_controller.rb index 851f3ac3..415d9377 100644 --- a/app/controllers/account/transfer_matches_controller.rb +++ b/app/controllers/transfer_matches_controller.rb @@ -1,9 +1,9 @@ -class Account::TransferMatchesController < ApplicationController +class TransferMatchesController < ApplicationController before_action :set_entry def new @accounts = Current.family.accounts.alphabetically.where.not(id: @entry.account_id) - @transfer_match_candidates = @entry.account_transaction.transfer_match_candidates + @transfer_match_candidates = @entry.transaction.transfer_match_candidates end def create @@ -11,7 +11,7 @@ class Account::TransferMatchesController < ApplicationController @transfer.save! @transfer.sync_account_later - redirect_back_or_to transactions_path, notice: t(".success") + redirect_back_or_to transactions_path, notice: "Transfer created" end private @@ -27,7 +27,7 @@ class Account::TransferMatchesController < ApplicationController if transfer_match_params[:method] == "new" target_account = Current.family.accounts.find(transfer_match_params[:target_account_id]) - missing_transaction = Account::Transaction.new( + missing_transaction = Transaction.new( entry: target_account.entries.build( amount: @entry.amount * -1, currency: @entry.currency, @@ -37,8 +37,8 @@ class Account::TransferMatchesController < ApplicationController ) transfer = Transfer.find_or_initialize_by( - inflow_transaction: @entry.amount.positive? ? missing_transaction : @entry.account_transaction, - outflow_transaction: @entry.amount.positive? ? @entry.account_transaction : missing_transaction + inflow_transaction: @entry.amount.positive? ? missing_transaction : @entry.transaction, + outflow_transaction: @entry.amount.positive? ? @entry.transaction : missing_transaction ) transfer.status = "confirmed" transfer @@ -46,8 +46,8 @@ class Account::TransferMatchesController < ApplicationController target_transaction = Current.family.entries.find(transfer_match_params[:matched_entry_id]) transfer = Transfer.find_or_initialize_by( - inflow_transaction: @entry.amount.negative? ? @entry.account_transaction : target_transaction.account_transaction, - outflow_transaction: @entry.amount.negative? ? target_transaction.account_transaction : @entry.account_transaction + inflow_transaction: @entry.amount.negative? ? @entry.transaction : target_transaction.transaction, + outflow_transaction: @entry.amount.negative? ? target_transaction.transaction : @entry.transaction ) transfer.status = "confirmed" transfer diff --git a/app/controllers/valuations_controller.rb b/app/controllers/valuations_controller.rb new file mode 100644 index 00000000..7d91a9a6 --- /dev/null +++ b/app/controllers/valuations_controller.rb @@ -0,0 +1,49 @@ +class ValuationsController < ApplicationController + include EntryableResource + + def create + account = Current.family.accounts.find(params.dig(:entry, :account_id)) + @entry = account.entries.new(entry_params.merge(entryable: Valuation.new)) + + if @entry.save + @entry.sync_account_later + + flash[:notice] = "Balance created" + + respond_to do |format| + format.html { redirect_back_or_to account_path(@entry.account) } + format.turbo_stream { stream_redirect_back_or_to(account_path(@entry.account)) } + end + else + render :new, status: :unprocessable_entity + end + end + + def update + if @entry.update(entry_params) + @entry.sync_account_later + + respond_to do |format| + format.html { redirect_back_or_to account_path(@entry.account), notice: "Balance updated" } + format.turbo_stream do + render turbo_stream: [ + turbo_stream.replace( + dom_id(@entry, :header), + partial: "valuations/header", + locals: { entry: @entry } + ), + turbo_stream.replace(@entry) + ] + end + end + else + render :show, status: :unprocessable_entity + end + end + + private + def entry_params + params.require(:entry) + .permit(:name, :enriched_name, :date, :amount, :currency, :notes) + end +end diff --git a/app/helpers/account/entries_helper.rb b/app/helpers/entries_helper.rb similarity index 78% rename from app/helpers/account/entries_helper.rb rename to app/helpers/entries_helper.rb index 5fac75cc..e198e6ee 100644 --- a/app/helpers/account/entries_helper.rb +++ b/app/helpers/entries_helper.rb @@ -1,8 +1,8 @@ -module Account::EntriesHelper +module EntriesHelper def entries_by_date(entries, totals: false) transfer_groups = entries.group_by do |entry| # Only check for transfer if it's a transaction - next nil unless entry.entryable_type == "Account::Transaction" + next nil unless entry.entryable_type == "Transaction" entry.entryable.transfer&.id end @@ -12,7 +12,7 @@ module Account::EntriesHelper grouped_entries else grouped_entries.reject do |e| - e.entryable_type == "Account::Transaction" && + e.entryable_type == "Transaction" && e.entryable.transfer_as_inflow.present? end end @@ -25,7 +25,7 @@ module Account::EntriesHelper next if content.blank? - render partial: "account/entries/entry_group", locals: { date:, entries: grouped_entries, content:, totals: } + render partial: "entries/entry_group", locals: { date:, entries: grouped_entries, content:, totals: } end.compact.join.html_safe end diff --git a/app/models/account.rb b/app/models/account.rb index b93a13e1..42a9d67f 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -7,11 +7,11 @@ class Account < ApplicationRecord belongs_to :import, optional: true has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping" - has_many :entries, dependent: :destroy, class_name: "Account::Entry" - has_many :transactions, through: :entries, source: :entryable, source_type: "Account::Transaction" - has_many :valuations, through: :entries, source: :entryable, source_type: "Account::Valuation" - has_many :trades, through: :entries, source: :entryable, source_type: "Account::Trade" - has_many :holdings, dependent: :destroy, class_name: "Account::Holding" + has_many :entries, dependent: :destroy + has_many :transactions, through: :entries, source: :entryable, source_type: "Transaction" + has_many :valuations, through: :entries, source: :entryable, source_type: "Valuation" + has_many :trades, through: :entries, source: :entryable, source_type: "Trade" + has_many :holdings, dependent: :destroy has_many :balances, dependent: :destroy monetize :balance, :cash_balance @@ -43,14 +43,14 @@ class Account < ApplicationRecord date: Date.current, amount: account.balance, currency: account.currency, - entryable: Account::Valuation.new + entryable: Valuation.new ) account.entries.build( name: "Initial Balance", date: 1.day.ago.to_date, amount: initial_balance, currency: account.currency, - entryable: Account::Valuation.new + entryable: Valuation.new ) account.save! @@ -113,7 +113,7 @@ class Account < ApplicationRecord end def update_balance!(balance) - valuation = entries.account_valuations.find_by(date: Date.current) + valuation = entries.valuations.find_by(date: Date.current) if valuation valuation.update! amount: balance @@ -123,7 +123,7 @@ class Account < ApplicationRecord name: "Balance update", amount: balance, currency: currency, - entryable: Account::Valuation.new + entryable: Valuation.new end end @@ -148,7 +148,7 @@ class Account < ApplicationRecord end def first_valuation - entries.account_valuations.order(:date).first + entries.valuations.order(:date).first end def first_valuation_amount diff --git a/app/models/account/chartable.rb b/app/models/account/chartable.rb index bac6a50e..d9a6c44b 100644 --- a/app/models/account/chartable.rb +++ b/app/models/account/chartable.rb @@ -7,7 +7,7 @@ module Account::Chartable series_interval = interval || period.interval - balances = Account::Balance.find_by_sql([ + balances = Balance.find_by_sql([ balance_series_query, { start_date: period.start_date, @@ -61,7 +61,7 @@ module Account::Chartable COUNT(CASE WHEN accounts.currency <> :target_currency AND er.rate IS NULL THEN 1 END) as missing_rates FROM dates d LEFT JOIN accounts ON accounts.id IN (#{all.select(:id).to_sql}) - LEFT JOIN account_balances ab ON ( + LEFT JOIN balances ab ON ( ab.date = d.date AND ab.currency = accounts.currency AND ab.account_id = accounts.id diff --git a/app/models/account/enrichable.rb b/app/models/account/enrichable.rb index 260aec5a..f5f175b4 100644 --- a/app/models/account/enrichable.rb +++ b/app/models/account/enrichable.rb @@ -2,9 +2,9 @@ module Account::Enrichable extend ActiveSupport::Concern def enrich_data - total_unenriched = entries.account_transactions - .joins("JOIN account_transactions at ON at.id = account_entries.entryable_id AND account_entries.entryable_type = 'Account::Transaction'") - .where("account_entries.enriched_at IS NULL OR at.merchant_id IS NULL OR at.category_id IS NULL") + total_unenriched = entries.transactions + .joins("JOIN transactions at ON at.id = entries.entryable_id AND entries.entryable_type = 'Transaction'") + .where("entries.enriched_at IS NULL OR at.merchant_id IS NULL OR at.category_id IS NULL") .count if total_unenriched > 0 @@ -63,7 +63,7 @@ module Account::Enrichable transactions.active .includes(:merchant, :category) .where( - "account_entries.enriched_at IS NULL", + "entries.enriched_at IS NULL", "OR merchant_id IS NULL", "OR category_id IS NULL" ) diff --git a/app/models/account/valuation.rb b/app/models/account/valuation.rb deleted file mode 100644 index 219ecd90..00000000 --- a/app/models/account/valuation.rb +++ /dev/null @@ -1,3 +0,0 @@ -class Account::Valuation < ApplicationRecord - include Account::Entryable -end diff --git a/app/models/account_import.rb b/app/models/account_import.rb index 98e7e0d0..96fdfd47 100644 --- a/app/models/account_import.rb +++ b/app/models/account_import.rb @@ -20,7 +20,7 @@ class AccountImport < Import currency: row.currency, date: Date.current, name: "Imported account value", - entryable: Account::Valuation.new + entryable: Valuation.new ) end end diff --git a/app/models/account/balance.rb b/app/models/balance.rb similarity index 85% rename from app/models/account/balance.rb rename to app/models/balance.rb index 5d4e3710..90c4df41 100644 --- a/app/models/account/balance.rb +++ b/app/models/balance.rb @@ -1,4 +1,4 @@ -class Account::Balance < ApplicationRecord +class Balance < ApplicationRecord include Monetizable belongs_to :account diff --git a/app/models/account/balance/base_calculator.rb b/app/models/balance/base_calculator.rb similarity index 84% rename from app/models/account/balance/base_calculator.rb rename to app/models/balance/base_calculator.rb index 7acb51e8..2d01dfe7 100644 --- a/app/models/account/balance/base_calculator.rb +++ b/app/models/balance/base_calculator.rb @@ -1,4 +1,4 @@ -class Account::Balance::BaseCalculator +class Balance::BaseCalculator attr_reader :account def initialize(account) @@ -13,11 +13,11 @@ class Account::Balance::BaseCalculator private def sync_cache - @sync_cache ||= Account::Balance::SyncCache.new(account) + @sync_cache ||= Balance::SyncCache.new(account) end def build_balance(date, cash_balance, holdings_value) - Account::Balance.new( + Balance.new( account_id: account.id, date: date, balance: holdings_value + cash_balance, diff --git a/app/models/account/balance/forward_calculator.rb b/app/models/balance/forward_calculator.rb similarity index 90% rename from app/models/account/balance/forward_calculator.rb rename to app/models/balance/forward_calculator.rb index 503e5b79..d024d2c6 100644 --- a/app/models/account/balance/forward_calculator.rb +++ b/app/models/balance/forward_calculator.rb @@ -1,4 +1,4 @@ -class Account::Balance::ForwardCalculator < Account::Balance::BaseCalculator +class Balance::ForwardCalculator < Balance::BaseCalculator private def calculate_balances current_cash_balance = 0 diff --git a/app/models/account/balance/reverse_calculator.rb b/app/models/balance/reverse_calculator.rb similarity index 92% rename from app/models/account/balance/reverse_calculator.rb rename to app/models/balance/reverse_calculator.rb index 151f4036..4c124ced 100644 --- a/app/models/account/balance/reverse_calculator.rb +++ b/app/models/balance/reverse_calculator.rb @@ -1,4 +1,4 @@ -class Account::Balance::ReverseCalculator < Account::Balance::BaseCalculator +class Balance::ReverseCalculator < Balance::BaseCalculator private def calculate_balances current_cash_balance = account.cash_balance diff --git a/app/models/account/balance/sync_cache.rb b/app/models/balance/sync_cache.rb similarity index 83% rename from app/models/account/balance/sync_cache.rb rename to app/models/balance/sync_cache.rb index 1fb7ea7f..aed2b64e 100644 --- a/app/models/account/balance/sync_cache.rb +++ b/app/models/balance/sync_cache.rb @@ -1,10 +1,10 @@ -class Account::Balance::SyncCache +class Balance::SyncCache def initialize(account) @account = account end def get_valuation(date) - converted_entries.find { |e| e.date == date && e.account_valuation? } + converted_entries.find { |e| e.date == date && e.valuation? } end def get_holdings(date) @@ -12,7 +12,7 @@ class Account::Balance::SyncCache end def get_entries(date) - converted_entries.select { |e| e.date == date && (e.account_transaction? || e.account_trade?) } + converted_entries.select { |e| e.date == date && (e.transaction? || e.trade?) } end private diff --git a/app/models/account/balance/syncer.rb b/app/models/balance/syncer.rb similarity index 86% rename from app/models/account/balance/syncer.rb rename to app/models/balance/syncer.rb index 7aeaebda..362b87aa 100644 --- a/app/models/account/balance/syncer.rb +++ b/app/models/balance/syncer.rb @@ -1,4 +1,4 @@ -class Account::Balance::Syncer +class Balance::Syncer attr_reader :account, :strategy def initialize(account, strategy:) @@ -7,7 +7,7 @@ class Account::Balance::Syncer end def sync_balances - Account::Balance.transaction do + Balance.transaction do sync_holdings calculate_balances @@ -26,7 +26,7 @@ class Account::Balance::Syncer private def sync_holdings - @holdings = Account::Holding::Syncer.new(account, strategy: strategy).sync_holdings + @holdings = Holding::Syncer.new(account, strategy: strategy).sync_holdings end def update_account_info @@ -63,9 +63,9 @@ class Account::Balance::Syncer def calculator if strategy == :reverse - Account::Balance::ReverseCalculator.new(account) + Balance::ReverseCalculator.new(account) else - Account::Balance::ForwardCalculator.new(account) + Balance::ForwardCalculator.new(account) end end end diff --git a/app/models/account/balance_trend_calculator.rb b/app/models/balance/trend_calculator.rb similarity index 95% rename from app/models/account/balance_trend_calculator.rb rename to app/models/balance/trend_calculator.rb index a9bfeb30..5fb8b406 100644 --- a/app/models/account/balance_trend_calculator.rb +++ b/app/models/balance/trend_calculator.rb @@ -2,7 +2,7 @@ # In most cases, this is sufficient. However, for the "Activity View", we need to show intraday balances # to show users how each entry affects their balances. This class calculates intraday balances by # interpolating between end-of-day balances. -class Account::BalanceTrendCalculator +class Balance::TrendCalculator BalanceTrend = Struct.new(:trend, :cash, keyword_init: true) class << self @@ -48,12 +48,12 @@ class Account::BalanceTrendCalculator todays_entries = entries.select { |e| e.date == entry.date } todays_entries.each_with_index do |e, idx| - if e.account_valuation? + if e.valuation? current_balance = e.amount current_cash_balance = e.amount else multiplier = e.account.liability? ? 1 : -1 - balance_change = e.account_trade? ? 0 : multiplier * e.amount + balance_change = e.trade? ? 0 : multiplier * e.amount cash_change = multiplier * e.amount current_balance = prior_balance + balance_change diff --git a/app/models/category.rb b/app/models/category.rb index 2adc7788..56fd3a63 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -1,5 +1,5 @@ class Category < ApplicationRecord - has_many :transactions, dependent: :nullify, class_name: "Account::Transaction" + has_many :transactions, dependent: :nullify, class_name: "Transaction" has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping" belongs_to :family diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb index 9d62e684..181b9806 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -361,7 +361,7 @@ class Demo::Generator unknown = Security.find_by(ticker: "UNKNOWN") # Buy 20 shares of the unknown stock to simulate a stock where we can't fetch security prices - account.entries.create! date: 10.days.ago.to_date, amount: 100, currency: "USD", name: "Buy unknown stock", entryable: Account::Trade.new(qty: 20, price: 5, security: unknown, currency: "USD") + account.entries.create! date: 10.days.ago.to_date, amount: 100, currency: "USD", name: "Buy unknown stock", entryable: Trade.new(qty: 20, price: 5, security: unknown, currency: "USD") trades = [ { security: aapl, qty: 20 }, { security: msft, qty: 10 }, { security: aapl, qty: -5 }, @@ -382,7 +382,7 @@ class Demo::Generator amount: qty * price, currency: "USD", name: name_prefix + "#{qty} shares of #{security.ticker}", - entryable: Account::Trade.new(qty: qty, price: price, currency: "USD", security: security) + entryable: Trade.new(qty: qty, price: price, currency: "USD", security: security) end end @@ -450,20 +450,20 @@ class Demo::Generator entry_defaults = { date: Faker::Number.between(from: 0, to: 730).days.ago.to_date, currency: "USD", - entryable: Account::Transaction.new(transaction_attributes) + entryable: Transaction.new(transaction_attributes) } - Account::Entry.create! entry_defaults.merge(entry_attributes) + Entry.create! entry_defaults.merge(entry_attributes) end def create_valuation!(account, date, amount) - Account::Entry.create! \ + Entry.create! \ account: account, date: date, amount: amount, currency: "USD", name: "Balance update", - entryable: Account::Valuation.new + entryable: Valuation.new end def random_family_record(model, family) diff --git a/app/models/account/entry.rb b/app/models/entry.rb similarity index 78% rename from app/models/account/entry.rb rename to app/models/entry.rb index 4e6292a1..0f35cd39 100644 --- a/app/models/account/entry.rb +++ b/app/models/entry.rb @@ -1,4 +1,4 @@ -class Account::Entry < ApplicationRecord +class Entry < ApplicationRecord include Monetizable monetize :amount @@ -7,11 +7,11 @@ class Account::Entry < ApplicationRecord belongs_to :transfer, optional: true belongs_to :import, optional: true - delegated_type :entryable, types: Account::Entryable::TYPES, dependent: :destroy + delegated_type :entryable, types: Entryable::TYPES, dependent: :destroy accepts_nested_attributes_for :entryable validates :date, :name, :amount, :currency, presence: true - validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { account_valuation? } + validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { valuation? } validates :date, comparison: { greater_than: -> { min_supported_date } } scope :active, -> { @@ -21,7 +21,7 @@ class Account::Entry < ApplicationRecord scope :chronological, -> { order( date: :asc, - Arel.sql("CASE WHEN account_entries.entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :asc, + Arel.sql("CASE WHEN entries.entryable_type = 'Valuation' THEN 1 ELSE 0 END") => :asc, created_at: :asc ) } @@ -29,7 +29,7 @@ class Account::Entry < ApplicationRecord scope :reverse_chronological, -> { order( date: :desc, - Arel.sql("CASE WHEN account_entries.entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :desc, + Arel.sql("CASE WHEN entries.entryable_type = 'Valuation' THEN 1 ELSE 0 END") => :desc, created_at: :desc ) } @@ -44,7 +44,7 @@ class Account::Entry < ApplicationRecord end def balance_trend(entries, balances) - Account::BalanceTrendCalculator.new(self, entries, balances).trend + Balance::TrendCalculator.new(self, entries, balances).trend end def display_name @@ -53,7 +53,7 @@ class Account::Entry < ApplicationRecord class << self def search(params) - Account::EntrySearch.new(params).build_query(all) + EntrySearch.new(params).build_query(all) end # arbitrary cutoff date to avoid expensive sync operations diff --git a/app/models/account/entry_search.rb b/app/models/entry_search.rb similarity index 74% rename from app/models/account/entry_search.rb rename to app/models/entry_search.rb index b08c338f..bed87613 100644 --- a/app/models/account/entry_search.rb +++ b/app/models/entry_search.rb @@ -1,4 +1,4 @@ -class Account::EntrySearch +class EntrySearch include ActiveModel::Model include ActiveModel::Attributes @@ -16,7 +16,7 @@ class Account::EntrySearch return scope if search.blank? query = scope - query = query.where("account_entries.name ILIKE :search OR account_entries.enriched_name ILIKE :search", + query = query.where("entries.name ILIKE :search OR entries.enriched_name ILIKE :search", search: "%#{ActiveRecord::Base.sanitize_sql_like(search)}%" ) query @@ -26,8 +26,8 @@ class Account::EntrySearch return scope if start_date.blank? && end_date.blank? query = scope - query = query.where("account_entries.date >= ?", start_date) if start_date.present? - query = query.where("account_entries.date <= ?", end_date) if end_date.present? + query = query.where("entries.date >= ?", start_date) if start_date.present? + query = query.where("entries.date <= ?", end_date) if end_date.present? query end @@ -38,11 +38,11 @@ class Account::EntrySearch case amount_operator when "equal" - query = query.where("ABS(ABS(account_entries.amount) - ?) <= 0.01", amount.to_f.abs) + query = query.where("ABS(ABS(entries.amount) - ?) <= 0.01", amount.to_f.abs) when "less" - query = query.where("ABS(account_entries.amount) < ?", amount.to_f.abs) + query = query.where("ABS(entries.amount) < ?", amount.to_f.abs) when "greater" - query = query.where("ABS(account_entries.amount) > ?", amount.to_f.abs) + query = query.where("ABS(entries.amount) > ?", amount.to_f.abs) end query diff --git a/app/models/account/entryable.rb b/app/models/entryable.rb similarity index 50% rename from app/models/account/entryable.rb rename to app/models/entryable.rb index 91df5521..84ab6c12 100644 --- a/app/models/account/entryable.rb +++ b/app/models/entryable.rb @@ -1,7 +1,7 @@ -module Account::Entryable +module Entryable extend ActiveSupport::Concern - TYPES = %w[Account::Valuation Account::Transaction Account::Trade] + TYPES = %w[Valuation Transaction Trade] def self.from_type(entryable_type) entryable_type.presence_in(TYPES).constantize @@ -12,18 +12,18 @@ module Account::Entryable scope :with_entry, -> { joins(:entry) } - scope :active, -> { with_entry.merge(Account::Entry.active) } + scope :active, -> { with_entry.merge(Entry.active) } scope :in_period, ->(period) { - with_entry.where(account_entries: { date: period.start_date..period.end_date }) + with_entry.where(entries: { date: period.start_date..period.end_date }) } scope :reverse_chronological, -> { - with_entry.merge(Account::Entry.reverse_chronological) + with_entry.merge(Entry.reverse_chronological) } scope :chronological, -> { - with_entry.merge(Account::Entry.chronological) + with_entry.merge(Entry.chronological) } end end diff --git a/app/models/family/auto_transfer_matchable.rb b/app/models/family/auto_transfer_matchable.rb index 32fe94b4..388ba5d6 100644 --- a/app/models/family/auto_transfer_matchable.rb +++ b/app/models/family/auto_transfer_matchable.rb @@ -1,12 +1,12 @@ module Family::AutoTransferMatchable def transfer_match_candidates - Account::Entry.select([ + Entry.select([ "inflow_candidates.entryable_id as inflow_transaction_id", "outflow_candidates.entryable_id as outflow_transaction_id", "ABS(inflow_candidates.date - outflow_candidates.date) as date_diff" - ]).from("account_entries inflow_candidates") + ]).from("entries inflow_candidates") .joins(" - JOIN account_entries outflow_candidates ON ( + JOIN entries outflow_candidates ON ( inflow_candidates.amount < 0 AND outflow_candidates.amount > 0 AND inflow_candidates.amount = -outflow_candidates.amount AND @@ -29,7 +29,7 @@ module Family::AutoTransferMatchable .where("inflow_accounts.family_id = ? AND outflow_accounts.family_id = ?", self.id, self.id) .where("inflow_accounts.is_active = true") .where("outflow_accounts.is_active = true") - .where("inflow_candidates.entryable_type = 'Account::Transaction' AND outflow_candidates.entryable_type = 'Account::Transaction'") + .where("inflow_candidates.entryable_type = 'Transaction' AND outflow_candidates.entryable_type = 'Transaction'") .where(existing_transfers: { id: nil }) .order("date_diff ASC") # Closest matches first end diff --git a/app/models/account/holding.rb b/app/models/holding.rb similarity index 79% rename from app/models/account/holding.rb rename to app/models/holding.rb index ba7a7e2d..fb9b001e 100644 --- a/app/models/account/holding.rb +++ b/app/models/holding.rb @@ -1,4 +1,4 @@ -class Account::Holding < ApplicationRecord +class Holding < ApplicationRecord include Monetizable, Gapfillable monetize :amount @@ -27,9 +27,9 @@ class Account::Holding < ApplicationRecord # Basic approximation of cost-basis def avg_cost - avg_cost = account.entries.account_trades - .joins("INNER JOIN account_trades ON account_trades.id = account_entries.entryable_id") - .where("account_trades.security_id = ? AND account_trades.qty > 0 AND account_entries.date <= ?", security.id, date) + avg_cost = account.entries.trades + .joins("INNER JOIN trades ON trades.id = entries.entryable_id") + .where("trades.security_id = ? AND trades.qty > 0 AND entries.date <= ?", security.id, date) .average(:price) Money.new(avg_cost || price, currency) diff --git a/app/models/account/holding/base_calculator.rb b/app/models/holding/base_calculator.rb similarity index 88% rename from app/models/account/holding/base_calculator.rb rename to app/models/holding/base_calculator.rb index 4359e9ab..47178d8b 100644 --- a/app/models/account/holding/base_calculator.rb +++ b/app/models/holding/base_calculator.rb @@ -1,4 +1,4 @@ -class Account::Holding::BaseCalculator +class Holding::BaseCalculator attr_reader :account def initialize(account) @@ -8,13 +8,13 @@ class Account::Holding::BaseCalculator def calculate Rails.logger.tagged(self.class.name) do holdings = calculate_holdings - Account::Holding.gapfill(holdings) + Holding.gapfill(holdings) end end private def portfolio_cache - @portfolio_cache ||= Account::Holding::PortfolioCache.new(account) + @portfolio_cache ||= Holding::PortfolioCache.new(account) end def empty_portfolio @@ -49,7 +49,7 @@ class Account::Holding::BaseCalculator next end - Account::Holding.new( + Holding.new( account_id: account.id, security_id: security_id, date: date, diff --git a/app/models/account/holding/forward_calculator.rb b/app/models/holding/forward_calculator.rb similarity index 77% rename from app/models/account/holding/forward_calculator.rb rename to app/models/holding/forward_calculator.rb index afb6b71f..d2f2e8d7 100644 --- a/app/models/account/holding/forward_calculator.rb +++ b/app/models/holding/forward_calculator.rb @@ -1,7 +1,7 @@ -class Account::Holding::ForwardCalculator < Account::Holding::BaseCalculator +class Holding::ForwardCalculator < Holding::BaseCalculator private def portfolio_cache - @portfolio_cache ||= Account::Holding::PortfolioCache.new(account) + @portfolio_cache ||= Holding::PortfolioCache.new(account) end def calculate_holdings diff --git a/app/models/account/holding/gapfillable.rb b/app/models/holding/gapfillable.rb similarity index 91% rename from app/models/account/holding/gapfillable.rb rename to app/models/holding/gapfillable.rb index e2462a6f..45c05089 100644 --- a/app/models/account/holding/gapfillable.rb +++ b/app/models/holding/gapfillable.rb @@ -1,4 +1,4 @@ -module Account::Holding::Gapfillable +module Holding::Gapfillable extend ActiveSupport::Concern class_methods do @@ -19,7 +19,7 @@ module Account::Holding::Gapfillable previous_holding = holding else # Create a new holding based on the previous day's data - filled_holdings << Account::Holding.new( + filled_holdings << Holding.new( account: previous_holding.account, security: previous_holding.security, date: date, diff --git a/app/models/account/holding/portfolio_cache.rb b/app/models/holding/portfolio_cache.rb similarity index 98% rename from app/models/account/holding/portfolio_cache.rb rename to app/models/holding/portfolio_cache.rb index 224d0b83..e8d3fcec 100644 --- a/app/models/account/holding/portfolio_cache.rb +++ b/app/models/holding/portfolio_cache.rb @@ -1,4 +1,4 @@ -class Account::Holding::PortfolioCache +class Holding::PortfolioCache attr_reader :account, :use_holdings class SecurityNotFound < StandardError @@ -49,7 +49,7 @@ class Account::Holding::PortfolioCache PriceWithPriority = Data.define(:price, :priority) def trades - @trades ||= account.entries.includes(entryable: :security).account_trades.chronological.to_a + @trades ||= account.entries.includes(entryable: :security).trades.chronological.to_a end def holdings diff --git a/app/models/account/holding/reverse_calculator.rb b/app/models/holding/reverse_calculator.rb similarity index 87% rename from app/models/account/holding/reverse_calculator.rb rename to app/models/holding/reverse_calculator.rb index d3677c88..f3996e5f 100644 --- a/app/models/account/holding/reverse_calculator.rb +++ b/app/models/holding/reverse_calculator.rb @@ -1,10 +1,10 @@ -class Account::Holding::ReverseCalculator < Account::Holding::BaseCalculator +class Holding::ReverseCalculator < Holding::BaseCalculator private # Reverse calculators will use the existing holdings as a source of security ids and prices # since it is common for a provider to supply "current day" holdings but not all the historical # trades that make up those holdings. def portfolio_cache - @portfolio_cache ||= Account::Holding::PortfolioCache.new(account, use_holdings: true) + @portfolio_cache ||= Holding::PortfolioCache.new(account, use_holdings: true) end def calculate_holdings diff --git a/app/models/account/holding/syncer.rb b/app/models/holding/syncer.rb similarity index 84% rename from app/models/account/holding/syncer.rb rename to app/models/holding/syncer.rb index bfccd6f0..345f2a3f 100644 --- a/app/models/account/holding/syncer.rb +++ b/app/models/holding/syncer.rb @@ -1,4 +1,4 @@ -class Account::Holding::Syncer +class Holding::Syncer def initialize(account, strategy:) @account = account @strategy = strategy @@ -36,7 +36,7 @@ class Account::Holding::Syncer end def purge_stale_holdings - portfolio_security_ids = account.entries.account_trades.map { |entry| entry.entryable.security_id }.uniq + portfolio_security_ids = account.entries.trades.map { |entry| entry.entryable.security_id }.uniq # If there are no securities in the portfolio, delete all holdings if portfolio_security_ids.empty? @@ -50,9 +50,9 @@ class Account::Holding::Syncer def calculator if strategy == :reverse - Account::Holding::ReverseCalculator.new(account) + Holding::ReverseCalculator.new(account) else - Account::Holding::ForwardCalculator.new(account) + Holding::ForwardCalculator.new(account) end end end diff --git a/app/models/import.rb b/app/models/import.rb index 662b4cee..bb367cf9 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -34,7 +34,7 @@ class Import < ApplicationRecord has_many :rows, dependent: :destroy has_many :mappings, dependent: :destroy has_many :accounts, dependent: :destroy - has_many :entries, dependent: :destroy, class_name: "Account::Entry" + has_many :entries, dependent: :destroy class << self def parse_csv_str(csv_str, col_sep: ",") diff --git a/app/models/import/row.rb b/app/models/import/row.rb index 622a9d0a..350d8084 100644 --- a/app/models/import/row.rb +++ b/app/models/import/row.rb @@ -63,7 +63,7 @@ class Import::Row < ApplicationRecord return end - min_date = Account::Entry.min_supported_date + min_date = Entry.min_supported_date max_date = Date.current if parsed_date < min_date || parsed_date > max_date diff --git a/app/models/income_statement/base_query.rb b/app/models/income_statement/base_query.rb index d2b17b81..ef1c8a99 100644 --- a/app/models/income_statement/base_query.rb +++ b/app/models/income_statement/base_query.rb @@ -11,13 +11,13 @@ module IncomeStatement::BaseQuery COUNT(ae.id) as transactions_count, BOOL_OR(ae.currency <> :target_currency AND er.rate IS NULL) as missing_exchange_rates FROM (#{transactions_scope.to_sql}) at - JOIN account_entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Account::Transaction' + JOIN entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Transaction' LEFT JOIN categories c ON c.id = at.category_id LEFT JOIN ( SELECT t.*, t.id as transfer_id, a.accountable_type FROM transfers t - JOIN account_entries ae ON ae.entryable_id = t.inflow_transaction_id - AND ae.entryable_type = 'Account::Transaction' + JOIN entries ae ON ae.entryable_id = t.inflow_transaction_id + AND ae.entryable_type = 'Transaction' JOIN accounts a ON a.id = ae.account_id ) transfer_info ON ( transfer_info.inflow_transaction_id = at.id OR diff --git a/app/models/merchant.rb b/app/models/merchant.rb index e363f6aa..030d9409 100644 --- a/app/models/merchant.rb +++ b/app/models/merchant.rb @@ -1,5 +1,5 @@ class Merchant < ApplicationRecord - has_many :transactions, dependent: :nullify, class_name: "Account::Transaction" + has_many :transactions, dependent: :nullify, class_name: "Transaction" belongs_to :family validates :name, :color, :family, presence: true diff --git a/app/models/mint_import.rb b/app/models/mint_import.rb index 66e3bb69..da9ced2a 100644 --- a/app/models/mint_import.rb +++ b/app/models/mint_import.rb @@ -35,7 +35,7 @@ class MintImport < Import name: row.name, currency: row.currency, notes: row.notes, - entryable: Account::Transaction.new(category: category, tags: tags), + entryable: Transaction.new(category: category, tags: tags), import: self entry.save! diff --git a/app/models/plaid_account.rb b/app/models/plaid_account.rb index e0e71f67..4f2b923c 100644 --- a/app/models/plaid_account.rb +++ b/app/models/plaid_account.rb @@ -87,7 +87,7 @@ class PlaidAccount < ApplicationRecord t.amount = plaid_txn.amount t.currency = plaid_txn.iso_currency_code t.date = plaid_txn.date - t.entryable = Account::Transaction.new( + t.entryable = Transaction.new( category: get_category(plaid_txn.personal_finance_category.primary), merchant: get_merchant(plaid_txn.merchant_name) ) @@ -120,7 +120,7 @@ class PlaidAccount < ApplicationRecord e.amount = loan_data.origination_principal_amount e.currency = account.currency e.date = loan_data.origination_date - e.entryable = Account::Valuation.new + e.entryable = Valuation.new end end end diff --git a/app/models/plaid_investment_sync.rb b/app/models/plaid_investment_sync.rb index bcb1f330..489b0ca1 100644 --- a/app/models/plaid_investment_sync.rb +++ b/app/models/plaid_investment_sync.rb @@ -31,7 +31,7 @@ class PlaidInvestmentSync t.amount = transaction.amount t.currency = transaction.iso_currency_code t.date = transaction.date - t.entryable = Account::Transaction.new + t.entryable = Transaction.new end else new_transaction = plaid_account.account.entries.find_or_create_by!(plaid_id: transaction.investment_transaction_id) do |t| @@ -39,7 +39,7 @@ class PlaidInvestmentSync t.amount = transaction.quantity * transaction.price t.currency = transaction.iso_currency_code t.date = transaction.date - t.entryable = Account::Trade.new( + t.entryable = Trade.new( security: security, qty: transaction.quantity, price: transaction.price, diff --git a/app/models/property.rb b/app/models/property.rb index b30a1071..42d2979e 100644 --- a/app/models/property.rb +++ b/app/models/property.rb @@ -44,6 +44,6 @@ class Property < ApplicationRecord private def first_valuation_amount - account.entries.account_valuations.order(:date).first&.amount_money || account.balance_money + account.entries.valuations.order(:date).first&.amount_money || account.balance_money end end diff --git a/app/models/rejected_transfer.rb b/app/models/rejected_transfer.rb index 9d1a1ce4..4c66d44d 100644 --- a/app/models/rejected_transfer.rb +++ b/app/models/rejected_transfer.rb @@ -1,4 +1,4 @@ class RejectedTransfer < ApplicationRecord - belongs_to :inflow_transaction, class_name: "Account::Transaction" - belongs_to :outflow_transaction, class_name: "Account::Transaction" + belongs_to :inflow_transaction, class_name: "Transaction" + belongs_to :outflow_transaction, class_name: "Transaction" end diff --git a/app/models/security.rb b/app/models/security.rb index 30abbe85..0adabd8a 100644 --- a/app/models/security.rb +++ b/app/models/security.rb @@ -3,7 +3,7 @@ class Security < ApplicationRecord before_save :upcase_ticker - has_many :trades, dependent: :nullify, class_name: "Account::Trade" + has_many :trades, dependent: :nullify, class_name: "Trade" has_many :prices, dependent: :destroy validates :ticker, presence: true diff --git a/app/models/tag.rb b/app/models/tag.rb index 6b2fb67b..c5bdc0bc 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -1,7 +1,7 @@ class Tag < ApplicationRecord belongs_to :family has_many :taggings, dependent: :destroy - has_many :transactions, through: :taggings, source: :taggable, source_type: "Account::Transaction" + has_many :transactions, through: :taggings, source: :taggable, source_type: "Transaction" has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping" validates :name, presence: true, uniqueness: { scope: :family } diff --git a/app/models/account/trade.rb b/app/models/trade.rb similarity index 83% rename from app/models/account/trade.rb rename to app/models/trade.rb index a683b2ca..5d71d978 100644 --- a/app/models/account/trade.rb +++ b/app/models/trade.rb @@ -1,5 +1,5 @@ -class Account::Trade < ApplicationRecord - include Account::Entryable, Monetizable +class Trade < ApplicationRecord + include Entryable, Monetizable monetize :price diff --git a/app/models/account/trade_builder.rb b/app/models/trade_builder.rb similarity index 94% rename from app/models/account/trade_builder.rb rename to app/models/trade_builder.rb index c632f272..9b7e0471 100644 --- a/app/models/account/trade_builder.rb +++ b/app/models/trade_builder.rb @@ -1,4 +1,4 @@ -class Account::TradeBuilder +class TradeBuilder include ActiveModel::Model attr_accessor :account, :date, :amount, :currency, :qty, @@ -46,7 +46,7 @@ class Account::TradeBuilder date: date, amount: signed_amount, currency: currency, - entryable: Account::Trade.new( + entryable: Trade.new( qty: signed_qty, price: price, currency: currency, @@ -74,7 +74,7 @@ class Account::TradeBuilder date: date, amount: signed_amount, currency: currency, - entryable: Account::Transaction.new + entryable: Transaction.new ) end end @@ -85,7 +85,7 @@ class Account::TradeBuilder date: date, amount: signed_amount, currency: currency, - entryable: Account::Transaction.new + entryable: Transaction.new ) end diff --git a/app/models/trade_import.rb b/app/models/trade_import.rb index db917f04..d126fec0 100644 --- a/app/models/trade_import.rb +++ b/app/models/trade_import.rb @@ -16,12 +16,12 @@ class TradeImport < Import exchange_operating_mic: row.exchange_operating_mic ) - Account::Trade.new( + Trade.new( security: security, qty: row.qty, currency: row.currency.presence || mapped_account.currency, price: row.price, - entry: Account::Entry.new( + entry: Entry.new( account: mapped_account, date: row.date_iso, amount: row.signed_amount, @@ -31,7 +31,7 @@ class TradeImport < Import ), ) end - Account::Trade.import!(trades, recursive: true) + Trade.import!(trades, recursive: true) end end diff --git a/app/models/account/transaction.rb b/app/models/transaction.rb similarity index 64% rename from app/models/account/transaction.rb rename to app/models/transaction.rb index e31a5607..fb5fb0df 100644 --- a/app/models/account/transaction.rb +++ b/app/models/transaction.rb @@ -1,5 +1,5 @@ -class Account::Transaction < ApplicationRecord - include Account::Entryable, Transferable, Provided +class Transaction < ApplicationRecord + include Entryable, Transferable, Provided belongs_to :category, optional: true belongs_to :merchant, optional: true @@ -11,7 +11,7 @@ class Account::Transaction < ApplicationRecord class << self def search(params) - Account::TransactionSearch.new(params).build_query(all) + Search.new(params).build_query(all) end end end diff --git a/app/models/account/transaction/provided.rb b/app/models/transaction/provided.rb similarity index 89% rename from app/models/account/transaction/provided.rb rename to app/models/transaction/provided.rb index 4bae0ab4..b4210e0a 100644 --- a/app/models/account/transaction/provided.rb +++ b/app/models/transaction/provided.rb @@ -1,4 +1,4 @@ -module Account::Transaction::Provided +module Transaction::Provided extend ActiveSupport::Concern def fetch_enrichment_info diff --git a/app/models/account/transaction_search.rb b/app/models/transaction/search.rb similarity index 77% rename from app/models/account/transaction_search.rb rename to app/models/transaction/search.rb index 215c6a98..067050f4 100644 --- a/app/models/account/transaction_search.rb +++ b/app/models/transaction/search.rb @@ -1,4 +1,4 @@ -class Account::TransactionSearch +class Transaction::Search include ActiveModel::Model include ActiveModel::Attributes @@ -22,10 +22,10 @@ class Account::TransactionSearch query = apply_type_filter(query, types) query = apply_merchant_filter(query, merchants) query = apply_tag_filter(query, tags) - query = Account::EntrySearch.apply_search_filter(query, search) - query = Account::EntrySearch.apply_date_filters(query, start_date, end_date) - query = Account::EntrySearch.apply_amount_filter(query, amount, amount_operator) - query = Account::EntrySearch.apply_accounts_filter(query, accounts, account_ids) + query = EntrySearch.apply_search_filter(query, search) + query = EntrySearch.apply_date_filters(query, start_date, end_date) + query = EntrySearch.apply_amount_filter(query, amount, amount_operator) + query = EntrySearch.apply_accounts_filter(query, accounts, account_ids) query end @@ -36,12 +36,12 @@ class Account::TransactionSearch LEFT JOIN ( SELECT t.*, t.id as transfer_id, a.accountable_type FROM transfers t - JOIN account_entries ae ON ae.entryable_id = t.inflow_transaction_id - AND ae.entryable_type = 'Account::Transaction' + JOIN entries ae ON ae.entryable_id = t.inflow_transaction_id + AND ae.entryable_type = 'Transaction' JOIN accounts a ON a.id = ae.account_id ) transfer_info ON ( - transfer_info.inflow_transaction_id = account_transactions.id OR - transfer_info.outflow_transaction_id = account_transactions.id + transfer_info.inflow_transaction_id = transactions.id OR + transfer_info.outflow_transaction_id = transactions.id ) SQL end @@ -68,8 +68,8 @@ class Account::TransactionSearch return query if types.sort == [ "expense", "income", "transfer" ] transfer_condition = "transfer_info.transfer_id IS NOT NULL" - expense_condition = "account_entries.amount >= 0" - income_condition = "account_entries.amount <= 0" + expense_condition = "entries.amount >= 0" + income_condition = "entries.amount <= 0" condition = case types.sort when [ "transfer" ] diff --git a/app/models/account/transaction/transferable.rb b/app/models/transaction/transferable.rb similarity index 96% rename from app/models/account/transaction/transferable.rb rename to app/models/transaction/transferable.rb index de0b70cb..d839047a 100644 --- a/app/models/account/transaction/transferable.rb +++ b/app/models/transaction/transferable.rb @@ -1,4 +1,4 @@ -module Account::Transaction::Transferable +module Transaction::Transferable extend ActiveSupport::Concern included do diff --git a/app/models/transaction_import.rb b/app/models/transaction_import.rb index cf3f6e12..dd44cca4 100644 --- a/app/models/transaction_import.rb +++ b/app/models/transaction_import.rb @@ -13,10 +13,10 @@ class TransactionImport < Import category = mappings.categories.mappable_for(row.category) tags = row.tags_list.map { |tag| mappings.tags.mappable_for(tag) }.compact - Account::Transaction.new( + Transaction.new( category: category, tags: tags, - entry: Account::Entry.new( + entry: Entry.new( account: mapped_account, date: row.date_iso, amount: row.signed_amount, @@ -28,7 +28,7 @@ class TransactionImport < Import ) end - Account::Transaction.import!(transactions, recursive: true) + Transaction.import!(transactions, recursive: true) end end diff --git a/app/models/transfer.rb b/app/models/transfer.rb index 3cb1f07b..d681d581 100644 --- a/app/models/transfer.rb +++ b/app/models/transfer.rb @@ -1,6 +1,6 @@ class Transfer < ApplicationRecord - belongs_to :inflow_transaction, class_name: "Account::Transaction" - belongs_to :outflow_transaction, class_name: "Account::Transaction" + belongs_to :inflow_transaction, class_name: "Transaction" + belongs_to :outflow_transaction, class_name: "Transaction" enum :status, { pending: "pending", confirmed: "confirmed" } @@ -23,22 +23,22 @@ class Transfer < ApplicationRecord end new( - inflow_transaction: Account::Transaction.new( + inflow_transaction: Transaction.new( entry: to_account.entries.build( amount: converted_amount.amount.abs * -1, currency: converted_amount.currency.iso_code, date: date, name: "Transfer from #{from_account.name}", - entryable: Account::Transaction.new + entryable: Transaction.new ) ), - outflow_transaction: Account::Transaction.new( + outflow_transaction: Transaction.new( entry: from_account.entries.build( amount: amount.abs, currency: from_account.currency, date: date, name: "Transfer to #{to_account.name}", - entryable: Account::Transaction.new + entryable: Transaction.new ) ), status: "confirmed" diff --git a/app/models/valuation.rb b/app/models/valuation.rb new file mode 100644 index 00000000..6d1d2b4b --- /dev/null +++ b/app/models/valuation.rb @@ -0,0 +1,3 @@ +class Valuation < ApplicationRecord + include Entryable +end diff --git a/app/models/vehicle.rb b/app/models/vehicle.rb index 6ba19540..255e11d6 100644 --- a/app/models/vehicle.rb +++ b/app/models/vehicle.rb @@ -31,6 +31,6 @@ class Vehicle < ApplicationRecord private def first_valuation_amount - account.entries.account_valuations.order(:date).first&.amount_money || account.balance_money + account.entries.valuations.order(:date).first&.amount_money || account.balance_money end end diff --git a/app/views/account/transactions/_header.html.erb b/app/views/account/transactions/_header.html.erb deleted file mode 100644 index 83f9d1b6..00000000 --- a/app/views/account/transactions/_header.html.erb +++ /dev/null @@ -1,23 +0,0 @@ -<%# locals: (entry:) %> - -<%= tag.header class: "mb-4 space-y-1", id: dom_id(entry, :header) do %> -
      -

      - - <%= format_money -entry.amount_money %> - - - - <%= entry.currency %> - -

      - - <% if entry.account_transaction.transfer? %> - <%= lucide_icon "arrow-left-right", class: "text-secondary mt-1 w-5 h-5" %> - <% end %> -
      - - - <%= I18n.l(entry.date, format: :long) %> - -<% end %> diff --git a/app/views/account/transactions/new.html.erb b/app/views/account/transactions/new.html.erb deleted file mode 100644 index 12c8f2f2..00000000 --- a/app/views/account/transactions/new.html.erb +++ /dev/null @@ -1,3 +0,0 @@ -<%= modal_form_wrapper title: t(".new_transaction") do %> - <%= render "form", entry: @entry %> -<% end %> diff --git a/app/views/accounts/show/_activity.html.erb b/app/views/accounts/show/_activity.html.erb index 88d9f78c..db399472 100644 --- a/app/views/accounts/show/_activity.html.erb +++ b/app/views/accounts/show/_activity.html.erb @@ -11,13 +11,13 @@ <%= tag.span t(".new") %>
    diff --git a/app/views/budgets/_budgeted_summary.html.erb b/app/views/budgets/_budgeted_summary.html.erb index ce55db5d..41400860 100644 --- a/app/views/budgets/_budgeted_summary.html.erb +++ b/app/views/budgets/_budgeted_summary.html.erb @@ -1,7 +1,7 @@ <%# locals: (budget:) %>
    -
    +

    Expected income

    diff --git a/app/views/categories/_form.html.erb b/app/views/categories/_form.html.erb index 1fb8c237..54605e1b 100644 --- a/app/views/categories/_form.html.erb +++ b/app/views/categories/_form.html.erb @@ -30,7 +30,7 @@
  • <% end %> diff --git a/app/views/imports/_table.html.erb b/app/views/imports/_table.html.erb index 8934769f..0339261f 100644 --- a/app/views/imports/_table.html.erb +++ b/app/views/imports/_table.html.erb @@ -16,9 +16,9 @@ <% headers.each_with_index do |header, index| %> + <%= index == headers.length - 1 ? "rounded-tr-md" : "" %> + <%= index < headers.length - 1 ? "border-r border-r-alpha-black-200" : "" %> "> <%= header %> @@ -31,9 +31,9 @@ <% row.each_with_index do |(header, value), col_index| %> + <%= !caption && row_index == rows.length - 1 && col_index == 0 ? "rounded-bl-md" : "" %> + <%= !caption && row_index == rows.length - 1 && col_index == row.length - 1 ? "rounded-br-md" : "" %> "> <%= value %> diff --git a/app/views/imports/new.html.erb b/app/views/imports/new.html.erb index 44fbd8b6..92794acb 100644 --- a/app/views/imports/new.html.erb +++ b/app/views/imports/new.html.erb @@ -21,7 +21,7 @@
    <%= lucide_icon("loader", class: "w-5 h-5 text-orange-500") %>
    - + <%= t(".resume", type: @pending_import.type.titleize) %> @@ -41,7 +41,7 @@
    <%= lucide_icon("file-spreadsheet", class: "w-5 h-5 text-indigo-500") %>
    - + <%= t(".import_transactions") %> @@ -61,7 +61,7 @@
    <%= lucide_icon("square-percent", class: "w-5 h-5 text-yellow-500") %>
    - + <%= t(".import_portfolio") %> @@ -81,7 +81,7 @@
    <%= lucide_icon("building", class: "w-5 h-5 text-violet-500") %>
    - + <%= t(".import_accounts") %> diff --git a/app/views/layouts/auth.html.erb b/app/views/layouts/auth.html.erb index 85adc63a..42930812 100644 --- a/app/views/layouts/auth.html.erb +++ b/app/views/layouts/auth.html.erb @@ -13,11 +13,11 @@ <% if (controller_name == "sessions" && action_name == "new") || (controller_name == "registrations" && action_name == "new") %>
    - <%= link_to new_session_path, + <%= link_to new_session_path, 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 %> <%= t("layouts.auth.sign_in") %> <% end %> - <%= link_to new_registration_path, + <%= link_to new_registration_path, 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 %> <%= t("layouts.auth.sign_up") %> <% end %> diff --git a/app/views/layouts/imports.html.erb b/app/views/layouts/imports.html.erb index c8b63348..8acf9c6c 100644 --- a/app/views/layouts/imports.html.erb +++ b/app/views/layouts/imports.html.erb @@ -1,5 +1,5 @@ <%= render "layouts/shared/htmldoc" do %> -
    +
    <%= link_to content_for(:previous_path) || imports_path do %> <%= lucide_icon "arrow-left", class: "w-5 h-5 text-secondary" %> diff --git a/app/views/layouts/shared/_head.html.erb b/app/views/layouts/shared/_head.html.erb index 2d0ee351..c3b74447 100644 --- a/app/views/layouts/shared/_head.html.erb +++ b/app/views/layouts/shared/_head.html.erb @@ -12,7 +12,7 @@ <%= javascript_importmap_tags %> <%= turbo_refreshes_with method: :morph, scroll: :preserve %> - + diff --git a/app/views/layouts/wizard.html.erb b/app/views/layouts/wizard.html.erb index d5a652f6..a188fe60 100644 --- a/app/views/layouts/wizard.html.erb +++ b/app/views/layouts/wizard.html.erb @@ -1,5 +1,5 @@ <%= render "layouts/shared/htmldoc" do %> -
    +
    <%= link_to content_for(:previous_path) || root_path do %> <%= lucide_icon "arrow-left", class: "w-5 h-5 text-secondary" %> diff --git a/app/views/messages/_chat_form.html.erb b/app/views/messages/_chat_form.html.erb index f2a36b63..10c35e5a 100644 --- a/app/views/messages/_chat_form.html.erb +++ b/app/views/messages/_chat_form.html.erb @@ -32,4 +32,4 @@ <% end %>

    AI responses are informational only and are not financial advice.

    -
    \ No newline at end of file +
    diff --git a/app/views/mfa/new.html.erb b/app/views/mfa/new.html.erb index fdf753c0..0726bf1b 100644 --- a/app/views/mfa/new.html.erb +++ b/app/views/mfa/new.html.erb @@ -11,7 +11,7 @@

    <%= t(".page_title") %>

    <%= settings_section title: t(".scan_title"), subtitle: t(".scan_description") do %>
    -
    +
    <%= generate_mfa_qr_code(Current.user.provisioning_uri) %>
    @@ -26,7 +26,7 @@ readonly autocomplete="off" value="<%= Current.user.otp_secret %>" - class="text-sm bg-gray-50 px-2 py-1 rounded border border-gray-200 w-96 font-mono"> + class="text-sm bg-container px-2 py-1 rounded border border-secondary w-96 font-mono">
    - +
    - +
    <%= lucide_icon "check", class: "w-4 h-4" %> @@ -90,8 +90,6 @@
    - - <%= form.submit t(".submit") %> <% end %> diff --git a/app/views/rule/actions/_action.html.erb b/app/views/rule/actions/_action.html.erb index 359eec33..84bc8b1c 100644 --- a/app/views/rule/actions/_action.html.erb +++ b/app/views/rule/actions/_action.html.erb @@ -12,7 +12,7 @@ <%= form.select :action_type, rule.action_executors.map { |executor| [ executor.label, executor.key ] }, {}, data: { action: "rule--actions#handleActionTypeChange" } %>
    - <%= tag.div class: class_names("min-w-1/2 flex items-center gap-2", "hidden" => !needs_value), + <%= tag.div class: class_names("min-w-1/2 flex items-center gap-2", "hidden" => !needs_value), data: { rule__actions_target: "actionValue" } do %> to <%= form.select :value, action.options || [], {}, disabled: !needs_value %> diff --git a/app/views/rule/conditions/_condition_group.html.erb b/app/views/rule/conditions/_condition_group.html.erb index 3da50432..e00df533 100644 --- a/app/views/rule/conditions/_condition_group.html.erb +++ b/app/views/rule/conditions/_condition_group.html.erb @@ -3,7 +3,7 @@ <% condition = form.object %> <% rule = condition.rule %> -
  • +
  • <%= form.hidden_field :condition_type, value: "compound" %> diff --git a/app/views/rules/_category_rule_cta.html.erb b/app/views/rules/_category_rule_cta.html.erb index 2035a4f0..54f2b752 100644 --- a/app/views/rules/_category_rule_cta.html.erb +++ b/app/views/rules/_category_rule_cta.html.erb @@ -17,4 +17,4 @@ <%= tag.a "Create rule", href: new_rule_path(resource_type: "transaction", action_type: "set_transaction_category", action_value: cta[:category_id]), class: "btn btn--primary", data: { turbo_frame: "modal" } %> <% end %> <% end %> -<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/rules/_form.html.erb b/app/views/rules/_form.html.erb index ce0aaf3a..5a03eff6 100644 --- a/app/views/rules/_form.html.erb +++ b/app/views/rules/_form.html.erb @@ -10,7 +10,7 @@ <% end %>
    -

    If <%= rule.resource_type %>

    +

    If <%= rule.resource_type %>

    <%# Condition template, used by Stimulus controller to add new conditions dynamically %>