1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-09 07:25:19 +02:00

Merge branch 'main' of github.com:maybe-finance/maybe into rule-name

# Conflicts:
#	app/views/rules/_rule.html.erb
This commit is contained in:
hatz 2025-05-13 12:44:15 -05:00
commit 5135b98dbe
No known key found for this signature in database
17 changed files with 122 additions and 137 deletions

View file

@ -21,12 +21,23 @@ export default class extends Controller {
}
remove(e) {
// Find the parent rules controller before removing the condition
const rulesEl = this.element.closest('[data-controller~="rules"]');
if (e.params.destroy) {
this.destroyFieldTarget.value = true;
this.element.classList.add("hidden");
} else {
this.element.remove();
}
// Update the prefixes of all conditions from the parent rules controller
if (rulesEl) {
const rulesController = this.application.getControllerForElementAndIdentifier(rulesEl, "rules");
if (rulesController && typeof rulesController.updateConditionPrefixes === "function") {
rulesController.updateConditionPrefixes();
}
}
}
handleConditionTypeChange(e) {

View file

@ -11,11 +11,17 @@ export default class extends Controller {
"effectiveDateInput",
];
connect() {
// Update condition prefixes on first connection (form render on edit)
this.updateConditionPrefixes();
}
addConditionGroup() {
this.#appendTemplate(
this.conditionGroupTemplateTarget,
this.conditionsListTarget,
);
this.updateConditionPrefixes();
}
addCondition() {
@ -23,6 +29,7 @@ export default class extends Controller {
this.conditionTemplateTarget,
this.conditionsListTarget,
);
this.updateConditionPrefixes();
}
addAction() {
@ -45,4 +52,27 @@ export default class extends Controller {
#uniqueKey() {
return Date.now();
}
// Updates the prefix visibility of all conditions and condition groups
// This is also called by the rule/conditions_controller when a subcondition is removed
updateConditionPrefixes() {
const conditions = Array.from(this.conditionsListTarget.children);
let conditionIndex = 0;
conditions.forEach((condition) => {
// Only process visible conditions, this prevents conditions that are marked for removal and hidden
// from being added to the index. This is important when editing a rule.
if (!condition.classList.contains('hidden')) {
const prefixEl = condition.querySelector('[data-condition-prefix]');
if (prefixEl) {
if (conditionIndex === 0) {
prefixEl.classList.add('hidden');
} else {
prefixEl.classList.remove('hidden');
}
conditionIndex++;
}
}
});
}
}

View file

@ -74,11 +74,6 @@ class Family < ApplicationRecord
account.sync_later(start_date: start_date, parent_sync: sync)
end
Rails.logger.info("Syncing plaid items for family #{id}")
plaid_items.each do |plaid_item|
plaid_item.sync_later(start_date: start_date, parent_sync: sync)
end
Rails.logger.info("Applying rules for family #{id}")
rules.each do |rule|
rule.apply_later

View file

@ -49,6 +49,18 @@ class Rule < ApplicationRecord
RuleJob.perform_later(self, ignore_attribute_locks: ignore_attribute_locks)
end
def primary_condition_title
return "No conditions" if conditions.none?
first_condition = conditions.first
if first_condition.compound? && first_condition.sub_conditions.any?
first_sub_condition = first_condition.sub_conditions.first
"If #{first_sub_condition.filter.label.downcase} #{first_sub_condition.operator} #{first_sub_condition.value_display}"
else
"If #{first_condition.filter.label.downcase} #{first_condition.operator} #{first_condition.value_display}"
end
end
private
def matching_resources_scope
scope = registry.resource_scope

View file

@ -21,58 +21,17 @@ class Sync < ApplicationRecord
begin
syncable.sync_data(self, start_date: start_date)
unless has_pending_child_syncs?
complete!
Rails.logger.info("Sync completed, starting post-sync")
syncable.post_sync(self)
Rails.logger.info("Post-sync completed")
end
rescue StandardError => error
fail! error, report_error: true
ensure
notify_parent_of_completion! if has_parent?
end
end
end
def handle_child_completion_event
Sync.transaction do
# We need this to ensure 2 child syncs don't update the parent at the exact same time with different results
# and cause the sync to hang in "syncing" status indefinitely
self.lock!
unless has_pending_child_syncs?
if has_failed_child_syncs?
fail!(Error.new("One or more child syncs failed"))
else
complete!
end
# 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)
end
end
end
private
def has_pending_child_syncs?
children.where(status: [ :pending, :syncing ]).any?
end
def has_failed_child_syncs?
children.where(status: :failed).any?
end
def has_parent?
parent_id.present?
end
def notify_parent_of_completion!
parent.handle_child_completion_event
end
def start!
Rails.logger.info("Starting sync")
update! status: :syncing

View file

@ -2,6 +2,7 @@
<h1 class="text-xl"><%= t(".accounts") %></h1>
<div class="flex items-center gap-5">
<div class="flex items-center gap-2">
<% if Rails.env.development? %>
<%= render ButtonComponent.new(
text: "Sync all",
href: sync_all_accounts_path,
@ -9,8 +10,8 @@
variant: "outline",
disabled: Current.family.syncing?,
icon: "refresh-cw",
class: ""
) %>
<% end %>
<%= render LinkComponent.new(
text: "New account",

View file

@ -20,23 +20,12 @@
<% end %>
<div class="flex items-center gap-1 ml-auto">
<% if account.plaid_account_id.present? %>
<% if Rails.env.development? %>
<%= icon(
"refresh-cw",
as_button: true,
size: "sm",
href: sync_plaid_item_path(account.plaid_account.plaid_item),
disabled: account.syncing?,
frame: :_top
) %>
<% end %>
<% else %>
<%= icon(
"refresh-cw",
as_button: true,
size: "sm",
href: sync_account_path(account),
href: account.linked? ? sync_plaid_item_path(account.plaid_account.plaid_item) : sync_account_path(account),
disabled: account.syncing?,
frame: :_top
) %>

View file

@ -92,7 +92,7 @@
</div>
</div>
<% end %>
<% else %>
<% elsif Rails.env.development? %>
<%= icon(
"refresh-cw",
as_button: true,

View file

@ -4,8 +4,11 @@
<% rule = condition.rule %>
<li data-controller="rule--conditions" data-rule--conditions-condition-filters-value="<%= rule.condition_filters.to_json %>" class="flex items-center gap-3">
<% if form.index.to_i > 0 && show_prefix %>
<div class="pl-4">
<%# Conditionally render the prefix %>
<%# Condition groups pass in show_prefix: false for subconditions since the ANY/ALL selector makes that clear %>
<% if show_prefix %>
<div class="pl-2" data-condition-prefix>
<span class="font-medium uppercase text-xs">and</span>
</div>
<% end %>

View file

@ -3,17 +3,16 @@
<% condition = form.object %>
<% rule = condition.rule %>
<li data-controller="rule--conditions element-removal" class="border border-secondary rounded-md p-4 space-y-3">
<li data-controller="rule--conditions" class="border border-secondary rounded-md p-4 space-y-3">
<%= form.hidden_field :condition_type, value: "compound" %>
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<% unless form.index == 0 %>
<div class="pl-2">
<%# Show prefix on condition groups, except the first one %>
<div class="pl-2" data-condition-prefix>
<span class="font-medium uppercase text-xs">and</span>
</div>
<% end %>
<p class="text-sm text-secondary">match</p>
<%= form.select :operator, [["all", "and"], ["any", "or"]], { container_class: "w-fit" }, data: { rules_target: "operatorField" } %>
<p class="text-sm text-secondary">of the following conditions</p>
@ -21,16 +20,16 @@
<%= icon(
"trash-2",
size: "sm",
as_button: true,
data: { action: "element-removal#remove" }
size: "sm",
data: { action: "rule--conditions#remove" }
) %>
</div>
<%# Sub-condition template, used by Stimulus controller to add new sub-conditions dynamically %>
<template data-rule--conditions-target="subConditionTemplate">
<%= form.fields_for :sub_conditions, Rule::Condition.new(parent: condition, condition_type: rule.condition_filters.first.key), child_index: "IDX_PLACEHOLDER" do |scf| %>
<%= render "rule/conditions/condition", form: scf %>
<%= render "rule/conditions/condition", form: scf, show_prefix: false %>
<% end %>
</template>
@ -44,6 +43,7 @@
text: "Add condition",
leading_icon: "plus",
variant: "ghost",
type: "button",
data: { action: "rule--conditions#addSubCondition" }
) %>
</li>

View file

@ -24,7 +24,7 @@
<h3 class="text-sm font-medium text-primary">IF</h3>
</div>
<%# Condition template, used by Stimulus controller to add new conditions dynamically %>
<%# Condition Group template, used by Stimulus controller to add new conditions dynamically %>
<template data-rules-target="conditionGroupTemplate">
<%= f.fields_for :conditions, Rule::Condition.new(rule: rule, condition_type: "compound", operator: "and"), child_index: "IDX_PLACEHOLDER" do |cf| %>
<%= render "rule/conditions/condition_group", form: cf %>

View file

@ -4,7 +4,6 @@
<% if rule.name.present? %>
<h3 class="font-medium text-md text-primary"><%= rule.name %></h3>
<% end %>
<% if rule.conditions.any? %>
<div class="flex items-center gap-2 mt-1">
<div class="flex items-center gap-1 text-secondary w-16 shrink-0">
@ -24,7 +23,6 @@
</p>
</div>
<% end %>
<div class="flex items-center gap-2 mt-1">
<div class="flex items-center gap-1 text-secondary w-16 shrink-0">
<span class="font-mono text-xs">THEN</span>
@ -42,7 +40,6 @@
<% end %>
</p>
</div>
<div class="flex items-center gap-2 mt-1">
<div class="flex items-center gap-1 text-secondary w-16 shrink-0">
<span class="font-mono text-xs">FOR</span>
@ -58,12 +55,10 @@
</p>
</div>
</div>
<div class="flex items-center gap-4">
<%= styled_form_with model: rule, data: { controller: "auto-submit-form" } do |f| %>
<%= f.toggle :active, { data: { auto_submit_form_target: "auto" } } %>
<% end %>
<%= render MenuComponent.new do |menu| %>
<% menu.with_item(variant: "link", text: "Edit", href: edit_rule_path(rule), icon: "pencil", data: { turbo_frame: "modal" }) %>
<% menu.with_item(variant: "link", text: "Re-apply rule", href: confirm_rule_path(rule), icon: "refresh-cw", data: { turbo_frame: "modal" }) %>

View file

@ -0,0 +1,6 @@
class AddUniquenessToSubscriptions < ActiveRecord::Migration[7.2]
def change
remove_index :subscriptions, :family_id
add_index :subscriptions, :family_id, unique: true
end
end

4
db/schema.rb generated
View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2025_05_09_182903) do
ActiveRecord::Schema[7.2].define(version: 2025_05_13_122703) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@ -581,7 +581,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_09_182903) do
t.datetime "trial_ends_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["family_id"], name: "index_subscriptions_on_family_id"
t.index ["family_id"], name: "index_subscriptions_on_family_id", unique: true
end
create_table "syncs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|

28
lib/tasks/stripe.rake Normal file
View file

@ -0,0 +1,28 @@
namespace :stripe do
desc "Sync legacy Stripe subscriptions"
task sync_legacy_subscriptions: :environment do
cli = Stripe::StripeClient.new(ENV["STRIPE_SECRET_KEY"])
subs = cli.v1.subscriptions.list
subs.auto_paging_each do |sub|
details = sub.items.data.first
family = Family.find_by(stripe_customer_id: sub.customer)
if family.nil?
puts "Family not found for Stripe customer ID: #{sub.customer}, skipping"
next
end
family.subscription.update!(
stripe_id: sub.id,
status: sub.status,
interval: details.plan.interval,
amount: details.plan.amount / 100.0,
currency: details.plan.currency.upcase,
current_period_ends_at: Time.at(details.current_period_end)
)
end
end
end

View file

@ -20,10 +20,6 @@ class FamilyTest < ActiveSupport::TestCase
.with(start_date: nil, parent_sync: family_sync)
.times(manual_accounts_count)
PlaidItem.any_instance.expects(:sync_later)
.with(start_date: nil, parent_sync: family_sync)
.times(items_count)
@syncable.sync_data(family_sync, start_date: family_sync.start_date)
end
end

View file

@ -31,44 +31,4 @@ class SyncTest < ActiveSupport::TestCase
assert_equal "failed", @sync.status
assert_equal "test sync error", @sync.error
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
family = families(:dylan_family)
parent = Sync.create!(syncable: family)
child1 = Sync.create!(syncable: family.accounts.first, 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
child1.syncable.expects(:sync_data).returns([]).once
child2.syncable.expects(:sync_data).returns([]).once
grandchild.syncable.expects(:sync_data).returns([]).once
assert_equal "pending", parent.status
assert_equal "pending", child1.status
assert_equal "pending", child2.status
assert_equal "pending", grandchild.status
parent.perform
assert_equal "syncing", parent.reload.status
child1.perform
assert_equal "completed", child1.reload.status
assert_equal "syncing", parent.reload.status
child2.perform
assert_equal "syncing", child2.reload.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