Data conversion — Stripe → BeeL

The Fiscal Mirror rule, how amounts and taxes are mapped, why IRPF is always 0, the silent sanitization BeeL applies to inbound payloads, and how the forward and rectificativa emitters split responsibilities.


When BeeL generates an invoice from a Stripe event, it does not recalculate the operation — it reflects the Stripe charge exactly. This is the Fiscal Mirror rule and it's the single most important thing to understand about the integration. This page covers it in detail and documents every conversion BeeL applies — including the silent sanitization of inbound recipient fields and the split between the forward and rectificativa emitters that route Stripe events to the right kind of invoice.

The Fiscal Mirror rule

A Stripe charge is the fiscal truth.
BeeL's job is to translate it into a registro de facturación,
not to second-guess its tax breakdown.

In practice:

  • If Stripe charged 121 €, BeeL's invoice total is 121.00 € (never 120.99 €, never 121.05 €).
  • If Stripe Tax says the operation is 100 € base + 21 € IVA, BeeL's invoice carries those exact numbers.
  • If Stripe Tax doesn't expose a breakdown, BeeL decomposes the charge using your configured default IVA rate treating the total as inclusive. The decomposition is exact (no residual rounding).

The only thing BeeL never copies from Stripe is the VeriFactu metadata (calificación, exención, régimen) — those depend on legal context Stripe doesn't model. BeeL infers them from your settings + the customer's geography.

There is no "we recalculate taxes for you" mode. Fiscal Mirror is the only behaviour — the invoice total always equals the Stripe charge total.

Field-by-field mapping

Amounts and currency

Stripe fieldBeeL invoice fieldConversion
payment_intent.amount_received (cents)total (EUR)Divide by 100
charge.amount (cents)Fallback when amount_received is absentDivide by 100
invoice.amount_paid (cents, Stripe Billing)Fallback for invoice.paid eventsDivide by 100
currencyValidated; must be eurOther currencies are rejected at processing time
metadata.external_payment_idmetadata.external_payment_idStripe payment_intent.id

Customer fiscal data

The resolved customer comes from Customer mappings. The conversion of fields:

Stripe sourceBeeL invoice field
customer.email → fallback charge.billing_details.emailrecipient.email
customer.name → fallback charge.billing_details.namerecipient.legal_name
customer.tax_ids[0].valuerecipient.nif (if Spanish) or recipient.alternative_id.number
customer.address.line1 / line2recipient.address.street
customer.address.postal_coderecipient.address.postal_code
customer.address.cityrecipient.address.city
customer.address.country (ISO-2)recipient.address.country (full name) and recipient.address.country_code (ISO-2)

Country no longer defaults to "ES". If Stripe sends no country, BeeL leaves it empty — and may fall back to a simplified invoice if F1 requirements aren't met. (Prior behaviour silently set ES, which masked malformed payloads.)

Tax breakdown — Stripe Tax enabled

When you have Stripe Tax active, every charge carries tax_amounts[] per line with the rate and inclusivity flag. BeeL reads these and pre-populates each invoice line:

Stripe says:
  line { amount: 12100, tax_amounts: [{ amount: 2100, inclusive: true, rate: 21.00 }] }

BeeL emits (BeeL invoice line):
  {
    "unit_price": 100,
    "taxable_base": 100,
    "main_tax": { "type": "IVA", "percentage": 21, "regime_key": "01" },
    "line_total": 121
  }

The user's default tax configuration is not consulted when Stripe exposes a breakdown — your Stripe Tax setup wins.

Tax breakdown — Stripe Tax NOT enabled

When tax_amounts[] is empty (simple PaymentIntents, manual Stripe Invoices without Tax), BeeL falls back to your account configuration:

  1. Read the connection's tax-inclusive pricing setting (per integration)
  2. Read your default tax configuration (Settings > Taxes): main tax (IVA / IGIC / IPSI) and standard rate
  3. Treat the Stripe total as inclusive of that rate
  4. Decompose: base = total / (1 + rate/100), tax = total - base

Example: Stripe charges 121.00 €, your default is IVA 21 %, inclusive → BeeL emits base: 100.00, IVA: 21.00.

This decomposition is exact; we don't round to "round" base values. If your IVA rate is 10 % and Stripe charged 121.00 €, the base is 110.00 and the IVA is 11.00.

IRPF — always 0 on Stripe lines

Every line BeeL emits from a Stripe webhook has irpf_rate: 0 (the API field) regardless of your default IRPF configuration.

Why: Stripe Connect serves B2C scenarios (e-commerce checkouts, subscriptions to consumers, point-of-sale terminals). These are not subject to IRPF withholding under Spanish law. If BeeL applied your default professional IRPF (e.g. 15 %), the invoice total would diverge from the Stripe charge — violating Fiscal Mirror.

If you need an IRPF-withheld invoice for a customer, don't issue it through Stripe — invoice them manually via the Invoicing API with your usual settings.

VeriFactu classification

BeeL infers each line's exemption_reason (or leaves it unset for the default S1) based on the customer's geography. The full mapping lives in Tax classification; a Stripe-specific summary:

Customer countryResulting exemption_reasonmain_tax.type
Spain (peninsula or Balearic)omitted (S1)IVA
Canary Islands (resolved from address)omittedIGIC if configured, else IVA
Ceuta / MelillaomittedIPSI if configured, else IVA
EU B2B (VIES VAT-ID on tax_ids)EXENTA_ART_25 (goods) or NO_SUJETA_LOCALIZACION (services)IVA
EU B2Comitted (origin IVA) or NO_SUJETA_LOCALIZACION + regime_key: "17" (if OSS enabled)IVA
Non-EUEXENTA_ART_21 + regime_key: "02" (goods) or NO_SUJETA_LOCALIZACION (services)IVA

BeeL doesn't auto-detect whether you sell goods or services — set the operation type per Stripe product (in Stripe metadata) or as a connection default.

What BeeL never modifies

  • The Stripe charge total — invoice total = Stripe total, always
  • The currency — must be EUR; we don't convert
  • The Stripe payment intent ID — kept as metadata.external_payment_id
  • The Stripe customer ID — kept on the customer mapping for future matching

Reconciling discrepancies

If you spot a Stripe charge whose generated invoice has a different total, it's almost always one of:

  1. A refund happened between charge and invoice generation — check for a sibling corrective
  2. The webhook was replayed after a config change — the new config produced a different invoice; the original is canonical
  3. Tax inclusivity flipped between Stripe Tax and the connection override — check the connection's tax-inclusive pricing toggle

For genuine mismatches that none of these explain, write to it@beel.es with the Stripe event ID and the BeeL invoice number.

Silent sanitization

Before the extracted Stripe payload reaches the invoice pipeline, BeeL runs it through a short chain of checks that trim or annotate fields that would otherwise crash downstream. The chain is ordered and explicit; every modification leaves a structured trace on the resulting invoice so the change is auditable.

FieldLimit / ruleWhat happens when violated
recipient.legal_nameMax 120 chars (matches VeriFactu's cap)Truncated to fit
recipient.emailMax 255 charsTruncated to fit. If the email is so long that the address can't be preserved, BeeL prefers to issue the invoice without an email rather than persist a malformed one
recipient.nif (Spanish context only)Must match Spanish DNI/NIE/CIF shape and pass the control-digit checkThe NIF itself is not modified — the value flows through. Downstream, validation rejects it and the invoice is parked at issuance
recipient.country_code (ISO 3166-1 alpha-2)Must be a recognised codeAn unrecognised code falls back to the non-EU branch for tax classification; the invoice is not aborted

The email-drop case is surprising. When the supplied address is so long that even @domain exceeds 255 chars, BeeL prefers to issue the invoice without an email rather than persist a malformed one (which would then trip the automatic email-sending rule and silently fail). The dropped value's original length is preserved in the warning so you can correlate the event.

The Spanish-context check for the NIF treats a payload with no country at all as Spain (BeeL's default) — so a missing country_code still triggers NIF validation, but a foreign country_code skips it.

Where the warnings end up

Every modification leaves a trace on the resulting invoice so the change is auditable from support's side. The exact location and shape of those warning records is not part of the public API today — write to it@beel.es with the invoice number if you need to trace a specific transformation.

A NIF warning means the invoice was created as a draft (or parked, depending on flow) and will be rejected at issuance with NIF_INVALID_CONTROL_DIGIT / NIF_INVALID_FORMAT. See Customer mappings for how the recipient is resolved and the Necesita atención section in Edge cases and limits for the operator workflow.

Forward vs Rectificativa routing

Stripe events fan out into two distinct routes, picked by the type of event:

  • Forward route — issues a brand-new F1 / F2 / F3 (forward invoice) with a fresh number against the resolved series.
  • Rectificativa route — looks up the original BeeL invoice via the Stripe payment identifier and issues an R1 / R4 / R5 (rectificativa) that mirrors the original's fiscal shape.
Stripe eventChannelRouteResult
payment_intent.succeededConnectForwardNew F1 / F2
checkout.session.completedConnect & SubscriptionForwardNew F1 / F2
invoice.payment_succeeded / invoice.paidSubscription / ConnectForwardNew F1 / F2
refund.updatedConnectRectificativaR1 / R4 / R5 against the original
credit_note.createdConnectRectificativaR1 / R4 / R5 against the original

The full list of supported events lives in Supported events; this page only documents how each is routed.

Why the split exists

The two routes do fundamentally different things:

  • The forward route is a constructor — it resolves the recipient (optionally auto-creating the customer mapping), picks the invoice type (F1 vs F2 vs simplificada based on amount, recipient address, and the connection's simplified threshold), and asks the series resolver for a fresh number.
  • The rectificativa route is a mirror — it never resolves a recipient. It copies the recipient from the original invoice. The Stripe refund.reason / credit_note.reason is mapped to a VeriFactu rectification code, and the inclusive/exclusive tax mode and IVA rate are mirrored from the original so the rectificativa actually nets correctly.

Keeping the two responsibilities separate avoids a class of recurring bugs (recipient overrides on refunds, lost tax mode, wrong sign on the line totals).

What happens when the rectificativa arrives first

Stripe does not guarantee event ordering. A refund.updated can land before BeeL has seen the payment_intent.succeeded that produced the original invoice. In that case the event is parked in the inbox with reason REFUND_ORIGINAL_NOT_FOUND and sits on the retry backoff for ~6 hours; once the forward arrives and creates the original, the next replay of the rectificativa resolves cleanly. See the Necesita atención section in Edge cases and limits.

Idempotency on both sides

Both routes de-duplicate server-side by the Stripe payment identifier:

  • The forward route deduplicates by the Stripe payment intent ID: replaying the same payment_intent.succeeded always resolves to the same invoice. Even under a true race between two concurrent retries, only one invoice is ever written and the Stripe retry resolves to a no-op success.
  • The rectificativa route deduplicates by the Stripe refund or credit-note ID: replaying the same re_... or cn_... always resolves to the same rectificativa.

The practical guarantee is: replaying any Stripe event always yields the same BeeL invoice, never a duplicate.