diff --git a/.idea/workspace.xml b/.idea/workspace.xml index ac7d38d..fe7e5cd 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -5,10 +5,12 @@ - - - - + + + + + + - { + "keyToString": { + "ModuleVcsDetector.initialDetectionPerformed": "true", + "RunOnceActivity.ShowReadmeOnStart": "true", + "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true", + "RunOnceActivity.git.unshallow": "true", + "com.intellij.lang.ruby.rbs.tools.collection.workspace.sync.RbsCollectionUpdateProjectActivity#LAST_UPDATE_TIMESTAMP": "1766085193730", + "git-widget-placeholder": "main", + "node.js.detected.package.eslint": "true", + "node.js.detected.package.tslint": "true", + "node.js.selected.package.eslint": "(autodetect)", + "node.js.selected.package.tslint": "(autodetect)", + "nodejs_package_manager_path": "yarn", + "ruby.structure.view.model.defaults.configured": "true", + "settings.editor.selected.configurable": "preferences.lookFeel", + "vue.rearranger.settings.migration": "true" } -}]]> +} diff --git a/app/controllers/letters_controller.rb b/app/controllers/letters_controller.rb index d0b40e1..63fbdbb 100644 --- a/app/controllers/letters_controller.rb +++ b/app/controllers/letters_controller.rb @@ -177,18 +177,32 @@ class LettersController < ApplicationController return end - payment_account = USPS::PaymentAccount.find_by(id: params[:usps_payment_account_id]) - if payment_account.nil? - redirect_to @letter, alert: "Please select a valid payment account." + usps_payment_account = USPS::PaymentAccount.find_by(id: params[:usps_payment_account_id]) + if usps_payment_account.nil? + redirect_to @letter, alert: "Please select a valid USPS payment account." return end - indicium = USPS::Indicium.new(letter: @letter, payment_account: payment_account) - begin - indicium.buy! - redirect_to @letter, notice: "Indicia purchased successfully." - rescue => e - redirect_to @letter, alert: "Failed to purchase indicia: #{e.message}" + indicium = USPS::Indicium.new(letter: @letter, payment_account: usps_payment_account) + + hcb_payment_account = current_user.hcb_payment_accounts.find_by(id: params[:hcb_payment_account_id]) + + if hcb_payment_account.present? + service = HCB::IndiciumPurchaseService.new(indicium: indicium, hcb_payment_account: hcb_payment_account) + if service.call + redirect_to @letter, notice: "Indicia purchased successfully (charged to #{hcb_payment_account.organization_name})." + else + redirect_to @letter, alert: service.errors.join(", ") + end + elsif current_user.can_use_indicia? + begin + indicium.buy! + redirect_to @letter, notice: "Indicia purchased successfully." + rescue => e + redirect_to @letter, alert: "Failed to purchase indicia: #{e.message}" + end + else + redirect_to @letter, alert: "You must select an HCB payment account to purchase indicia." end end diff --git a/app/models/hcb/oauth_connection.rb b/app/models/hcb/oauth_connection.rb index 5469af7..b638ecf 100644 --- a/app/models/hcb/oauth_connection.rb +++ b/app/models/hcb/oauth_connection.rb @@ -1,3 +1,23 @@ +# == Schema Information +# +# Table name: hcb_oauth_connections +# +# id :bigint not null, primary key +# access_token_ciphertext :text +# expires_at :datetime +# refresh_token_ciphertext :text +# created_at :datetime not null +# updated_at :datetime not null +# user_id :bigint not null +# +# Indexes +# +# index_hcb_oauth_connections_on_user_id (user_id) +# +# Foreign Keys +# +# fk_rails_... (user_id => users.id) +# class HCB::OauthConnection < ApplicationRecord belongs_to :user has_many :payment_accounts, dependent: :destroy diff --git a/app/models/hcb/payment_account.rb b/app/models/hcb/payment_account.rb index bfab47b..297d41d 100644 --- a/app/models/hcb/payment_account.rb +++ b/app/models/hcb/payment_account.rb @@ -1,3 +1,25 @@ +# == Schema Information +# +# Table name: hcb_payment_accounts +# +# id :bigint not null, primary key +# organization_name :string +# created_at :datetime not null +# updated_at :datetime not null +# hcb_oauth_connection_id :bigint not null +# organization_id :string +# user_id :bigint not null +# +# Indexes +# +# index_hcb_payment_accounts_on_hcb_oauth_connection_id (hcb_oauth_connection_id) +# index_hcb_payment_accounts_on_user_id (user_id) +# +# Foreign Keys +# +# fk_rails_... (hcb_oauth_connection_id => hcb_oauth_connections.id) +# fk_rails_... (user_id => users.id) +# class HCB::PaymentAccount < ApplicationRecord belongs_to :user belongs_to :oauth_connection, class_name: "HCB::OauthConnection", foreign_key: :hcb_oauth_connection_id diff --git a/app/models/letter/instant_queue.rb b/app/models/letter/instant_queue.rb index 9039b3a..5201bd0 100644 --- a/app/models/letter/instant_queue.rb +++ b/app/models/letter/instant_queue.rb @@ -19,6 +19,7 @@ # user_facing_title :string # created_at :datetime not null # updated_at :datetime not null +# hcb_payment_account_id :bigint # letter_mailer_id_id :bigint # letter_return_address_id :bigint # user_id :bigint not null @@ -26,6 +27,7 @@ # # Indexes # +# index_letter_queues_on_hcb_payment_account_id (hcb_payment_account_id) # index_letter_queues_on_letter_mailer_id_id (letter_mailer_id_id) # index_letter_queues_on_letter_return_address_id (letter_return_address_id) # index_letter_queues_on_type (type) @@ -33,6 +35,7 @@ # # Foreign Keys # +# fk_rails_... (hcb_payment_account_id => hcb_payment_accounts.id) # fk_rails_... (letter_mailer_id_id => usps_mailer_ids.id) # fk_rails_... (letter_return_address_id => return_addresses.id) # fk_rails_... (user_id => users.id) @@ -83,22 +86,27 @@ class Letter::InstantQueue < Letter::Queue if indicia? Rails.logger.info("Creating indicia for letter #{letter.id}") begin - payment_account = USPS::PaymentAccount.find(usps_payment_account_id) - Rails.logger.info("Found payment account #{payment_account.id}") + usps_payment_account = USPS::PaymentAccount.find(usps_payment_account_id) + Rails.logger.info("Found USPS payment account #{usps_payment_account.id}") - # Create and save the indicium first indicium = USPS::Indicium.create!( letter: letter, - payment_account: payment_account, + payment_account: usps_payment_account, mailing_date: letter.mailing_date, ) Rails.logger.info("Created indicium #{indicium.id} for letter #{letter.id}") - # Then buy the indicium - indicium.buy! + if hcb_payment_account.present? + Rails.logger.info("Using HCB payment account #{hcb_payment_account.id} for letter #{letter.id}") + service = HCB::IndiciumPurchaseService.new(indicium: indicium, hcb_payment_account: hcb_payment_account) + unless service.call + raise "HCB payment failed: #{service.errors.join(', ')}" + end + else + indicium.buy! + end Rails.logger.info("Successfully bought indicium for letter #{letter.id}") - # Reload the letter to ensure we have the latest indicium association letter.reload if letter.usps_indicium.present? Rails.logger.info("Verified indicium #{letter.usps_indicium.id} is associated with letter #{letter.id}") diff --git a/app/models/letter/queue.rb b/app/models/letter/queue.rb index 782859b..7949bd2 100644 --- a/app/models/letter/queue.rb +++ b/app/models/letter/queue.rb @@ -19,6 +19,7 @@ # user_facing_title :string # created_at :datetime not null # updated_at :datetime not null +# hcb_payment_account_id :bigint # letter_mailer_id_id :bigint # letter_return_address_id :bigint # user_id :bigint not null @@ -26,6 +27,7 @@ # # Indexes # +# index_letter_queues_on_hcb_payment_account_id (hcb_payment_account_id) # index_letter_queues_on_letter_mailer_id_id (letter_mailer_id_id) # index_letter_queues_on_letter_return_address_id (letter_return_address_id) # index_letter_queues_on_type (type) @@ -33,6 +35,7 @@ # # Foreign Keys # +# fk_rails_... (hcb_payment_account_id => hcb_payment_accounts.id) # fk_rails_... (letter_mailer_id_id => usps_mailer_ids.id) # fk_rails_... (letter_return_address_id => return_addresses.id) # fk_rails_... (user_id => users.id) diff --git a/app/models/usps/indicium.rb b/app/models/usps/indicium.rb index 32cc206..746d9cc 100644 --- a/app/models/usps/indicium.rb +++ b/app/models/usps/indicium.rb @@ -14,16 +14,20 @@ # usps_sku :string # created_at :datetime not null # updated_at :datetime not null +# hcb_payment_account_id :bigint +# hcb_transfer_id :string # letter_id :bigint # usps_payment_account_id :bigint not null # # Indexes # +# index_usps_indicia_on_hcb_payment_account_id (hcb_payment_account_id) # index_usps_indicia_on_letter_id (letter_id) # index_usps_indicia_on_usps_payment_account_id (usps_payment_account_id) # # Foreign Keys # +# fk_rails_... (hcb_payment_account_id => hcb_payment_accounts.id) # fk_rails_... (letter_id => letters.id) # fk_rails_... (usps_payment_account_id => usps_payment_accounts.id) # diff --git a/app/policies/usps/indicium_policy.rb b/app/policies/usps/indicium_policy.rb new file mode 100644 index 0000000..2d173e1 --- /dev/null +++ b/app/policies/usps/indicium_policy.rb @@ -0,0 +1,23 @@ +class USPS::IndiciumPolicy < ApplicationPolicy + def create? + user&.can_use_indicia? + end + + def index? + user_is_admin + end + + def show? + user_is_admin + end + + class Scope < ApplicationPolicy::Scope + def resolve + if user&.admin? + scope.all + else + scope.none + end + end + end +end diff --git a/app/services/hcb/indicium_purchase_service.rb b/app/services/hcb/indicium_purchase_service.rb new file mode 100644 index 0000000..0902f13 --- /dev/null +++ b/app/services/hcb/indicium_purchase_service.rb @@ -0,0 +1,61 @@ +class HCB::IndiciumPurchaseService + class DisbursementError < StandardError; end + + attr_reader :indicium, :hcb_payment_account, :errors + + def initialize(indicium:, hcb_payment_account:) + @indicium = indicium + @hcb_payment_account = hcb_payment_account + @errors = [] + end + + def call + return failure("No HCB payment account provided") unless hcb_payment_account + return failure("No letter attached to indicium") unless indicium.letter + + estimated_cost_cents = estimate_cost_cents + + ActiveRecord::Base.transaction do + transfer = create_disbursement!(estimated_cost_cents) + indicium.hcb_payment_account = hcb_payment_account + indicium.hcb_transfer_id = transfer.id + indicium.save! + + indicium.buy! + end + + true + rescue HCBV4::APIError => e + failure("HCB disbursement failed: #{e.message}") + rescue => e + failure("Purchase failed: #{e.message}") + end + + private + + def create_disbursement!(amount_cents) + hcb_payment_account.create_disbursement!( + amount_cents: amount_cents, + memo: disbursement_memo, + ) + end + + def estimate_cost_cents + letter = indicium.letter + base_cents = if letter.processing_category == "flat" + 150 + else + 73 + end + (base_cents * 1.0).ceil + end + + def disbursement_memo + "Theseus postage: #{indicium.letter.public_id}" + end + + def failure(message) + @errors << message + false + end +end diff --git a/db/migrate/20251218194333_add_hcb_payment_account_to_usps_indicia.rb b/db/migrate/20251218194333_add_hcb_payment_account_to_usps_indicia.rb new file mode 100644 index 0000000..092f3e2 --- /dev/null +++ b/db/migrate/20251218194333_add_hcb_payment_account_to_usps_indicia.rb @@ -0,0 +1,6 @@ +class AddHCBPaymentAccountToUSPSIndicia < ActiveRecord::Migration[8.0] + def change + add_reference :usps_indicia, :hcb_payment_account, null: true, foreign_key: true + add_column :usps_indicia, :hcb_transfer_id, :string + end +end diff --git a/db/migrate/20251218194459_add_hcb_payment_account_to_letter_queues.rb b/db/migrate/20251218194459_add_hcb_payment_account_to_letter_queues.rb new file mode 100644 index 0000000..3eaf49a --- /dev/null +++ b/db/migrate/20251218194459_add_hcb_payment_account_to_letter_queues.rb @@ -0,0 +1,5 @@ +class AddHCBPaymentAccountToLetterQueues < ActiveRecord::Migration[8.0] + def change + add_reference :letter_queues, :hcb_payment_account, null: true, foreign_key: true + end +end diff --git a/db/schema.rb b/db/schema.rb index ba1d3c9..1125044 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_12_18_193953) do +ActiveRecord::Schema[8.0].define(version: 2025_12_18_194459) do # These are extensions that must be enabled in order to support this database enable_extension "citext" enable_extension "pg_catalog.plpgsql" @@ -310,6 +310,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_18_193953) do t.bigint "usps_payment_account_id" t.boolean "include_qr_code", default: true t.date "letter_mailing_date" + t.bigint "hcb_payment_account_id" + t.index ["hcb_payment_account_id"], name: "index_letter_queues_on_hcb_payment_account_id" t.index ["letter_mailer_id_id"], name: "index_letter_queues_on_letter_mailer_id_id" t.index ["letter_return_address_id"], name: "index_letter_queues_on_letter_return_address_id" t.index ["type"], name: "index_letter_queues_on_type" @@ -455,6 +457,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_18_193953) do t.jsonb "raw_json_response" t.boolean "flirted" t.decimal "fees" + t.bigint "hcb_payment_account_id" + t.string "hcb_transfer_id" + t.index ["hcb_payment_account_id"], name: "index_usps_indicia_on_hcb_payment_account_id" t.index ["letter_id"], name: "index_usps_indicia_on_letter_id" t.index ["usps_payment_account_id"], name: "index_usps_indicia_on_usps_payment_account_id" end @@ -602,6 +607,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_18_193953) do add_foreign_key "hcb_oauth_connections", "users" add_foreign_key "hcb_payment_accounts", "hcb_oauth_connections" add_foreign_key "hcb_payment_accounts", "users" + add_foreign_key "letter_queues", "hcb_payment_accounts" add_foreign_key "letter_queues", "return_addresses", column: "letter_return_address_id" add_foreign_key "letter_queues", "users" add_foreign_key "letter_queues", "usps_mailer_ids", column: "letter_mailer_id_id" @@ -618,6 +624,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_18_193953) do add_foreign_key "return_addresses", "users" add_foreign_key "users", "return_addresses", column: "home_return_address_id" add_foreign_key "users", "usps_mailer_ids", column: "home_mid_id" + add_foreign_key "usps_indicia", "hcb_payment_accounts" add_foreign_key "usps_indicia", "letters" add_foreign_key "usps_indicia", "usps_payment_accounts" add_foreign_key "usps_iv_mtr_events", "letters", on_delete: :nullify