Building Autofatturala: Italian Self-Invoicing Is Crazy, Rails + RubyLLM Made It Simple

By Giovanni Panasiti

rails ruby ai llm
Building Autofatturala: Italian Self-Invoicing Is Crazy, Rails + RubyLLM Made It Simple

Here is a fun fact about doing business in Italy: when you buy something from a foreign company, you often have to write an invoice to yourself.

Not a note. Not a line in a spreadsheet. A real electronic invoice, in a specific XML format, with a specific document type code, sent to a government system called SDI (Sistema di Interscambio), by a specific deadline. If you get one field wrong, the government system rejects the file.

I built a product to make this bearable: Autofatturala. In this article I want to tell you the story of the journey: how absurd the problem is, and how surprisingly easy it was to build the solution with Rails and RubyLLM. I will show some real code, but not everything. Some parts of the product stay in the kitchen.

The problem, for non-Italian readers

Say your Italian company pays for a SaaS subscription from a US provider, or buys goods from a German supplier. The foreign supplier does not charge Italian VAT, obviously. But VAT is still due in Italy. So Italian law says that the buyer must “integrate” the invoice. In practice, you issue a new electronic document where you are both the receiver and, fiscally speaking, the issuer. This is the autofattura, the self-invoice, part of the reverse charge mechanism.

Sounds manageable so far. Then you sit down to actually do it, and you discover how many things you need to get right.

First, the document type. TD17 is for services from abroad, TD18 for intra-EU goods, TD19 for goods that are already in Italy but sold by a non-resident. Three codes for what a normal person would describe as “I bought something from a foreign company”.

Then there is the Natura code, which you must use, except when you must not. If the operation is taxable, you apply 22% VAT and leave the Natura field empty. If it is not taxable, you put 0% and a Natura code like N2.1 or N3.4. If you fill both, SDI rejects the file (checks 00400 and 00401). And there is a whole group of codes, N6.x, that literally means “reverse charge” and still must never be used here, because it is reserved for the domestic reverse charge. Yes, really.

If the invoice is in dollars or any other currency, you also need to convert it to EUR using the official ECB reference rate for the right date. Except the rate does not exist on weekends and holidays, so you take the last available one. For EU suppliers you should also verify the counterpart on VIES, the EU-wide VAT registry, which is famously moody about being online.

And all of this has a deadline: the 15th of the month following the one when you received the invoice.

Every Italian company that buys anything from abroad, which today means every company, has to do this dance for every single foreign invoice. Most of them forward PDFs to their accountant and hope. That is the product opportunity.

What Autofatturala does

The idea is simple. You upload the foreign supplier’s PDF, or forward it to a dedicated email address, and a pipeline of small AI agents does the boring part. It extracts the data from the PDF, checks the VAT number on VIES, applies the ECB exchange rate, proposes the right document type and VAT treatment, validates everything, and prepares the self-invoice. You review it, press approve, and it goes to your invoicing system and then to SDI.

The pipeline

Every step is shown live in the UI with its own explanation, so the user always sees why the AI made a choice. Nothing is sent anywhere without human approval.

Review screen

The stack

Nothing exotic, and that is the point. Rails 8.1 and Ruby 3.3. RubyLLM for everything LLM related. Hotwire for the live pipeline timeline, which means the whole app has about 200 lines of JavaScript. Solid Queue for background jobs, Action Mailbox for the “forward your invoice by email” feature, Nokogiri for the FatturaPA XML (validated locally against the official XSD), and Kamal to deploy everything on a single server.

The whole app, pipeline included, is around 9,000 lines of Ruby, with roughly the same amount of test code. One person, a few weeks. Ten years ago the extraction part alone would have been a company.

RubyLLM made the AI part boring (in a good way)

This is my favorite part. People imagine that “AI-powered document processing” means a Python microservice, a vector database, an orchestration framework. Here is the entire LLM configuration of the app:

# config/initializers/ruby_llm.rb
RubyLLM.configure do |config|
  config.openrouter_api_key = ENV["OPENROUTER_API_KEY"]
  config.default_model = "anthropic/claude-sonnet-4.5"
end

And here is the heart of the product, the agent that reads the supplier’s PDF. This is not a simplified example, this is the real method:

def call(inbound_invoice)
  response = RubyLLM.chat
                    .with_schema(ExtractionSchema)
                    .ask(PROMPT, with: inbound_invoice.pdf)

  data = response.content
  # ...
end

That with: inbound_invoice.pdf is an Active Storage attachment. RubyLLM takes the PDF, sends it to a vision model, and because of with_schema the answer comes back as validated structured data, not free text I have to parse. The schema is plain Ruby too:

class ExtractionSchema < RubyLLM::Schema
  boolean :is_invoice
  number :confidence

  object :supplier do
    string :name
    string :country_code, description: "ISO 3166-1 alpha-2, es. IE"
    string :vat_id, required: false
  end

  object :invoice do
    string :number
    string :date, description: "ISO 8601 (YYYY-MM-DD)"
    string :currency, description: "ISO 4217, es. USD"
    number :total
  end

  array :lines do
    object do
      string :description
      number :amount
    end
  end
end

That is_invoice boolean exists because users upload everything: brochures, contracts, bank statements. The model classifies first, extracts after, and rejects non-invoices with a human-readable reason.

The rule that kept me sane: the LLM proposes, Ruby decides

Early on I made one architectural decision that shaped the whole app: the LLM never has the last word on anything fiscal.

The classifier agent proposes TD17, TD18 or TD19 and the VAT treatment, but deterministic Ruby code recalculates every amount and applies guard rules. The exchange rate comes from the ECB API, not from the model. The VIES check is a real call to the EU service. The legal reference printed on the document is picked from a frozen constant, never generated. When the LLM output conflicts with the law, for example a Natura code together with a positive VAT rate, the code flags it and forces human review:

if natura.present? && aliquota.positive?
  output[:warnings] << "Natura #{natura} indicata con aliquota #{aliquota}%..."
  output[:needs_human] = true
end

if natura.start_with?("N6")
  output[:warnings] << "Natura #{natura} non si applica alle autofatture estere..."
  output[:needs_human] = true
end

Yes, part of Italian tax law lives in my codebase as if statements with comments citing Agenzia delle Entrate rulings. Even the famous deadline is just this:

# Termine di emissione dell'autofattura: il 15 del mese successivo
# al ricevimento della fattura estera.
self.deadline = inbound_invoice.received_at.to_date.end_of_month + 15.days

This split, AI for the fuzzy work of reading documents and Ruby for the rules, is what makes the product trustworthy. The model is very good at reading a Croatian invoice for machine parts. It should not be trusted to remember that check 00401 exists. Code remembers.

The parts that were actually hard

Spoiler: not the AI.

The hardest single piece was the FatturaPA XML, the official Italian e-invoice format. It is a beautiful museum of edge cases. The root element is namespaced but the children must not be. Amounts must match the regex [0-9]{1,11}\.[0-9]{2}. Text fields only accept Latin-1, so a typographically correct en dash in a legal reference would be silently stripped and break the text. My builder has a comment that says “ASCII hyphen, not en dash” and I stand by it. I now validate every file against the official XSD locally before sending anything, which turned a whole class of production errors into test failures.

Then there is the small comedy of putting foreign suppliers into a format designed for Italians. The format wants a postal code and a municipality for every party, so foreign suppliers get CAP "00000" and Comune "ESTERO" by convention. Nobody tells you this. You learn it from forum posts and rejected files.

And the external services deserve a mention too. VIES goes down regularly, so verification is non-blocking, with an explicit “VIES unavailable” state instead of a failed pipeline. ECB rates skip holidays, so the FX client looks back up to ten days for the last valid quote and caches everything.

None of this is glamorous. All of it is exactly the kind of work Rails is great at: a state machine on a model, a couple of small client classes, background jobs, tests.

What I learned

The moat is the domain, not the AI. The LLM call is five lines and anyone can write it. The three hundred small decisions about Natura codes, XSD quirks and deadline rules are the product.

Structured output changed how I build with LLMs. Once the model’s answer is a validated Ruby hash with a schema, an “AI agent” becomes just another service object. You test it like one, too. In my test suite the whole LLM is a small stub, and the pipeline tests run without any network.

And Rails plus RubyLLM is a genuinely great combo for this kind of product. Active Storage holds the PDFs and hands them to the vision model. Action Mailbox turns “email your invoice” into a feature you build in an afternoon. Turbo Streams broadcast each agent step to the browser with no custom JavaScript. Solid Queue runs the pipeline. Every hard non-AI problem I had was already solved by the framework.

If you are Italian, you already knew the pain and maybe now you want the product. If you are not Italian, I hope you enjoyed this little tour of our bureaucracy. And next time someone tells you that you need a complicated AI stack to ship an AI product, remember that mine is a Rails monolith where the smartest part is five lines long.