From 194e3d9f9a78fa2f25ad450470b1ca398ac975b4 Mon Sep 17 00:00:00 2001 From: Otter Date: Thu, 30 Jul 2026 03:29:13 +0300 Subject: [PATCH] Fix transactions posting a day early when booked around midnight. (#2744) * Fix 2668 * Fix CodeRabbit nitpick * Fix Akahu parsing * Anchor provider transaction date parsing to family timezone * Coderabbit suggestion for Date/DateTime order * Remove duplicate condition in wise * Safe unless column_exists + pass family to date parse to family components --- app/models/akahu_entry/processor.rb | 10 +++++--- app/models/brex_entry/processor.rb | 10 +++++--- app/models/coinstats_entry/processor.rb | 10 +++++--- app/models/enable_banking_entry/processor.rb | 10 +++++--- .../activities_processor.rb | 12 ++++----- .../indexa_capital_account/data_helpers.rb | 17 ++++++++----- app/models/lunchflow_entry/processor.rb | 11 +++++--- app/models/mercury_entry/processor.rb | 12 +++++---- app/models/sophtron_entry/processor.rb | 11 +++++--- app/models/up_entry/processor.rb | 10 ++++++-- app/models/wise_entry/processor.rb | 11 ++++++-- ...00_add_aspsp_metadata_to_enable_banking.rb | 16 ++++++------ ...nce_reversal_to_enable_banking_accounts.rb | 2 +- .../templates/activities_processor.rb.tt | 12 ++++----- .../family/templates/data_helpers.rb.tt | 17 ++++++++----- .../templates/transactions_processor.rb.tt | 2 +- test/models/akahu_entry/processor_test.rb | 20 +++++++++++++++ .../enable_banking_entry/processor_test.rb | 21 ++++++++++++++++ test/models/lunchflow_entry/processor_test.rb | 25 +++++++++++++++++++ 19 files changed, 176 insertions(+), 63 deletions(-) diff --git a/app/models/akahu_entry/processor.rb b/app/models/akahu_entry/processor.rb index a928fab96..a7b8d0375 100644 --- a/app/models/akahu_entry/processor.rb +++ b/app/models/akahu_entry/processor.rb @@ -172,11 +172,15 @@ class AkahuEntry::Processor value = data[:date] case value when String - Date.parse(value) + if value.include?("T") || value.include?(":") + Time.parse(value).in_time_zone(account&.family&.timezone).to_date + else + Date.parse(value) + end when Integer, Float - Time.at(value).to_date + Time.at(value).in_time_zone(account&.family&.timezone).to_date when Time, DateTime - value.to_date + value.in_time_zone(account&.family&.timezone).to_date when Date value else diff --git a/app/models/brex_entry/processor.rb b/app/models/brex_entry/processor.rb index 03bbb8689..4e31fc46a 100644 --- a/app/models/brex_entry/processor.rb +++ b/app/models/brex_entry/processor.rb @@ -146,11 +146,15 @@ class BrexEntry::Processor case date_value when String - Date.parse(date_value) + if date_value.include?("T") || date_value.include?(":") + Time.parse(date_value).in_time_zone(account&.family&.timezone).to_date + else + Date.parse(date_value) + end when Integer, Float - Time.at(date_value).to_date + Time.at(date_value).in_time_zone(account&.family&.timezone).to_date when Time, DateTime - date_value.to_date + date_value.in_time_zone(account&.family&.timezone).to_date when Date date_value else diff --git a/app/models/coinstats_entry/processor.rb b/app/models/coinstats_entry/processor.rb index ce1731b4c..33b75d5cb 100644 --- a/app/models/coinstats_entry/processor.rb +++ b/app/models/coinstats_entry/processor.rb @@ -255,11 +255,15 @@ class CoinstatsEntry::Processor case timestamp when Integer, Float - Time.at(timestamp).to_date + Time.at(timestamp).in_time_zone(account&.family&.timezone).to_date when String - Time.parse(timestamp).to_date + if timestamp.include?("T") || timestamp.include?(":") + Time.parse(timestamp).in_time_zone(account&.family&.timezone).to_date + else + Date.parse(timestamp) + end when Time, DateTime - timestamp.to_date + timestamp.in_time_zone(account&.family&.timezone).to_date when Date timestamp else diff --git a/app/models/enable_banking_entry/processor.rb b/app/models/enable_banking_entry/processor.rb index 4f41b21f8..6afee84f0 100644 --- a/app/models/enable_banking_entry/processor.rb +++ b/app/models/enable_banking_entry/processor.rb @@ -258,11 +258,15 @@ class EnableBankingEntry::Processor case date_value when String - Date.parse(date_value) + if date_value.include?("T") || date_value.include?(":") + Time.parse(date_value).in_time_zone(account&.family&.timezone).to_date + else + Date.parse(date_value) + end when Integer, Float - Time.at(date_value).to_date + Time.at(date_value).in_time_zone(account&.family&.timezone).to_date when Time, DateTime - date_value.to_date + date_value.in_time_zone(account&.family&.timezone).to_date when Date date_value else diff --git a/app/models/indexa_capital_account/activities_processor.rb b/app/models/indexa_capital_account/activities_processor.rb index 12ba6e940..082467fc2 100644 --- a/app/models/indexa_capital_account/activities_processor.rb +++ b/app/models/indexa_capital_account/activities_processor.rb @@ -131,9 +131,9 @@ class IndexaCapitalAccount::ActivitiesProcessor # Get the activity date # TODO: Customize date field names - activity_date = parse_date(data[:settlement_date]) || - parse_date(data[:trade_date]) || - parse_date(data[:date]) || + activity_date = parse_date(data[:settlement_date], family: account&.family) || + parse_date(data[:trade_date], family: account&.family) || + parse_date(data[:date], family: account&.family) || Date.current currency = extract_currency(data, fallback: account.currency) @@ -165,9 +165,9 @@ class IndexaCapitalAccount::ActivitiesProcessor # Get the activity date # TODO: Customize date field names - activity_date = parse_date(data[:settlement_date]) || - parse_date(data[:trade_date]) || - parse_date(data[:date]) || + activity_date = parse_date(data[:settlement_date], family: account&.family) || + parse_date(data[:trade_date], family: account&.family) || + parse_date(data[:date], family: account&.family) || Date.current # Build description diff --git a/app/models/indexa_capital_account/data_helpers.rb b/app/models/indexa_capital_account/data_helpers.rb index db9283788..943d2bfe4 100644 --- a/app/models/indexa_capital_account/data_helpers.rb +++ b/app/models/indexa_capital_account/data_helpers.rb @@ -58,17 +58,22 @@ module IndexaCapitalAccount::DataHelpers hash[:identifier] || hash[:isin_code] || hash[:isin] || hash[:symbol] || hash[:ticker] end - def parse_date(date_value) + def parse_date(date_value, family: nil) return nil if date_value.nil? + tz = family&.timezone + case date_value + when String + if tz && (date_value.include?("T") || date_value.include?(":")) + Time.parse(date_value).in_time_zone(tz).to_date + else + Date.parse(date_value) + end + when Time, DateTime, ActiveSupport::TimeWithZone + date_value.in_time_zone(tz).to_date when Date date_value - when String - # Use Time.zone.parse for external timestamps (Rails timezone guidelines) - Time.zone.parse(date_value)&.to_date - when Time, DateTime, ActiveSupport::TimeWithZone - date_value.to_date else nil end diff --git a/app/models/lunchflow_entry/processor.rb b/app/models/lunchflow_entry/processor.rb index 11e42c755..f9d43df8f 100644 --- a/app/models/lunchflow_entry/processor.rb +++ b/app/models/lunchflow_entry/processor.rb @@ -188,12 +188,15 @@ class LunchflowEntry::Processor def date case data[:date] when String - Date.parse(data[:date]) + if data[:date].include?("T") || data[:date].include?(":") + Time.parse(data[:date]).in_time_zone(account&.family&.timezone).to_date + else + Date.parse(data[:date]) + end when Integer, Float - # Unix timestamp - Time.at(data[:date]).to_date + Time.at(data[:date]).in_time_zone(account&.family&.timezone).to_date when Time, DateTime - data[:date].to_date + data[:date].in_time_zone(account&.family&.timezone).to_date when Date data[:date] else diff --git a/app/models/mercury_entry/processor.rb b/app/models/mercury_entry/processor.rb index ee0bed387..e900a9882 100644 --- a/app/models/mercury_entry/processor.rb +++ b/app/models/mercury_entry/processor.rb @@ -158,13 +158,15 @@ class MercuryEntry::Processor case date_value when String - # Mercury uses ISO 8601 format: "2024-01-15T10:30:00Z" - DateTime.parse(date_value).to_date + if date_value.include?("T") || date_value.include?(":") + Time.parse(date_value).in_time_zone(account&.family&.timezone).to_date + else + Date.parse(date_value) + end when Integer, Float - # Unix timestamp - Time.at(date_value).to_date + Time.at(date_value).in_time_zone(account&.family&.timezone).to_date when Time, DateTime - date_value.to_date + date_value.in_time_zone(account&.family&.timezone).to_date when Date date_value else diff --git a/app/models/sophtron_entry/processor.rb b/app/models/sophtron_entry/processor.rb index 2ca30c124..6b57ac736 100644 --- a/app/models/sophtron_entry/processor.rb +++ b/app/models/sophtron_entry/processor.rb @@ -210,12 +210,15 @@ class SophtronEntry::Processor def date case data[:date] when String - Date.parse(data[:date]) + if data[:date].include?("T") || data[:date].include?(":") + Time.parse(data[:date]).in_time_zone(account&.family&.timezone).to_date + else + Date.parse(data[:date]) + end when Integer, Float - # Unix timestamp - Time.at(data[:date]).to_date + Time.at(data[:date]).in_time_zone(account&.family&.timezone).to_date when Time, DateTime - data[:date].to_date + data[:date].in_time_zone(account&.family&.timezone).to_date when Date data[:date] else diff --git a/app/models/up_entry/processor.rb b/app/models/up_entry/processor.rb index 59e9b9e89..fad499776 100644 --- a/app/models/up_entry/processor.rb +++ b/app/models/up_entry/processor.rb @@ -166,9 +166,15 @@ class UpEntry::Processor value = data[:settledAt].presence || data[:createdAt].presence case value when String - Time.parse(value).to_date + if value.include?("T") || value.include?(":") + Time.parse(value).in_time_zone(account&.family&.timezone).to_date + else + Date.parse(value) + end + when Integer, Float + Time.at(value).in_time_zone(account&.family&.timezone).to_date when Time, DateTime - value.to_date + value.in_time_zone(account&.family&.timezone).to_date when Date value else diff --git a/app/models/wise_entry/processor.rb b/app/models/wise_entry/processor.rb index 8bd42a92e..af4fe2c81 100644 --- a/app/models/wise_entry/processor.rb +++ b/app/models/wise_entry/processor.rb @@ -150,8 +150,15 @@ class WiseEntry::Processor raise ArgumentError, "Wise transfer missing created date" unless raw case raw - when Date then raw - when String then DateTime.parse(raw).to_date + when String + if raw.include?("T") || raw.include?(":") + Time.parse(raw).in_time_zone(account&.family&.timezone).to_date + else + Date.parse(raw) + end + when Time, DateTime + raw.in_time_zone(account&.family&.timezone).to_date + when Date then raw else raise ArgumentError, "Invalid date format: #{raw.inspect}" end rescue ArgumentError diff --git a/db/migrate/20260405120000_add_aspsp_metadata_to_enable_banking.rb b/db/migrate/20260405120000_add_aspsp_metadata_to_enable_banking.rb index b1553e87e..2e4b60920 100644 --- a/db/migrate/20260405120000_add_aspsp_metadata_to_enable_banking.rb +++ b/db/migrate/20260405120000_add_aspsp_metadata_to_enable_banking.rb @@ -1,15 +1,15 @@ class AddAspspMetadataToEnableBanking < ActiveRecord::Migration[7.2] def change # ASPSP-level metadata on the item (stored when user selects a bank) - add_column :enable_banking_items, :aspsp_required_psu_headers, :jsonb, default: [] - add_column :enable_banking_items, :aspsp_maximum_consent_validity, :integer # in seconds - add_column :enable_banking_items, :aspsp_auth_approach, :string # REDIRECT | EMBEDDED | DECOUPLED - add_column :enable_banking_items, :aspsp_psu_types, :jsonb, default: [] + add_column :enable_banking_items, :aspsp_required_psu_headers, :jsonb, default: [] unless column_exists?(:enable_banking_items, :aspsp_required_psu_headers) + add_column :enable_banking_items, :aspsp_maximum_consent_validity, :integer unless column_exists?(:enable_banking_items, :aspsp_maximum_consent_validity) # in seconds + add_column :enable_banking_items, :aspsp_auth_approach, :string unless column_exists?(:enable_banking_items, :aspsp_auth_approach) # REDIRECT | EMBEDDED | DECOUPLED + add_column :enable_banking_items, :aspsp_psu_types, :jsonb, default: [] unless column_exists?(:enable_banking_items, :aspsp_psu_types) # PII/GDPR Notice: last_psu_ip stores the user's IP address. # - Required for the Psu-Ip-Address header in Enable Banking API requests # - Must be declared in the privacy policy # - Data retention: consider nullifying after session expiry or 90 days - add_column :enable_banking_items, :last_psu_ip, :string # user IP captured at request time + add_column :enable_banking_items, :last_psu_ip, :string unless column_exists?(:enable_banking_items, :last_psu_ip) # user IP captured at request time # Fix sync_start_date type: was datetime, should be date reversible do |dir| @@ -28,8 +28,8 @@ class AddAspspMetadataToEnableBanking < ActiveRecord::Migration[7.2] end # Account-level fields from AccountResource - add_column :enable_banking_accounts, :product, :string # bank's proprietary product name - add_column :enable_banking_accounts, :credit_limit, :decimal, precision: 19, scale: 4 - add_column :enable_banking_accounts, :identification_hashes, :jsonb, default: [] + add_column :enable_banking_accounts, :product, :string unless column_exists?(:enable_banking_accounts, :product) # bank's proprietary product name + add_column :enable_banking_accounts, :credit_limit, :decimal, precision: 19, scale: 4 unless column_exists?(:enable_banking_accounts, :credit_limit) + add_column :enable_banking_accounts, :identification_hashes, :jsonb, default: [] unless column_exists?(:enable_banking_accounts, :identification_hashes) end end diff --git a/db/migrate/20260627101954_add_balance_reversal_to_enable_banking_accounts.rb b/db/migrate/20260627101954_add_balance_reversal_to_enable_banking_accounts.rb index fa08c2213..d34d7262d 100644 --- a/db/migrate/20260627101954_add_balance_reversal_to_enable_banking_accounts.rb +++ b/db/migrate/20260627101954_add_balance_reversal_to_enable_banking_accounts.rb @@ -1,5 +1,5 @@ class AddBalanceReversalToEnableBankingAccounts < ActiveRecord::Migration[7.2] def change - add_column :enable_banking_accounts, :treat_balance_as_available_credit, :boolean, default: false, null: false + add_column :enable_banking_accounts, :treat_balance_as_available_credit, :boolean, default: false, null: false unless column_exists?(:enable_banking_accounts, :treat_balance_as_available_credit) end end diff --git a/lib/generators/provider/family/templates/activities_processor.rb.tt b/lib/generators/provider/family/templates/activities_processor.rb.tt index 956ff5ced..48ca69256 100644 --- a/lib/generators/provider/family/templates/activities_processor.rb.tt +++ b/lib/generators/provider/family/templates/activities_processor.rb.tt @@ -131,9 +131,9 @@ class <%= class_name %>Account::ActivitiesProcessor # Get the activity date # TODO: Customize date field names - activity_date = parse_date(data[:settlement_date]) || - parse_date(data[:trade_date]) || - parse_date(data[:date]) || + activity_date = parse_date(data[:settlement_date], family: account&.family) || + parse_date(data[:trade_date], family: account&.family) || + parse_date(data[:date], family: account&.family) || Date.current currency = extract_currency(data, fallback: account.currency) @@ -165,9 +165,9 @@ class <%= class_name %>Account::ActivitiesProcessor # Get the activity date # TODO: Customize date field names - activity_date = parse_date(data[:settlement_date]) || - parse_date(data[:trade_date]) || - parse_date(data[:date]) || + activity_date = parse_date(data[:settlement_date], family: account&.family) || + parse_date(data[:trade_date], family: account&.family) || + parse_date(data[:date], family: account&.family) || Date.current # Build description diff --git a/lib/generators/provider/family/templates/data_helpers.rb.tt b/lib/generators/provider/family/templates/data_helpers.rb.tt index c3d1555e8..aaeff473a 100644 --- a/lib/generators/provider/family/templates/data_helpers.rb.tt +++ b/lib/generators/provider/family/templates/data_helpers.rb.tt @@ -39,17 +39,22 @@ module <%= class_name %>Account::DataHelpers nil end - def parse_date(date_value) + def parse_date(date_value, family: nil) return nil if date_value.nil? + tz = family&.timezone + case date_value + when String + if tz && (date_value.include?("T") || date_value.include?(":")) + Time.parse(date_value).in_time_zone(tz).to_date + else + Date.parse(date_value) + end + when Time, DateTime, ActiveSupport::TimeWithZone + date_value.in_time_zone(tz).to_date when Date date_value - when String - # Use Time.zone.parse for external timestamps (Rails timezone guidelines) - Time.zone.parse(date_value)&.to_date - when Time, DateTime, ActiveSupport::TimeWithZone - date_value.to_date else nil end diff --git a/lib/generators/provider/family/templates/transactions_processor.rb.tt b/lib/generators/provider/family/templates/transactions_processor.rb.tt index d3ef1639a..4d221739c 100644 --- a/lib/generators/provider/family/templates/transactions_processor.rb.tt +++ b/lib/generators/provider/family/templates/transactions_processor.rb.tt @@ -96,7 +96,7 @@ class <%= class_name %>Account::Transactions::Processor return nil if amount.nil? # TODO: Customize date field names based on your provider - date = parse_date(data[:date] || data[:transaction_date] || data[:posted_at]) + date = parse_date(data[:date] || data[:transaction_date] || data[:posted_at], family: account&.family) return nil if date.nil? name = data[:name] || data[:description] || data[:merchant_name] || "Transaction" diff --git a/test/models/akahu_entry/processor_test.rb b/test/models/akahu_entry/processor_test.rb index 488c7d841..701b455d8 100644 --- a/test/models/akahu_entry/processor_test.rb +++ b/test/models/akahu_entry/processor_test.rb @@ -96,4 +96,24 @@ class AkahuEntry::ProcessorTest < ActiveSupport::TestCase assert_equal first_entry.id, second_entry.id assert_equal 1, @account.entries.where(source: "akahu").count end + + test "converts ISO string timestamp date using family timezone not UTC" do + # 2025-07-14T23:30:00Z (23:30:00 UTC) == 2025-07-15 11:30:00 NZST + @family.update!(timezone: "Pacific/Auckland") + + transaction_data = { + _id: "tz_nz_test", + _account: "acc_123", + date: "2025-07-14T23:30:00Z", # 2025-07-14 23:30:00 UTC + merchant: { name: "Late Night Shop" }, + description: "After midnight", + amount: -10.00, + currency: "NZD" + } + + entry = AkahuEntry::Processor.new(transaction_data, akahu_account: @akahu_account).process + + assert_not_nil entry + assert_equal Date.new(2025, 7, 15), entry.date + end end diff --git a/test/models/enable_banking_entry/processor_test.rb b/test/models/enable_banking_entry/processor_test.rb index 5b54b29be..d916e66c4 100644 --- a/test/models/enable_banking_entry/processor_test.rb +++ b/test/models/enable_banking_entry/processor_test.rb @@ -430,4 +430,25 @@ class EnableBankingEntry::ProcessorTest < ActiveSupport::TestCase assert_nil processor.send(:merchant) end + + test "converts unix timestamp date using family timezone not UTC" do + # 2025-07-14 23:30:00 UTC == 2025-07-15 01:30:00 CEST + @family.update!(timezone: "Europe/Berlin") + + tx = { + entry_reference: "tz_ref", + transaction_id: nil, + booking_date: 1752535800, # 2025-07-14 23:30:00 UTC + transaction_amount: { amount: "10.00", currency: "EUR" }, + creditor: { name: "Late Night Shop" }, + credit_debit_indicator: "DBIT", + remittance_information: [ "After midnight" ], + status: "BOOK" + } + + result = EnableBankingEntry::Processor.new(tx, enable_banking_account: @enable_banking_account).process + + assert_not_nil result + assert_equal Date.new(2025, 7, 15), result.date + end end diff --git a/test/models/lunchflow_entry/processor_test.rb b/test/models/lunchflow_entry/processor_test.rb index 18cd193af..0f51c2980 100644 --- a/test/models/lunchflow_entry/processor_test.rb +++ b/test/models/lunchflow_entry/processor_test.rb @@ -378,4 +378,29 @@ class LunchflowEntry::ProcessorTest < ActiveSupport::TestCase assert result.entryable.pending?, "Should create new pending entry when merchant doesn't match" assert result.external_id.start_with?("lunchflow_pending_"), "Should have temporary ID" end + + test "converts unix timestamp date using negative family timezone offset" do + # 2025-07-15 01:20:00 UTC == 2025-07-14 21:20:00 EDT (UTC-4 in summer) + # Without timezone fix this would land on July 15; with fix it lands on July 14. + @family.update!(timezone: "America/New_York") + + transaction_data = { + id: "lf_tz_neg_test", + accountId: 456, + amount: -10.00, + currency: "USD", + date: 1752542400, # 2025-07-15 01:20:00 UTC == 2025-07-14 21:20:00 EDT + merchant: "Late Night Shop", + description: "After midnight" + } + + result = LunchflowEntry::Processor.new( + transaction_data, + lunchflow_account: @lunchflow_account + ).process + + assert_not_nil result + assert_equal Date.new(2025, 7, 14), result.date, + "Transaction at 21:20 EDT should land on July 14, not July 15" + end end