npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@attestwire/stripe-einvoice

v0.1.0

Published

Turn a finalized Stripe invoice into a compliant EN 16931 e-invoice — XRechnung UBL or Factur-X/ZUGFeRD CII XML — mapped, generated and validated locally, with an optional call to the hosted Attestwire API for the Factur-X PDF.

Readme

@attestwire/stripe-einvoice

Turn a finalized Stripe invoice into a compliant EN 16931 e-invoice — XRechnung UBL or Factur-X / ZUGFeRD CII XML — entirely with the local, zero-dependency @attestwire/en16931 engine. No account, no per-invoice cost, no network call, unless you opt into the hosted API's PDF.

Stripe's own invoicing does not generate a compliant e-invoice by itself: it has your account name and country, not the address, VAT number, contact details and payment instructions an XRechnung or Factur-X invoice must carry, and its "Finalize" event is a payment document, not a tax document in the EN 16931 sense. This package is the missing mapping layer.

Install

npm install @attestwire/stripe-einvoice

Node 18+. If you also want typed access to the real Stripe SDK objects (rather than raw webhook JSON), install stripe yourself — this package accepts both and depends on neither.

Quickstart

import { writeFileSync } from "node:fs";
import Stripe from "stripe";
import { stripeInvoiceToXml } from "@attestwire/stripe-einvoice";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function exportInvoice(invoiceId: string) {
  const invoice = await stripe.invoices.retrieve(invoiceId);

  // The invoice embeds its first 10 lines. Fetch them all.
  const lines = [];
  for await (const line of stripe.invoices.listLineItems(invoiceId, { limit: 100 })) lines.push(line);

  // Stripe states each line's tax amount, but the percentage lives on the tax rate.
  const rateIds = new Set(
    lines.flatMap((l) => (l.taxes ?? []).map((t) => t.tax_rate_details?.tax_rate)).filter((r) => typeof r === "string"),
  );
  const taxRates = Object.fromEntries(
    await Promise.all([...rateIds].map(async (id) => [id, await stripe.taxRates.retrieve(id)])),
  );

  // Validates first (validateInput), then generates. Throws with every rule,
  // business term and fix if the invoice does not satisfy the profile.
  const { xml, profile, notes } = stripeInvoiceToXml(
    { ...invoice, lines: { data: lines, has_more: false } },
    {
      seller: {
        name: "Your Company GmbH",
        vatId: "DE123456789",
        address: { line1: "Musterstraße 1", city: "Berlin", postalCode: "10115", countryCode: "DE" },
        electronicAddress: { schemeId: "EM", value: "[email protected]" },
        contact: { name: "Accounts", email: "[email protected]", phone: "+49 30 1234567" },
      },
      payment: { meansCode: "58", iban: "DE02120300000000202051" }, // SEPA credit transfer
      // BT-10, required by XRechnung: the buyer's Leitweg-ID (public sector) or
      // the reference they gave you. Or set metadata.buyer_reference in Stripe.
      buyerReference: invoice.metadata?.buyer_reference ?? "your-customer-reference",
      taxRates,
    },
  );

  for (const note of notes) console.warn(note);
  writeFileSync(`${invoice.number}.xml`, xml);
}

From a webhook

import { invoiceFinalizedEventToXml } from "@attestwire/stripe-einvoice";

app.post("/webhooks/stripe", async (req, res) => {
  const event = stripe.webhooks.constructEvent(req.rawBody, req.headers["stripe-signature"], endpointSecret);
  if (event.type !== "invoice.finalized") return res.sendStatus(200);

  // event.data.object.lines embeds only the first 10 lines — expand as above
  // if your invoices can have more, then rebuild the event's invoice object.
  const { xml, profile } = invoiceFinalizedEventToXml(event, options);
  // ... store xml, attach to the invoice record, send to the buyer, etc.
  res.sendStatus(200);
});

invoiceFinalizedEventToXml and stripeInvoiceToXml run only the local engine: mapping, validateInput, then generateXRechnungUBL or generateCii depending on profile. Nothing is sent anywhere. A fatal validation finding throws before anything is generated, with every failing rule, the business term it constrains, and the fix.

For CII instead of UBL, pass profile: "xrechnung-cii" (XRechnung, CII syntax) or profile: "facturx-en16931" (the CII payload of a Factur-X / ZUGFeRD PDF). This library writes the Factur-X XML, not the PDF — see Optional: the hosted PDF below, or embed the XML yourself with a PDF/A-3 tool of your choice.

What Stripe doesn't know, and how to supply it

Pass these as options to every function in this package:

| Missing from Stripe | Option | Why | | --- | --- | --- | | Your legal name, address, VAT ID, contact | seller: Party | Stripe has your account name and country, not the seller block BG-4/BG-5/BG-6 requires. | | How to pay you | payment: PaymentInstructions | XRechnung requires payment instructions (BG-16, rule BR-DE-1) — e.g. { meansCode: "58", iban }. | | The buyer reference | buyerReference | XRechnung requires BT-10 (BR-DE-15). For a German public-sector buyer this is their Leitweg-ID. Falls back to metadata.buyer_reference on the Stripe object. | | Peppol participant IDs | seller.electronicAddress / buyer.electronicAddress | { schemeId, value } — e.g. a German seller's Leitweg-ID scheme "9930", or scheme "EM" with an email address (what this package defaults the buyer to, from customer_email). | | VAT exemption reason text | vatExemptionReasons | BT-120. Category E (exempt) has no standard wording and needs one per BR-E-10; AE/K/G/O get sensible defaults from the engine. | | Tax rate percentages | taxRates | Stripe states a line's tax amount, not its percentage — that's on the TaxRate object. Retrieve the invoice with expand: ["lines.data.taxes.tax_rate_details.tax_rate"], or pass the rates you already have. |

Nothing is invented on your behalf: a missing customer name or address goes through as an empty string, and validateInput (or the throw from stripeInvoiceToXml) reports the exact rule — BR-07, BR-11, BR-DE-8, and so on — rather than silently passing with a placeholder.

How it maps

| EN 16931 | From Stripe | |---|---| | BT-1 invoice number | number (a draft has none, so drafts are refused) | | BT-2 issue date | status_transitions.finalized_at, else created | | BT-9 due date | due_date | | BT-44 / BG-8 buyer | customer_name, customer_address | | BT-48 buyer VAT ID | the eu_vat entry in customer_tax_ids, else any *_vat | | BT-49 buyer electronic address | customer_email, scheme EM | | BT-129 / BT-146 quantity, net price | quantity_decimal, pricing.unit_amount_decimal (tax-exclusive); taxable_amount ÷ quantity (tax-inclusive) | | BT-149 base quantity | the line quantity, when the unit amount does not multiply out to the line amount (package or tiered pricing) | | BG-27 line allowance | the line's discount_amounts (tax-exclusive lines) | | BT-151 / BT-152 VAT category, rate | taxability_reason and the tax rate's percentage | | BG-26 line period | the line's period | | BT-113 paid amount | total − amount_remaining: card payments, customer balance and credits alike |

VAT categories: standard_rated and reduced_rated become S, zero_rated becomes Z, reverse_charge becomes AE, the *_exempt reasons become E, and not_subject_to_tax becomes O. An exempt (E) line also needs vatExemptionReasons: { E: "…" } (BR-E-10). The invoice's own customer_tax_exempt snapshot ("none" / "exempt" / "reverse") is cross-checked against the lines' own categories, and a mismatch — Stripe recorded the customer as reverse-charge but no line came out AE — is returned as a note, not silently dropped.

What it refuses, and why

stripeInvoiceToInput / stripeCreditNoteToInput throw StripeMappingError with the reason when:

  • the currency has three decimals (BHD, JOD, KWD, OMR, TND). EN 16931 amounts have two, and this package won't round money. Zero-decimal currencies such as JPY are read in whole units.
  • the amount due includes a previous debt. EN 16931 has nowhere to carry a balance from another invoice.
  • the invoice or credit note is a draft / has no number.
  • a line has no tax information. Every EN 16931 line needs a VAT category, zero-rated and reverse-charge lines included. Use Stripe Tax or tax rates.
  • a line has two taxes. An EN 16931 line carries one rate. Split the item.
  • a taxability reason has no single category, such as not_collecting, portion_* or proportionally_rated.
  • the invoice has more lines than are embedded. Fetch them all with stripe.invoices.listLineItems() (or creditNotes.listLineItems()) first.

Credit notes

A Stripe credit note is a separate object from an invoice, and stripeCreditNoteToInput maps it the same way, with invoiceTypeCode: "381" and every amount stated positively — the type code carries the direction, per the engine's own convention:

import { stripeCreditNoteToInput } from "@attestwire/stripe-einvoice";
import { generateXRechnungUBL, validateInput } from "@attestwire/en16931";

const creditNote = await stripe.creditNotes.retrieve(id, { expand: ["lines.data.taxes.tax_rate_details.tax_rate"] });
const { input, notes } = stripeCreditNoteToInput(creditNote, {
  ...options,
  // BG-3. Not required by EN 16931 for a credit note, but its absence is
  // flagged (ATW-CREDIT-NOTE-NO-PRECEDING-INVOICE, information level) so an
  // auditor can trace the credit back to what it corrects.
  precedingInvoice: { invoiceNumber: "ACME-0042", issueDate: "2026-09-10" },
});

const result = validateInput(input);
if (result.valid) writeFileSync(`${input.invoiceNumber}.xml`, generateXRechnungUBL(input));

This assumes a Stripe credit note line carries the same taxes array shape as an invoice line item. That has held for every credit note this package is tested against; if your Stripe API version's credit note lines look different, stripeCreditNoteToInput will say exactly which field it could not find.

Optional: the hosted PDF

Everything above works with no account. requestFacturXPdf is the one thing in this package that makes a network call, and only when you call it:

import { requestFacturXPdf } from "@attestwire/stripe-einvoice";
import { validateInput } from "@attestwire/en16931";
import { writeFileSync } from "node:fs";

const facturxInput = { ...input, profile: "facturx-en16931" as const };
if (validateInput(facturxInput).valid) {
  const pdf = await requestFacturXPdf(facturxInput, { apiKey: process.env.ATTESTWIRE_API_KEY! });
  writeFileSync(`${facturxInput.invoiceNumber}.pdf`, pdf);
}

It posts your InvoiceInput as JSON to POST https://api.attestwire.com/v1/generate?format=pdf with Authorization: Bearer <apiKey>, and resolves to the PDF's raw bytes: an A4 invoice drawn from your JSON, written as PDF/A-3B, with the CII embedded as factur-x.xml — a Factur-X / ZUGFeRD PDF (EN 16931 profile). It is a paid plan feature; a free-plan key gets a HostedApiError with status === 402, code === "plan_required" and upgradeUrl pointing at the pricing page, before anything is charged. See api.attestwire.com/docs for the full contract, rate limits and error codes. Get a free API key at POST /v1/keys (no card required for the free plan).

requestFacturXPdf only ever accepts profile: "facturx-en16931" — the only profile a Factur-X PDF can carry — and refuses locally, without a network call, for any other profile.

API

stripeInvoiceToInput(invoice, options): { input: InvoiceInput; notes: string[] }
stripeCreditNoteToInput(creditNote, options): { input: InvoiceInput; notes: string[] }
stripeInvoiceToXml(invoice, options): { input, xml, profile, syntax, notes }
invoiceFinalizedEventToXml(event, options): { input, xml, profile, syntax, notes }
requestFacturXPdf(input, { apiKey, origin?, fetch? }): Promise<Uint8Array>

All the Stripe-shaped types (StripeInvoice, StripeInvoiceLine, StripeCreditNote, StripeMappingOptions, …) and StripeMappingError / HostedApiError are exported too. See the source doc-comments in src/mapping.ts, src/webhook.ts and src/hosted.ts for the full field-by-field reasoning — they're written to be read.

Scope, honestly

In scope: mapping a finalized Stripe invoice or credit note to an EN 16931 InvoiceInput; local, free generation and validation of XRechnung UBL and Factur-X/XRechnung CII XML; a webhook helper; an optional hosted-API PDF helper.

Not in scope:

  • Building the Factur-X PDF/A-3 container yourself, offline. This package (like the underlying engine) reads Factur-X PDFs but does not write one locally — only the hosted API does, as a paid feature. If you need a fully offline PDF pipeline, embed the CII XML with a PDF/A-3 library of your own.
  • Transmission. Nothing is sent to a buyer, a Peppol access point or Chorus Pro. This package produces the document; delivery is yours.
  • More than one tax jurisdiction per line, or two taxes on one line — an EN 16931 line has exactly one VAT category and rate.
  • Stripe invoices in more than one currency mismatch — VAT reporting in a currency other than the invoice's own (BT-6/BT-111) needs an exchange rate Stripe does not carry; supply vatAccountingCurrency / taxAmountInAccountingCurrency on the mapped input yourself if you need it.

Development

npm install
npm test          # vitest run
npm run typecheck
npm run build      # tsc

Related

License

MIT.

Trademark

"Attestwire"™ and the Attestwire logo are trademarks of this project's owner. The MIT license covers the code and grants no trademark rights. You may say your integration uses this package, or is built on it; you may not name or brand a product or service "Attestwire", or imply that we endorse yours.