Add HCB payment integration for indicia purchases

- USPS::IndiciumPolicy to check can_use_indicia?
- HCB::IndiciumPurchaseService for disbursement + purchase flow
- Add hcb_payment_account to letter_queues and usps_indicia
- Wire HCB payment into LettersController#buy_indicia
- Wire HCB payment into Letter::InstantQueue
This commit is contained in:
24c02 2025-12-18 14:45:50 -05:00
parent b574d57a80
commit 11c5f53074
12 changed files with 214 additions and 39 deletions

46
.idea/workspace.xml generated
View file

@ -5,10 +5,12 @@
</component>
<component name="ChangeListManager">
<list default="true" id="d1cc2402-3c65-4046-bb1c-ec3e5b4aef52" name="Changes" comment="">
<change beforePath="$PROJECT_DIR$/Gemfile" beforeDir="false" afterPath="$PROJECT_DIR$/Gemfile" afterDir="false" />
<change beforePath="$PROJECT_DIR$/Gemfile.lock" beforeDir="false" afterPath="$PROJECT_DIR$/Gemfile.lock" afterDir="false" />
<change beforePath="$PROJECT_DIR$/app/models/public/user.rb" beforeDir="false" afterPath="$PROJECT_DIR$/app/models/public/user.rb" afterDir="false" />
<change beforePath="$PROJECT_DIR$/app/models/user.rb" beforeDir="false" afterPath="$PROJECT_DIR$/app/models/user.rb" afterDir="false" />
<change beforePath="$PROJECT_DIR$/app/controllers/letters_controller.rb" beforeDir="false" afterPath="$PROJECT_DIR$/app/controllers/letters_controller.rb" afterDir="false" />
<change beforePath="$PROJECT_DIR$/app/models/hcb/oauth_connection.rb" beforeDir="false" afterPath="$PROJECT_DIR$/app/models/hcb/oauth_connection.rb" afterDir="false" />
<change beforePath="$PROJECT_DIR$/app/models/hcb/payment_account.rb" beforeDir="false" afterPath="$PROJECT_DIR$/app/models/hcb/payment_account.rb" afterDir="false" />
<change beforePath="$PROJECT_DIR$/app/models/letter/instant_queue.rb" beforeDir="false" afterPath="$PROJECT_DIR$/app/models/letter/instant_queue.rb" afterDir="false" />
<change beforePath="$PROJECT_DIR$/app/models/letter/queue.rb" beforeDir="false" afterPath="$PROJECT_DIR$/app/models/letter/queue.rb" afterDir="false" />
<change beforePath="$PROJECT_DIR$/app/models/usps/indicium.rb" beforeDir="false" afterPath="$PROJECT_DIR$/app/models/usps/indicium.rb" afterDir="false" />
<change beforePath="$PROJECT_DIR$/db/schema.rb" beforeDir="false" afterPath="$PROJECT_DIR$/db/schema.rb" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
@ -40,24 +42,24 @@
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"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"
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;com.intellij.lang.ruby.rbs.tools.collection.workspace.sync.RbsCollectionUpdateProjectActivity#LAST_UPDATE_TIMESTAMP&quot;: &quot;1766085193730&quot;,
&quot;git-widget-placeholder&quot;: &quot;main&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;yarn&quot;,
&quot;ruby.structure.view.model.defaults.configured&quot;: &quot;true&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;preferences.lookFeel&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
}
}]]></component>
}</component>
<component name="RubyModuleManagerSettings">
<option name="blackListedRootsPaths">
<list>
@ -161,7 +163,7 @@
<workItem from="1765302419304" duration="4201000" />
<workItem from="1765488873945" duration="19000" />
<workItem from="1765488900213" duration="5988000" />
<workItem from="1766085135057" duration="1297000" />
<workItem from="1766085135057" duration="1617000" />
</task>
<servers />
</component>

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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}")

View file

@ -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)

View file

@ -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)
#

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

9
db/schema.rb generated
View file

@ -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