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 @@
-
-
-
-
+
+
+
+
+
+
@@ -40,24 +42,24 @@
- {
+ "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