Skip to content

Instantly share code, notes, and snippets.

@Chocksy
Last active February 5, 2025 16:47
Show Gist options
  • Save Chocksy/d409085d90c2565c3a0f80468e4c7832 to your computer and use it in GitHub Desktop.
Save Chocksy/d409085d90c2565c3a0f80468e4c7832 to your computer and use it in GitHub Desktop.
Hubstaff-ACH
Okay, let's tackle this ACH migration problem. The user received a notice from Stripe about migrating from the Sources API to the PaymentIntents API by February 2025. The main goal is to update their codebase to comply with this.

First, I need to understand where ACH is currently used in the code. Looking at the provided code snippets:

  1. StripeCustomer Adapter: The default_payment_method method in hubstaff_billing/lib/hubstaff_billing/adapters/stripe_customer.rb checks if the payment method starts with 'ba_' or 'src_', which are legacy Source IDs. This indicates they're using the old Sources API for ACH.

  2. Organizations Controller: The show_billing action retrieves the default payment method, which might be a Stripe::Source for ACH.

  3. Billing View (Slim Template): The view has conditional rendering for ACH status, like 'Pending' or 'Verified', and buttons for verifying deposits.

  4. Admin Organizations Page: Displays ACH status and allows removing billing info, which might be tied to the old Sources API.

The Stripe migration guide suggests using PaymentMethods and PaymentIntents instead of Sources. So, the key steps would be:

  • Update Payment Method Handling: Replace references to Sources (like 'ba_' or 'src_') with PaymentMethods. In the StripeCustomer adapter, instead of retrieving a Source, they should retrieve a PaymentMethod. This means modifying the default_payment_method method to use the PaymentMethods API.

  • Modify ACH Initialization: In the JavaScript code (organizations_billing.js), the ACH initialization uses createToken with 'bank_account'. They need to switch to using createPaymentMethod for ACH via the PaymentIntents API.

  • Update Webhook Handling: Ensure that any webhooks processing ACH events are updated to handle PaymentIntents instead of Sources.

  • Admin Interface Adjustments: The admin panel displaying ACH status might need to fetch data from PaymentMethods instead of Sources.

Potential code changes:

  1. StripeCustomer Adapter:

    • Remove the check for 'ba_' or 'src_' prefixes.
    • Always retrieve the default payment method using the PaymentMethods API.
  2. Organizations Controller:

    • Ensure that when fetching the default payment method, it's a PaymentMethod object, not a Source.
  3. JavaScript ACH Handling:

    • Replace stripe.createToken('bank_account', data) with stripe.createPaymentMethod({ type: 'us_bank_account', ... }).
  4. Templates:

    • Update any references to Stripe::Source or Stripe::BankAccount to use PaymentMethod details.

Testing is crucial. They should add debug statements to log the payment method type and ensure that ACH transactions go through the new API. Also, updating tests in the organization specs to reflect the new PaymentMethod approach.

By systematically replacing Sources with PaymentMethods and testing each part, the migration can be completed before the deadline.

Let's break down the ACH migration steps based on the codebase analysis:

  1. Key Code Locations Requiring Changes:
          Stripe::PaymentMethod.retrieve(default_payment_method_id)
        end
      end

      # The only place where we use StripeCustomer Adapter is in the Organization#hs_billing_customer, which is memoized
      # During memoization we call Stripe API to retrieve the customer expanded with "subscriptions" and "tax".
      # If "save" is called on the adapter, @customer mutates, though there is no more "subscriptions" expansion inside.
      #
      # @return [StripeCustomer]
      def save
        customer.save(expand: %w[subscriptions tax])
      end

This is the critical path handling legacy Sources API integration for ACH. The default_payment_method method needs to be updated to use PaymentMethods API instead of Sources.

  1. Migration Steps Required:

a) Update StripeCustomer Adapter:

def default_payment_method
  return if default_payment_method_id.nil?
  
  # New implementation using PaymentMethods API
  Stripe::PaymentMethod.retrieve(default_payment_method_id)
end

b) Update ACH Verification Flow in Controller:

Need to replace Source-based verification with PaymentIntent confirmation:

def confirm_billing
  payment_intent = Stripe::PaymentIntent.retrieve(params[:payment_intent_id])
  # Add verification logic using payment_intent.payment_method
end
  1. Frontend Changes Needed:
        stripe.createToken('bank_account', data).then(OrganizationBilling.stripeACHResponseHandler);
      }

Update from token-based to PaymentIntent-based flow:

// Replace createToken with createPaymentMethod
stripe.createPaymentMethod({
  type: 'us_bank_account',
  billing_details: { /* ... */ },
  us_bank_account: { /* ... */ }
}).then(handleResponse)
  1. Admin Interface Updates:
      elsif organization.payment_method.ach?
        row :ach_status
      end

Update ACH status display to use PaymentMethod metadata instead of legacy Source status.

  1. Suggested Testing Approach:

Add debug logging in critical paths:

# In StripeCustomer#default_payment_method
Rails.logger.debug "[STRIPE] Retrieving payment method: #{default_payment_method_id}"
Rails.logger.debug "[STRIPE] Payment method type: #{payment_method.type}"
  1. Timeline Considerations:
  • Phase 1 (1 month): Implement parallel processing of both APIs with feature flag
  • Phase 2 (2 months): Migrate all new ACH transactions to PaymentIntents API
  • Phase 3 (1 month): Backfill historical data and remove legacy code

Would you like me to provide the specific implementation details for any of these migration steps?

Yes please go into the documentation on stripe and explain exactly where that would be applied.

RIght now our implementation requires a CS person to go to admin page in @organizations.rb and change the payment method to bank/ach. Then on the @billing.html.slim page the change card button is replaced with the ACH one. Well that is one approach of adding ACH and we have a dialog that allows for ACH to be verified. Once verified the @stripe_customer.rb takes care to handle it properly.

BUt i can see you mention how we can advance that but i'm thinking we could update the js code in @organizations_billing.js to allow for ACH bank in the actual dialog that we have when a user pays for the plan.

I'm thinking the approach we need to take is to not require an ACH payment method change in admin anymore. No more a verification like we do now with a custom dialog but by using the stripe js code.

Can you please take the changes required from this approach??!!

Okay, let's tackle this ACH migration step by step. The user wants to move away from the admin panel approach and use Stripe's PaymentIntents API directly in the frontend.

First, I need to look at the existing JavaScript code in organizations_billing.js, especially the initACH function. Right now, it's using createToken with 'bank_account', which is part of the Sources API. The goal is to replace this with the PaymentMethods API.

Stripe's documentation says that for ACH, we should use createPaymentMethod with type 'us_bank_account'. So, in the initACH function, instead of createToken, we should call createPaymentMethod. That means updating lines 121-122 in the JavaScript file.

Next, the response handler (stripeACHResponseHandler) currently expects a token, but with PaymentMethods, we'll get a paymentMethod object. So, the hidden input named "token" should now carry paymentMethod.id. That affects lines 237-238 in the JavaScript.

On the backend, the StripeCustomer adapter's default_payment_method method currently checks for 'ba_' or 'src_' prefixes to handle legacy sources. Since we're moving to PaymentMethods, we can remove that conditional and always retrieve the PaymentMethod. This means modifying lines 69-75 in the StripeCustomer Ruby file.

The admin interface in organizations.rb might still reference ach_status in the config. We should update that to check the PaymentMethod's status instead. However, the exact lines aren't provided, but it's likely in the ACH status display section.

Testing is crucial. Adding debug logs in the StripeCustomer#default_payment_method will help verify that the correct PaymentMethod is retrieved. Also, updating the specs in organization_spec.rb to reflect the new PaymentMethod handling ensures existing tests pass.

The billing.html.slim view might need adjustments in how ACH status is displayed, moving from config-based to PaymentMethod-based checks. But without seeing the exact lines, I'll focus on the known parts.

In summary, the key steps are updating the frontend to use createPaymentMethod, adjusting the backend to handle PaymentMethod IDs, and modifying the StripeCustomer adapter to stop relying on legacy sources. This approach removes the need for admin intervention and uses Stripe's recommended API.

Let's implement the modern Stripe ACH integration using PaymentMethods API. Here are the key changes needed:

  1. Frontend Payment Flow Update (critical path):
  initACH: function () {
    var $account_holder_name = $('#billing_account_holder_name');
    var $account_holder_type = $('#billing_account_holder_type');
    var $routing_number = $('#billing_routing_number');
    var $account_number = $('#billing_account_number');

    $account_holder_type.select2();

    $('#payment-form-internal').on('submit', function (e) {
      e.preventDefault();
      e.stopImmediatePropagation();

      var data = {
        country: 'US',
        currency: 'usd',
        account_holder_name: $account_holder_name.val(),
        account_holder_type: $account_holder_type.val(),
        routing_number: $routing_number.val(),
        account_number: $account_number.val(),
      };
      var good = true;

      [$account_holder_name, $account_holder_type, $routing_number, $account_number].forEach(function ($elem) {
        if ($elem.val().length === 0) {
          FormHelpers.setError($elem);
          good = false;
        }
      });
      if (good) {
        stripe.createToken('bank_account', data).then(OrganizationBilling.stripeACHResponseHandler);
      }

      return false;
    });
  },

Change from token-based to PaymentMethod-based flow:

initACH: function() {
  // ... existing setup code ...

  $('#payment-form-internal').on('submit', function(e) {
    // ... validation code ...

    if (good) {
      // Replace createToken with createPaymentMethod
      stripe.createPaymentMethod({
        type: 'us_bank_account',
        billing_details: {
          name: $account_holder_name.val(),
          email: '<%= current_user.email %>' // Add email from server
        },
        us_bank_account: {
          routing_number: $routing_number.val(),
          account_number: $account_number.val(),
          account_holder_type: $account_holder_type.val()
        }
      }).then(OrganizationBilling.stripeACHResponseHandler);
    }
  });
}
  1. Update Response Handling:
  stripeACHResponseHandler: function (response) {
    var $form = $('#payment-form-internal');
    var $account_holder_name = $('#billing_account_holder_name');
    var $account_holder_type = $('#billing_account_holder_type');
    var $routing_number = $('#billing_routing_number');
    var $account_number = $('#billing_account_number');
    var $submitBtn = $form.find('.btn-primary[type="submit"]');

    $submitBtn.button('loading');

    var empty_fields = [];

    [$account_holder_name, $account_holder_type, $routing_number, $account_number].forEach(function ($elem) {
      FormHelpers.clearError($elem);
      if ($elem.val().length === 0) {
        FormHelpers.setError($elem);
        var $wrap = $element.closest('.block-form-group, .form-group');
        var label = $wrap.find('label').text();

        empty_fields.push(label.replace(/\s*\*/g, ''));
      }
    });

    var error_message = null;

    if (response.error) {
      error_message = response.error.message;
    } else if (empty_fields.length > 0) {
      error_message = empty_fields.join(', ') + ' cannot be empty';
    } else {
      $form.find('input[name="token"]').remove();
      $form.append($('<input type="hidden" name="token" />').val(response.token.id));

      if ($form.attr('data-remote') === 'true') {
        $form.trigger('submit.rails');
      } else {
        $form.get(0).submit();

Modify the ACH response handler to work with PaymentMethod:

stripeACHResponseHandler: function(response) {
  // ... existing validation code ...

  if (response.error) {
    error_message = response.error.message;
  } else if (response.paymentMethod) { // Changed from response.token
    $form.find('input[name="payment_method_id"]').remove();
    $form.append($('<input type="hidden" name="payment_method_id" />')
      .val(response.paymentMethod.id));
    
    // Submit form to attach payment method to customer
    if ($form.attr('data-remote') === 'true') {
      $form.trigger('submit.rails');
    } else {
      $form.get(0).submit();
    }
  }
}
  1. Backend Adapter Update (critical):
          Stripe::PaymentMethod.retrieve(default_payment_method_id)
        end
      end

      # The only place where we use StripeCustomer Adapter is in the Organization#hs_billing_customer, which is memoized
      # During memoization we call Stripe API to retrieve the customer expanded with "subscriptions" and "tax".
      # If "save" is called on the adapter, @customer mutates, though there is no more "subscriptions" expansion inside.

Simplify payment method retrieval:

memoize def default_payment_method
  return if default_payment_method_id.nil?
  
  # Directly retrieve payment method - no legacy source handling
  Stripe::PaymentMethod.retrieve(default_payment_method_id)
end
  1. Verification Flow Update:
  # This also handles the issue where the subscription expired on the last attempt to pick a plan (create a subscription)
  # so in that case Stripe marks the payment_intent as require_source_action so we need to ask for the credit card again
  # and 3D Secure confirmation.
  # @route GET /organizations/:id/confirm_payment (confirm_payment_organization)
  def confirm_payment

Update verification endpoint to use modern flow:

def confirm_billing
  # Use payment method ID instead of source
  payment_method = Stripe::PaymentMethod.retrieve(params[:payment_method_id])
  # Add verification logic using payment_method.us_bank_account.verification_method
end
  1. Remove Admin Dependency:
      elsif organization.payment_method.ach?
        row :ach_status
      end

Update ACH status display to use payment method metadata:

# Replace config-based status with payment method check
if organization.payment_method.ach?
  row do |o|
    status_tag(o.hs_billing_customer.default_payment_method.us_bank_account.verified ? 'Verified' : 'Pending')
  end
end

Key advantages of this approach:

  1. Eliminates admin panel requirement for ACH setup
  2. Uses Stripe's built-in verification system
  3. Follows Stripe's recommended PaymentIntents flow
  4. Maintains backward compatibility during transition

Testing recommendations:

  1. Add debug logging in StripeCustomer:
Rails.logger.debug "[STRIPE] Payment Method Type: #{default_payment_method.type}"
Rails.logger.debug "[STRIPE] ACH Status: #{default_payment_method.us_bank_account.verified?}" 
  1. Update specs to reflect new flow:
      context 'when there is ach enabled' do
        it 'removes the stripe config' do
          organization.config['stripe'] = {method: 'ach', ach_status: 'verified'}
          organization.save

          expect {
            subject
          }.to change { organization.config['stripe']['ach_status'] }.to be_nil
        end

Update to check payment method verification status instead of config:

context 'when there is ach enabled' do
  it 'shows verified status' do
    payment_method = double('PaymentMethod', type: 'us_bank_account', us_bank_account: double(verified: true))
    allow(organization).to receive(:hs_billing_customer).and_return(double(default_payment_method: payment_method))
    
    expect(organization.ach_status).to eq('verified')
  end
end

This aligns with Stripe's migration guide requirements while maintaining existing functionality. Would you like me to elaborate on any specific aspect of the implementation?

Below is one approach to migrate your ACH integration away from the legacy Sources API and into PaymentIntents/PaymentMethod. The idea is to update both the front‐end and back‐end so that ACH bank accounts (which now use PaymentMethod objects with a "us_bank_account" type) are created, stored, and later retrieved without relying on Stripe sources.

Below are two key changes you can make:


1. Update the JavaScript ACH Flow

Previously the ACH form used a token‑based flow (calling stripe.createToken). Update the form submission handler (in your organizations_billing.js file) so that it calls the new API,   stripe.createPaymentMethod, with the proper data structure. Also update the response handler to check for a returned PaymentMethod rather than a token.

Below is an updated version of the relevant functions:

var OrganizationBilling = {
  // ... other functions ...

  initACH: function () {
    var $account_holder_name = $('#billing_account_holder_name');
    var $account_holder_type = $('#billing_account_holder_type');
    var $routing_number = $('#billing_routing_number');
    var $account_number = $('#billing_account_number');

    $account_holder_type.select2();

    $('#payment-form-internal').on('submit', function (e) {
      e.preventDefault();
      e.stopImmediatePropagation();

      var data = {
        account_holder_name: $account_holder_name.val(),
        account_holder_type: $account_holder_type.val(),
        routing_number: $routing_number.val(),
        account_number: $account_number.val()
      };

      var good = true;
      [$account_holder_name, $account_holder_type, $routing_number, $account_number].forEach(function ($elem) {
        if ($elem.val().length === 0) {
          FormHelpers.setError($elem);
          good = false;
        }
      });

      if (good) {
        console.log("Creating PaymentMethod with data:", data);
        stripe.createPaymentMethod({
          type: 'us_bank_account',
          billing_details: {
            name: $account_holder_name.val()
          },
          us_bank_account: {
            account_holder_type: $account_holder_type.val(),
            routing_number: $routing_number.val(),
            account_number: $account_number.val()
          }
        }).then(OrganizationBilling.stripeACHResponseHandler);
      }

      return false;
    });
  },

  stripeACHResponseHandler: function (response) {
    var $form = $('#payment-form-internal');
    var $submitBtn = $form.find('.btn-primary[type="submit"]');
    $submitBtn.button('loading');
    console.log("Stripe ACH response:", response);

    var error_message = null;

    if (response.error) {
      error_message = response.error.message;
    } else {
      // For PaymentMethod API, the response is returned in response.paymentMethod
      var methodId = response.paymentMethod ? response.paymentMethod.id : (response.token && response.token.id);
      if (!methodId) {
        error_message = 'Invalid response from Stripe.';
      } else {
        $form.find('input[name="token"]').remove();
        $form.append($('<input type="hidden" name="token" />').val(methodId));

        if ($form.attr('data-remote') === 'true') {
          $form.trigger('submit.rails');
        } else {
          $form.get(0).submit();
        }
      }
    }

    if (error_message) {
      OrganizationBilling.showError(error_message);
      $submitBtn.prop('disabled', false).button('reset');
    }
  },

  // ... other functions ...
};

In this change:
• The form now calls createPaymentMethod with type "us_bank_account" and the appropriate bank account details.
• A debug statement is added so you can trace the data and response.
• The response handler checks for a returned paymentMethod and then injects a hidden input (still named "token") so that your server‑side code continues to work without change.


2. Update the Ruby StripeCustomer Adapter

Your adapter currently checks if the default payment method ID starts with "ba_" or "src_" (to support sources), but for ACH the migration requires that bank accounts come in as PaymentMethod objects (typically with a "pm_" prefix). To that end you can update the condition so that legacy "ba_" tokens are no longer treated specially. For example, change the condition to only use the legacy handling for "src_" types.

Below is the updated method in your adapter:

# frozen_string_literal: true

module HubstaffBilling
  module Adapters
    class StripeCustomer < Base
      attr_reader :customer

      SPECIAL_COUNTRY_CODES = %w[RU BLR IN]

      delegate_missing_to :customer
      delegate :==, to: :customer

      def self.expand(customer)
        return customer if customer.respond_to?(:subscriptions) && customer.respond_to?(:tax_ids)
        retrieve(customer.id)
      end

      def self.retrieve(customer_id)
        Stripe::Customer.retrieve({id: customer_id, expand: %w[subscriptions tax_ids]})
      end

      def initialize(customer)
        super()
        @customer = customer or raise HubstaffBilling::NoCustomerError, 'Please provide a customer record.'
      end

      memoize def default_payment_method_id
        customer.invoice_settings.try(:default_payment_method) || customer.default_source
      end

      memoize def default_payment_method
        return if default_payment_method_id.nil?

        # For non-card payment methods, we now want to ensure ACH bank accounts come through via PaymentMethod.
        # Only treat sources (starting with 'src_') as a legacy case.
        if default_payment_method_id.start_with?('src_')
          Stripe::Customer.retrieve_source(customer.id, default_payment_method_id)
        else
          Stripe::PaymentMethod.retrieve(default_payment_method_id)
        end
      end

      def save
        customer.save(expand: %w[subscriptions tax])
      end

      def country_code
        default_payment_method.try(:card).try(:country)
      end

      def from_special_country?
        country_code.in?(SPECIAL_COUNTRY_CODES)
      end

      memoize def outstanding_invoices
        Stripe::Invoice.list({customer: id, status: 'open', created: {gt: Time.utc(2021, 11, 1).to_i}})
      end
    end
  end
end

Here the change is to remove the handling for IDs that start with "ba_" and have ACH bank accounts always be fetched via Stripe::PaymentMethod.retrieve. This aligns with the migration to the PaymentMethods API.


How It All Connects

  1. UI & JavaScript:
    • The ACH form (billing.html.slim) still shows the ACH fields and logic but now calls the updated JS methods.
    • When a user submits their ACH bank details, the updated initACH function calls stripe.createPaymentMethod instead of createToken.
    • The response handler (stripeACHResponseHandler) now extracts response.paymentMethod.id to pass to your server.

  2. Server & Adapter:
    • On the server side (likely in your organizations_controller.rb and related actions), the submitted token (which now represents a PaymentMethod ID) is used when updating the organization’s billing info.
    • The HubstaffBilling::Adapters::StripeCustomer adapter now always retrieves a PaymentMethod (unless it’s a legacy card source indicated by a 'src_' prefix).

  3. Admin & Views:
    • Wherever your admin interface or billing views check for ACH (such as the conditional logic in organizations/admin/organizations.rb or billing.html.slim) they should now see a PaymentMethod with type "us_bank_account" instead of a legacy bank account source.

These changes should remove your ACH dependency on the Sources API and align your integration with Stripe’s PaymentIntents and PaymentMethod APIs per the migration documentation: Stripe ACH Migration Documentation


Debugging & Next Steps

• Add additional console.log() statements (as shown) and Ruby logging (if needed) to track data flow.
• Verify that the front‑end and back‑end are using the same token/PaymentMethod ID.
• Update your tests so they simulate the new PaymentMethod API responses and include debug output (e.g. using print or puts in Ruby tests).
• Ensure that any references in controllers (such as in organizations_controller.rb) that handle the token continue to work or are updated as needed.

This approach breaks the migration task into smaller steps and provides a clear graph of how ACH payments are processed in your system.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment