Files
sure/test/controllers/api/v1/recurring_transactions_controller_test.rb
T
Brandon 686205c0ff feat(bills): schema and domain core for the bills subsystem (#3201)
* feat(bills): schema and domain core for the bills subsystem

First of three chunks carved out of #3083. This one carries the schema and the
domain layer: no bills pages, no calendar feed, no assistant tools. Nothing here
is reachable from the UI yet, so it changes no user-visible behavior on its own.

Schema, in a single migration with a full down:

- recurrence_rules, recurring_occurrences, recurring_allocations,
  recurring_price_changes and recurring_match_rejections
- bill columns on recurring_transactions (bill_type, payment_url, autopay,
  notes, anchor and end conditions, weekend adjustment, dedup scope)
- the four data backfills, in their original order

Domain layer:

- Schedule, the pure date PORO every cadence resolves through, and
  FrequencyPreset for the labels
- OccurrenceGenerator, Matcher, Allocator, PriceChangeDetector, Classifier,
  DeclaredBill, HistoryBackfiller and PaycheckPlanner
- Pipeline, tying detection to generation, plus the nightly job and rake task

Existing detection code changed in three places, each a bug this schema exposes:

- Cleaner used a flat two-month staleness threshold, which silently retired
  every quarterly and annual series
- SubscriptionAuditGenerator used a flat 45-day overdue threshold, meaningless
  at both ends of the frequency range
- CashFlowWarningGenerator read one projected entry per series, which only
  equalled the monthly amount because every series was monthly; weekly bills
  were under-counted fourfold in its 30-day projection

The JSON API travels with the model rather than the UI, because the status enum
widens here. The API accepts only active and inactive on write; suggested,
paused and ended are lifecycle states owned by detection, so the documented
enum stays truthful.

Uniqueness keys gain dedup_scope alongside amount, never instead of it: a
series that is not price-forked carries a blank scope, so amount is what keeps
two different prices apart.

Suite 7,550 runs, 0 failures. Rubocop and brakeman clean. Eager loading
verified, and the migration reverses and re-applies. Includes the first review round: orphan repair matches income and refuses coincidental twins, session imports persist occurrence mappings across chunks, semimonthly anchors canonicalize, classifier keywords match whole words, and the down refuses rather than failing when price-forked rows exist.

* Address second review round

Bound the cross-currency default allocation by the entry leftover and the
occurrence remainder, matching the same-currency path. Let keyword stems
carry a suffix again after the word-boundary fix silenced them. Skip an
incoherent recurrence rule row instead of rolling back the whole import.
Check rollback collisions per restored index so a refusal cannot land
after the bills tables are dropped. Replay the closed_at test through a
real second import. Preload the orphan repair associations and move the
allocator errors to locale keys.

* Match index NULL semantics in the rollback collision checks

GROUP BY treats NULLs as equal but the restored unique indexes do not:
account_id is nullable and indexed, so two accountless rows can never
collide under any of them. Excluding NULL accounts keeps the guard from
refusing a rollback PostgreSQL can perform. Verified live both ways:
accountless duplicates roll back, a real collision still refuses.

* Address maintainer review

Scope the payable debt-destination subquery to the row and its family
instead of scanning every account in the installation. Batch the cash
flow generator remaining-amount sums into one grouped query, matching
the two sibling sites. Enforce both window bounds in the after_count
branch so a future-anchored plan cannot leak past the requested end
date. Skip the explicit regeneration when the day column change will
fire the model callback anyway. Add the missing locale entry for the
allocation currency validation.
2026-08-31 23:41:38 +02:00

561 lines
20 KiB
Ruby

# frozen_string_literal: true
require "test_helper"
class Api::V1::RecurringTransactionsControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:family_admin)
@family = @user.family
@account = accounts(:depository)
@merchant = @family.merchants.create!(name: "Streaming Service")
@user.api_keys.active.destroy_all
@api_key = ApiKey.create!(
user: @user,
name: "Test Read-Write Key",
scopes: [ "read_write" ],
source: "web",
display_key: "test_rw_#{SecureRandom.hex(8)}"
)
@read_only_api_key = ApiKey.create!(
user: @user,
name: "Test Read Key",
scopes: [ "read" ],
display_key: "test_read_#{SecureRandom.hex(8)}",
source: "mobile"
)
@recurring_transaction = @family.recurring_transactions.create!(
account: @account,
merchant: @merchant,
amount: 19.99,
currency: "USD",
expected_day_of_month: 15,
last_occurrence_date: Date.new(2026, 4, 15),
next_expected_date: Date.new(2026, 5, 15),
status: "active",
occurrence_count: 3,
manual: true
)
end
test "should list recurring transactions" do
get api_v1_recurring_transactions_url, headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert response_data.key?("recurring_transactions")
assert response_data.key?("pagination")
assert_includes response_data["recurring_transactions"].map { |item| item["id"] }, @recurring_transaction.id
end
test "should require authentication when listing recurring transactions" do
get api_v1_recurring_transactions_url
assert_response :unauthorized
end
test "should show recurring transaction" do
get api_v1_recurring_transaction_url(@recurring_transaction), headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert_equal @recurring_transaction.id, response_data["id"]
assert_equal 1999, response_data["amount_cents"]
assert response_data.key?("expected_amount_min_cents")
assert response_data.key?("expected_amount_max_cents")
assert response_data.key?("expected_amount_avg_cents")
assert_equal @account.id, response_data["account"]["id"]
assert_equal @merchant.id, response_data["merchant"]["id"]
end
test "should not mutate recurring transaction on read only shared account" do
member = users(:family_member)
member.api_keys.active.destroy_all
member_api_key = ApiKey.create!(
user: member,
name: "Member Read-Write Key",
scopes: [ "read_write" ],
source: "web",
display_key: "test_member_rw_#{SecureRandom.hex(8)}"
)
read_only_account = accounts(:credit_card)
recurring_transaction = @family.recurring_transactions.create!(
account: read_only_account,
name: "Read Only Shared Subscription",
amount: 9.99,
currency: "USD",
expected_day_of_month: 5,
last_occurrence_date: Date.new(2026, 4, 5),
next_expected_date: Date.new(2026, 5, 5),
status: "active",
occurrence_count: 2,
manual: true
)
get api_v1_recurring_transaction_url(recurring_transaction), headers: api_headers(member_api_key)
assert_response :success
patch api_v1_recurring_transaction_url(recurring_transaction),
params: { recurring_transaction: { status: "inactive" } },
headers: api_headers(member_api_key)
assert_response :not_found
response_data = JSON.parse(response.body)
assert_equal "record_not_found", response_data["error"]
assert_no_difference("@family.recurring_transactions.count") do
delete api_v1_recurring_transaction_url(recurring_transaction), headers: api_headers(member_api_key)
end
assert_response :not_found
response_data = JSON.parse(response.body)
assert_equal "record_not_found", response_data["error"]
end
test "should return not found for missing recurring transaction" do
get api_v1_recurring_transaction_url(SecureRandom.uuid), headers: api_headers(@api_key)
assert_response :not_found
response_data = JSON.parse(response.body)
assert_equal "record_not_found", response_data["error"]
end
test "should return not found for malformed recurring transaction id" do
get api_v1_recurring_transaction_url("not-a-uuid"), headers: api_headers(@api_key)
assert_response :not_found
response_data = JSON.parse(response.body)
assert_equal "record_not_found", response_data["error"]
end
test "should reject malformed account filter" do
get api_v1_recurring_transactions_url,
params: { account_id: "not-a-uuid" },
headers: api_headers(@api_key)
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
end
test "should require authentication when showing recurring transaction" do
get api_v1_recurring_transaction_url(@recurring_transaction)
assert_response :unauthorized
end
test "should create recurring transaction" do
assert_difference("@family.recurring_transactions.count", 1) do
post api_v1_recurring_transactions_url,
params: valid_recurring_transaction_params,
headers: api_headers(@api_key)
end
assert_response :created
response_data = JSON.parse(response.body)
assert_equal "Gym Membership", response_data["name"]
assert_equal 4999, response_data["amount_cents"]
assert_equal true, response_data["manual"]
end
test "should default null manual to true when creating recurring transaction" do
params = valid_recurring_transaction_params.deep_dup
params[:recurring_transaction][:manual] = nil
assert_difference("@family.recurring_transactions.count", 1) do
post api_v1_recurring_transactions_url,
params: params,
headers: api_headers(@api_key)
end
assert_response :created
response_data = JSON.parse(response.body)
assert_equal true, response_data["manual"]
end
test "should require authentication when creating recurring transaction" do
post api_v1_recurring_transactions_url, params: valid_recurring_transaction_params
assert_response :unauthorized
end
test "should reject create with read-only API key" do
post api_v1_recurring_transactions_url,
params: valid_recurring_transaction_params,
headers: api_headers(@read_only_api_key)
assert_response :forbidden
end
test "should reject create without recurring transaction wrapper" do
assert_no_difference("@family.recurring_transactions.count") do
post api_v1_recurring_transactions_url,
params: { name: "Gym Membership" },
headers: api_headers(@api_key)
end
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
end
test "should reject create with malformed account id" do
params = valid_recurring_transaction_params.deep_dup
params[:recurring_transaction][:account_id] = "not-a-uuid"
assert_no_difference("@family.recurring_transactions.count") do
post api_v1_recurring_transactions_url,
params: params,
headers: api_headers(@api_key)
end
assert_response :not_found
response_data = JSON.parse(response.body)
assert_equal "record_not_found", response_data["error"]
end
test "should reject create with malformed merchant id" do
params = valid_recurring_transaction_params.deep_dup
params[:recurring_transaction].delete(:name)
params[:recurring_transaction][:merchant_id] = "not-a-uuid"
assert_no_difference("@family.recurring_transactions.count") do
post api_v1_recurring_transactions_url,
params: params,
headers: api_headers(@api_key)
end
assert_response :not_found
response_data = JSON.parse(response.body)
assert_equal "record_not_found", response_data["error"]
end
test "should reject create without name or merchant" do
params = valid_recurring_transaction_params.deep_dup
params[:recurring_transaction].delete(:name)
post api_v1_recurring_transactions_url,
params: params,
headers: api_headers(@api_key)
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
end
test "should reject create without required dates" do
params = valid_recurring_transaction_params.deep_dup
params[:recurring_transaction].delete(:last_occurrence_date)
params[:recurring_transaction].delete(:next_expected_date)
assert_no_difference("@family.recurring_transactions.count") do
post api_v1_recurring_transactions_url,
params: params,
headers: api_headers(@api_key)
end
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
assert_includes response_data["errors"], "Last occurrence date can't be blank"
assert_includes response_data["errors"], "Next expected date can't be blank"
end
test "should reject create with nil status" do
params = valid_recurring_transaction_params.deep_dup
params[:recurring_transaction][:status] = nil
assert_no_difference("@family.recurring_transactions.count") do
post api_v1_recurring_transactions_url,
params: params,
headers: api_headers(@api_key)
end
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
assert_includes response_data["errors"], "Status can't be blank"
end
test "should reject create with negative occurrence count" do
params = valid_recurring_transaction_params.deep_dup
params[:recurring_transaction][:occurrence_count] = -1
assert_no_difference("@family.recurring_transactions.count") do
post api_v1_recurring_transactions_url,
params: params,
headers: api_headers(@api_key)
end
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
assert_includes response_data["errors"], "Occurrence count must be greater than or equal to 0"
end
test "should return conflict when creating duplicate recurring transaction" do
params = {
recurring_transaction: {
account_id: @account.id,
merchant_id: @merchant.id,
amount: @recurring_transaction.amount.to_s,
currency: @recurring_transaction.currency,
expected_day_of_month: 15,
last_occurrence_date: "2026-04-15",
next_expected_date: "2026-05-15"
}
}
# The unique index intentionally ignores recurrence dates; matching family,
# account, merchant, amount, and currency is enough to conflict.
assert_no_difference("@family.recurring_transactions.count") do
post api_v1_recurring_transactions_url,
params: params,
headers: api_headers(@api_key)
end
assert_response :conflict
response_data = JSON.parse(response.body)
assert_equal "conflict", response_data["error"]
assert_equal "Recurring transaction already exists", response_data["message"]
end
test "should update recurring transaction" do
patch api_v1_recurring_transaction_url(@recurring_transaction),
params: { recurring_transaction: { status: "inactive", expected_day_of_month: 16 } },
headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert_equal "inactive", response_data["status"]
assert_equal 16, response_data["expected_day_of_month"]
end
test "should update payment url and normalize a bare host" do
patch api_v1_recurring_transaction_url(@recurring_transaction),
params: { recurring_transaction: { payment_url: "pay.example.com/bill" } },
headers: api_headers(@api_key)
assert_response :success
assert_equal "https://pay.example.com/bill", JSON.parse(response.body)["payment_url"]
end
# The API is a second door onto the same field, so the scheme allowlist has to hold
# here too and not only in the web form.
test "should reject a payment url with a non-http scheme" do
patch api_v1_recurring_transaction_url(@recurring_transaction),
params: { recurring_transaction: { payment_url: "javascript:alert(1)" } },
headers: api_headers(@api_key)
assert_response :unprocessable_entity
assert_nil @recurring_transaction.reload.payment_url
end
test "should require authentication when updating recurring transaction" do
patch api_v1_recurring_transaction_url(@recurring_transaction),
params: { recurring_transaction: { status: "inactive" } }
assert_response :unauthorized
end
test "should reject update with read-only API key" do
patch api_v1_recurring_transaction_url(@recurring_transaction),
params: { recurring_transaction: { status: "inactive" } },
headers: api_headers(@read_only_api_key)
assert_response :forbidden
end
test "should reject update without recurring transaction wrapper" do
patch api_v1_recurring_transaction_url(@recurring_transaction),
params: { status: "inactive" },
headers: api_headers(@api_key)
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
end
test "should reject update with invalid status" do
patch api_v1_recurring_transaction_url(@recurring_transaction),
params: { recurring_transaction: { status: "bogus_status" } },
headers: api_headers(@api_key)
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
end
# suggested, paused and ended are valid model statuses, but they are
# lifecycle states the API does not document as writable and must refuse.
test "should reject update with an undocumented model status" do
%w[suggested paused ended].each do |status|
patch api_v1_recurring_transaction_url(@recurring_transaction),
params: { recurring_transaction: { status: status } },
headers: api_headers(@api_key)
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
assert_equal "active", @recurring_transaction.reload.status,
"#{status} must not be written through the API"
end
end
test "should reject create with an undocumented model status" do
params = valid_recurring_transaction_params.deep_dup
params[:recurring_transaction][:status] = "suggested"
assert_no_difference("@family.recurring_transactions.count") do
post api_v1_recurring_transactions_url,
params: params,
headers: api_headers(@api_key)
end
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
end
test "should reject update with nil status" do
patch api_v1_recurring_transaction_url(@recurring_transaction),
params: { recurring_transaction: { status: nil } },
headers: api_headers(@api_key)
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
assert_includes response_data["errors"], "Status can't be blank"
end
test "should reject update with nil next expected date" do
patch api_v1_recurring_transaction_url(@recurring_transaction),
params: { recurring_transaction: { next_expected_date: nil } },
headers: api_headers(@api_key)
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
assert_includes response_data["errors"], "Next expected date can't be blank"
end
test "should ignore internal fields on update" do
patch api_v1_recurring_transaction_url(@recurring_transaction),
params: {
recurring_transaction: {
status: "inactive",
occurrence_count: 99,
manual: false,
amount: 1.23
}
},
headers: api_headers(@api_key)
assert_response :success
@recurring_transaction.reload
assert_equal "inactive", @recurring_transaction.status
assert_equal 3, @recurring_transaction.occurrence_count
assert_equal true, @recurring_transaction.manual
assert_equal 19.99, @recurring_transaction.amount.to_f
end
test "should return not found when updating missing recurring transaction" do
patch api_v1_recurring_transaction_url(SecureRandom.uuid),
params: { recurring_transaction: { status: "inactive" } },
headers: api_headers(@api_key)
assert_response :not_found
response_data = JSON.parse(response.body)
assert_equal "record_not_found", response_data["error"]
end
test "should reject invalid recurring transaction update" do
patch api_v1_recurring_transaction_url(@recurring_transaction),
params: { recurring_transaction: { expected_day_of_month: 32 } },
headers: api_headers(@api_key)
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
end
test "should destroy recurring transaction" do
assert_difference("@family.recurring_transactions.count", -1) do
delete api_v1_recurring_transaction_url(@recurring_transaction), headers: api_headers(@api_key)
end
assert_response :ok
end
test "should require authentication when destroying recurring transaction" do
delete api_v1_recurring_transaction_url(@recurring_transaction)
assert_response :unauthorized
end
test "should reject destroy with read-only API key" do
delete api_v1_recurring_transaction_url(@recurring_transaction), headers: api_headers(@read_only_api_key)
assert_response :forbidden
end
test "should return not found when destroying missing recurring transaction" do
delete api_v1_recurring_transaction_url(SecureRandom.uuid), headers: api_headers(@api_key)
assert_response :not_found
response_data = JSON.parse(response.body)
assert_equal "record_not_found", response_data["error"]
end
test "should not create recurring transaction for another family account" do
other_family = Family.create!(name: "Other Family", currency: "USD", locale: "en")
other_account = Account.create!(
family: other_family,
name: "Other Checking",
currency: "USD",
classification: "asset",
accountable: Depository.create!,
balance: 0
)
post api_v1_recurring_transactions_url,
params: {
recurring_transaction: {
account_id: other_account.id,
name: "Gym Membership",
amount: 49.99,
currency: "USD",
expected_day_of_month: 1,
last_occurrence_date: "2026-04-01",
next_expected_date: "2026-05-01"
}
},
headers: api_headers(@api_key)
assert_response :not_found
response_data = JSON.parse(response.body)
assert_equal "record_not_found", response_data["error"]
end
private
def valid_recurring_transaction_params
{
recurring_transaction: {
account_id: @account.id,
name: "Gym Membership",
amount: 49.99,
currency: "USD",
expected_day_of_month: 1,
last_occurrence_date: "2026-04-01",
next_expected_date: "2026-05-01",
status: "active",
occurrence_count: 1
}
}
end
def api_headers(api_key)
{ "X-Api-Key" => api_key.plain_key }
end
end