The PSP is the one who withholds, you're left with the hard part.
Everyone wrote the same article: change the CNPJ column to VARCHAR, use DECIMAL(18,2), update the NF-e schema, done, except none of that is what's actually going to break in production.
Since the Receita Federal and CGIBS published the Integration Manual for the Split Payment Public Platform (Joint Act RFB/CGIBS No. 2/2026, published in the Federal Official Gazette on June 3, 2026), LinkedIn and other networks have turned into a conveyor belt of nearly identical posts and articles, some are correct, all of them are shallow, and they stop exactly where the real problem begins.
The pattern is always the same: explain CBS and IBS, cite the "end of the fiscal float," mention that the CNPJ became alphanumeric, recommend DECIMAL(18,2), warn that the batch is atomic, and close with "start studying the documentation," it's the release-notes version of a change that is, in practice, a rewrite of the transactional model for any system that moves money.
I work with banking systems: double-entry ledgers, settlement, reconciliation, Elixir/OTP on top of Postgres, and I'm going to write the article I wish I had read: what actually breaks, and why.
Scope note: this is an engineering piece, not a tax opinion, where the law allows for interpretation, I'll say so, and where the code doesn't allow for it, I'll insist.

Fig. 1 - The path of the money after Split Payment, the dotted line on the left is the one that breaks in most systems.
First: you probably won't be doing any splitting at all
The most common conceptual mistake in the articles I've read is treating "implementing Split Payment" as a task for the ERP's backend team, it isn't.
The one who withholds, segregates, and remits is the PSP: the financial institution, the acquirer, the sub-acquirer, the bank that settles the boleto, the PSP that settles the Pix transaction, that layer is the one that delivers the net amount to the seller and the tax amount to the tax authority, not your application, and the Public Platform is just a channel between PSPs and the tax authority.
So what's left for whoever builds the ERP, the e-commerce platform, the marketplace, the management system?
What's left is the hard part: supplying the tax identity of the operation in a way that lets the PSP match the payment to the document.
This isn't a new column, it's a key-propagation problem across systems that were never designed to carry that key, and that's where it bleeds: in any architecture where the tax module and the payment module don't share an identifier, the key dies somewhere along the way, and most of the architectures I've seen are exactly like that.
Real problem #1: matching payment to tax document
The "smart" split, the standard model under LC 214/2025, depends on a real-time query that returns the exact amount to withhold in that settlement, already net of chain credits, and for that to work, the platform needs to know which tax document that payment is settling.
The manual structures this by payment arrangement, and each arrangement has a different identifier:
| Arrangement | Identifier | Cycle |
|---|---|---|
| Boleto | bill identification | full cycle (billing ----> settlement) |
| Dynamic Pix | txId | full cycle |
| Automatic Pix | txId | full cycle |
| Static Pix | E2E | direct settlement |
| TED | numCtrlTED | direct settlement |
| TEF | numCtrlTEF | direct settlement |
Look at the right-hand column, it's the single most important thing in the entire manual and almost nobody talked about it.

Fig. 2 - The choice of payment arrangement has become a tax decision.
Full cycle means there's a billing object created before the payment, one you can attach the NF-e access key to, the PSP receives the payment and already knows what it's settling, smart split, exact amount, everyone's happy.
Direct settlement means the money arrives with no context, a static Pix is a QR code taped to the counter, a TED is a TED, there's no prior billing object, and the PSP has a credit in the seller's account and no idea which invoice it's paying, or whether it's paying one at all.
And here's the consequence: the choice of payment arrangement has become a tax decision.
When the operation can't be identified precisely, the system doesn't halt, it degrades, and it matters to know where to, it doesn't degrade to the simplified split: that's the B2C regime, chosen based on the acquirer's profile (non-taxpayer of IBS/CBS), with a percentage pre-set by joint act of the RFB and the CG-IBS, and what actually gets you when the query is unavailable is contingency: withholding based on whatever is available, typically the amount stated on the tax document itself, with no deduction of chain credits, adjusted later.
From a cash-flow standpoint, that means withholding the full amount and waiting for a refund, and the deadline here deserves a careful read: the up to three business days allowed for returning the excess count from the conclusion of the assessment, not from the withholding itself, in other words, the money sits still for the rest of the assessment period plus three more days, it's a forced loan your finance team never asked for, with a much longer term than the phrase "three business days" suggests, caused by a decision that today gets made out of habit at checkout.
If your product offers static Pix because it's simpler to implement, that engineering decision now has a price tag in working capital, and if your marketplace lets the seller choose the payment method, you just handed them a button that changes their own withholding regime.
The real work here: trace, from checkout to settlement, whether the DFe access key survives the trip, in most systems it doesn't, because the tax module and the payment module were written by different teams, at different times, and they communicate through a pedido_id that only exists in your database.
Real problem #2: DECIMAL(18,2) is the right answer to the wrong question
The advice going around is: use DECIMAL(18,2), avoid FLOAT, correct, obvious, and insufficient.
DECIMAL(18,2) protects you from binary representation errors, it doesn't protect you from anything that actually happens in a split.
In the ledgers I've worked on, money is integer, cents, not decimal, not float, integer, the reason is boring and final: a monetary value isn't a real number, it's a discrete quantity of indivisible units, and every reconciliation bug I've ever hunted down was born from someone treating the two as the same thing, and DECIMAL(18,2) in the schema is a floor; the type inside the application matters more.
But the central point isn't the type, it's the invariant:
valor_bruto = valor_líquido + valor_ibs + valor_cbs
Exactly, always, with no rounding tolerance, if that equation doesn't balance, you have an orphan cent, and an orphan cent in a financial system doesn't disappear, it accumulates, shows up in next month's reconciliation, and someone's going to spend a Sunday hunting for it.
And it's easy to break that invariant without noticing, consider art. 34, II, of LC 214/2025: in an installment sale, segregation and remittance are proportional to the financial settlement of each installment, you don't withhold the entire tax on the first one.
Operation with a base of R$ 100,00, estimated rate of 26,5%1, and since IBS and CBS are calculated on top, the customer is charged R$ 126,50, of which R$ 26,50 is tax, in three installments of R$ 42,17, R$ 42,17, and R$ 42,16, and the tax embedded in them needs to add up to exactly R$ 26,50.
26,50 / 3 = 8,8333...
rounds --> 8,83 per installment
8,83 × 3 = 26,49
One cent short, where does it go?
That question doesn't have a technical answer, it has an arbitrated one, and it needs to be the same in your system, in the PSP's, and in the tax authority's audit, it can't be an emergent property of the language's default rounding.
The deterministic rule you want allocates the remainder in a stable way:

distribuir(2650, 3) -> [884, 883, 883], sum: 2650, balances.
por_maior_resto(2650, [50, 30, 20]) -> [1325, 795, 530], sum: 2650, balances.

Fig. 3 - The orphan cent, one per installment sale, every day, until the month's reconciliation.
And since this isn't opinion, it's invariant, it deserves a property-based test, not an example:

That's the kind of test that's worth more than thirty hand-written cases, and if all you have is an assertion about R$ 100,00 split into 2, you haven't tested anything, you've tested the case that already worked.
Real problem #3: this isn't a column, it's a new leg in the ledger
This is where I think the difference between "the dev who read the news" and "the dev who works with money" becomes most visible.
Before, a settlement was a two-leg event:
debit payer's account 100,00
credit payee's account 100,00
With Split Payment, it has at least four: one debit leg and three credit legs:
debit payer's account 126,50
credit payee's account 100,00
credit taxes payable --- CBS 8,80
credit taxes payable --- IBS 17,70

Fig. 4 - Settlement is no longer a pair, any code that assumes from + to as a single pair becomes debt.
And there's a subtlety that almost every system gets wrong on the first try: the withheld amount was never the seller's revenue.
The temptation is to model the tax as a discount on the credit: create a valor_desconto_tributo field, subtract it, credit the net amount, that works in the report and lies in the statement, if you post R$ 126,50 to the seller's account and then debit the tax, you're claiming that money passed through their equity, it didn't, they never had title to that amount.
This matters for three practical reasons, not philosophical ones:
- Statement. The customer will open the app and see a credit they could never use, followed by a debit, and that generates a support ticket, and the ticket is right.
- Derived calculation base. Anything you calculate based on "credited volume", fee, commission, limit, yield, score, ends up applying to an inflated amount.
- Freezes and garnishment. A balance that existed for 200ms in the ledger is a balance that existed, ask legal what they think about that.
The correct model is a transitory account: the tax amount goes into a third-party/segregation account, never into the seller's own account, in double-entry bookkeeping that's trivial to express, what isn't trivial is accepting that the settlement operation is no longer binary, and every piece of your code that assumes from_account_id + to_account_id as a single pair will need to become N postings within the same transaction, with the invariant soma(débitos) == soma(créditos) validated inside the commit, not after.
In Ecto, that's an Ecto.Multi with all the legs or none, and if today your flow does two sequential Repo.insert/1 calls and prays, now it has four legs to pray over.
Real problem #4: the tax authority can change the past
This point shows up in other articles as a throwaway line: "the Receita may issue corrections to the amounts after payment, prepare your systems," except "preparing your systems" for that is an architectural decision, not a TODO.
If the IBS/CBS amount for a settlement that already happened can be corrected, UPDATE is your enemy, not because it's ugly, because it destroys the one thing that saves you in an audit: the ability to answer "what value did I know on March 12th?"
Two properties your model needs to have, and that almost no management system has today:
1. Append-only ledger. A correction doesn't alter a posting, a correction generates a new posting, either a reversal or a supplement, referencing the original, the balance is derived from the sum, not stored as primary truth, and that's expensive on reads, you compensate with a daily balance snapshot, not by abandoning the principle.
2. Bitemporality on tax rates. There are two dates in play, and they aren't the same:
- the date of the triggering event (when the operation occurred);
- the date you found out about that value (when the data entered your system).
The rate in effect is a function of the first date, if your rate table is queried with WHERE now() BETWEEN vigencia_inicio AND vigencia_fim, you have a bug that only shows up when someone reprocesses an invoice from six months ago, and given the reform's transition schedule, with rates changing year by year through 2033, "reprocessing old stuff" is going to stop being the exception.

The EXCLUDE USING gist there isn't decoration: it physically prevents two conflicting rates from coexisting for the same tax, jurisdiction level, entity, and CST, and that's the difference between a business rule the database guarantees and one that depends on everyone remembering.
And notice what the second axis buys you, with $7 = now(), the query answers which rate is valid today, with $7 = '2027-03-12', it answers which rate I believed was valid on that day, even if the tax authority corrected the value afterward, and it's that second answer that an audit asks for, and it's the one that vanishes the moment someone runs UPDATE on a currently valid row.
Keeping all of this isn't optional, the statute of limitations for taxes in Brazil is five years, your segregation data from 2027 needs to be queryable in 2032, both with the value that was valid in 2027 and with every correction applied afterward, and that's a retention requirement, and a retention requirement combined with volume becomes a partitioning requirement.
Real problem #5: batch atomicity isn't solved with try/catch
The advice going around is: "the batch is atomic, so have good exception handling and detailed logs," that's a bit like saying planes crash, so wear a seatbelt.
The manual's submission flow has three steps:
- Opening. The PSP sends the metadata, the platform returns a
ResourceId. - Transmission. Batches of up to 1,000 transactions, tied to the
ResourceId. - Closing. The platform validates the total and closes the submission.
The whole design is atomic: one invalid item invalidates the entire batch, and the Segregation Report (idInfSegr) is the record that attests how much was withheld and how much remained as the seller's net credit.

Fig. 5 - The three steps of the submission, the danger isn't in the error, it's in the absence of a response.
The real problems aren't "what if it errors", they're these:
A. A timeout on closing isn't an error, it's ambiguity. You sent the closing request and got no response, was the submission closed or not? If you resend, you duplicate it, if you don't, it stays pending, the answer isn't retry, it's checking state before any resend, plus a stable idempotency key, generated at the source and persisted before the first attempt, and an idempotency key regenerated on retry isn't an idempotency key, it's decoration.
B. Batch poisoning. One bad transaction takes down 999 good ones, blindly retrying the entire batch reproduces the failure indefinitely, and what works is quarantine by bisection: it failed, split it in two, resend the halves, isolating the problem item in a few rounds instead of a linear scan, and the isolated item goes to an exception queue with human intervention, not into oblivion.
C. Dual writes. You have to record it in your own database and transmit it to the platform, there's no distributed transaction between your Postgres and the Receita's API, and if you run Repo.transaction and call an HTTP endpoint inside it, you have a database connection held open for the latency of a federal network, and a rollback that doesn't undo what's already been transmitted.
The pattern is outbox: the business transaction writes the intent to send within the same transaction as the data, a separate worker reads the outbox and transmits it, the transmission can fail, repeat, or lag, the local data is already consistent, and delivery is guaranteed by reprocessing, not by wishful thinking.

Note that the idempotency key is derived from the settlement, it isn't a new UUID on every attempt, and that line is the difference between reprocessing safely and remitting the tax twice.
Real problem #6: the receivable is no longer what it used to be
This is the point that didn't show up in any of the articles I read, and it's the one that hurts most for anyone who works in banking.
LC 214/2025 is explicit: early payment of receivables doesn't remove the obligation to segregate and remit, the tax is withheld at settlement regardless of whether the credit right was assigned beforehand.
Translating for whoever works in credit, and paying close attention to which number is which: a receivable with a face value of R$ 100 mil, what the payer will actually pay, carries the tax inside that total, and with an estimated rate of 26,5% calculated on top, the tax portion is 100.000 × (26,5 / 126,5) ≈ R$ 20,9 mil, and what's left as real collateral comes out to around R$ 79,1 mil.
If your system records R$ 100 mil as the base rather than the face value, the operation is worth R$ 126,5 mil and the tax is R$ 26,5 mil, both scenarios are defensible; what isn't defensible is the credit engine not knowing which of the two it stored, and that ambiguity alone is already a 27% difference in collateral, and it's invisible until the first settlement comes in different from expected.
Either way, the conclusion doesn't change: the tax portion will never reach the assignor, it gets withheld at settlement and goes to the tax authority.

Fig. 6 - The receivable isn't worth what the system says it's worth.
The cascading consequences:
- Credit engine. Every LTV, haircut, and eligibility calculation that uses the receivable's gross value is overestimating collateral, this isn't a parameter tweak, it's a change in the calculation base.
- Registries and domicile lock. The registered receivable has the gross value; the amount that actually settles is net, the mismatch between what's registered and what settles needs to be modeled explicitly, not discovered during reconciliation.
- Assignment and tax identity. The assigned credit carries the tax origin of the operation with it, the assignment changes the holder of the right, it doesn't erase the triggering event, and contracts and systems that treat a receivable as fungible, anonymous value have a broken premise.
- FIDC and structuring. A portfolio priced on gross cash flow has systematic, not random, overvaluation.
If you build credit products, early-payment products, collateral, or factoring, this is the item on the list that changes your risk model, not the CNPJ's VARCHAR.
Real problem #7: cancellation happens after remittance
Everything discussed so far assumes the operation went through, but sales get canceled, merchandise gets returned, services don't get rendered, and by then the tax has already left the system, gone to the tax authority, and the seller never had title to it.
LC 227/2026 authorized the regulation to provide for transferring the amount remitted via split payment back to the supplier in cases of return or cancellation, on the CBS side, that's in art. 57, § 4, of Decree No. 12.955/2026; on the IBS side, in art. 57, § 4, of CGIBS Resolution No. 6/2026.
The simple case is trivial: it got canceled, the amount goes back to the supplier who carried out the operation, the case that breaks systems is the intersection with the previous section: what if the receivable was already assigned? The supplier named on the Segregation Report may have already sold the credit and received the price of the assignment, and for tax purposes, they're still the party to the original operation, and that's who the amount goes back to, except the receivable's money is with someone else.
What this means in code:
- A reversal is not a
DELETEor anUPDATE. It's a new posting, with the opposite sign, referencing the original, exactly the append-only pattern from section 4, and if you "canceled" by deleting a row, you lost the ability to prove the remittance happened, and it did happen. - The reversal's counterparty may not be the operation's counterparty. The tax refund goes to the supplier; the commercial credit went to the assignee, and your model needs two distinct counterparties in the same event, and most marketplace ledgers only have room for one.
- The reversal has its own latency. It isn't synchronous with the cancellation, and between the cancellation and the return of the amount there's a period during which your balance sheet has a receivable from the tax authority that no current report knows how to represent.
- Partial cancellation on an installment sale combines this section with the apportionment from section 2, and refunding half of a three-installment operation whose cent remainder has already been distributed is the test nobody wrote.
If you're only going to model one exception flow before 2027, model this one.
Real problem #8: what happens when the platform goes down?
Dario Durigan, the Minister of Finance, stated in July 2026 that the system will handle a volume roughly 170 times that of Pix, with a projection of approximately 70 billion tax documents per year and R$ 2 billion invested in 2026 alone, and it's worth noting the detail: the tax authority's own estimate was 150 times, and it was revised upward during development.
Two possible readings, the optimistic one: this is a nationwide-scale infrastructure investment, and the one from anyone who's already run an integration with a public system on a peak-traffic day: it's a point of external dependency on your settlement's critical path.
The question every article avoided: what does your system do when the Public Platform doesn't respond?
That's not an infrastructure question, it's a product question, and the answer needs to be decided before the incident, because in the middle of an incident you're going to have to choose between three bad options:
- Hold the settlement until the platform comes back, the customer doesn't get paid, support catches fire.
- Settle the full amount and resolve the tax later, you just took on tax and credit risk against a seller who may have already withdrawn the money.
- Withhold by estimate (contingency) and settle up later, the customer gets less, and you generate a refund liability and a reconciliation process.
There's no good option, there's the option you chose consciously and documented, and on the engineering side, the minimum is: an aggressive timeout, a circuit breaker with observable state, an explicit and alertable degraded mode, and, critically, a reconciliation path that assumes the remote state is unknown, not failed, a timeout doesn't mean "it didn't happen", it means "I don't know", a financial system that treats a timeout as a failure duplicates the operation.
Real problem #9: volume, partitioning, and the table that's going to swallow you
70 billion documents a year at the national aggregate, your slice is smaller, obviously, but your segregation records, reports, retries, and corrections grow as multiples of your transaction volume, not at parity, each settlement becomes: ledger posting(s) + segregation record + report + any subsequent corrections.
A couple of things that matter more than any query optimization you'll do later:

Four things in this DDL that aren't style choices:
bigintin cents. No floating point anywhere along the path, and nonumericwhere the value is intrinsically an integer.- The equality
CHECK. It's the same invariant from section 2, now guaranteed by the database, an inequality (>=) would let through exactly the orphan cent this whole article has been chasing, a business rule the database guarantees doesn't depend on code review. chave_dfeis nullable. Because direct settlement with no match exists and is legitimate, modeling it as required forces someone to invent a value, and an invented value in a tax field is technical debt with interest.PRIMARY KEY (id, data_fato_gerador). Not a preference: declarative partitioning requires the partition key to be part of every uniqueness constraint, a consequence that bites later,idalone isn't guaranteed unique by the database.
And id_transacao as a generic text with arranjo next to it, instead of four specific columns: the identifiers have different formats and rules per arrangement, and you'll always query by the pair (arrangement, identifier).
And here the article needs to do justice to its own title: there's one place where typing actually bites, and it's not the CNPJ sitting in a column, it's the access key.
Up until now, the validation rule in every Brazilian system has been the same: 44 positions, all numeric, that's over, under Joint Technical Note DF-e 2025.001, the key keeps its 44 positions but now accepts uppercase letters in the 12 central positions, exactly the first 12 digits of the issuer's CNPJ, which is the part that can hold a letter, the layout becomes [0-9]{6}[A-Z0-9]{12}[0-9]{26}, and the first alphanumeric CNPJs are expected for July 2026.
So yes: every bigint, every char(44) assuming a digit, every parser that calls to_number somewhere along the key's path turns into a migration, it was a VARCHAR problem, just not the one the articles talked about, and not for the reason they gave.
But what really separates whoever read the technical note from whoever read the summary of it is the next paragraph: the check-digit calculation changes too, each character in the key now gets converted using its decimal ASCII value, minus 48, before applying modulo 11, in other words, whoever changed the column type to text, breathed a sigh of relief, and never touched the validator has a check-digit algorithm that's silently wrong, one that keeps accepting the old numeric keys and will reject, or worse, wrongly accept, the new ones.
Changing the type is the part you can do with sed, the check digit is the part that requires understanding what you're actually validating.
Bonus: you already have a split in your code
Small detail with disproportionate potential for damage.
If your system is a marketplace, a gateway, or a multi-seller platform, you've already had "payment split" in your domain for years: dividing the amount between seller, platform, and affiliates, and there's probably a splits table, a SplitService, a split_rules field.
It's not the same thing, the marketplace split is a commercial division between private parties, the fiscal Split Payment is a compulsory withholding in favor of the tax authority, with its own legal regime, order of precedence, and effects that the commercial division doesn't have.
Putting both in the same namespace will generate, with statistical certainty, a bug where the commercial apportionment rule applies to the wrong base, or worse, where the tax gets split among the marketplace's participants as if it were revenue, and separate the vocabulary now, while it costs a rename, not after the first customer complains about the amount they received.
What to do on Monday
No generic checklist, six questions that, answered honestly, tell you the size of your problem:
- Does the DFe access key survive from checkout to settlement? If it dies at some hop, that hop is your first job.
- Which of your payment methods are "direct settlement"? Those are the ones that will fall into simplified split or contingency, quantify the volume, that's your estimated cash cost.
- Does your ledger accept more than two legs in the same transaction, with the invariant validated at commit? If not, that's a structural refactor, not a feature.
- Can you answer what tax value you knew as of a past date? If the answer involves
UPDATE, you can't. - What does your system do when a sale is canceled after remittance? If the answer is "we reverse the posting", reread question 4.
- Is there a written decision about what happens when the platform doesn't respond? If there isn't, it's going to get made at 3am by whoever's on call.
And two dates that aren't in 2027: August 1st, 2026 ends the penalty waiver for IBS and CBS information on tax documents, and August 3rd, 2026 is when production rejection begins, the first punishes the mistake; the second stops the sale from being invoiced, and if you're reading this in July, that's the deadline that matters before anything else I wrote above.
The staging environment is open, and 2026 is the operational testing year, with a symbolic rate of 1% (0,9% CBS + 0,1% IBS), that's a generous window and it won't repeat, just don't mistake the window for a comfortable deadline: split payment starts in 2027 on an optional basis for B2B, with mandatory adoption phased in through joint acts by the RFB and the CG-IBS, meaning the date it becomes mandatory for you doesn't exist yet, and it'll come out via administrative act with no guaranteed lead time, and in 2033 the old taxes die for good.
Use the window to discover that your ledger only has two legs, not to discover that the CNPJ is a VARCHAR.
Sources
- Joint Act RFB/CGIBS No. 2, of May 27, 2026 - authorizes publication of the Integration Manual and the Swagger for the Public Platform (published in the Federal Official Gazette on June 3, 2026). Text in the Federal Official Gazette (Diário Oficial da União).
- LC 227/2026 - conversion of PLP 108/2024; IBS governance and authorization for the regulation to address the return of amounts remitted upon cancellation
- Decree No. 12.955/2026, art. 57, § 4 (CBS) and CGIBS Resolution No. 6/2026, art. 57, § 4 (IBS) - transfer to the supplier of the amount remitted in cases of return or cancellation
- Joint Technical Note DF-e 2025.001, of April 25, 2025 - alphanumeric CNPJ: new access-key layout and check-digit recalculation via the ASCII table (ENCAT). Download the current version from the NF-e Portal, Documents tab, Technical Notes - the version number changes.
- Technical Note 2026.004 - evolution of the XML schemas for the alphanumeric CNPJ: issuer, recipient, transport, payment, intermediary, and referenced document fields
- Normative Instruction RFB No. 2.229/2024 - establishes the alphanumeric CNPJ
- Receita Federal - publication of the technical documentation for the Split Payment Public Platform
- Ministry of Finance - technical documentation for the Public Platform
- Systax - Split Payment models under LC No. 214/2025: structure, operationalization, and challenges
- TecnoSpeed - Integration Manual for the Split Payment Public Platform
- ConJur - Segregation report in split payment and receivables early payment
- ConJur - Split payment and receivables assignment: the tax identity of the assigned credit
- Omie - Segregation Report in Split Payment: impact on receivables
- Official documentation: National Consumption Tax Portal (
consumo.tributos.gov.br-> Menu -> Manuals) and CGIBS (cgibs.gov.br-> Technical Documents)
Footnotes
-
The reference rate hasn't been set yet by Senate resolution, I use 26,5% because that's the cap from the 2024 technical note; official estimates run close to 28%, wherever I write a number, read it as "order of magnitude." ↩