mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-09 07:25:19 +02:00
Merge branch 'maybe-finance:main' into simple-fin-integration
This commit is contained in:
commit
a5cf70f2df
58 changed files with 556 additions and 493 deletions
|
@ -71,18 +71,22 @@
|
||||||
|
|
||||||
/* Typography */
|
/* Typography */
|
||||||
.prose {
|
.prose {
|
||||||
@apply max-w-none;
|
@apply max-w-none text-primary;
|
||||||
|
|
||||||
|
a {
|
||||||
|
@apply text-link;
|
||||||
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
@apply text-xl font-medium;
|
@apply text-xl font-medium text-primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
@apply text-lg font-medium;
|
@apply text-lg font-medium text-primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
li {
|
li {
|
||||||
@apply m-0;
|
@apply m-0 text-primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
details {
|
details {
|
||||||
|
|
|
@ -26,6 +26,11 @@
|
||||||
--color-destructive: var(--color-red-600);
|
--color-destructive: var(--color-red-600);
|
||||||
--color-shadow: --alpha(var(--color-black) / 6%);
|
--color-shadow: --alpha(var(--color-black) / 6%);
|
||||||
|
|
||||||
|
/* Colors used in Stimulus controllers with SVGs (easier to define light/dark mode here than toggle within the controllers) */
|
||||||
|
/* See @layer base block below for dark mode overrides */
|
||||||
|
--budget-unused-fill: var(--color-gray-200);
|
||||||
|
--budget-unallocated-fill: var(--color-gray-50);
|
||||||
|
|
||||||
/* Gray scale */
|
/* Gray scale */
|
||||||
--color-gray-25: #FAFAFA;
|
--color-gray-25: #FAFAFA;
|
||||||
--color-gray-50: #F7F7F7;
|
--color-gray-50: #F7F7F7;
|
||||||
|
@ -250,6 +255,10 @@
|
||||||
--color-destructive: var(--color-red-400);
|
--color-destructive: var(--color-red-400);
|
||||||
--color-shadow: --alpha(var(--color-white) / 8%);
|
--color-shadow: --alpha(var(--color-white) / 8%);
|
||||||
|
|
||||||
|
/* Dark mode overrides for colors used in Stimulus controllers with SVGs */
|
||||||
|
--budget-unused-fill: var(--color-gray-500);
|
||||||
|
--budget-unallocated-fill: var(--color-gray-700);
|
||||||
|
|
||||||
--shadow-xs: 0px 1px 2px 0px --alpha(var(--color-white) / 8%);
|
--shadow-xs: 0px 1px 2px 0px --alpha(var(--color-white) / 8%);
|
||||||
--shadow-sm: 0px 1px 6px 0px --alpha(var(--color-white) / 8%);
|
--shadow-sm: 0px 1px 6px 0px --alpha(var(--color-white) / 8%);
|
||||||
--shadow-md: 0px 4px 8px -2px --alpha(var(--color-white) / 8%);
|
--shadow-md: 0px 4px 8px -2px --alpha(var(--color-white) / 8%);
|
||||||
|
@ -330,7 +339,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-field__input {
|
.form-field__input {
|
||||||
@apply text-primary border-none bg-transparent text-sm opacity-100 w-full p-0;
|
@apply text-primary border-none bg-container text-sm opacity-100 w-full p-0;
|
||||||
@apply focus:opacity-100 focus:outline-hidden focus:ring-0;
|
@apply focus:opacity-100 focus:outline-hidden focus:ring-0;
|
||||||
@apply placeholder-shown:opacity-50;
|
@apply placeholder-shown:opacity-50;
|
||||||
@apply disabled:text-subdued;
|
@apply disabled:text-subdued;
|
||||||
|
@ -348,8 +357,6 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-field__radio {
|
.form-field__radio {
|
||||||
|
|
|
@ -52,8 +52,12 @@ module AccountableResource
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
@account.destroy_later
|
if @account.linked?
|
||||||
redirect_to accounts_path, notice: t("accounts.destroy.success", type: accountable_type.name.underscore.humanize)
|
redirect_to account_path(@account), alert: "Cannot delete a linked account"
|
||||||
|
else
|
||||||
|
@account.destroy_later
|
||||||
|
redirect_to accounts_path, notice: t("accounts.destroy.success", type: accountable_type.name.underscore.humanize)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
@ -45,10 +45,6 @@ module ApplicationHelper
|
||||||
content_for(:header_description) { page_description }
|
content_for(:header_description) { page_description }
|
||||||
end
|
end
|
||||||
|
|
||||||
def family_stream
|
|
||||||
turbo_stream_from Current.family if Current.family
|
|
||||||
end
|
|
||||||
|
|
||||||
def page_active?(path)
|
def page_active?(path)
|
||||||
current_page?(path) || (request.path.start_with?(path) && path != "/")
|
current_page?(path) || (request.path.start_with?(path) && path != "/")
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,22 +1,36 @@
|
||||||
import { Controller } from "@hotwired/stimulus"
|
import { Controller } from "@hotwired/stimulus";
|
||||||
import Pickr from '@simonwep/pickr'
|
import Pickr from "@simonwep/pickr";
|
||||||
|
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
static targets = ["pickerBtn", "colorInput", "colorsSection", "paletteSection", "pickerSection", "colorPreview", "avatar", "details", "icon","validationMessage","selection","colorPickerRadioBtn"];
|
static targets = [
|
||||||
|
"pickerBtn",
|
||||||
|
"colorInput",
|
||||||
|
"colorsSection",
|
||||||
|
"paletteSection",
|
||||||
|
"pickerSection",
|
||||||
|
"colorPreview",
|
||||||
|
"avatar",
|
||||||
|
"details",
|
||||||
|
"icon",
|
||||||
|
"validationMessage",
|
||||||
|
"selection",
|
||||||
|
"colorPickerRadioBtn",
|
||||||
|
];
|
||||||
|
|
||||||
static values = {
|
static values = {
|
||||||
presetColors: Array,
|
presetColors: Array,
|
||||||
};
|
};
|
||||||
|
|
||||||
initialize() {
|
initialize() {
|
||||||
this.pickerBtnTarget.addEventListener('click', () => {
|
this.pickerBtnTarget.addEventListener("click", () => {
|
||||||
this.showPaletteSection();
|
this.showPaletteSection();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.colorInputTarget.addEventListener('input', (e) => {
|
this.colorInputTarget.addEventListener("input", (e) => {
|
||||||
this.picker.setColor(e.target.value);
|
this.picker.setColor(e.target.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.detailsTarget.addEventListener('toggle', (e) => {
|
this.detailsTarget.addEventListener("toggle", (e) => {
|
||||||
if (!this.colorInputTarget.checkValidity()) {
|
if (!this.colorInputTarget.checkValidity()) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.colorInputTarget.reportValidity();
|
this.colorInputTarget.reportValidity();
|
||||||
|
@ -38,7 +52,7 @@ export default class extends Controller {
|
||||||
|
|
||||||
this.picker = Pickr.create({
|
this.picker = Pickr.create({
|
||||||
el: this.pickerBtnTarget,
|
el: this.pickerBtnTarget,
|
||||||
theme: 'monolith',
|
theme: "monolith",
|
||||||
container: ".pickerContainer",
|
container: ".pickerContainer",
|
||||||
useAsButton: true,
|
useAsButton: true,
|
||||||
showAlways: true,
|
showAlways: true,
|
||||||
|
@ -48,7 +62,7 @@ export default class extends Controller {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.picker.on('change', (color) => {
|
this.picker.on("change", (color) => {
|
||||||
const hexColor = color.toHEXA().toString();
|
const hexColor = color.toHEXA().toString();
|
||||||
const rgbacolor = color.toRGBA();
|
const rgbacolor = color.toRGBA();
|
||||||
|
|
||||||
|
@ -77,20 +91,23 @@ export default class extends Controller {
|
||||||
|
|
||||||
const currentColor = this.colorInputTarget.value;
|
const currentColor = this.colorInputTarget.value;
|
||||||
|
|
||||||
this.iconTargets.forEach(icon => {
|
this.iconTargets.forEach((icon) => {
|
||||||
const iconWrapper = icon.nextElementSibling;
|
const iconWrapper = icon.nextElementSibling;
|
||||||
iconWrapper.style.removeProperty("background-color")
|
iconWrapper.style.removeProperty("background-color");
|
||||||
iconWrapper.style.color = "black";
|
iconWrapper.style.removeProperty("color");
|
||||||
});
|
});
|
||||||
|
|
||||||
this.updateSelectedIconColor(currentColor);
|
this.updateSelectedIconColor(currentColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleIconChange(e) {
|
handleIconChange(e) {
|
||||||
const iconSVG = e.currentTarget.closest('label').querySelector('svg').cloneNode(true);
|
const iconSVG = e.currentTarget
|
||||||
this.avatarTarget.innerHTML = '';
|
.closest("label")
|
||||||
iconSVG.style.padding = "0px"
|
.querySelector("svg")
|
||||||
iconSVG.classList.add("w-8","h-8")
|
.cloneNode(true);
|
||||||
|
this.avatarTarget.innerHTML = "";
|
||||||
|
iconSVG.style.padding = "0px";
|
||||||
|
iconSVG.classList.add("w-8", "h-8");
|
||||||
this.avatarTarget.appendChild(iconSVG);
|
this.avatarTarget.appendChild(iconSVG);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -112,7 +129,9 @@ export default class extends Controller {
|
||||||
|
|
||||||
handleContrastValidation(contrastRatio) {
|
handleContrastValidation(contrastRatio) {
|
||||||
if (contrastRatio < 4.5) {
|
if (contrastRatio < 4.5) {
|
||||||
this.colorInputTarget.setCustomValidity("Poor contrast, choose darker color or auto-adjust.");
|
this.colorInputTarget.setCustomValidity(
|
||||||
|
"Poor contrast, choose darker color or auto-adjust.",
|
||||||
|
);
|
||||||
|
|
||||||
this.validationMessageTarget.classList.remove("hidden");
|
this.validationMessageTarget.classList.remove("hidden");
|
||||||
} else {
|
} else {
|
||||||
|
@ -121,7 +140,7 @@ export default class extends Controller {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
autoAdjust(e){
|
autoAdjust(e) {
|
||||||
const currentRGBA = this.picker.getColor();
|
const currentRGBA = this.picker.getColor();
|
||||||
const adjustedRGBA = this.darkenColor(currentRGBA).toString();
|
const adjustedRGBA = this.darkenColor(currentRGBA).toString();
|
||||||
this.picker.setColor(adjustedRGBA);
|
this.picker.setColor(adjustedRGBA);
|
||||||
|
@ -129,19 +148,26 @@ export default class extends Controller {
|
||||||
|
|
||||||
handleParentChange(e) {
|
handleParentChange(e) {
|
||||||
const parent = e.currentTarget.value;
|
const parent = e.currentTarget.value;
|
||||||
const display = typeof parent === "string" && parent !== "" ? "none" : "flex";
|
const display =
|
||||||
|
typeof parent === "string" && parent !== "" ? "none" : "flex";
|
||||||
this.selectionTarget.style.display = display;
|
this.selectionTarget.style.display = display;
|
||||||
}
|
}
|
||||||
|
|
||||||
backgroundColor([r,g,b,a], percentage) {
|
backgroundColor([r, g, b, a], percentage) {
|
||||||
const mixedR = Math.round((r * (percentage / 100)) + (255 * (1 - percentage / 100)));
|
const mixedR = Math.round(
|
||||||
const mixedG = Math.round((g * (percentage / 100)) + (255 * (1 - percentage / 100)));
|
r * (percentage / 100) + 255 * (1 - percentage / 100),
|
||||||
const mixedB = Math.round((b * (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];
|
return [mixedR, mixedG, mixedB];
|
||||||
}
|
}
|
||||||
|
|
||||||
luminance([r,g,b]) {
|
luminance([r, g, b]) {
|
||||||
const toLinear = c => {
|
const toLinear = (c) => {
|
||||||
const scaled = c / 255;
|
const scaled = c / 255;
|
||||||
return scaled <= 0.04045
|
return scaled <= 0.04045
|
||||||
? scaled / 12.92
|
? scaled / 12.92
|
||||||
|
@ -162,12 +188,15 @@ export default class extends Controller {
|
||||||
const backgroundColor = this.backgroundColor(darkened, 10);
|
const backgroundColor = this.backgroundColor(darkened, 10);
|
||||||
let contrastRatio = this.contrast(darkened, backgroundColor);
|
let contrastRatio = this.contrast(darkened, backgroundColor);
|
||||||
|
|
||||||
while (contrastRatio < 4.5 && (darkened[0] > 0 || darkened[1] > 0 || darkened[2] > 0)) {
|
while (
|
||||||
|
contrastRatio < 4.5 &&
|
||||||
|
(darkened[0] > 0 || darkened[1] > 0 || darkened[2] > 0)
|
||||||
|
) {
|
||||||
darkened = [
|
darkened = [
|
||||||
Math.max(0, darkened[0] - 10),
|
Math.max(0, darkened[0] - 10),
|
||||||
Math.max(0, darkened[1] - 10),
|
Math.max(0, darkened[1] - 10),
|
||||||
Math.max(0, darkened[2] - 10),
|
Math.max(0, darkened[2] - 10),
|
||||||
darkened[3]
|
darkened[3],
|
||||||
];
|
];
|
||||||
contrastRatio = this.contrast(darkened, backgroundColor);
|
contrastRatio = this.contrast(darkened, backgroundColor);
|
||||||
}
|
}
|
||||||
|
@ -177,23 +206,23 @@ export default class extends Controller {
|
||||||
|
|
||||||
showPaletteSection() {
|
showPaletteSection() {
|
||||||
this.initPicker();
|
this.initPicker();
|
||||||
this.colorsSectionTarget.classList.add('hidden');
|
this.colorsSectionTarget.classList.add("hidden");
|
||||||
this.paletteSectionTarget.classList.remove('hidden');
|
this.paletteSectionTarget.classList.remove("hidden");
|
||||||
this.pickerSectionTarget.classList.remove('hidden');
|
this.pickerSectionTarget.classList.remove("hidden");
|
||||||
this.picker.show();
|
this.picker.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
showColorsSection() {
|
showColorsSection() {
|
||||||
this.colorsSectionTarget.classList.remove('hidden');
|
this.colorsSectionTarget.classList.remove("hidden");
|
||||||
this.paletteSectionTarget.classList.add('hidden');
|
this.paletteSectionTarget.classList.add("hidden");
|
||||||
this.pickerSectionTarget.classList.add('hidden');
|
this.pickerSectionTarget.classList.add("hidden");
|
||||||
if (this.picker) {
|
if (this.picker) {
|
||||||
this.picker.destroyAndRemove();
|
this.picker.destroyAndRemove();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleSections() {
|
toggleSections() {
|
||||||
if (this.colorsSectionTarget.classList.contains('hidden')) {
|
if (this.colorsSectionTarget.classList.contains("hidden")) {
|
||||||
this.showColorsSection();
|
this.showColorsSection();
|
||||||
} else {
|
} else {
|
||||||
this.showPaletteSection();
|
this.showPaletteSection();
|
||||||
|
|
|
@ -136,13 +136,13 @@ export default class extends Controller {
|
||||||
.attr("fill", function () {
|
.attr("fill", function () {
|
||||||
if (this.dataset.segmentId === segmentId) {
|
if (this.dataset.segmentId === segmentId) {
|
||||||
if (this.dataset.segmentId === unusedSegmentId) {
|
if (this.dataset.segmentId === unusedSegmentId) {
|
||||||
return "#A3A3A3";
|
return "var(--budget-unused-fill)";
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.dataset.originalColor;
|
return this.dataset.originalColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
return "#F0F0F0";
|
return "var(--budget-unallocated-fill)";
|
||||||
});
|
});
|
||||||
|
|
||||||
this.defaultContentTarget.classList.add("hidden");
|
this.defaultContentTarget.classList.add("hidden");
|
||||||
|
|
16
app/javascript/controllers/sidebar_tabs_controller.js
Normal file
16
app/javascript/controllers/sidebar_tabs_controller.js
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { Controller } from "@hotwired/stimulus";
|
||||||
|
|
||||||
|
// Connects to data-controller="sidebar-tabs"
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["account"];
|
||||||
|
|
||||||
|
select(event) {
|
||||||
|
this.accountTargets.forEach((account) => {
|
||||||
|
if (account.contains(event.target)) {
|
||||||
|
account.classList.add("bg-container");
|
||||||
|
} else {
|
||||||
|
account.classList.remove("bg-container");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,7 +4,6 @@ export default class extends Controller {
|
||||||
static values = { userPreference: String };
|
static values = { userPreference: String };
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
this.applyTheme();
|
|
||||||
this.startSystemThemeListener();
|
this.startSystemThemeListener();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -45,7 +44,7 @@ export default class extends Controller {
|
||||||
if (isDark) {
|
if (isDark) {
|
||||||
document.documentElement.setAttribute("data-theme", "dark");
|
document.documentElement.setAttribute("data-theme", "dark");
|
||||||
} else {
|
} else {
|
||||||
document.documentElement.removeAttribute("data-theme");
|
document.documentElement.setAttribute("data-theme", "light");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -60,20 +59,12 @@ export default class extends Controller {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
toDark() {
|
|
||||||
this.setTheme(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
toLight() {
|
|
||||||
this.setTheme(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
toggle() {
|
toggle() {
|
||||||
const currentTheme = document.documentElement.getAttribute("data-theme");
|
const currentTheme = document.documentElement.getAttribute("data-theme");
|
||||||
if (currentTheme === "dark") {
|
if (currentTheme === "dark") {
|
||||||
this.toLight();
|
this.setTheme(false);
|
||||||
} else {
|
} else {
|
||||||
this.toDark();
|
this.setTheme(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -138,36 +138,48 @@ export default class extends Controller {
|
||||||
.attr("x1", this._d3XScale.range()[0])
|
.attr("x1", this._d3XScale.range()[0])
|
||||||
.attr("x2", this._d3XScale.range()[1]);
|
.attr("x2", this._d3XScale.range()[1]);
|
||||||
|
|
||||||
|
// First stop - solid trend color
|
||||||
gradient
|
gradient
|
||||||
.append("stop")
|
.append("stop")
|
||||||
.attr("class", "start-color")
|
.attr("class", "start-color")
|
||||||
.attr("offset", "0%")
|
.attr("offset", "0%")
|
||||||
.attr("stop-color", this.dataValue.trend.color);
|
.attr("stop-color", this.dataValue.trend.color);
|
||||||
|
|
||||||
|
// Second stop - trend color right before split
|
||||||
gradient
|
gradient
|
||||||
.append("stop")
|
.append("stop")
|
||||||
.attr("class", "middle-color")
|
.attr("class", "split-before")
|
||||||
.attr("offset", "100%")
|
.attr("offset", "100%")
|
||||||
.attr("stop-color", this.dataValue.trend.color);
|
.attr("stop-color", this.dataValue.trend.color);
|
||||||
|
|
||||||
|
// Third stop - gray color right after split
|
||||||
|
gradient
|
||||||
|
.append("stop")
|
||||||
|
.attr("class", "split-after")
|
||||||
|
.attr("offset", "100%")
|
||||||
|
.attr("stop-color", "var(--color-gray-400)");
|
||||||
|
|
||||||
|
// Fourth stop - solid gray to end
|
||||||
gradient
|
gradient
|
||||||
.append("stop")
|
.append("stop")
|
||||||
.attr("class", "end-color")
|
.attr("class", "end-color")
|
||||||
.attr("offset", "100%")
|
.attr("offset", "100%")
|
||||||
.attr("class", "fg-subdued")
|
.attr("stop-color", "var(--color-gray-400)");
|
||||||
.attr("stop-color", "currentColor");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_setTrendlineSplitAt(percent) {
|
_setTrendlineSplitAt(percent) {
|
||||||
|
const position = percent * 100;
|
||||||
|
|
||||||
|
// Update both stops at the split point
|
||||||
this._d3Svg
|
this._d3Svg
|
||||||
.select(`#${this.element.id}-split-gradient`)
|
.select(`#${this.element.id}-split-gradient`)
|
||||||
.select(".middle-color")
|
.select(".split-before")
|
||||||
.attr("offset", `${percent * 100}%`);
|
.attr("offset", `${position}%`);
|
||||||
|
|
||||||
this._d3Svg
|
this._d3Svg
|
||||||
.select(`#${this.element.id}-split-gradient`)
|
.select(`#${this.element.id}-split-gradient`)
|
||||||
.select(".end-color")
|
.select(".split-after")
|
||||||
.attr("offset", `${percent * 100}%`);
|
.attr("offset", `${position}%`);
|
||||||
|
|
||||||
this._d3Svg
|
this._d3Svg
|
||||||
.select(`#${this.element.id}-trendline-gradient-rect`)
|
.select(`#${this.element.id}-trendline-gradient-rect`)
|
||||||
|
|
|
@ -21,7 +21,13 @@ class Balance::ReverseCalculator < Balance::BaseCalculator
|
||||||
if valuation.present?
|
if valuation.present?
|
||||||
@balances << build_balance(date, previous_cash_balance, holdings_value)
|
@balances << build_balance(date, previous_cash_balance, holdings_value)
|
||||||
else
|
else
|
||||||
@balances << build_balance(date, current_cash_balance, holdings_value)
|
# If date is today, we don't distinguish cash vs. total since provider's are inconsistent with treatment
|
||||||
|
# of the cash component. Instead, just set the balance equal to the "total value" reported by the provider
|
||||||
|
if date == Date.current
|
||||||
|
@balances << build_balance(date, account.cash_balance, account.balance - account.cash_balance)
|
||||||
|
else
|
||||||
|
@balances << build_balance(date, current_cash_balance, holdings_value)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
current_cash_balance = previous_cash_balance
|
current_cash_balance = previous_cash_balance
|
||||||
|
|
|
@ -5,90 +5,26 @@
|
||||||
class Balance::TrendCalculator
|
class Balance::TrendCalculator
|
||||||
BalanceTrend = Struct.new(:trend, :cash, keyword_init: true)
|
BalanceTrend = Struct.new(:trend, :cash, keyword_init: true)
|
||||||
|
|
||||||
class << self
|
def initialize(balances)
|
||||||
def for(entries)
|
|
||||||
return nil if entries.blank?
|
|
||||||
|
|
||||||
account = entries.first.account
|
|
||||||
|
|
||||||
date_range = entries.minmax_by(&:date)
|
|
||||||
min_entry_date, max_entry_date = date_range.map(&:date)
|
|
||||||
|
|
||||||
# In case view is filtered and there are entry gaps, refetch all entries in range
|
|
||||||
all_entries = account.entries.where(date: min_entry_date..max_entry_date).chronological.to_a
|
|
||||||
balances = account.balances.where(date: (min_entry_date - 1.day)..max_entry_date).chronological.to_a
|
|
||||||
holdings = account.holdings.where(date: (min_entry_date - 1.day)..max_entry_date).to_a
|
|
||||||
|
|
||||||
new(all_entries, balances, holdings)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def initialize(entries, balances, holdings)
|
|
||||||
@entries = entries
|
|
||||||
@balances = balances
|
@balances = balances
|
||||||
@holdings = holdings
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def trend_for(entry)
|
def trend_for(date)
|
||||||
intraday_balance = nil
|
balance = @balances.find { |b| b.date == date }
|
||||||
intraday_cash_balance = nil
|
prior_balance = @balances.find { |b| b.date == date - 1.day }
|
||||||
|
|
||||||
start_of_day_balance = balances.find { |b| b.date == entry.date - 1.day && b.currency == entry.currency }
|
return BalanceTrend.new(trend: nil) unless balance.present?
|
||||||
end_of_day_balance = balances.find { |b| b.date == entry.date && b.currency == entry.currency }
|
|
||||||
|
|
||||||
return BalanceTrend.new(trend: nil) if start_of_day_balance.blank? || end_of_day_balance.blank?
|
|
||||||
|
|
||||||
todays_holdings_value = holdings.select { |h| h.date == entry.date }.sum(&:amount)
|
|
||||||
|
|
||||||
prior_balance = start_of_day_balance.balance
|
|
||||||
prior_cash_balance = start_of_day_balance.cash_balance
|
|
||||||
current_balance = nil
|
|
||||||
current_cash_balance = nil
|
|
||||||
|
|
||||||
todays_entries = entries.select { |e| e.date == entry.date }
|
|
||||||
|
|
||||||
todays_entries.each_with_index do |e, idx|
|
|
||||||
if e.valuation?
|
|
||||||
current_balance = e.amount
|
|
||||||
current_cash_balance = e.amount
|
|
||||||
else
|
|
||||||
multiplier = e.account.liability? ? 1 : -1
|
|
||||||
balance_change = e.trade? ? 0 : multiplier * e.amount
|
|
||||||
cash_change = multiplier * e.amount
|
|
||||||
|
|
||||||
current_balance = prior_balance + balance_change
|
|
||||||
current_cash_balance = prior_cash_balance + cash_change
|
|
||||||
end
|
|
||||||
|
|
||||||
if e.id == entry.id
|
|
||||||
# Final entry should always match the end-of-day balances
|
|
||||||
if idx == todays_entries.size - 1
|
|
||||||
intraday_balance = end_of_day_balance.balance
|
|
||||||
intraday_cash_balance = end_of_day_balance.cash_balance
|
|
||||||
else
|
|
||||||
intraday_balance = current_balance
|
|
||||||
intraday_cash_balance = current_cash_balance
|
|
||||||
end
|
|
||||||
|
|
||||||
break
|
|
||||||
else
|
|
||||||
prior_balance = current_balance
|
|
||||||
prior_cash_balance = current_cash_balance
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
return BalanceTrend.new(trend: nil) unless intraday_balance.present?
|
|
||||||
|
|
||||||
BalanceTrend.new(
|
BalanceTrend.new(
|
||||||
trend: Trend.new(
|
trend: Trend.new(
|
||||||
current: Money.new(intraday_balance, entry.currency),
|
current: Money.new(balance.balance, balance.currency),
|
||||||
previous: Money.new(prior_balance, entry.currency),
|
previous: Money.new(prior_balance.balance, balance.currency),
|
||||||
favorable_direction: entry.account.favorable_direction
|
favorable_direction: balance.account.favorable_direction
|
||||||
),
|
),
|
||||||
cash: Money.new(intraday_cash_balance, entry.currency),
|
cash: Money.new(balance.cash_balance, balance.currency),
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
attr_reader :entries, :balances, :holdings
|
attr_reader :balances
|
||||||
end
|
end
|
||||||
|
|
|
@ -131,14 +131,14 @@ class Budget < ApplicationRecord
|
||||||
unused_segment_id = "unused"
|
unused_segment_id = "unused"
|
||||||
|
|
||||||
# Continuous gray segment for empty budgets
|
# Continuous gray segment for empty budgets
|
||||||
return [ { color: "#F0F0F0", amount: 1, id: unused_segment_id } ] unless allocations_valid?
|
return [ { color: "var(--budget-unallocated-fill)", amount: 1, id: unused_segment_id } ] unless allocations_valid?
|
||||||
|
|
||||||
segments = budget_categories.map do |bc|
|
segments = budget_categories.map do |bc|
|
||||||
{ color: bc.category.color, amount: budget_category_actual_spending(bc), id: bc.id }
|
{ color: bc.category.color, amount: budget_category_actual_spending(bc), id: bc.id }
|
||||||
end
|
end
|
||||||
|
|
||||||
if available_to_spend.positive?
|
if available_to_spend.positive?
|
||||||
segments.push({ color: "#F0F0F0", amount: available_to_spend, id: unused_segment_id })
|
segments.push({ color: "var(--budget-unallocated-fill)", amount: available_to_spend, id: unused_segment_id })
|
||||||
end
|
end
|
||||||
|
|
||||||
segments
|
segments
|
||||||
|
|
|
@ -79,14 +79,14 @@ class BudgetCategory < ApplicationRecord
|
||||||
unused_segment_id = "unused"
|
unused_segment_id = "unused"
|
||||||
overage_segment_id = "overage"
|
overage_segment_id = "overage"
|
||||||
|
|
||||||
return [ { color: "#F0F0F0", amount: 1, id: unused_segment_id } ] unless actual_spending > 0
|
return [ { color: "var(--budget-unallocated-fill)", amount: 1, id: unused_segment_id } ] unless actual_spending > 0
|
||||||
|
|
||||||
segments = [ { color: category.color, amount: actual_spending, id: id } ]
|
segments = [ { color: category.color, amount: actual_spending, id: id } ]
|
||||||
|
|
||||||
if available_to_spend.negative?
|
if available_to_spend.negative?
|
||||||
segments.push({ color: "#EF4444", amount: available_to_spend.abs, id: overage_segment_id })
|
segments.push({ color: "var(--color-destructive)", amount: available_to_spend.abs, id: overage_segment_id })
|
||||||
else
|
else
|
||||||
segments.push({ color: "#F0F0F0", amount: available_to_spend, id: unused_segment_id })
|
segments.push({ color: "var(--budget-unallocated-fill)", amount: available_to_spend, id: unused_segment_id })
|
||||||
end
|
end
|
||||||
|
|
||||||
segments
|
segments
|
||||||
|
|
|
@ -97,15 +97,15 @@ class Family < ApplicationRecord
|
||||||
broadcast_refresh
|
broadcast_refresh
|
||||||
end
|
end
|
||||||
|
|
||||||
# If family has any syncs pending/syncing within the last hour, we show a persistent "syncing" notice.
|
# If family has any syncs pending/syncing within the last 10 minutes, we show a persistent "syncing" notice.
|
||||||
# Ignore syncs older than 1 hour as they are considered "stale"
|
# Ignore syncs older than 10 minutes as they are considered "stale"
|
||||||
def syncing?
|
def syncing?
|
||||||
Sync.where(
|
Sync.where(
|
||||||
"(syncable_type = 'Family' AND syncable_id = ?) OR
|
"(syncable_type = 'Family' AND syncable_id = ?) OR
|
||||||
(syncable_type = 'Account' AND syncable_id IN (SELECT id FROM accounts WHERE family_id = ? AND plaid_account_id IS NULL)) OR
|
(syncable_type = 'Account' AND syncable_id IN (SELECT id FROM accounts WHERE family_id = ? AND plaid_account_id IS NULL)) OR
|
||||||
(syncable_type = 'PlaidItem' AND syncable_id IN (SELECT id FROM plaid_items WHERE family_id = ?))",
|
(syncable_type = 'PlaidItem' AND syncable_id IN (SELECT id FROM plaid_items WHERE family_id = ?))",
|
||||||
id, id, id
|
id, id, id
|
||||||
).where(status: [ "pending", "syncing" ], created_at: 1.hour.ago..).exists?
|
).where(status: [ "pending", "syncing" ], created_at: 10.minutes.ago..).exists?
|
||||||
end
|
end
|
||||||
|
|
||||||
def eu?
|
def eu?
|
||||||
|
|
|
@ -11,55 +11,55 @@ class Period
|
||||||
|
|
||||||
PERIODS = {
|
PERIODS = {
|
||||||
"last_day" => {
|
"last_day" => {
|
||||||
date_range: [ 1.day.ago.to_date, Date.current ],
|
date_range: -> { [ 1.day.ago.to_date, Date.current ] },
|
||||||
label_short: "1D",
|
label_short: "1D",
|
||||||
label: "Last Day",
|
label: "Last Day",
|
||||||
comparison_label: "vs. yesterday"
|
comparison_label: "vs. yesterday"
|
||||||
},
|
},
|
||||||
"current_week" => {
|
"current_week" => {
|
||||||
date_range: [ Date.current.beginning_of_week, Date.current ],
|
date_range: -> { [ Date.current.beginning_of_week, Date.current ] },
|
||||||
label_short: "WTD",
|
label_short: "WTD",
|
||||||
label: "Current Week",
|
label: "Current Week",
|
||||||
comparison_label: "vs. start of week"
|
comparison_label: "vs. start of week"
|
||||||
},
|
},
|
||||||
"last_7_days" => {
|
"last_7_days" => {
|
||||||
date_range: [ 7.days.ago.to_date, Date.current ],
|
date_range: -> { [ 7.days.ago.to_date, Date.current ] },
|
||||||
label_short: "7D",
|
label_short: "7D",
|
||||||
label: "Last 7 Days",
|
label: "Last 7 Days",
|
||||||
comparison_label: "vs. last week"
|
comparison_label: "vs. last week"
|
||||||
},
|
},
|
||||||
"current_month" => {
|
"current_month" => {
|
||||||
date_range: [ Date.current.beginning_of_month, Date.current ],
|
date_range: -> { [ Date.current.beginning_of_month, Date.current ] },
|
||||||
label_short: "MTD",
|
label_short: "MTD",
|
||||||
label: "Current Month",
|
label: "Current Month",
|
||||||
comparison_label: "vs. start of month"
|
comparison_label: "vs. start of month"
|
||||||
},
|
},
|
||||||
"last_30_days" => {
|
"last_30_days" => {
|
||||||
date_range: [ 30.days.ago.to_date, Date.current ],
|
date_range: -> { [ 30.days.ago.to_date, Date.current ] },
|
||||||
label_short: "30D",
|
label_short: "30D",
|
||||||
label: "Last 30 Days",
|
label: "Last 30 Days",
|
||||||
comparison_label: "vs. last month"
|
comparison_label: "vs. last month"
|
||||||
},
|
},
|
||||||
"last_90_days" => {
|
"last_90_days" => {
|
||||||
date_range: [ 90.days.ago.to_date, Date.current ],
|
date_range: -> { [ 90.days.ago.to_date, Date.current ] },
|
||||||
label_short: "90D",
|
label_short: "90D",
|
||||||
label: "Last 90 Days",
|
label: "Last 90 Days",
|
||||||
comparison_label: "vs. last quarter"
|
comparison_label: "vs. last quarter"
|
||||||
},
|
},
|
||||||
"current_year" => {
|
"current_year" => {
|
||||||
date_range: [ Date.current.beginning_of_year, Date.current ],
|
date_range: -> { [ Date.current.beginning_of_year, Date.current ] },
|
||||||
label_short: "YTD",
|
label_short: "YTD",
|
||||||
label: "Current Year",
|
label: "Current Year",
|
||||||
comparison_label: "vs. start of year"
|
comparison_label: "vs. start of year"
|
||||||
},
|
},
|
||||||
"last_365_days" => {
|
"last_365_days" => {
|
||||||
date_range: [ 365.days.ago.to_date, Date.current ],
|
date_range: -> { [ 365.days.ago.to_date, Date.current ] },
|
||||||
label_short: "365D",
|
label_short: "365D",
|
||||||
label: "Last 365 Days",
|
label: "Last 365 Days",
|
||||||
comparison_label: "vs. 1 year ago"
|
comparison_label: "vs. 1 year ago"
|
||||||
},
|
},
|
||||||
"last_5_years" => {
|
"last_5_years" => {
|
||||||
date_range: [ 5.years.ago.to_date, Date.current ],
|
date_range: -> { [ 5.years.ago.to_date, Date.current ] },
|
||||||
label_short: "5Y",
|
label_short: "5Y",
|
||||||
label: "Last 5 Years",
|
label: "Last 5 Years",
|
||||||
comparison_label: "vs. 5 years ago"
|
comparison_label: "vs. 5 years ago"
|
||||||
|
@ -72,7 +72,7 @@ class Period
|
||||||
raise InvalidKeyError, "Invalid period key: #{key}"
|
raise InvalidKeyError, "Invalid period key: #{key}"
|
||||||
end
|
end
|
||||||
|
|
||||||
start_date, end_date = PERIODS[key].fetch(:date_range)
|
start_date, end_date = PERIODS[key].fetch(:date_range).call
|
||||||
|
|
||||||
new(key: key, start_date: start_date, end_date: end_date)
|
new(key: key, start_date: start_date, end_date: end_date)
|
||||||
end
|
end
|
||||||
|
|
|
@ -15,13 +15,20 @@ class PlaidAccount < ApplicationRecord
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
def find_or_create_from_plaid_data!(plaid_data, family)
|
def find_or_create_from_plaid_data!(plaid_data, family)
|
||||||
find_or_create_by!(plaid_id: plaid_data.account_id) do |a|
|
PlaidAccount.transaction do
|
||||||
a.account = family.accounts.new(
|
plaid_account = find_or_create_by!(plaid_id: plaid_data.account_id)
|
||||||
name: plaid_data.name,
|
|
||||||
balance: plaid_data.balances.current || plaid_data.balances.available,
|
internal_account = family.accounts.find_or_initialize_by(plaid_account_id: plaid_account.id)
|
||||||
currency: plaid_data.balances.iso_currency_code,
|
|
||||||
accountable: TYPE_MAPPING[plaid_data.type].new
|
internal_account.name = plaid_data.name
|
||||||
)
|
internal_account.balance = plaid_data.balances.current || plaid_data.balances.available
|
||||||
|
internal_account.currency = plaid_data.balances.iso_currency_code
|
||||||
|
internal_account.accountable = TYPE_MAPPING[plaid_data.type].new
|
||||||
|
|
||||||
|
internal_account.save!
|
||||||
|
plaid_account.save!
|
||||||
|
|
||||||
|
plaid_account
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -11,6 +11,7 @@ class PlaidInvestmentSync
|
||||||
@securities = securities
|
@securities = securities
|
||||||
|
|
||||||
PlaidAccount.transaction do
|
PlaidAccount.transaction do
|
||||||
|
normalize_cash_balance!
|
||||||
sync_transactions!
|
sync_transactions!
|
||||||
sync_holdings!
|
sync_holdings!
|
||||||
end
|
end
|
||||||
|
@ -19,6 +20,23 @@ class PlaidInvestmentSync
|
||||||
private
|
private
|
||||||
attr_reader :transactions, :holdings, :securities
|
attr_reader :transactions, :holdings, :securities
|
||||||
|
|
||||||
|
# Plaid considers "brokerage cash" and "cash equivalent holdings" to all be part of "cash balance"
|
||||||
|
# Internally, we DO NOT.
|
||||||
|
# Maybe clearly distinguishes between "brokerage cash" vs. "holdings (i.e. invested cash)"
|
||||||
|
# For this reason, we must back out cash + cash equivalent holdings from the reported cash balance to avoid double counting
|
||||||
|
def normalize_cash_balance!
|
||||||
|
excludable_cash_holdings = holdings.select do |h|
|
||||||
|
internal_security, plaid_security = get_security(h.security_id, securities)
|
||||||
|
internal_security.present? && (plaid_security&.is_cash_equivalent || plaid_security&.type == "cash")
|
||||||
|
end
|
||||||
|
|
||||||
|
excludable_cash_holdings_value = excludable_cash_holdings.sum { |h| h.quantity * h.institution_price }
|
||||||
|
|
||||||
|
plaid_account.account.update!(
|
||||||
|
cash_balance: plaid_account.account.cash_balance - excludable_cash_holdings_value
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
def sync_transactions!
|
def sync_transactions!
|
||||||
transactions.each do |transaction|
|
transactions.each do |transaction|
|
||||||
security, plaid_security = get_security(transaction.security_id, securities)
|
security, plaid_security = get_security(transaction.security_id, securities)
|
||||||
|
@ -88,8 +106,8 @@ class PlaidInvestmentSync
|
||||||
|
|
||||||
# Find any matching security
|
# Find any matching security
|
||||||
security = Security.find_or_create_by!(
|
security = Security.find_or_create_by!(
|
||||||
ticker: plaid_security.ticker_symbol,
|
ticker: plaid_security.ticker_symbol&.upcase,
|
||||||
exchange_operating_mic: operating_mic
|
exchange_operating_mic: operating_mic&.upcase
|
||||||
)
|
)
|
||||||
|
|
||||||
[ security, plaid_security ]
|
[ security, plaid_security ]
|
||||||
|
|
|
@ -42,16 +42,15 @@ class PlaidItem < ApplicationRecord
|
||||||
|
|
||||||
begin
|
begin
|
||||||
Rails.logger.info("Fetching and loading Plaid data")
|
Rails.logger.info("Fetching and loading Plaid data")
|
||||||
plaid_data = fetch_and_load_plaid_data
|
fetch_and_load_plaid_data(sync)
|
||||||
update!(status: :good) if requires_update?
|
update!(status: :good) if requires_update?
|
||||||
|
|
||||||
# Schedule account syncs
|
# Schedule account syncs
|
||||||
accounts.each do |account|
|
accounts.each do |account|
|
||||||
account.sync_later(start_date: start_date)
|
account.sync_later(start_date: start_date, parent_sync: sync)
|
||||||
end
|
end
|
||||||
|
|
||||||
Rails.logger.info("Plaid data fetched and loaded")
|
Rails.logger.info("Plaid data fetched and loaded")
|
||||||
plaid_data
|
|
||||||
rescue Plaid::ApiError => e
|
rescue Plaid::ApiError => e
|
||||||
handle_plaid_error(e)
|
handle_plaid_error(e)
|
||||||
raise e
|
raise e
|
||||||
|
@ -120,7 +119,7 @@ class PlaidItem < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def fetch_and_load_plaid_data
|
def fetch_and_load_plaid_data(sync)
|
||||||
data = {}
|
data = {}
|
||||||
|
|
||||||
# Log what we're about to fetch
|
# Log what we're about to fetch
|
||||||
|
@ -147,6 +146,7 @@ class PlaidItem < ApplicationRecord
|
||||||
# Accounts
|
# Accounts
|
||||||
fetched_accounts = plaid_provider.get_item_accounts(self).accounts
|
fetched_accounts = plaid_provider.get_item_accounts(self).accounts
|
||||||
data[:accounts] = fetched_accounts || []
|
data[:accounts] = fetched_accounts || []
|
||||||
|
sync.update!(data: data)
|
||||||
Rails.logger.info "Processing Plaid accounts (count: #{fetched_accounts.size})"
|
Rails.logger.info "Processing Plaid accounts (count: #{fetched_accounts.size})"
|
||||||
|
|
||||||
internal_plaid_accounts = fetched_accounts.map do |account|
|
internal_plaid_accounts = fetched_accounts.map do |account|
|
||||||
|
@ -158,6 +158,7 @@ class PlaidItem < ApplicationRecord
|
||||||
# Transactions
|
# Transactions
|
||||||
fetched_transactions = safe_fetch_plaid_data(:get_item_transactions)
|
fetched_transactions = safe_fetch_plaid_data(:get_item_transactions)
|
||||||
data[:transactions] = fetched_transactions || []
|
data[:transactions] = fetched_transactions || []
|
||||||
|
sync.update!(data: data)
|
||||||
|
|
||||||
if 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})"
|
Rails.logger.info "Processing Plaid transactions (added: #{fetched_transactions.added.size}, modified: #{fetched_transactions.modified.size}, removed: #{fetched_transactions.removed.size})"
|
||||||
|
@ -177,6 +178,7 @@ class PlaidItem < ApplicationRecord
|
||||||
# Investments
|
# Investments
|
||||||
fetched_investments = safe_fetch_plaid_data(:get_item_investments)
|
fetched_investments = safe_fetch_plaid_data(:get_item_investments)
|
||||||
data[:investments] = fetched_investments || []
|
data[:investments] = fetched_investments || []
|
||||||
|
sync.update!(data: data)
|
||||||
|
|
||||||
if 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})"
|
Rails.logger.info "Processing Plaid investments (transactions: #{fetched_investments.transactions.size}, holdings: #{fetched_investments.holdings.size}, securities: #{fetched_investments.securities.size})"
|
||||||
|
@ -194,6 +196,7 @@ class PlaidItem < ApplicationRecord
|
||||||
# Liabilities
|
# Liabilities
|
||||||
fetched_liabilities = safe_fetch_plaid_data(:get_item_liabilities)
|
fetched_liabilities = safe_fetch_plaid_data(:get_item_liabilities)
|
||||||
data[:liabilities] = fetched_liabilities || []
|
data[:liabilities] = fetched_liabilities || []
|
||||||
|
sync.update!(data: data)
|
||||||
|
|
||||||
if 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})"
|
Rails.logger.info "Processing Plaid liabilities (credit: #{fetched_liabilities.credit&.size || 0}, mortgage: #{fetched_liabilities.mortgage&.size || 0}, student: #{fetched_liabilities.student&.size || 0})"
|
||||||
|
@ -209,8 +212,6 @@ class PlaidItem < ApplicationRecord
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
data
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def safe_fetch_plaid_data(method)
|
def safe_fetch_plaid_data(method)
|
||||||
|
|
|
@ -11,7 +11,7 @@ class Rule::ActionExecutor::AutoCategorize < Rule::ActionExecutor
|
||||||
enrichable_transactions = transaction_scope.enrichable(:category_id)
|
enrichable_transactions = transaction_scope.enrichable(:category_id)
|
||||||
|
|
||||||
if enrichable_transactions.empty?
|
if enrichable_transactions.empty?
|
||||||
Rails.logger.info("No transactions to auto-categorize for #{rule.title} #{rule.id}")
|
Rails.logger.info("No transactions to auto-categorize for #{rule.id}")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,7 @@ class Rule::ActionExecutor::AutoDetectMerchants < Rule::ActionExecutor
|
||||||
enrichable_transactions = transaction_scope.enrichable(:merchant_id)
|
enrichable_transactions = transaction_scope.enrichable(:merchant_id)
|
||||||
|
|
||||||
if enrichable_transactions.empty?
|
if enrichable_transactions.empty?
|
||||||
Rails.logger.info("No transactions to auto-detect merchants for #{rule.title} #{rule.id}")
|
Rails.logger.info("No transactions to auto-detect merchants for #{rule.id}")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
class Security < ApplicationRecord
|
class Security < ApplicationRecord
|
||||||
include Provided
|
include Provided
|
||||||
|
|
||||||
before_save :upcase_ticker
|
before_validation :upcase_symbols
|
||||||
|
|
||||||
has_many :trades, dependent: :nullify, class_name: "Trade"
|
has_many :trades, dependent: :nullify, class_name: "Trade"
|
||||||
has_many :prices, dependent: :destroy
|
has_many :prices, dependent: :destroy
|
||||||
|
@ -29,8 +29,8 @@ class Security < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
def upcase_symbols
|
||||||
def upcase_ticker
|
|
||||||
self.ticker = ticker.upcase
|
self.ticker = ticker.upcase
|
||||||
|
self.exchange_operating_mic = exchange_operating_mic.upcase if exchange_operating_mic.present?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -19,33 +19,35 @@ class Sync < ApplicationRecord
|
||||||
start!
|
start!
|
||||||
|
|
||||||
begin
|
begin
|
||||||
data = syncable.sync_data(self, start_date: start_date)
|
syncable.sync_data(self, start_date: start_date)
|
||||||
update!(data: data) if data
|
|
||||||
|
|
||||||
complete! unless has_pending_child_syncs?
|
unless has_pending_child_syncs?
|
||||||
|
complete!
|
||||||
Rails.logger.info("Sync completed, starting post-sync")
|
Rails.logger.info("Sync completed, starting post-sync")
|
||||||
|
syncable.post_sync(self)
|
||||||
syncable.post_sync(self) unless has_pending_child_syncs?
|
Rails.logger.info("Post-sync completed")
|
||||||
|
|
||||||
if has_parent?
|
|
||||||
notify_parent_of_completion!
|
|
||||||
end
|
end
|
||||||
|
|
||||||
Rails.logger.info("Post-sync completed")
|
|
||||||
rescue StandardError => error
|
rescue StandardError => error
|
||||||
fail! error
|
fail! error
|
||||||
raise error if Rails.env.development?
|
raise error if Rails.env.development?
|
||||||
|
ensure
|
||||||
|
notify_parent_of_completion! if has_parent?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_child_completion_event
|
def handle_child_completion_event
|
||||||
unless has_pending_child_syncs?
|
Sync.transaction do
|
||||||
if has_failed_child_syncs?
|
# We need this to ensure 2 child syncs don't update the parent at the exact same time with different results
|
||||||
fail!(Error.new("One or more child syncs failed"))
|
# and cause the sync to hang in "syncing" status indefinitely
|
||||||
else
|
self.lock!
|
||||||
|
|
||||||
|
unless has_pending_child_syncs?
|
||||||
complete!
|
complete!
|
||||||
|
|
||||||
|
# If this sync is both a child and a parent, we need to notify the parent of completion
|
||||||
|
notify_parent_of_completion! if has_parent?
|
||||||
|
|
||||||
syncable.post_sync(self)
|
syncable.post_sync(self)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -56,10 +58,6 @@ class Sync < ApplicationRecord
|
||||||
children.where(status: [ :pending, :syncing ]).any?
|
children.where(status: [ :pending, :syncing ]).any?
|
||||||
end
|
end
|
||||||
|
|
||||||
def has_failed_child_syncs?
|
|
||||||
children.where(status: :failed).any?
|
|
||||||
end
|
|
||||||
|
|
||||||
def has_parent?
|
def has_parent?
|
||||||
parent_id.present?
|
parent_id.present?
|
||||||
end
|
end
|
||||||
|
|
|
@ -90,7 +90,7 @@ class TradeImport < Import
|
||||||
return internal_security if internal_security.present?
|
return internal_security if internal_security.present?
|
||||||
|
|
||||||
# If security prices provider isn't properly configured or available, create with nil exchange_operating_mic
|
# If security prices provider isn't properly configured or available, create with nil exchange_operating_mic
|
||||||
return Security.find_or_create_by!(ticker: ticker, exchange_operating_mic: nil) unless Security.provider.present?
|
return Security.find_or_create_by!(ticker: ticker&.upcase, exchange_operating_mic: nil) unless Security.provider.present?
|
||||||
|
|
||||||
# Cache provider responses so that when we're looping through rows and importing,
|
# 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
|
# we only hit our provider for the unique combinations of ticker / exchange_operating_mic
|
||||||
|
@ -104,9 +104,9 @@ class TradeImport < Import
|
||||||
).first
|
).first
|
||||||
end
|
end
|
||||||
|
|
||||||
return Security.find_or_create_by!(ticker: ticker, exchange_operating_mic: nil) if provider_security.nil?
|
return Security.find_or_create_by!(ticker: ticker&.upcase, exchange_operating_mic: nil) if provider_security.nil?
|
||||||
|
|
||||||
Security.find_or_create_by!(ticker: provider_security[:ticker], exchange_operating_mic: provider_security[:exchange_operating_mic]) do |security|
|
Security.find_or_create_by!(ticker: provider_security[:ticker]&.upcase, exchange_operating_mic: provider_security[:exchange_operating_mic]&.upcase) do |security|
|
||||||
security.name = provider_security[:name]
|
security.name = provider_security[:name]
|
||||||
security.country_code = provider_security[:country_code]
|
security.country_code = provider_security[:country_code]
|
||||||
security.logo_url = provider_security[:logo_url]
|
security.logo_url = provider_security[:logo_url]
|
||||||
|
|
|
@ -21,16 +21,17 @@
|
||||||
</details>
|
</details>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= render TabsComponent.new(active_tab: active_account_group_tab, url_param_key: "account_group_tab", testid: "account-sidebar-tabs") do |tabs| %>
|
<div data-controller="sidebar-tabs">
|
||||||
<% tabs.with_nav do |nav| %>
|
<%= render TabsComponent.new(active_tab: active_account_group_tab, url_param_key: "account_group_tab", testid: "account-sidebar-tabs") do |tabs| %>
|
||||||
<% nav.with_btn(id: "assets", label: "Assets") %>
|
<% tabs.with_nav do |nav| %>
|
||||||
<% nav.with_btn(id: "debts", label: "Debts") %>
|
<% nav.with_btn(id: "assets", label: "Assets") %>
|
||||||
<% nav.with_btn(id: "all", label: "All") %>
|
<% nav.with_btn(id: "debts", label: "Debts") %>
|
||||||
<% end %>
|
<% nav.with_btn(id: "all", label: "All") %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<% tabs.with_panel(tab_id: "assets") do %>
|
<% tabs.with_panel(tab_id: "assets") do %>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<%= render LinkComponent.new(
|
<%= render LinkComponent.new(
|
||||||
text: "New asset",
|
text: "New asset",
|
||||||
variant: "ghost",
|
variant: "ghost",
|
||||||
href: new_account_path(step: "method_select", classification: "asset"),
|
href: new_account_path(step: "method_select", classification: "asset"),
|
||||||
|
@ -40,17 +41,17 @@
|
||||||
class: "justify-start"
|
class: "justify-start"
|
||||||
) %>
|
) %>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<% family.balance_sheet.account_groups("asset").each do |group| %>
|
<% family.balance_sheet.account_groups("asset").each do |group| %>
|
||||||
<%= render "accounts/accountable_group", account_group: group %>
|
<%= render "accounts/accountable_group", account_group: group %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<% end %>
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<% tabs.with_panel(tab_id: "debts") do %>
|
<% tabs.with_panel(tab_id: "debts") do %>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<%= render LinkComponent.new(
|
<%= render LinkComponent.new(
|
||||||
text: "New debt",
|
text: "New debt",
|
||||||
variant: "ghost",
|
variant: "ghost",
|
||||||
href: new_account_path(step: "method_select", classification: "liability"),
|
href: new_account_path(step: "method_select", classification: "liability"),
|
||||||
|
@ -60,17 +61,17 @@
|
||||||
class: "justify-start"
|
class: "justify-start"
|
||||||
) %>
|
) %>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<% family.balance_sheet.account_groups("liability").each do |group| %>
|
<% family.balance_sheet.account_groups("liability").each do |group| %>
|
||||||
<%= render "accounts/accountable_group", account_group: group %>
|
<%= render "accounts/accountable_group", account_group: group %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<% end %>
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<% tabs.with_panel(tab_id: "all") do %>
|
<% tabs.with_panel(tab_id: "all") do %>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<%= render LinkComponent.new(
|
<%= render LinkComponent.new(
|
||||||
text: "New account",
|
text: "New account",
|
||||||
variant: "ghost",
|
variant: "ghost",
|
||||||
full_width: true,
|
full_width: true,
|
||||||
|
@ -80,12 +81,13 @@
|
||||||
class: "justify-start"
|
class: "justify-start"
|
||||||
) %>
|
) %>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<% family.balance_sheet.account_groups.each do |group| %>
|
<% family.balance_sheet.account_groups.each do |group| %>
|
||||||
<%= render "accounts/accountable_group", account_group: group %>
|
<%= render "accounts/accountable_group", account_group: group %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<%# locals: (account_group:) %>
|
<%# locals: (account_group:) %>
|
||||||
|
|
||||||
<%= render DisclosureComponent.new(title: account_group.name, align: :left) do |disclosure| %>
|
<%= render DisclosureComponent.new(title: account_group.name, align: :left, open: account_group.accounts.any? { |account| page_active?(account_path(account)) }) do |disclosure| %>
|
||||||
<% disclosure.with_summary_content do %>
|
<% disclosure.with_summary_content do %>
|
||||||
<div class="ml-auto text-right grow">
|
<div class="ml-auto text-right grow">
|
||||||
<%= tag.p format_money(account_group.total_money), class: "text-sm font-medium text-primary" %>
|
<%= tag.p format_money(account_group.total_money), class: "text-sm font-medium text-primary" %>
|
||||||
|
@ -15,7 +15,13 @@
|
||||||
|
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<% account_group.accounts.each do |account| %>
|
<% account_group.accounts.each do |account| %>
|
||||||
<%= link_to account_path(account), class: "block flex items-center gap-2 px-3 py-2 hover:bg-surface-hover", title: account.name do %>
|
<%= link_to account_path(account),
|
||||||
|
class: class_names(
|
||||||
|
"block flex items-center gap-2 px-3 py-2 rounded-lg",
|
||||||
|
page_active?(account_path(account)) ? "bg-container" : "hover:bg-surface-hover"
|
||||||
|
),
|
||||||
|
data: { sidebar_tabs_target: "account", action: "click->sidebar-tabs#select" },
|
||||||
|
title: account.name do %>
|
||||||
<%= render "accounts/logo", account: account, size: "sm", color: account_group.color %>
|
<%= render "accounts/logo", account: account, size: "sm", color: account_group.color %>
|
||||||
|
|
||||||
<div class="min-w-0 grow">
|
<div class="min-w-0 grow">
|
||||||
|
@ -36,13 +42,15 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= render LinkComponent.new(
|
<div class="my-2">
|
||||||
href: new_polymorphic_path(account_group.key, step: "method_select"),
|
<%= render LinkComponent.new(
|
||||||
text: "New #{account_group.name.downcase.singularize}",
|
href: new_polymorphic_path(account_group.key, step: "method_select"),
|
||||||
icon: "plus",
|
text: "New #{account_group.name.downcase.singularize}",
|
||||||
full_width: true,
|
icon: "plus",
|
||||||
variant: "ghost",
|
full_width: true,
|
||||||
frame: :modal,
|
variant: "ghost",
|
||||||
class: "justify-start"
|
frame: :modal,
|
||||||
) %>
|
class: "justify-start"
|
||||||
|
) %>
|
||||||
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -6,7 +6,10 @@
|
||||||
<%= form.hidden_field :return_to, value: params[:return_to] %>
|
<%= form.hidden_field :return_to, value: params[:return_to] %>
|
||||||
|
|
||||||
<%= form.text_field :name, placeholder: t(".name_placeholder"), required: "required", label: t(".name_label") %>
|
<%= form.text_field :name, placeholder: t(".name_placeholder"), required: "required", label: t(".name_label") %>
|
||||||
<%= form.money_field :balance, label: t(".balance"), required: true, default_currency: Current.family.currency %>
|
|
||||||
|
<% unless account.linked? %>
|
||||||
|
<%= form.money_field :balance, label: t(".balance"), required: true, default_currency: Current.family.currency %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<%= yield form %>
|
<%= yield form %>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -77,10 +77,11 @@
|
||||||
<div>
|
<div>
|
||||||
<div class="rounded-tl-lg rounded-tr-lg bg-container border-alpha-black-25 shadow-xs">
|
<div class="rounded-tl-lg rounded-tr-lg bg-container border-alpha-black-25 shadow-xs">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<% calculator = Balance::TrendCalculator.for(@entries) %>
|
<% calculator = Balance::TrendCalculator.new(@account.balances) %>
|
||||||
|
|
||||||
<%= entries_by_date(@entries) do |entries| %>
|
<%= entries_by_date(@entries) do |entries| %>
|
||||||
<% entries.each do |entry| %>
|
<% entries.each_with_index do |entry, index| %>
|
||||||
<%= render entry, balance_trend: calculator&.trend_for(entry), view_ctx: "account" %>
|
<%= render entry, balance_trend: index == 0 ? calculator.trend_for(entry.date) : nil, view_ctx: "account" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -28,7 +28,7 @@
|
||||||
Period.as_options,
|
Period.as_options,
|
||||||
{ selected: period.key },
|
{ selected: period.key },
|
||||||
data: { "auto-submit-form-target": "auto" },
|
data: { "auto-submit-form-target": "auto" },
|
||||||
class: "bg-container border border-secondary font-medium rounded-lg px-3 py-2 text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0" %>
|
class: "bg-container border border-secondary rounded-lg px-3 py-2 text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0" %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -13,13 +13,15 @@
|
||||||
) %>
|
) %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% menu.with_item(
|
<% unless account.linked? %>
|
||||||
variant: "button",
|
<% menu.with_item(
|
||||||
text: "Delete account",
|
variant: "button",
|
||||||
href: account_path(account),
|
text: "Delete account",
|
||||||
method: :delete,
|
href: account_path(account),
|
||||||
icon: "trash-2",
|
method: :delete,
|
||||||
confirm: CustomConfirm.for_resource_deletion("account", high_severity: true),
|
icon: "trash-2",
|
||||||
data: { turbo_frame: :_top }
|
confirm: CustomConfirm.for_resource_deletion("account", high_severity: true),
|
||||||
) %>
|
data: { turbo_frame: :_top }
|
||||||
|
) %>
|
||||||
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -15,7 +15,7 @@
|
||||||
<div class="rounded-md h-1.5 bg-green-500" style="width: <%= budget.surplus_percent %>%"></div>
|
<div class="rounded-md h-1.5 bg-green-500" style="width: <%= budget.surplus_percent %>%"></div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="rounded-md h-1.5 bg-green-500" style="width: <%= budget.actual_income_percent %>%"></div>
|
<div class="rounded-md h-1.5 bg-green-500" style="width: <%= budget.actual_income_percent %>%"></div>
|
||||||
<div class="rounded-md h-1.5 bg-gray-100" style="width: <%= 100 - budget.actual_income_percent %>%"></div>
|
<div class="rounded-md h-1.5 bg-surface-inset" style="width: <%= 100 - budget.actual_income_percent %>%"></div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between text-sm">
|
<div class="flex justify-between text-sm">
|
||||||
|
@ -41,18 +41,18 @@
|
||||||
<div>
|
<div>
|
||||||
<div class="flex h-1.5 mb-3 gap-1">
|
<div class="flex h-1.5 mb-3 gap-1">
|
||||||
<% if budget.available_to_spend.negative? %>
|
<% if budget.available_to_spend.negative? %>
|
||||||
<div class="rounded-md h-1.5 bg-gray-900" style="width: <%= 100 - budget.overage_percent %>%"></div>
|
<div class="rounded-md h-1.5 bg-inverse" style="width: <%= 100 - budget.overage_percent %>%"></div>
|
||||||
<div class="rounded-md h-1.5 bg-red-500" style="width: <%= budget.overage_percent %>%"></div>
|
<div class="rounded-md h-1.5 bg-destructive" style="width: <%= budget.overage_percent %>%"></div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="rounded-md h-1.5 bg-gray-900" style="width: <%= budget.percent_of_budget_spent %>%"></div>
|
<div class="rounded-md h-1.5 bg-inverse" style="width: <%= budget.percent_of_budget_spent %>%"></div>
|
||||||
<div class="rounded-md h-1.5 bg-gray-100" style="width: <%= 100 - budget.percent_of_budget_spent %>%"></div>
|
<div class="rounded-md h-1.5 bg-surface-inset" style="width: <%= 100 - budget.percent_of_budget_spent %>%"></div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between text-sm">
|
<div class="flex justify-between text-sm">
|
||||||
<p class="text-secondary"><%= format_money(budget.actual_spending_money) %> spent</p>
|
<p class="text-secondary"><%= format_money(budget.actual_spending_money) %> spent</p>
|
||||||
<p class="font-medium">
|
<p class="font-medium">
|
||||||
<% if budget.available_to_spend.negative? %>
|
<% if budget.available_to_spend.negative? %>
|
||||||
<span class="text-red-500"><%= format_money(budget.available_to_spend_money.abs) %> over</span>
|
<span class="text-destructive"><%= format_money(budget.available_to_spend_money.abs) %> over</span>
|
||||||
<% else %>
|
<% else %>
|
||||||
<span class="text-primary"><%= format_money(budget.available_to_spend_money) %> left</span>
|
<span class="text-primary"><%= format_money(budget.available_to_spend_money) %> left</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -16,31 +16,26 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
||||||
<% if @budget.initialized? && @budget.available_to_allocate.positive? %>
|
<% if @budget.initialized? && @budget.available_to_allocate.positive? %>
|
||||||
<div class="flex gap-2 mb-2 rounded-lg bg-alpha-black-25 p-1">
|
<%= render TabsComponent.new(active_tab: params[:tab].presence || "budgeted") do |tabs| %>
|
||||||
<% base_classes = "rounded-md px-2 py-1 flex-1 text-center" %>
|
<% tabs.with_nav do |nav| %>
|
||||||
<% selected_tab = params[:tab].presence || "budgeted" %>
|
<% nav.with_btn(id: "budgeted", label: "Budgeted") %>
|
||||||
|
<% nav.with_btn(id: "actuals", label: "Actual") %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<%= link_to "Budgeted",
|
<% tabs.with_panel(tab_id: "budgeted") do %>
|
||||||
budget_path(@budget, tab: "budgeted"),
|
<div class="bg-container rounded-xl shadow-border-xs">
|
||||||
class: class_names(
|
<%= render "budgets/budgeted_summary", budget: @budget %>
|
||||||
base_classes,
|
</div>
|
||||||
"bg-container shadow-xs text-primary": selected_tab == "budgeted",
|
<% end %>
|
||||||
"text-secondary": selected_tab != "budgeted"
|
|
||||||
) %>
|
|
||||||
|
|
||||||
<%= link_to "Actual",
|
<% tabs.with_panel(tab_id: "actuals") do %>
|
||||||
budget_path(@budget, tab: "actuals"),
|
<div class="bg-container rounded-xl shadow-border-xs">
|
||||||
class: class_names(
|
<%= render "budgets/actuals_summary", budget: @budget %>
|
||||||
base_classes,
|
</div>
|
||||||
"bg-container shadow-xs text-primary": selected_tab == "actuals",
|
<% end %>
|
||||||
"text-secondary": selected_tab != "actuals"
|
<% end %>
|
||||||
) %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-container rounded-xl shadow-border-xs">
|
|
||||||
<%= render selected_tab == "budgeted" ? "budgets/budgeted_summary" : "budgets/actuals_summary", budget: @budget %>
|
|
||||||
</div>
|
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="bg-container rounded-xl shadow-border-xs">
|
<div class="bg-container rounded-xl shadow-border-xs">
|
||||||
<%= render "budgets/actuals_summary", budget: @budget %>
|
<%= render "budgets/actuals_summary", budget: @budget %>
|
||||||
|
|
|
@ -2,10 +2,10 @@
|
||||||
<% category ||= Category.uncategorized %>
|
<% category ||= Category.uncategorized %>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span class="flex items-center gap-1 text-sm font-medium rounded-full px-1.5 py-1 border truncate"
|
<span class="flex items-center gap-1 text-sm font-medium rounded-full px-1.5 py-1 border truncate focus-visible:outline-none focus-visible:ring-0"
|
||||||
style="
|
style="
|
||||||
background-color: color-mix(in srgb, <%= category.color %> 5%, white);
|
background-color: color-mix(in oklab, <%= category.color %> 10%, transparent);
|
||||||
border-color: color-mix(in srgb, <%= category.color %> 30%, white);
|
border-color: color-mix(in oklab, <%= category.color %> 10%, transparent);
|
||||||
color: <%= category.color %>;">
|
color: <%= category.color %>;">
|
||||||
<% if category.lucide_icon.present? %>
|
<% if category.lucide_icon.present? %>
|
||||||
<%= icon category.lucide_icon, size: "sm", color: "current" %>
|
<%= icon category.lucide_icon, size: "sm", color: "current" %>
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
<%= render partial: "color_avatar", locals: { category: category } %>
|
<%= render partial: "color_avatar", locals: { category: category } %>
|
||||||
|
|
||||||
<details data-category-target="details">
|
<details data-category-target="details">
|
||||||
<summary class="cursor-pointer absolute -bottom-2 -right-2 flex justify-center items-center bg-gray-50 border-2 w-7 h-7 border-white rounded-full text-gray-500">
|
<summary class="cursor-pointer absolute -bottom-2 -right-2 flex justify-center items-center bg-surface-inset hover:bg-surface-inset-hover border-2 w-7 h-7 border-subdued rounded-full text-secondary">
|
||||||
<%= icon("pen", size: "sm") %>
|
<%= icon("pen", size: "sm") %>
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
|
@ -19,7 +19,7 @@
|
||||||
<% Category::COLORS.each do |color| %>
|
<% Category::COLORS.each do |color| %>
|
||||||
<label class="relative">
|
<label class="relative">
|
||||||
<%= f.radio_button :color, color, class: "sr-only peer", data: { action: "change->category#handleColorChange" } %>
|
<%= f.radio_button :color, color, class: "sr-only peer", data: { action: "change->category#handleColorChange" } %>
|
||||||
<div class="w-6 h-6 rounded-full cursor-pointer peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-blue-500" style="background-color: <%= color %>"></div>
|
<div class="w-6 h-6 rounded-full cursor-pointer peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-gray-500" style="background-color: <%= color %>"></div>
|
||||||
</label>
|
</label>
|
||||||
<% end %>
|
<% end %>
|
||||||
<label class="relative">
|
<label class="relative">
|
||||||
|
@ -41,12 +41,12 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2 justify-center flex-col w-87">
|
<div class="flex flex-wrap gap-2 justify-center flex-col w-87">
|
||||||
<h4 class="text-gray-500 text-sm">Icon</h4>
|
<h4 class="text-secondary text-sm">Icon</h4>
|
||||||
<div class="flex flex-wrap gap-0.5">
|
<div class="flex flex-wrap gap-0.5">
|
||||||
<% Category.icon_codes.each do |icon| %>
|
<% Category.icon_codes.each do |icon| %>
|
||||||
<label class="relative">
|
<label class="relative">
|
||||||
<%= f.radio_button :lucide_icon, icon, class: "sr-only peer", data: { action: "change->category#handleIconChange change->category#handleIconColorChange", category_target:"icon" } %>
|
<%= f.radio_button :lucide_icon, icon, class: "sr-only peer", data: { action: "change->category#handleIconChange change->category#handleIconColorChange", category_target:"icon" } %>
|
||||||
<div class="w-7 h-7 flex m-0.5 items-center justify-center rounded-full cursor-pointer hover:bg-gray-100 peer-checked:bg-gray-100 border-1 border-transparent">
|
<div class="text-secondary w-7 h-7 flex m-0.5 items-center justify-center rounded-full cursor-pointer hover:bg-container-inset-hover peer-checked:bg-container-inset border-1 border-transparent">
|
||||||
<%= icon(icon, size: "sm", color: "current") %>
|
<%= icon(icon, size: "sm", color: "current") %>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
|
@ -2,8 +2,8 @@
|
||||||
<% is_selected = category.id === @selected_category&.id %>
|
<% is_selected = category.id === @selected_category&.id %>
|
||||||
|
|
||||||
<%= content_tag :div,
|
<%= content_tag :div,
|
||||||
class: ["filterable-item flex justify-between items-center border-none rounded-lg px-2 py-1 group w-full hover:bg-gray-25 focus-within:bg-gray-25",
|
class: ["filterable-item flex justify-between items-center border-none rounded-lg px-2 py-1 group w-full hover:bg-container-inset-hover",
|
||||||
{ "bg-gray-25": is_selected }],
|
{ "bg-container-inset": is_selected }],
|
||||||
data: { filter_name: category.name } do %>
|
data: { filter_name: category.name } do %>
|
||||||
<%= button_to transaction_category_path(
|
<%= button_to transaction_category_path(
|
||||||
@transaction.entry,
|
@transaction.entry,
|
||||||
|
|
|
@ -2,7 +2,13 @@
|
||||||
<div class="flex flex-col relative" data-controller="list-filter">
|
<div class="flex flex-col relative" data-controller="list-filter">
|
||||||
<div class="grow p-1.5">
|
<div class="grow p-1.5">
|
||||||
<div class="relative flex items-center bg-container border border-secondary rounded-lg">
|
<div class="relative flex items-center bg-container border border-secondary rounded-lg">
|
||||||
<input placeholder="<%= t(".search_placeholder") %>" autocomplete="nope" type="search" class="placeholder:text-sm placeholder:text-secondary font-normal h-10 relative pl-10 w-full border-none rounded-lg" data-list-filter-target="input" data-action="list-filter#filter">
|
<input
|
||||||
|
placeholder="<%= t(".search_placeholder") %>"
|
||||||
|
autocomplete="nope"
|
||||||
|
type="search"
|
||||||
|
class="bg-container placeholder:text-sm placeholder:text-secondary font-normal h-10 relative pl-10 w-full border-none rounded-lg focus:outline-hidden focus:ring-0"
|
||||||
|
data-list-filter-target="input"
|
||||||
|
data-action="list-filter#filter" />
|
||||||
<%= icon("search", class: "absolute inset-0 ml-2 transform top-1/2 -translate-y-1/2") %>
|
<%= icon("search", class: "absolute inset-0 ml-2 transform top-1/2 -translate-y-1/2") %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -33,14 +39,16 @@
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<hr>
|
|
||||||
|
<hr class="border-tertiary">
|
||||||
|
|
||||||
<div class="relative p-1.5 w-full">
|
<div class="relative p-1.5 w-full">
|
||||||
<% if @transaction.category %>
|
<% if @transaction.category %>
|
||||||
<%= button_to transaction_path(@transaction.entry),
|
<%= button_to transaction_path(@transaction.entry),
|
||||||
method: :patch,
|
method: :patch,
|
||||||
data: { turbo_frame: dom_id(@transaction.entry) },
|
data: { turbo_frame: dom_id(@transaction.entry) },
|
||||||
params: { entry: { entryable_type: "Transaction", entryable_attributes: { id: @transaction.id, category_id: nil } } },
|
params: { entry: { entryable_type: "Transaction", entryable_attributes: { id: @transaction.id, category_id: nil } } },
|
||||||
class: "flex text-sm font-medium items-center gap-2 text-secondary w-full rounded-lg p-2 hover:bg-gray-100" do %>
|
class: "flex text-sm font-medium items-center gap-2 text-secondary w-full rounded-lg p-2 hover:bg-container-inset-hover" do %>
|
||||||
<%= icon("minus") %>
|
<%= icon("minus") %>
|
||||||
|
|
||||||
<%= t(".clear") %>
|
<%= t(".clear") %>
|
||||||
|
@ -49,7 +57,7 @@
|
||||||
|
|
||||||
<% unless @transaction.transfer? %>
|
<% unless @transaction.transfer? %>
|
||||||
<%= link_to new_transaction_transfer_match_path(@transaction.entry),
|
<%= link_to new_transaction_transfer_match_path(@transaction.entry),
|
||||||
class: "flex text-sm font-medium items-center gap-2 text-secondary w-full rounded-lg p-2 hover:bg-gray-100",
|
class: "flex text-sm font-medium items-center gap-2 text-secondary w-full rounded-lg p-2 hover:bg-container-inset-hover",
|
||||||
data: { turbo_frame: "modal" } do %>
|
data: { turbo_frame: "modal" } do %>
|
||||||
<%= icon("refresh-cw") %>
|
<%= icon("refresh-cw") %>
|
||||||
|
|
||||||
|
|
|
@ -30,8 +30,8 @@
|
||||||
<% questions.each do |question| %>
|
<% questions.each do |question| %>
|
||||||
<button data-action="chat#submitSampleQuestion"
|
<button data-action="chat#submitSampleQuestion"
|
||||||
data-chat-question-param="<%= question[:text] %>"
|
data-chat-question-param="<%= question[:text] %>"
|
||||||
class="w-full flex items-center gap-2 border border-tertiary rounded-full py-1.5 px-2.5 hover:bg-gray-100">
|
class="w-fit flex items-center gap-2 border border-tertiary rounded-full py-1.5 px-2.5 hover:bg-gray-100">
|
||||||
<%= icon(question[:icon], color: "gray") %> <%= question[:text] %>
|
<%= icon(question[:icon]) %> <%= question[:text] %>
|
||||||
</button>
|
</button>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -4,9 +4,12 @@
|
||||||
<% path = chat.new_record? ? chats_path : chats_path(previous_chat_id: chat.id) %>
|
<% path = chat.new_record? ? chats_path : chats_path(previous_chat_id: chat.id) %>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 grow">
|
<div class="flex items-center gap-2 grow">
|
||||||
<%= link_to path, id: "chat-nav-back", class: "w-9 h-9 flex items-center justify-center rounded-lg hover:bg-surface-hover" do %>
|
<%= render LinkComponent.new(
|
||||||
<%= icon("menu", color: "gray" ) %>
|
id: "chat-nav-back",
|
||||||
<% end %>
|
variant: "icon",
|
||||||
|
icon: "menu",
|
||||||
|
href: path,
|
||||||
|
) %>
|
||||||
|
|
||||||
<div class="grow">
|
<div class="grow">
|
||||||
<%= render "chats/chat_title", chat: chat, ctx: "chat" %>
|
<%= render "chats/chat_title", chat: chat, ctx: "chat" %>
|
||||||
|
|
|
@ -4,9 +4,12 @@
|
||||||
<% if @chats.any? %>
|
<% if @chats.any? %>
|
||||||
<nav class="mb-6">
|
<nav class="mb-6">
|
||||||
<% back_path = @last_viewed_chat ? chat_path(@last_viewed_chat) : new_chat_path %>
|
<% back_path = @last_viewed_chat ? chat_path(@last_viewed_chat) : new_chat_path %>
|
||||||
<%= link_to back_path, class: "w-9 h-9 flex items-center justify-center rounded-lg hover:bg-surface-hover" do %>
|
|
||||||
<%= icon("arrow-left", color: "gray" ) %>
|
<%= render LinkComponent.new(
|
||||||
<% end %>
|
variant: "icon",
|
||||||
|
icon: "arrow-left",
|
||||||
|
href: back_path,
|
||||||
|
) %>
|
||||||
</nav>
|
</nav>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<%= turbo_frame_tag chat_frame do %>
|
<%= turbo_frame_tag chat_frame do %>
|
||||||
<div class="flex flex-col h-full md:p-4">
|
<div class="flex flex-col h-full md:px-4 md:pb-4">
|
||||||
<%= render "chats/chat_nav", chat: @chat %>
|
<%= render "chats/chat_nav", chat: @chat %>
|
||||||
|
|
||||||
<div class="mt-auto py-8">
|
<div class="mt-auto py-8">
|
||||||
|
|
|
@ -30,7 +30,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%# DESKTOP - Chat form %>
|
<%# DESKTOP - Chat form %>
|
||||||
<div class="p-4 lg:mt-auto fixed lg:static left-0 bottom-16 w-full bg-surface">
|
<div class="p-4 pt-0 lg:mt-auto fixed lg:static left-0 bottom-16 w-full bg-surface">
|
||||||
<%= render "messages/chat_form", chat: @chat %>
|
<%= render "messages/chat_form", chat: @chat %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -30,7 +30,9 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= family_stream %>
|
<% if Current.family %>
|
||||||
|
<%= turbo_stream_from Current.family %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<%= turbo_frame_tag "modal" %>
|
<%= turbo_frame_tag "modal" %>
|
||||||
<%= turbo_frame_tag "drawer" %>
|
<%= turbo_frame_tag "drawer" %>
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
<%= tag.div class: class_names(
|
<%= tag.div class: class_names(
|
||||||
"w-8 h-8 flex items-center justify-center mx-auto rounded-lg",
|
"w-8 h-8 flex items-center justify-center mx-auto rounded-lg",
|
||||||
active ? "bg-container shadow-xs text-primary" : "group-hover:bg-container-hover text-secondary"
|
active ? "bg-container shadow-xs text-primary" : "group-hover:bg-surface-hover text-secondary"
|
||||||
) do %>
|
) do %>
|
||||||
<%= icon(icon, color: active ? "current" : "default", custom: icon_custom) %>
|
<%= icon(icon, color: active ? "current" : "default", custom: icon_custom) %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -49,15 +49,15 @@
|
||||||
<div class="shadow-border-xs rounded-lg bg-container min-w-fit">
|
<div class="shadow-border-xs rounded-lg bg-container min-w-fit">
|
||||||
<% classification_group.account_groups.each do |account_group| %>
|
<% classification_group.account_groups.each do |account_group| %>
|
||||||
<details class="group rounded-lg open:bg-surface font-medium text-sm">
|
<details class="group rounded-lg open:bg-surface font-medium text-sm">
|
||||||
<summary class="cursor-pointer p-4 group-open:bg-surface bg-container rounded-lg flex items-center justify-between">
|
<summary class="focus-visible:outline-none focus-visible:ring-0 cursor-pointer p-4 group-open:bg-surface bg-container rounded-lg flex items-center justify-between">
|
||||||
<div class="w-40 shrink-0 flex items-center gap-4">
|
<div class="w-40 shrink-0 flex items-center gap-4">
|
||||||
<%= icon("chevron-right", class: "group-open:rotate-90") %>
|
<%= icon("chevron-right", class: "group-open:rotate-90") %>
|
||||||
|
|
||||||
<p><%= account_group.name %></p>
|
<p><%= account_group.name %></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ml-auto flex items-center text-right gap-6">
|
<div class="flex items-center justify-between text-right gap-6">
|
||||||
<div class="w-24 shrink-0 flex items-center justify-end gap-2">
|
<div class="w-28 shrink-0 flex items-center justify-end gap-2">
|
||||||
<%= render "pages/dashboard/group_weight", weight: account_group.weight, color: account_group.color %>
|
<%= render "pages/dashboard/group_weight", weight: account_group.weight, color: account_group.color %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -77,7 +77,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ml-auto flex items-center text-right gap-6">
|
<div class="ml-auto flex items-center text-right gap-6">
|
||||||
<div class="w-24 shrink-0 flex items-center justify-end gap-2">
|
<div class="w-28 shrink-0 flex items-center justify-end gap-2">
|
||||||
<%= render "pages/dashboard/group_weight", weight: account.weight, color: account_group.color %>
|
<%= render "pages/dashboard/group_weight", weight: account.weight, color: account_group.color %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
<%# locals: (weight:, color:) %>
|
<%# locals: (weight:, color:) %>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="w-full flex items-center justify-between gap-2">
|
||||||
<div class="flex gap-[3px]">
|
<div class="flex gap-[3px]">
|
||||||
<% 10.times do |i| %>
|
<% 10.times do |i| %>
|
||||||
<div class="w-[2px] h-[10px] rounded-lg <%= i < (weight / 10.0).ceil ? "" : "opacity-20" %>" style="background-color: <%= color %>;"></div>
|
<div class="w-0.5 h-2.5 rounded-lg <%= i < (weight / 10.0).ceil ? "" : "opacity-20" %>" style="background-color: <%= color %>;"></div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm"><%= number_to_percentage(weight, precision: 2) %></p>
|
<p class="text-sm"><%= number_to_percentage(weight, precision: 2) %></p>
|
||||||
|
|
|
@ -3,9 +3,9 @@
|
||||||
%>
|
%>
|
||||||
|
|
||||||
<% if self_hosted_first_login? %>
|
<% if self_hosted_first_login? %>
|
||||||
<div class="fixed inset-0 w-full h-fit bg-gray-25 p-5 border-b border-secondary flex flex-col gap-3 items-center text-center mb-12">
|
<div class="fixed inset-0 w-full h-fit bg-container p-5 border-b border-secondary flex flex-col gap-3 items-center text-center mb-12">
|
||||||
<h2 class="font-bold text-xl"><%= t(".welcome_title") %></h2>
|
<h2 class="font-bold text-primary text-xl"><%= t(".welcome_title") %></h2>
|
||||||
<p class="text-secondary text-sm"><%= t(".welcome_body") %></p>
|
<p class="text-secondary text-secondary text-sm"><%= t(".welcome_body") %></p>
|
||||||
</div>
|
</div>
|
||||||
<% elsif @invitation %>
|
<% elsif @invitation %>
|
||||||
<div class="space-y-1 mb-6 text-center">
|
<div class="space-y-1 mb-6 text-center">
|
||||||
|
|
|
@ -1,3 +1,36 @@
|
||||||
|
<%
|
||||||
|
nav_sections = [
|
||||||
|
{
|
||||||
|
header: t('.general_section_title'),
|
||||||
|
items: [
|
||||||
|
{ label: t('.profile_label'), path: settings_profile_path, icon: 'circle-user' },
|
||||||
|
{ label: t('.preferences_label'), path: settings_preferences_path, icon: 'bolt' },
|
||||||
|
{ label: t('.security_label'), path: settings_security_path, icon: 'shield-check' },
|
||||||
|
{ label: t('.self_hosting_label'), path: settings_hosting_path, icon: 'database', if: self_hosted? },
|
||||||
|
{ label: t('.billing_label'), path: settings_billing_path, icon: 'circle-dollar-sign', if: !self_hosted? },
|
||||||
|
{ label: t('.accounts_label'), path: accounts_path, icon: 'layers' },
|
||||||
|
{ label: t('.imports_label'), path: imports_path, icon: 'download' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t('.transactions_section_title'),
|
||||||
|
items: [
|
||||||
|
{ label: t('.tags_label'), path: tags_path, icon: 'tags' },
|
||||||
|
{ label: t('.categories_label'), path: categories_path, icon: 'shapes' },
|
||||||
|
{ label: t('.rules_label'), path: rules_path, icon: 'git-branch' },
|
||||||
|
{ label: t('.merchants_label'), path: family_merchants_path, icon: 'store' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t('.other_section_title'),
|
||||||
|
items: [
|
||||||
|
{ label: t('.whats_new_label'), path: changelog_path, icon: 'box' },
|
||||||
|
{ label: t('.feedback_label'), path: feedback_path, icon: 'megaphone' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
%>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="hidden lg:flex items-center gap-2 p-1.5">
|
<div class="hidden lg:flex items-center gap-2 p-1.5">
|
||||||
<%= render LinkComponent.new(
|
<%= render LinkComponent.new(
|
||||||
|
@ -6,86 +39,27 @@
|
||||||
href: previous_path,
|
href: previous_path,
|
||||||
variant: "ghost",
|
variant: "ghost",
|
||||||
) %>
|
) %>
|
||||||
|
|
||||||
<%= link_to previous_path, class: "hidden md:block uppercase bg-surface-inset-hover rounded-sm px-1 py-0.5 text-xs text-secondary shadow-sm ml-1 pointer-events-none", data: { controller: "hotkey", hotkey: "Escape" } do %>
|
<%= link_to previous_path, class: "hidden md:block uppercase bg-surface-inset-hover rounded-sm px-1 py-0.5 text-xs text-secondary shadow-sm ml-1 pointer-events-none", data: { controller: "hotkey", hotkey: "Escape" } do %>
|
||||||
<kbd>esc</kbd>
|
<kbd>esc</kbd>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="space-y-4 hidden md:block">
|
<nav class="space-y-4 hidden md:block">
|
||||||
<section class="space-y-2">
|
<% nav_sections.each do |section| %>
|
||||||
<div class="flex items-center gap-2 px-3">
|
<section class="space-y-2">
|
||||||
<h3 class="uppercase text-secondary font-medium text-xs"><%= t(".general_section_title") %></h3>
|
<div class="flex items-center gap-2 px-3">
|
||||||
<div class="h-px bg-alpha-black-100 w-full"></div>
|
<h3 class="uppercase text-secondary font-medium text-xs"><%= section[:header] %></h3>
|
||||||
</div>
|
<div class="h-px bg-alpha-black-100 w-full"></div>
|
||||||
<ul class="space-y-1">
|
</div>
|
||||||
<li>
|
<ul class="space-y-1">
|
||||||
<%= render "settings/settings_nav_item", name: t(".profile_label"), path: settings_profile_path, icon: "circle-user" %>
|
<% section[:items].each do |item| %>
|
||||||
</li>
|
<% next if item[:if] == false %>
|
||||||
|
<li>
|
||||||
<li>
|
<%= render "settings/settings_nav_item", name: item[:label], path: item[:path], icon: item[:icon] %>
|
||||||
<%= render "settings/settings_nav_item", name: t(".preferences_label"), path: settings_preferences_path, icon: "bolt" %>
|
</li>
|
||||||
</li>
|
<% end %>
|
||||||
|
</ul>
|
||||||
<li>
|
</section>
|
||||||
<%= render "settings/settings_nav_item", name: t(".security_label"), path: settings_security_path, icon: "shield-check" %>
|
<% end %>
|
||||||
</li>
|
|
||||||
|
|
||||||
<% if self_hosted? %>
|
|
||||||
<li>
|
|
||||||
<%= render "settings/settings_nav_item", name: t(".self_hosting_label"), path: settings_hosting_path, icon: "database" %>
|
|
||||||
</li>
|
|
||||||
<% end %>
|
|
||||||
<% unless self_hosted? %>
|
|
||||||
<li>
|
|
||||||
<%= render "settings/settings_nav_item", name: t(".billing_label"), path: settings_billing_path, icon: "circle-dollar-sign" %>
|
|
||||||
</li>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<li>
|
|
||||||
<%= render "settings/settings_nav_item", name: t(".accounts_label"), path: accounts_path, icon: "layers" %>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li>
|
|
||||||
<%= render "settings/settings_nav_item", name: t(".imports_label"), path: imports_path, icon: "download" %>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="space-y-2">
|
|
||||||
<div class="flex items-center gap-2 px-3">
|
|
||||||
<h3 class="uppercase text-secondary font-medium text-xs"><%= t(".transactions_section_title") %></h3>
|
|
||||||
<div class="h-px bg-alpha-black-100 w-full"></div>
|
|
||||||
</div>
|
|
||||||
<ul class="space-y-1">
|
|
||||||
<li>
|
|
||||||
<%= render "settings/settings_nav_item", name: t(".tags_label"), path: tags_path, icon: "tags" %>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<%= render "settings/settings_nav_item", name: t(".categories_label"), path: categories_path, icon: "shapes" %>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<%= render "settings/settings_nav_item", name: "Rules", path: rules_path, icon: "git-branch" %>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<%= render "settings/settings_nav_item", name: t(".merchants_label"), path: family_merchants_path, icon: "store" %>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="space-y-2">
|
|
||||||
<div class="flex items-center gap-2 px-3">
|
|
||||||
<h3 class="uppercase text-secondary font-medium text-xs"><%= t(".other_section_title") %></h3>
|
|
||||||
<div class="h-px bg-alpha-black-100 w-full"></div>
|
|
||||||
</div>
|
|
||||||
<ul class="space-y-1">
|
|
||||||
<li>
|
|
||||||
<%= render "settings/settings_nav_item", name: t(".whats_new_label"), path: changelog_path, icon: "box" %>
|
|
||||||
<%= render "settings/settings_nav_item", name: t(".feedback_label"), path: feedback_path, icon: "megaphone" %>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<%= button_to session_path(Current.session), method: :delete, class: "flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-destructive hover:bg-surface-hover w-full" do %>
|
<%= button_to session_path(Current.session), method: :delete, class: "flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-destructive hover:bg-surface-hover w-full" do %>
|
||||||
<%= icon("log-out", color: "current") %>
|
<%= icon("log-out", color: "current") %>
|
||||||
|
@ -93,7 +67,6 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
</section>
|
</section>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<nav class="space-y-4 overflow-y-auto md:hidden" id="mobile-settings-nav">
|
<nav class="space-y-4 overflow-y-auto md:hidden" id="mobile-settings-nav">
|
||||||
<ul class="flex space-y-1">
|
<ul class="flex space-y-1">
|
||||||
<li>
|
<li>
|
||||||
|
@ -104,61 +77,18 @@
|
||||||
variant: "ghost",
|
variant: "ghost",
|
||||||
) %>
|
) %>
|
||||||
</li>
|
</li>
|
||||||
|
<% nav_sections.each do |section| %>
|
||||||
<li>
|
<% section[:items].each do |item| %>
|
||||||
<%= render "settings/settings_nav_item", name: t(".profile_label"), path: settings_profile_path, icon: "circle-user" %>
|
<% next if item[:if] == false %>
|
||||||
</li>
|
<li>
|
||||||
|
<%= render "settings/settings_nav_item", name: item[:label], path: item[:path], icon: item[:icon] %>
|
||||||
<li>
|
</li>
|
||||||
<%= render "settings/settings_nav_item", name: t(".preferences_label"), path: settings_preferences_path, icon: "bolt" %>
|
<% end %>
|
||||||
</li>
|
|
||||||
|
|
||||||
<li>
|
|
||||||
<%= render "settings/settings_nav_item", name: t(".security_label"), path: settings_security_path, icon: "shield-check" %>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<% if self_hosted? %>
|
|
||||||
<li>
|
|
||||||
<%= render "settings/settings_nav_item", name: t(".self_hosting_label"), path: settings_hosting_path, icon: "database" %>
|
|
||||||
</li>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
<% unless self_hosted? %>
|
|
||||||
<li>
|
|
||||||
<%= render "settings/settings_nav_item", name: t(".billing_label"), path: settings_billing_path, icon: "circle-dollar-sign" %>
|
|
||||||
</li>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<li>
|
|
||||||
<%= render "settings/settings_nav_item", name: t(".accounts_label"), path: accounts_path, icon: "layers" %>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li>
|
|
||||||
<%= render "settings/settings_nav_item", name: t(".imports_label"), path: imports_path, icon: "download" %>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li>
|
|
||||||
<%= render "settings/settings_nav_item", name: t(".tags_label"), path: tags_path, icon: "tags" %>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<%= render "settings/settings_nav_item", name: t(".categories_label"), path: categories_path, icon: "shapes" %>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<%= render "settings/settings_nav_item", name: t(".merchants_label"), path: family_merchants_path, icon: "store" %>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li>
|
|
||||||
<%= render "settings/settings_nav_item", name: t(".whats_new_label"), path: changelog_path, icon: "box" %>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<%= render "settings/settings_nav_item", name: t(".feedback_label"), path: feedback_path, icon: "megaphone" %>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<%= button_to session_path(Current.session), method: :delete, class: "flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-destructive hover:bg-surface-hover w-full" do %>
|
<%= button_to session_path(Current.session), method: :delete, class: "flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-destructive hover:bg-surface-hover w-full" do %>
|
||||||
<%= icon("log-out", color: "current") %>
|
<%= icon("log-out", color: "current") %>
|
||||||
<span><%= t(".logout") %></span>
|
<span><%= t(".logout") %></span>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<%# locals: (message:) %>
|
<%# locals: (message:) %>
|
||||||
|
|
||||||
<%= tag.div class: "flex gap-3 rounded-lg bg-container-inset p-4 group w-full md:max-w-80 shadow-border-lg",
|
<%= tag.div class: "flex gap-3 rounded-lg bg-container p-4 group w-full md:max-w-80 shadow-border-lg",
|
||||||
data: { controller: "element-removal" } do %>
|
data: { controller: "element-removal" } do %>
|
||||||
<div class="h-5 w-5 shrink-0 p-px text-primary">
|
<div class="h-5 w-5 shrink-0 p-px text-primary">
|
||||||
<div class="flex h-full items-center justify-center rounded-full bg-destructive">
|
<div class="flex h-full items-center justify-center rounded-full bg-destructive">
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<%# locals: (totals:) %>
|
<%# locals: (totals:) %>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 bg-container rounded-xl shadow-border-xs md:divide-x divide-y md:divide-y-0 divide-alpha-black-100">
|
<div class="grid grid-cols-1 md:grid-cols-3 bg-container rounded-xl shadow-border-xs md:divide-x divide-y md:divide-y-0 divide-alpha-black-100 theme-dark:divide-alpha-white-200">
|
||||||
<div class="p-4 space-y-2">
|
<div class="p-4 space-y-2">
|
||||||
<p class="text-sm text-secondary">Total transactions</p>
|
<p class="text-sm text-secondary">Total transactions</p>
|
||||||
<p class="text-primary font-medium text-xl" id="total-transactions"><%= totals.transactions_count.round(0) %></p>
|
<p class="text-primary font-medium text-xl" id="total-transactions"><%= totals.transactions_count.round(0) %></p>
|
||||||
|
|
|
@ -4,12 +4,11 @@
|
||||||
|
|
||||||
<%= turbo_frame_tag dom_id(entry) do %>
|
<%= turbo_frame_tag dom_id(entry) do %>
|
||||||
<%= turbo_frame_tag dom_id(transaction) do %>
|
<%= turbo_frame_tag dom_id(transaction) do %>
|
||||||
<div class="grid grid-cols-12 items-center text-primary text-sm font-medium p-4 md:p-4
|
<div class="grid grid-cols-12 items-center text-primary text-sm font-medium p-4 lg:p-4
|
||||||
<%= @focused_record == entry || @focused_record == transaction ?
|
<%= @focused_record == entry || @focused_record == transaction ?
|
||||||
"border border-gray-900 rounded-lg" : "" %>">
|
"border border-gray-900 rounded-lg" : "" %>">
|
||||||
|
|
||||||
<div class="pr-4 md:pr-10 flex items-center gap-3 md:gap-4
|
<div class="pr-4 lg:pr-10 flex items-center gap-3 lg:gap-4 col-span-8 lg:col-span-6">
|
||||||
<%= balance_trend ? "col-span-8 md:col-span-6" : "col-span-8" %>">
|
|
||||||
<%= check_box_tag dom_id(entry, "selection"),
|
<%= check_box_tag dom_id(entry, "selection"),
|
||||||
disabled: transaction.transfer?,
|
disabled: transaction.transfer?,
|
||||||
class: "checkbox checkbox--light",
|
class: "checkbox checkbox--light",
|
||||||
|
@ -58,7 +57,7 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-secondary text-xs font-normal hidden md:block">
|
<div class="text-secondary text-xs font-normal hidden lg:block">
|
||||||
<% if transaction.transfer? %>
|
<% if transaction.transfer? %>
|
||||||
<%= render "transfers/account_links",
|
<%= render "transfers/account_links",
|
||||||
transfer: transaction.transfer,
|
transfer: transaction.transfer,
|
||||||
|
@ -76,26 +75,24 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hidden md:flex items-center gap-1 col-span-2">
|
<div class="hidden lg:flex items-center gap-1 col-span-2">
|
||||||
<%= render "transactions/transaction_category", transaction: transaction %>
|
<%= render "transactions/transaction_category", transaction: transaction %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-4 md:col-span-2 ml-auto text-right">
|
<div class="col-span-4 lg:col-span-2 ml-auto text-right">
|
||||||
<%= content_tag :p,
|
<%= content_tag :p,
|
||||||
transaction.transfer? && view_ctx == "global" ? "+/- #{format_money(entry.amount_money.abs)}" : format_money(-entry.amount_money),
|
transaction.transfer? && view_ctx == "global" ? "+/- #{format_money(entry.amount_money.abs)}" : format_money(-entry.amount_money),
|
||||||
class: ["text-green-600": entry.amount.negative?] %>
|
class: ["text-green-600": entry.amount.negative?] %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if balance_trend %>
|
<div class="col-span-2 justify-self-end hidden lg:block">
|
||||||
<div class="col-span-2 justify-self-end hidden md:block">
|
<% if balance_trend&.trend %>
|
||||||
<% if balance_trend.trend %>
|
<%= tag.p format_money(balance_trend.trend.current),
|
||||||
<%= tag.p format_money(balance_trend.trend.current),
|
|
||||||
class: "font-medium text-sm text-primary" %>
|
class: "font-medium text-sm text-primary" %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= tag.p "--", class: "font-medium text-sm text-gray-400" %>
|
<%= tag.p "--", class: "font-medium text-sm text-gray-400" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -3,10 +3,10 @@
|
||||||
<div id="<%= dom_id(transaction, "transfer_match") %>" class="flex items-center gap-1">
|
<div id="<%= dom_id(transaction, "transfer_match") %>" class="flex items-center gap-1">
|
||||||
<% if transaction.transfer.confirmed? %>
|
<% if transaction.transfer.confirmed? %>
|
||||||
<span title="<%= transaction.transfer.payment? ? "Payment" : "Transfer" %> is confirmed">
|
<span title="<%= transaction.transfer.payment? ? "Payment" : "Transfer" %> is confirmed">
|
||||||
<%= icon "link-2", size: "sm", class: "text-indigo-600" %>
|
<%= icon "link-2", size: "sm", class: "text-secondary" %>
|
||||||
</span>
|
</span>
|
||||||
<% elsif transaction.transfer.pending? %>
|
<% elsif transaction.transfer.pending? %>
|
||||||
<span class="inline-flex items-center rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-medium text-indigo-700">
|
<span class="inline-flex items-center rounded-full bg-surface-inset px-2 py-0.5 text-xs font-medium text-secondary">
|
||||||
Auto-matched
|
Auto-matched
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
@ -14,7 +14,7 @@
|
||||||
method: :patch,
|
method: :patch,
|
||||||
class: "text-secondary hover:text-gray-800 flex items-center justify-center cursor-pointer",
|
class: "text-secondary hover:text-gray-800 flex items-center justify-center cursor-pointer",
|
||||||
title: "Confirm match" do %>
|
title: "Confirm match" do %>
|
||||||
<%= icon "check", size: "sm", class: "text-indigo-400 hover:text-indigo-600" %>
|
<%= icon "check", size: "sm", class: "text-secondary hover:text-primary" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= button_to transfer_path(transaction.transfer, transfer: { status: "rejected" }),
|
<%= button_to transfer_path(transaction.transfer, transfer: { status: "rejected" }),
|
||||||
|
@ -22,7 +22,7 @@
|
||||||
data: { turbo: false },
|
data: { turbo: false },
|
||||||
class: "text-secondary hover:text-gray-800 flex items-center justify-center cursor-pointer",
|
class: "text-secondary hover:text-gray-800 flex items-center justify-center cursor-pointer",
|
||||||
title: "Reject match" do %>
|
title: "Reject match" do %>
|
||||||
<%= icon "x", size: "sm", class: "text-subdued hover:text-gray-600" %>
|
<%= icon "x", size: "sm", class: "text-subdued hover:text-primary" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -66,7 +66,7 @@
|
||||||
<%= f.fields_for :entryable do |ef| %>
|
<%= f.fields_for :entryable do |ef| %>
|
||||||
|
|
||||||
<%= ef.collection_select :merchant_id,
|
<%= ef.collection_select :merchant_id,
|
||||||
Current.family.assigned_merchants.alphabetically,
|
Current.family.merchants.alphabetically,
|
||||||
:id, :name,
|
:id, :name,
|
||||||
{ include_blank: t(".none"),
|
{ include_blank: t(".none"),
|
||||||
label: t(".merchant_label"),
|
label: t(".merchant_label"),
|
||||||
|
|
|
@ -14,9 +14,7 @@
|
||||||
data: { id: entry.id, "bulk-select-target": "row", action: "bulk-select#toggleRowSelection" } %>
|
data: { id: entry.id, "bulk-select-target": "row", action: "bulk-select#toggleRowSelection" } %>
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<%= tag.div class: "w-6 h-6 rounded-full p-1.5 flex items-center justify-center", style: "color: #{color}" do %>
|
<%= render FilledIconComponent.new(icon: icon, size: "sm", hex_color: color, rounded: true) %>
|
||||||
<%= icon(icon, size: "sm", color: "current") %>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<div class="truncate text-primary">
|
<div class="truncate text-primary">
|
||||||
<%= link_to entry.name,
|
<%= link_to entry.name,
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# exit on error
|
|
||||||
set -o errexit
|
set -o errexit
|
||||||
|
|
||||||
echo "Installing gems..."
|
echo "Installing gems..."
|
||||||
bundle install
|
bundle install
|
||||||
|
|
||||||
echo "Precompiling assets..."
|
echo "Clobbering old assets..."
|
||||||
./bin/rails assets:precompile
|
bundle exec rails assets:clobber
|
||||||
./bin/rails assets:clean
|
|
||||||
|
|
||||||
echo "Build complete"
|
echo "Precompiling assets for production..."
|
||||||
|
bundle exec rails assets:precompile
|
||||||
|
|
||||||
|
echo "✅ Build complete"
|
|
@ -59,6 +59,8 @@ services:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- maybe_net
|
||||||
|
|
||||||
worker:
|
worker:
|
||||||
image: ghcr.io/maybe-finance/maybe:latest
|
image: ghcr.io/maybe-finance/maybe:latest
|
||||||
|
@ -69,6 +71,8 @@ services:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
<<: *rails_env
|
<<: *rails_env
|
||||||
|
networks:
|
||||||
|
- maybe_net
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
|
@ -82,11 +86,11 @@ services:
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- maybe_net
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:latest
|
image: redis:latest
|
||||||
ports:
|
|
||||||
- 6379:6379
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- redis-data:/data
|
- redis-data:/data
|
||||||
|
@ -95,8 +99,14 @@ services:
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- maybe_net
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
app-storage:
|
app-storage:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
redis-data:
|
redis-data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
maybe_net:
|
||||||
|
driver: bridge
|
||||||
|
|
|
@ -80,6 +80,7 @@ en:
|
||||||
other_section_title: More
|
other_section_title: More
|
||||||
preferences_label: Preferences
|
preferences_label: Preferences
|
||||||
profile_label: Account
|
profile_label: Account
|
||||||
|
rules_label: Rules
|
||||||
security_label: Security
|
security_label: Security
|
||||||
self_hosting_label: Self hosting
|
self_hosting_label: Self hosting
|
||||||
tags_label: Tags
|
tags_label: Tags
|
||||||
|
|
|
@ -120,4 +120,23 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
|
||||||
|
|
||||||
assert_equal expected, calculated
|
assert_equal expected, calculated
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "uses provider reported holdings and cash value on current day" do
|
||||||
|
aapl = securities(:aapl)
|
||||||
|
|
||||||
|
# Implied holdings value of $1,000 from provider
|
||||||
|
@account.update!(cash_balance: 19000, balance: 20000)
|
||||||
|
|
||||||
|
# Create a holding that differs in value from provider ($2,000 vs. the $1,000 reported by provider)
|
||||||
|
Holding.create!(date: Date.current, account: @account, security: aapl, qty: 10, price: 100, amount: 2000, currency: "USD")
|
||||||
|
Holding.create!(date: 1.day.ago.to_date, account: @account, security: aapl, qty: 10, price: 100, amount: 2000, currency: "USD")
|
||||||
|
|
||||||
|
# Today reports the provider value. Yesterday, provider won't give us any data, so we MUST look at the generated holdings value
|
||||||
|
# to calculate the end balance ($19,000 cash + $2,000 holdings = $21,000 total value)
|
||||||
|
expected = [ 21000, 20000 ]
|
||||||
|
|
||||||
|
calculated = Balance::ReverseCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
|
||||||
|
|
||||||
|
assert_equal expected, calculated
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
41
test/models/security_test.rb
Normal file
41
test/models/security_test.rb
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class SecurityTest < ActiveSupport::TestCase
|
||||||
|
# Below has 3 example scenarios:
|
||||||
|
# 1. Original ticker
|
||||||
|
# 2. Duplicate ticker on a different exchange (different market price)
|
||||||
|
# 3. "Offline" version of the same ticker (for users not connected to a provider)
|
||||||
|
test "can have duplicate tickers if exchange is different" do
|
||||||
|
original = Security.create!(ticker: "TEST", exchange_operating_mic: "XNAS")
|
||||||
|
duplicate = Security.create!(ticker: "TEST", exchange_operating_mic: "CBOE")
|
||||||
|
offline = Security.create!(ticker: "TEST", exchange_operating_mic: nil)
|
||||||
|
|
||||||
|
assert original.valid?
|
||||||
|
assert duplicate.valid?
|
||||||
|
assert offline.valid?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cannot have duplicate tickers if exchange is the same" do
|
||||||
|
original = Security.create!(ticker: "TEST", exchange_operating_mic: "XNAS")
|
||||||
|
duplicate = Security.new(ticker: "TEST", exchange_operating_mic: "XNAS")
|
||||||
|
|
||||||
|
assert_not duplicate.valid?
|
||||||
|
assert_equal [ "has already been taken" ], duplicate.errors[:ticker]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cannot have duplicate tickers if exchange is nil" do
|
||||||
|
original = Security.create!(ticker: "TEST", exchange_operating_mic: nil)
|
||||||
|
duplicate = Security.new(ticker: "TEST", exchange_operating_mic: nil)
|
||||||
|
|
||||||
|
assert_not duplicate.valid?
|
||||||
|
assert_equal [ "has already been taken" ], duplicate.errors[:ticker]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "casing is ignored when checking for duplicates" do
|
||||||
|
original = Security.create!(ticker: "TEST", exchange_operating_mic: "XNAS")
|
||||||
|
duplicate = Security.new(ticker: "tEst", exchange_operating_mic: "xNaS")
|
||||||
|
|
||||||
|
assert_not duplicate.valid?
|
||||||
|
assert_equal [ "has already been taken" ], duplicate.errors[:ticker]
|
||||||
|
end
|
||||||
|
end
|
|
@ -32,29 +32,43 @@ class SyncTest < ActiveSupport::TestCase
|
||||||
assert_equal "test sync error", @sync.error
|
assert_equal "test sync error", @sync.error
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Order is important here. Parent syncs must implement sync_data so that their own work
|
||||||
|
# is 100% complete *prior* to queueing up child syncs.
|
||||||
test "runs sync with child syncs" do
|
test "runs sync with child syncs" do
|
||||||
family = families(:dylan_family)
|
family = families(:dylan_family)
|
||||||
|
|
||||||
parent = Sync.create!(syncable: family)
|
parent = Sync.create!(syncable: family)
|
||||||
child1 = Sync.create!(syncable: family.accounts.first, parent: parent)
|
child1 = Sync.create!(syncable: family.accounts.first, parent: parent)
|
||||||
child2 = Sync.create!(syncable: family.accounts.last, parent: parent)
|
child2 = Sync.create!(syncable: family.accounts.second, parent: parent)
|
||||||
|
grandchild = Sync.create!(syncable: family.accounts.last, parent: child2)
|
||||||
|
|
||||||
parent.syncable.expects(:sync_data).returns([]).once
|
parent.syncable.expects(:sync_data).returns([]).once
|
||||||
child1.syncable.expects(:sync_data).returns([]).once
|
child1.syncable.expects(:sync_data).returns([]).once
|
||||||
child2.syncable.expects(:sync_data).returns([]).once
|
child2.syncable.expects(:sync_data).returns([]).once
|
||||||
|
grandchild.syncable.expects(:sync_data).returns([]).once
|
||||||
|
|
||||||
parent.perform # no-op
|
assert_equal "pending", parent.status
|
||||||
|
|
||||||
assert_equal "syncing", parent.status
|
|
||||||
assert_equal "pending", child1.status
|
assert_equal "pending", child1.status
|
||||||
assert_equal "pending", child2.status
|
assert_equal "pending", child2.status
|
||||||
|
assert_equal "pending", grandchild.status
|
||||||
|
|
||||||
|
parent.perform
|
||||||
|
assert_equal "syncing", parent.reload.status
|
||||||
|
|
||||||
child1.perform
|
child1.perform
|
||||||
assert_equal "completed", child1.status
|
assert_equal "completed", child1.reload.status
|
||||||
assert_equal "syncing", parent.status
|
assert_equal "syncing", parent.reload.status
|
||||||
|
|
||||||
child2.perform
|
child2.perform
|
||||||
assert_equal "completed", child2.status
|
assert_equal "syncing", child2.reload.status
|
||||||
assert_equal "completed", parent.status
|
assert_equal "completed", child1.reload.status
|
||||||
|
assert_equal "syncing", parent.reload.status
|
||||||
|
|
||||||
|
# Will complete the parent and grandparent syncs
|
||||||
|
grandchild.perform
|
||||||
|
assert_equal "completed", grandchild.reload.status
|
||||||
|
assert_equal "completed", child1.reload.status
|
||||||
|
assert_equal "completed", child2.reload.status
|
||||||
|
assert_equal "completed", parent.reload.status
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue