mirror of
https://github.com/we-promise/sure.git
synced 2026-09-05 14:51:15 +00:00
* 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.
408 lines
12 KiB
Ruby
408 lines
12 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require 'swagger_helper'
|
|
|
|
RSpec.describe 'API V1 Recurring Transactions', type: :request do
|
|
let(:family) do
|
|
Family.create!(
|
|
name: 'API Family',
|
|
currency: 'USD',
|
|
locale: 'en',
|
|
date_format: '%m-%d-%Y'
|
|
)
|
|
end
|
|
|
|
let(:user) do
|
|
family.users.create!(
|
|
email: 'api-user@example.com',
|
|
password: 'password123',
|
|
password_confirmation: 'password123'
|
|
)
|
|
end
|
|
|
|
let(:api_key) do
|
|
key = ApiKey.generate_secure_key
|
|
ApiKey.create!(
|
|
user: user,
|
|
name: 'API Docs Key',
|
|
key: key,
|
|
scopes: %w[read_write],
|
|
source: 'web'
|
|
)
|
|
end
|
|
|
|
let(:read_only_api_key) do
|
|
key = ApiKey.generate_secure_key
|
|
ApiKey.create!(
|
|
user: user,
|
|
name: 'Read Only Docs Key',
|
|
key: key,
|
|
scopes: %w[read],
|
|
source: 'mobile'
|
|
)
|
|
end
|
|
|
|
let(:'X-Api-Key') { api_key.plain_key }
|
|
|
|
let(:account) do
|
|
Account.create!(
|
|
family: family,
|
|
owner: user,
|
|
name: 'Checking Account',
|
|
balance: 1000,
|
|
currency: 'USD',
|
|
accountable: Depository.create!
|
|
)
|
|
end
|
|
|
|
let(:merchant) { family.merchants.create!(name: 'Streaming Service') }
|
|
|
|
let!(:recurring_transaction) do
|
|
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
|
|
|
|
path '/api/v1/recurring_transactions' do
|
|
get 'List recurring transactions' do
|
|
tags 'Recurring Transactions'
|
|
security [ { apiKeyAuth: [] } ]
|
|
produces 'application/json'
|
|
parameter name: :page, in: :query, type: :integer, required: false,
|
|
description: 'Page number (default: 1)'
|
|
parameter name: :per_page, in: :query, type: :integer, required: false,
|
|
description: 'Items per page (default: 25, max: 100)'
|
|
parameter name: :status, in: :query, required: false,
|
|
description: 'Filter by recurring status',
|
|
schema: { type: :string, enum: %w[active inactive] }
|
|
parameter name: :account_id, in: :query, required: false, description: 'Filter by account ID',
|
|
schema: { type: :string, format: :uuid }
|
|
|
|
response '200', 'recurring transactions listed' do
|
|
schema '$ref' => '#/components/schemas/RecurringTransactionCollection'
|
|
|
|
run_test!
|
|
end
|
|
|
|
response '401', 'unauthorized' do
|
|
schema '$ref' => '#/components/schemas/ErrorResponse'
|
|
|
|
let(:'X-Api-Key') { nil }
|
|
|
|
run_test!
|
|
end
|
|
|
|
response '422', 'validation error - malformed account filter' do
|
|
schema '$ref' => '#/components/schemas/ErrorResponse'
|
|
|
|
let(:account_id) { 'not-a-uuid' }
|
|
|
|
run_test!
|
|
end
|
|
end
|
|
|
|
post 'Create recurring transaction' do
|
|
tags 'Recurring Transactions'
|
|
security [ { apiKeyAuth: [] } ]
|
|
consumes 'application/json'
|
|
produces 'application/json'
|
|
parameter name: :body, in: :body, required: true, schema: {
|
|
type: :object,
|
|
properties: {
|
|
recurring_transaction: {
|
|
type: :object,
|
|
properties: {
|
|
account_id: { type: :string, format: :uuid, nullable: true },
|
|
merchant_id: { type: :string, format: :uuid, nullable: true },
|
|
name: { type: :string, nullable: true },
|
|
amount: { type: :number },
|
|
currency: { type: :string },
|
|
expected_day_of_month: { type: :integer, minimum: 1, maximum: 31 },
|
|
last_occurrence_date: { type: :string, format: :date },
|
|
next_expected_date: { type: :string, format: :date },
|
|
status: { type: :string, enum: %w[active inactive] },
|
|
occurrence_count: { type: :integer, minimum: 0 },
|
|
manual: { type: :boolean },
|
|
payment_url: { type: :string, nullable: true, description: 'Link to the biller portal. Only http and https are accepted; a bare host is stored as https.' },
|
|
autopay: { type: :boolean, description: 'Whether this bill pays itself automatically.' },
|
|
notes: { type: :string, nullable: true, description: 'Free-text notes shown alongside the bill.' },
|
|
expected_amount_min: { type: :number, nullable: true },
|
|
expected_amount_max: { type: :number, nullable: true },
|
|
expected_amount_avg: { type: :number, nullable: true }
|
|
},
|
|
required: %w[amount currency expected_day_of_month last_occurrence_date next_expected_date],
|
|
anyOf: [
|
|
{ required: %w[name] },
|
|
{ required: %w[merchant_id] }
|
|
]
|
|
}
|
|
},
|
|
required: %w[recurring_transaction]
|
|
}
|
|
|
|
let(:body) do
|
|
{
|
|
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'
|
|
}
|
|
}
|
|
end
|
|
|
|
response '201', 'recurring transaction created' do
|
|
schema '$ref' => '#/components/schemas/RecurringTransaction'
|
|
|
|
run_test!
|
|
end
|
|
|
|
response '401', 'unauthorized' do
|
|
schema '$ref' => '#/components/schemas/ErrorResponse'
|
|
|
|
let(:'X-Api-Key') { nil }
|
|
|
|
run_test!
|
|
end
|
|
|
|
response '403', 'forbidden - requires read_write scope' do
|
|
schema '$ref' => '#/components/schemas/ErrorResponse'
|
|
|
|
let(:'X-Api-Key') { read_only_api_key.plain_key }
|
|
|
|
run_test!
|
|
end
|
|
|
|
response '404', 'account not found' do
|
|
schema '$ref' => '#/components/schemas/ErrorResponse'
|
|
|
|
let(:body) do
|
|
{
|
|
recurring_transaction: {
|
|
account_id: SecureRandom.uuid,
|
|
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'
|
|
}
|
|
}
|
|
end
|
|
|
|
run_test!
|
|
end
|
|
|
|
response '422', 'validation error - missing merchant or name' do
|
|
schema '$ref' => '#/components/schemas/ErrorResponse'
|
|
|
|
let(:body) do
|
|
{
|
|
recurring_transaction: {
|
|
account_id: account.id,
|
|
amount: 49.99,
|
|
currency: 'USD',
|
|
expected_day_of_month: 1,
|
|
last_occurrence_date: '2026-04-01',
|
|
next_expected_date: '2026-05-01'
|
|
}
|
|
}
|
|
end
|
|
|
|
run_test!
|
|
end
|
|
|
|
response '422', 'validation error - nil status' do
|
|
schema '$ref' => '#/components/schemas/ErrorResponse'
|
|
|
|
let(:body) do
|
|
{
|
|
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: nil
|
|
}
|
|
}
|
|
end
|
|
|
|
run_test!
|
|
end
|
|
|
|
response '422', 'validation error - negative occurrence count' do
|
|
schema '$ref' => '#/components/schemas/ErrorResponse'
|
|
|
|
let(:body) do
|
|
{
|
|
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',
|
|
occurrence_count: -1
|
|
}
|
|
}
|
|
end
|
|
|
|
run_test!
|
|
end
|
|
end
|
|
end
|
|
|
|
path '/api/v1/recurring_transactions/{id}' do
|
|
parameter name: :id, in: :path, type: :string, required: true, description: 'Recurring transaction ID'
|
|
|
|
get 'Retrieve recurring transaction' do
|
|
tags 'Recurring Transactions'
|
|
security [ { apiKeyAuth: [] } ]
|
|
produces 'application/json'
|
|
|
|
let(:id) { recurring_transaction.id }
|
|
|
|
response '200', 'recurring transaction retrieved' do
|
|
schema '$ref' => '#/components/schemas/RecurringTransaction'
|
|
|
|
run_test!
|
|
end
|
|
|
|
response '401', 'unauthorized' do
|
|
schema '$ref' => '#/components/schemas/ErrorResponse'
|
|
|
|
let(:'X-Api-Key') { nil }
|
|
|
|
run_test!
|
|
end
|
|
|
|
response '404', 'recurring transaction not found' do
|
|
schema '$ref' => '#/components/schemas/ErrorResponse'
|
|
|
|
let(:id) { SecureRandom.uuid }
|
|
|
|
run_test!
|
|
end
|
|
end
|
|
|
|
patch 'Update recurring transaction' do
|
|
tags 'Recurring Transactions'
|
|
security [ { apiKeyAuth: [] } ]
|
|
consumes 'application/json'
|
|
produces 'application/json'
|
|
|
|
let(:id) { recurring_transaction.id }
|
|
|
|
parameter name: :body, in: :body, required: true, schema: {
|
|
type: :object,
|
|
properties: {
|
|
recurring_transaction: {
|
|
type: :object,
|
|
properties: {
|
|
status: { type: :string, enum: %w[active inactive] },
|
|
expected_day_of_month: { type: :integer, minimum: 1, maximum: 31 },
|
|
next_expected_date: { type: :string, format: :date },
|
|
payment_url: { type: :string, nullable: true, description: 'Link to the biller portal. Only http and https are accepted; a bare host is stored as https. Send an empty string to clear it.' },
|
|
autopay: { type: :boolean, description: 'Whether this bill pays itself automatically.' },
|
|
notes: { type: :string, nullable: true, description: 'Free-text notes shown alongside the bill.' }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let(:body) { { recurring_transaction: { status: 'inactive' } } }
|
|
|
|
response '200', 'recurring transaction updated' do
|
|
schema '$ref' => '#/components/schemas/RecurringTransaction'
|
|
|
|
run_test!
|
|
end
|
|
|
|
response '401', 'unauthorized' do
|
|
schema '$ref' => '#/components/schemas/ErrorResponse'
|
|
|
|
let(:'X-Api-Key') { nil }
|
|
|
|
run_test!
|
|
end
|
|
|
|
response '403', 'forbidden - requires read_write scope' do
|
|
schema '$ref' => '#/components/schemas/ErrorResponse'
|
|
|
|
let(:'X-Api-Key') { read_only_api_key.plain_key }
|
|
|
|
run_test!
|
|
end
|
|
|
|
response '404', 'recurring transaction not found' do
|
|
schema '$ref' => '#/components/schemas/ErrorResponse'
|
|
|
|
let(:id) { SecureRandom.uuid }
|
|
|
|
run_test!
|
|
end
|
|
|
|
response '422', 'validation error' do
|
|
schema '$ref' => '#/components/schemas/ErrorResponse'
|
|
|
|
let(:body) { { recurring_transaction: { expected_day_of_month: 32 } } }
|
|
|
|
run_test!
|
|
end
|
|
end
|
|
|
|
delete 'Delete recurring transaction' do
|
|
tags 'Recurring Transactions'
|
|
security [ { apiKeyAuth: [] } ]
|
|
produces 'application/json'
|
|
|
|
let(:id) { recurring_transaction.id }
|
|
|
|
response '200', 'recurring transaction deleted' do
|
|
schema '$ref' => '#/components/schemas/SuccessMessage'
|
|
|
|
run_test!
|
|
end
|
|
|
|
response '401', 'unauthorized' do
|
|
schema '$ref' => '#/components/schemas/ErrorResponse'
|
|
|
|
let(:'X-Api-Key') { nil }
|
|
|
|
run_test!
|
|
end
|
|
|
|
response '403', 'forbidden - requires read_write scope' do
|
|
schema '$ref' => '#/components/schemas/ErrorResponse'
|
|
|
|
let(:'X-Api-Key') { read_only_api_key.plain_key }
|
|
|
|
run_test!
|
|
end
|
|
|
|
response '404', 'recurring transaction not found' do
|
|
schema '$ref' => '#/components/schemas/ErrorResponse'
|
|
|
|
let(:id) { SecureRandom.uuid }
|
|
|
|
run_test!
|
|
end
|
|
end
|
|
end
|
|
end
|