{"id":14921,"date":"2026-09-28T17:30:00","date_gmt":"2026-09-28T12:00:00","guid":{"rendered":"https:\/\/www.allerin.com\/blog\/?p=14921"},"modified":"2026-09-25T11:20:21","modified_gmt":"2026-09-25T05:50:21","slug":"rails-payment-accounting-reconciliation","status":"publish","type":"post","link":"https:\/\/www.allerin.com\/blog\/rails-payment-accounting-reconciliation\/","title":{"rendered":"Reconciling a payment after an accounting update partly succeeds"},"content":{"rendered":"<style>.allerin-rails-question{max-width:100%;overflow-wrap:anywhere}.allerin-rails-question h2{scroll-margin-top:125px}.allerin-rails-question nav{border-left:3px solid #17665b;padding:10px 18px;margin:24px 0}.allerin-rails-question nav ol{padding-left:22px}.allerin-rails-question nav a{display:inline-block}.allerin-rails-question code{white-space:normal;overflow-wrap:anywhere}.allerin-rails-question pre{max-width:100%;overflow:auto}.allerin-rails-question pre code{white-space:pre;overflow-wrap:normal}.allerin-rails-question .table-wrap{max-width:100%;overflow-x:auto;margin:22px 0}.allerin-rails-question table{width:100%;border-collapse:collapse;line-height:1.5}.allerin-rails-question th,.allerin-rails-question td{border:1px solid #d1d9d5;padding:10px;text-align:left;vertical-align:top}.allerin-rails-question th{background:#edf2ef}.allerin-rails-question[data-question=\"q05\"] th:nth-child(-n+2),.allerin-rails-question[data-question=\"q05\"] td:nth-child(-n+2){white-space:nowrap;overflow-wrap:normal}.allerin-rails-question .experiment-download{margin-top:28px;border-top:1px solid #d1d9d5;padding-top:18px;font-size:0.93em}<\/style>\n<div class=\"allerin-rails-question\" data-question=\"q05\">\n<nav aria-label=\"In this article\"><strong>In this article<\/strong><\/p>\n<ol>\n<li><a href=\"#recover-an-uncertain-posting-before-creating-another\">Recover an uncertain posting before creating another<\/a><\/li>\n<li><a href=\"#give-every-comparison-a-cutoff\">Set a source cutoff for Rails payment reconciliation<\/a><\/li>\n<li><a href=\"#keep-timing-differences-apart-from-unexplained-amounts\">Keep timing differences apart from unexplained amounts<\/a><\/li>\n<li><a href=\"#make-the-unresolved-case-actionable\">Make the unresolved case actionable<\/a><\/li>\n<\/ol>\n<\/nav>\n<p>Rails payment reconciliation must distinguish the customer payment from its accounting-system posting. If the accounting request succeeds but its response disappears, Rails can show a failed update while the partner already holds the entry. Retrying with a new reference can turn a missing acknowledgment into a duplicate posting.<\/p>\n<p>The useful recovery question is whether the intended entry exists and agrees with the payment. The <a href=\"#download-experiments\">companion experiment<\/a> makes that question explicit using Active Record, SQLite and a fictional accounting partner. Its contract supports a unique external reference and lookup by that reference. Those capabilities must be verified for the actual partner before adopting the approach.<\/p>\n<h2 id=\"recover-an-uncertain-posting-before-creating-another\">Recover an uncertain posting before creating another<\/h2>\n<p>Each synthetic collection has a stable reference, amount, currency and posting state. The unsafe baseline creates a posting, loses the response, and retries with a different reference. The fake partner ends up with two entries totaling twice the intended amount.<\/p>\n<p>The corrected adapter first looks up the original reference. When a posting exists, it compares its amount and currency with the intended collection before recording the partner ID locally. Replaying the operation after a lost response recovers one entry. A mismatched currency raises an explicit discrepancy instead of accepting a superficially successful response.<\/p>\n<p>Between those attempts, the reconciliation report labels the entry <code>local_receipt_unconfirmed<\/code>. That is more informative than calling it missing. The partner has the entry; the local application has not yet recorded a confirmed receipt. The test verifies that this distinction disappears after recovery without creating another posting.<\/p>\n<h2 id=\"give-every-comparison-a-cutoff\">Set a source cutoff for Rails payment reconciliation<\/h2>\n<p>Reconciliation needs to explain which source data was available. The fixture assumes its input rows already belong to the selected source snapshot. It records an observation time as metadata and uses the cutoff to classify collections; it does not ingest reports or prove their completeness. A collection after that cutoff belongs in an awaiting-data category.<\/p>\n<p>Provider reporting rules matter here. Stripe&#8217;s <a href=\"https:\/\/docs.stripe.com\/reports\/payout-reconciliation\" target=\"_blank\" rel=\"noopener\">payout reconciliation documentation<\/a> describes reports organized around automatic payout batches, distinguishes balance reporting for other use cases, and notes that instant payouts require separate reconciliation. The fixture&#8217;s fictional accounting partner is not Stripe. The source documentation establishes why the chosen report and its timing must be identified, not that one report works for every integration.<\/p>\n<p>The experiment uses integer minor units internally. Its table below presents dollars for readability. All four collections are USD, and the dates are deliberately represented by a fixed synthetic clock rather than real customer transactions.<\/p>\n<div class=\"table-wrap\" tabindex=\"0\" role=\"region\" aria-label=\"Experiment comparison table\">\n<table>\n<thead>\n<tr>\n<th>Collection<\/th>\n<th>Amount<\/th>\n<th>What the executed report shows<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>A<\/td>\n<td>$100.00<\/td>\n<td>Posting missing; settlement still pending<\/td>\n<\/tr>\n<tr>\n<td>B<\/td>\n<td>$75.00<\/td>\n<td>Posting matched; $2.00 fee and $73.00 net reconcile<\/td>\n<\/tr>\n<tr>\n<td>C<\/td>\n<td>$40.00<\/td>\n<td>Posting exists; later return requires review<\/td>\n<\/tr>\n<tr>\n<td>D<\/td>\n<td>$25.00<\/td>\n<td>Posting matched; collection falls after the source cutoff<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/div>\n<p>Collection A appears in two reason categories because both conditions apply. Adding those category totals would double-count the same money. The report retains individual references and labels the totals as overlapping amounts by reason and currency.<\/p>\n<h2 id=\"keep-timing-differences-apart-from-unexplained-amounts\">Keep timing differences apart from unexplained amounts<\/h2>\n<p>An unposted collection, pending settlement and later return need different next actions. The fixture leaves each reason visible instead of reducing everything to one red balance. It also checks that gross minus fee equals net for the simplified source row. Collection B therefore matches even though its net amount differs from the original collection.<\/p>\n<p>Additional tests introduce an unexpected currency, a source record with no local collection, a missing source row and inconsistent arithmetic. These cases remain visible. The routine neither edits the original amount to make the comparison pass nor nets a EUR discrepancy against a USD collection.<\/p>\n<p>The simplified arithmetic is not a general accounting model. Refunds, multiple fees, conversions, reserves and payout adjustments can require more records and different relationships. A responsible finance owner must define those rules and any corrective entries. This example demonstrates operational discrepancy handling; it does not approve accounting policy or decide how revenue should be recognized.<\/p>\n<h2 id=\"make-the-unresolved-case-actionable\">Make the unresolved case actionable<\/h2>\n<p>Before handover, agree who can investigate each category, what evidence resolves it and which actions need approval. A support view should expose the reference, amount, currency, source cutoff and latest recovery attempt. A retry button without that context makes it easy to repeat the original uncertainty.<\/p>\n<p>The five tests passed with Ruby 3.3.3, Active Record 8.1.3.1, sqlite3 gem 2.9.5 and SQLite 3.53.2. The fake partner&#8217;s memory survives a simulated response loss within the test process; this does not prove partner durability or arbitrary crash recovery. A real <a href=\"https:\/\/www.allerin.com\/services\/ruby-on-rails\">Rails integration project<\/a> should include the actual partner contract, a reviewed discrepancy report and an owner for unresolved cases in its acceptance criteria.<\/p>\n<p><em>This AI-assisted article uses a synthetic experiment. No customer incident, real accounting API, money movement or finance sign-off is represented.<\/em><\/p>\n<section class=\"experiment-download\" aria-labelledby=\"download-experiments\">\n<h2 id=\"download-experiments\">Download the experiment<\/h2>\n<p><a href=\"https:\/\/www.allerin.com\/blog\/wp-content\/uploads\/2026\/09\/allerin-rails-synthetic-experiments-2026-09-20.zip\">Download the runnable Rails examples and evidence summary (ZIP, 36 KB)<\/a>. This article\u2019s example is in <code>q05\/<\/code>; the shared setup and reproduction instructions are in <code>README.md<\/code>. The package contains eight synthetic examples, checked on 20 September 2026. The recorded runtime, provider fakes and limits are documented inside.<\/p>\n<\/section>\n<\/div>\n","protected":false},"excerpt":{"rendered":"<p>A Rails reconciliation fixture distinguishes uncertain postings, settlement timing, fees, returns and reporting cutoffs.<\/p>\n","protected":false},"author":2,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":"","_links_to":"","_links_to_target":""},"categories":[2037],"tags":[2077,2061,20],"class_list":["post-14921","post","type-post","status-publish","format-standard","hentry","category-ruby-on-rails","tag-rails-integrations","tag-rails-testing","tag-ruby-on-rails"],"_links":{"self":[{"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/posts\/14921","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/comments?post=14921"}],"version-history":[{"count":4,"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/posts\/14921\/revisions"}],"predecessor-version":[{"id":14958,"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/posts\/14921\/revisions\/14958"}],"wp:attachment":[{"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/media?parent=14921"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/categories?post=14921"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/tags?post=14921"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}