Ruby on Rails

Verifying a legacy ACH migration in a Rails application

A legacy ACH migration in Rails changes more than the endpoint that creates a payment. Existing payments can still be processing when new requests move to another path. The Rails application must preserve their identities, follow their later outcomes and avoid collecting the same obligation again.

The route examined here is specific. It starts with saved, verified Stripe BankAccount objects from a legacy Tokens/Charges integration and selects the Customers v1 PaymentIntents path. It does not cover a move between payment providers, Checkout Sessions, Accounts v2 or subscription migration. The companion experiment uses persisted Rails records and a local fake, with 2025-03-31.basil selected for the PaymentIntent creation contract.

Establish the payment and authorization inputs

Stripe’s legacy ACH migration guide describes using a saved bank-account ID as the PaymentIntent’s payment method. For this route, the bank account must be verified and have an active mandate. Already verified bank accounts do not require verification again solely for this migration. The guide also explains that viewing a BankAccount through the Payment Methods API does not create another underlying object.

The fixture preserves a synthetic ba_ identifier and refuses creation when either verification or mandate evidence is absent. Those inputs are booleans supplied to the experiment. They do not establish valid customer authorization, exercise Stripe verification or validate mandate wording. A real migration must connect those checks to actual records and the responsible payment owner.

Use the versioned PaymentIntent reference when reviewing request fields. The migration guide contains several integration variants, including preview examples. Selecting the Customers v1 route does not authorize copying an Accounts v2 example into an existing application.

Preserve the obligation separately from its provider object

The example stores a unique business reference alongside the route, provider ID, bank-account ID, amount and currency. If a legacy Charge already owns that business reference, the new PaymentIntent path refuses to create another payment. That prevents a migration script from interpreting an old pending record as an unpaid obligation that needs collecting again.

An injected timeout occurs after the fake accepts a new PaymentIntent but before Rails receives the response. The local record remains uncertain. Repeating the same input uses the same request identity and recovers the original object. Duplicate input then produces neither another local payment nor another remote object.

This is a short-lived recovery test. It does not simulate idempotency-key retention, concurrent migration workers or every crash between an external action and local persistence. Those require separate acceptance cases before a real cutover.

Creation is not collection

The unsafe baseline treats the presence of a provider ID as payment success. The fake returns an ID with processing, making the mismatch visible. The corrected record keeps that state until a subsequent refresh reads succeeded from the simulated provider.

Notifications in this example trigger a current-resource read. A delayed notification therefore cannot directly overwrite the record with an older payload. The test repeats a refresh after success and checks that the local state stays successful. This is a deliberately small demonstration, not a complete concurrent webhook processor.

Acceptance case Executed local result
Verified saved account and supplied mandate flag Original account ID retained; payment starts processing
Missing verification or mandate flag No fake payment created
Timeout followed by repeated input One provider object and one local payment
Creation path disabled Existing payments with known provider IDs still refresh
Amount differs during reconciliation Local amount preserved; mismatch raised
Later return received twice One separate dispute record retained

Stripe documents that an ACH failure can arrive after a PaymentIntent has succeeded, in which case a dispute can be created. The fixture consequently stores a synthetic return as a separate dispute record. Repeated input does not duplicate its amount, and a later refresh of the successful PaymentIntent does not remove the dispute flag. It does not invent a returned PaymentIntent status.

Stop new requests without abandoning existing payments

The switch blocks the fixture’s creation path, including retries of uncertain creations. Known provider IDs can still refresh. One old Charge reaches success after the switch closes; one new PaymentIntent moves to requires_payment_method. Both records and identifiers remain available, without a transition audit log. An uncertain creation with no recovered provider ID needs a separate reconciliation path before cutover; this fixture does not supply it.

The nine tests passed with Ruby 3.3.3, Active Record 8.1.3.1, sqlite3 gem 2.9.5 and SQLite 3.53.2. Their contribution is a concrete local acceptance boundary. No bank movement, Stripe request or mandate validation occurred. Before delivery, agree who reviews mismatches, how existing recurring collections are owned, and what rollback can change after an external payment has begun. Those decisions can define one contained Rails migration workstream.

Prepared with AI assistance using synthetic data. No human engineering or payment-domain sign-off is claimed.

Download the experiment

Download the runnable Rails examples and evidence summary (ZIP, 36 KB). This article’s example is in q02/; the shared setup and reproduction instructions are in README.md. The package contains eight synthetic examples, checked on 20 September 2026. The recorded runtime, provider fakes and limits are documented inside.

Leave a Comment

Your email address will not be published. Required fields are marked *