Add batch-level HCB disbursement support

- HCB::BatchPurchaseService for single disbursement per batch
- Add hcb_payment_account and hcb_transfer_id to batches
- Wire into Letter::Batch#process! and batches controller
This commit is contained in:
24c02 2025-12-18 14:48:22 -05:00
parent 11c5f53074
commit b860c9d112
7 changed files with 131 additions and 16 deletions

View file

@ -103,9 +103,12 @@ class Letter::BatchesController < BaseBatchesController
end
end
hcb_payment_account = current_user.hcb_payment_accounts.find_by(id: letter_batch_params[:hcb_payment_account_id])
begin
@batch.process!(
payment_account: payment_account,
hcb_payment_account: hcb_payment_account,
us_postage_type: letter_batch_params[:us_postage_type],
intl_postage_type: letter_batch_params[:intl_postage_type],
template_cycle: letter_batch_params[:template_cycle].to_s.split(",").compact_blank,
@ -219,6 +222,7 @@ class Letter::BatchesController < BaseBatchesController
:us_postage_type,
:intl_postage_type,
:usps_payment_account_id,
:hcb_payment_account_id,
:include_qr_code,
:print_immediately,
:template_cycle,

View file

@ -18,6 +18,8 @@
# warehouse_user_facing_title :string
# created_at :datetime not null
# updated_at :datetime not null
# hcb_payment_account_id :bigint
# hcb_transfer_id :string
# letter_mailer_id_id :bigint
# letter_queue_id :bigint
# letter_return_address_id :bigint
@ -26,6 +28,7 @@
#
# Indexes
#
# index_batches_on_hcb_payment_account_id (hcb_payment_account_id)
# index_batches_on_letter_mailer_id_id (letter_mailer_id_id)
# index_batches_on_letter_queue_id (letter_queue_id)
# index_batches_on_letter_return_address_id (letter_return_address_id)
@ -36,6 +39,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_queue_id => letter_queues.id)
# fk_rails_... (letter_return_address_id => return_addresses.id)
@ -69,6 +73,7 @@ class Batch < ApplicationRecord
self.inheritance_column = "type"
belongs_to :user
belongs_to :letter_queue, optional: true, class_name: "Letter::Queue"
belongs_to :hcb_payment_account, class_name: "HCB::PaymentAccount", optional: true
has_one_attached :csv
has_one_attached :labels_pdf
has_one_attached :pdf_document

View file

@ -18,6 +18,8 @@
# warehouse_user_facing_title :string
# created_at :datetime not null
# updated_at :datetime not null
# hcb_payment_account_id :bigint
# hcb_transfer_id :string
# letter_mailer_id_id :bigint
# letter_queue_id :bigint
# letter_return_address_id :bigint
@ -26,6 +28,7 @@
#
# Indexes
#
# index_batches_on_hcb_payment_account_id (hcb_payment_account_id)
# index_batches_on_letter_mailer_id_id (letter_mailer_id_id)
# index_batches_on_letter_queue_id (letter_queue_id)
# index_batches_on_letter_return_address_id (letter_return_address_id)
@ -36,6 +39,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_queue_id => letter_queues.id)
# fk_rails_... (letter_return_address_id => return_addresses.id)
@ -136,7 +140,7 @@ class Letter::Batch < Batch
raise "...we're out of money (ask Nora to put at least #{ActiveSupport::NumberHelper.number_to_currency(indicia_cost)} in the #{options[:payment_account].display_name} account!)"
end
purchase_batch_indicia(options[:payment_account])
purchase_batch_indicia(options[:payment_account], hcb_payment_account: options[:hcb_payment_account])
end
# Generate PDF labels with the provided options
@ -151,23 +155,32 @@ class Letter::Batch < Batch
end
# Purchase indicia for all letters in the batch using a single payment token
def purchase_batch_indicia(payment_account)
# Create a single payment token for the entire batch
payment_token = payment_account.create_payment_token
# If hcb_payment_account is provided, creates a single disbursement for the whole batch
def purchase_batch_indicia(usps_payment_account, hcb_payment_account: nil)
if hcb_payment_account.present?
service = HCB::BatchPurchaseService.new(
batch: self,
hcb_payment_account: hcb_payment_account,
usps_payment_account: usps_payment_account,
)
unless service.call
raise StandardError, service.errors.join(", ")
end
else
payment_token = usps_payment_account.create_payment_token
# Preload associations to avoid N+1 queries
letters.includes(:address).each do |letter|
next unless letter.postage_type == "indicia" && letter.usps_indicium.nil?
# Create and purchase indicia for each letter using the same payment token
indicium = USPS::Indicium.new(
letter: letter,
payment_account: payment_account,
payment_account: usps_payment_account,
mailing_date: letter_mailing_date,
)
indicium.buy!(payment_token)
end
end
end
def postage_cost
# Preload associations to avoid N+1 queries

View file

@ -18,6 +18,8 @@
# warehouse_user_facing_title :string
# created_at :datetime not null
# updated_at :datetime not null
# hcb_payment_account_id :bigint
# hcb_transfer_id :string
# letter_mailer_id_id :bigint
# letter_queue_id :bigint
# letter_return_address_id :bigint
@ -26,6 +28,7 @@
#
# Indexes
#
# index_batches_on_hcb_payment_account_id (hcb_payment_account_id)
# index_batches_on_letter_mailer_id_id (letter_mailer_id_id)
# index_batches_on_letter_queue_id (letter_queue_id)
# index_batches_on_letter_return_address_id (letter_return_address_id)
@ -36,6 +39,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_queue_id => letter_queues.id)
# fk_rails_... (letter_return_address_id => return_addresses.id)

View file

@ -0,0 +1,79 @@
class HCB::BatchPurchaseService
class DisbursementError < StandardError; end
attr_reader :batch, :hcb_payment_account, :usps_payment_account, :errors
def initialize(batch:, hcb_payment_account:, usps_payment_account:)
@batch = batch
@hcb_payment_account = hcb_payment_account
@usps_payment_account = usps_payment_account
@errors = []
end
def call
return failure("No HCB payment account provided") unless hcb_payment_account
return failure("No USPS payment account provided") unless usps_payment_account
letters_needing_indicia = batch.letters.select do |letter|
letter.postage_type == "indicia" && letter.usps_indicium.nil?
end
return true if letters_needing_indicia.empty?
total_cost_cents = estimate_total_cost_cents(letters_needing_indicia)
ActiveRecord::Base.transaction do
transfer = create_disbursement!(total_cost_cents)
batch.update!(
hcb_payment_account: hcb_payment_account,
hcb_transfer_id: transfer.id,
)
purchase_indicia_for_letters!(letters_needing_indicia)
end
true
rescue HCBV4::APIError => e
failure("HCB disbursement failed: #{e.message}")
rescue => e
failure("Batch purchase failed: #{e.message}")
end
private
def create_disbursement!(amount_cents)
hcb_payment_account.create_disbursement!(
amount_cents: amount_cents,
memo: "Theseus batch postage: #{batch.letters.count} letters",
)
end
def purchase_indicia_for_letters!(letters)
payment_token = usps_payment_account.create_payment_token
letters.each do |letter|
indicium = USPS::Indicium.create!(
letter: letter,
payment_account: usps_payment_account,
hcb_payment_account: hcb_payment_account,
mailing_date: batch.letter_mailing_date,
)
indicium.buy!(payment_token)
end
end
def estimate_total_cost_cents(letters)
letters.sum do |letter|
if letter.processing_category == "flat"
150
else
73
end
end
end
def failure(message)
@errors << message
false
end
end

View file

@ -0,0 +1,6 @@
class AddHCBPaymentAccountToBatches < ActiveRecord::Migration[8.0]
def change
add_reference :batches, :hcb_payment_account, null: true, foreign_key: true
add_column :batches, :hcb_transfer_id, :string
end
end

6
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_194459) do
ActiveRecord::Schema[8.0].define(version: 2025_12_18_194622) do
# These are extensions that must be enabled in order to support this database
enable_extension "citext"
enable_extension "pg_catalog.plpgsql"
@ -106,6 +106,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_18_194459) do
t.string "template_cycle", default: [], array: true
t.string "letter_return_address_name"
t.bigint "letter_queue_id"
t.bigint "hcb_payment_account_id"
t.string "hcb_transfer_id"
t.index ["hcb_payment_account_id"], name: "index_batches_on_hcb_payment_account_id"
t.index ["letter_mailer_id_id"], name: "index_batches_on_letter_mailer_id_id"
t.index ["letter_queue_id"], name: "index_batches_on_letter_queue_id"
t.index ["letter_return_address_id"], name: "index_batches_on_letter_return_address_id"
@ -599,6 +602,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_18_194459) do
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "addresses", "batches"
add_foreign_key "api_keys", "users"
add_foreign_key "batches", "hcb_payment_accounts"
add_foreign_key "batches", "letter_queues"
add_foreign_key "batches", "return_addresses", column: "letter_return_address_id"
add_foreign_key "batches", "users"