Diagnostic Number Ranges and Documents

Invoice document created without a number or date

A staff member opens an order, clicks Invoice, clicks Create document, and everything looks fine. Then later someone opens that same invoice and Shopware throws an error, or the PDF shows a blank where the invoice number should be. The document exists, it is attached to the order, but it never received a real documentNumber or documentDate. Here is why that gap opens up and a small script that finds every invoice document stuck this way so a human can review it before it goes anywhere near accounting.

Python and Node.js Admin API Safe by default (dry run)
The short answer

The admin Invoice modal in Shopware 6 works in two steps. It first loads a default document config, which includes a preview of the next number range value and the line items, and only after you confirm does it call POST /api/_action/order/{orderId}/document/invoice, which creates the document row and then fills its config with the real documentNumber and documentDate through the DocumentService, drawing the number from the document_invoice number range through NumberRangeValueGenerator. If a merchant confirms before that initial config request has resolved, the document is still created, but with an empty or missing documentNumber and documentDate. Shopware tracks this as issue 745, closed as not planned. Query /api/search/document filtered to the invoice document type, look at each result's config for a missing number or date, and flag those for manual review. Never backfill a number by hand. Full code, tests, and sources are below.

The problem in plain words

Generating an invoice in the Shopware 6 admin is not one request, it is two. The moment the Invoice modal opens, the frontend asks the backend for a default document config: what the line items look like, what the next invoice number would be, what the layout options are. That is a preview, nothing is written yet.

Only when the merchant clicks Create document does the real work start. The admin calls the document creation route for the order, a document entity gets persisted, and then, as a follow-up step inside that same flow, the DocumentService fills the document's config JSON blob with the actual documentNumber pulled from the document_invoice number range and the actual documentDate. Two steps, two moments in time, and nothing forces the merchant to wait for the first one before triggering the second.

Invoice modal opens loads default config still loading… Config not resolved number preview pending merchant clicks Create too soon POST document/invoice document row persisted Config saved early number, date never filled orphaned invoice document
The config preview and the create call are two separate requests. Confirming before the preview resolves still creates the document, just without a documentNumber or documentDate.

Why it happens

Nothing in the admin UI blocks the confirm click until the config request finishes, so the race is easy to trigger by accident:

See the citations at the end for the upstream issue and the community reports of the same symptom.

The key insight

An orphaned invoice document is not a data quality nitpick, it is a compliance gap. A legally valid invoice number has to come from the single atomic NumberRangeValueGenerator behind document_invoice, so you cannot just write a number into the broken row after the fact without risking a collision with a number issued elsewhere. Detection can be pure and simple: a document of type invoice whose config is missing a number or a date is broken, full stop. Fixing it is a human decision, not a script's.

The fix, as a flow

We never invent an invoice number. The script authenticates, resolves the invoice document type id, pages through documents of that type, and runs a pure check against each one's config. Every broken document gets logged with its id and order number for someone to look at. Only when DRY_RUN is explicitly turned off does it delete the broken row, so staff can reopen the order and regenerate a correct one.

Resolve invoice type id document-type search Search invoice documents with order, config Number or date missing? yes, orphaned Log id and order number no, well formed Leave it alone DRY_RUN false only DELETE /api/document/{id} staff regenerate in admin
Orphaned documents are always reported first. Deletion only happens with DRY_RUN explicitly off, and the replacement invoice is regenerated by hand through Order Documents.

Build it step by step

1

Get an Admin API access token

Create an integration in Shopware under Settings, System, Integrations, and note its access key id and secret key. Exchange them for a bearer token with POST /api/oauth/token using grant_type: client_credentials. Keep the shop URL and credentials in environment variables, never in the file.

setup (shell)
pip install requests

export SHOPWARE_URL="https://yourstore.example.com"
export SHOPWARE_CLIENT_ID="SWIAXXXXXXXXXXXXXXXXXXXXXX"
export SHOPWARE_CLIENT_SECRET="..."
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export SHOPWARE_URL="https://yourstore.example.com"
export SHOPWARE_CLIENT_ID="SWIAXXXXXXXXXXXXXXXXXXXXXX"
export SHOPWARE_CLIENT_SECRET="..."
export DRY_RUN="true"   // start safe, change to false to write
2

Authenticate and call the Admin API

A small helper gets a bearer token once, then sends every request with Authorization: Bearer <token> and Accept: application/json. We reuse it to resolve the document type, search documents, and delete the confirmed broken ones.

step2.py
import os, requests

SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]

def get_token():
    r = requests.post(
        f"{SHOPWARE_URL}/api/oauth/token",
        json={"grant_type": "client_credentials", "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["access_token"]

def api_post(path, token, body=None):
    r = requests.post(
        f"{SHOPWARE_URL}{path}",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body or {},
        timeout=30,
    )
    r.raise_for_status()
    return r.json() if r.text else {}
step2.js
const SHOPWARE_URL = (process.env.SHOPWARE_URL || "").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID;
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET;

async function getToken() {
  const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ grant_type: "client_credentials", client_id: CLIENT_ID, client_secret: CLIENT_SECRET }),
  });
  if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
  const body = await res.json();
  return body.access_token;
}

async function apiPost(path, token, body = {}) {
  const res = await fetch(`${SHOPWARE_URL}${path}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on ${path}`);
  const text = await res.text();
  return text ? JSON.parse(text) : {};
}
3

Resolve the invoice document type id

Document types are their own entity, and their id is not stable across installs, so look it up by its technicalName of invoice once at the start of the run.

step3.py
def find_invoice_document_type_id(token):
    body = {
        "page": 1,
        "limit": 1,
        "filter": [{"type": "equals", "field": "technicalName", "value": "invoice"}],
        "total-count-mode": 1,
    }
    data = api_post("/api/search/document-type", token, body)
    rows = data.get("data") or []
    return rows[0]["id"] if rows else None
step3.js
async function findInvoiceDocumentTypeId(token) {
  const body = {
    page: 1,
    limit: 1,
    filter: [{ type: "equals", field: "technicalName", value: "invoice" }],
    "total-count-mode": 1,
  };
  const data = await apiPost("/api/search/document-type", token, body);
  const rows = data.data || [];
  return rows.length ? rows[0].id : null;
}
4

Page every invoice document with its config and order

Search documents filtered to that type id, asking for the documentType and order associations so each result carries an order number to report, sorted by createdAt descending so the newest, most likely still fixable ones surface first.

step4.py
def page_invoice_documents(token, document_type_id, page):
    body = {
        "page": page,
        "limit": 200,
        "filter": [{"type": "equals", "field": "documentTypeId", "value": document_type_id}],
        "associations": {"documentType": {}, "order": {}},
        "sort": [{"field": "createdAt", "order": "DESC"}],
        "total-count-mode": 1,
    }
    data = api_post("/api/search/document", token, body)
    return data.get("data") or []

def all_invoice_documents(token, document_type_id):
    page = 1
    while True:
        rows = page_invoice_documents(token, document_type_id, page)
        for row in rows:
            yield row
        if len(rows) < 200:
            return
        page += 1
step4.js
async function pageInvoiceDocuments(token, documentTypeId, page) {
  const body = {
    page,
    limit: 200,
    filter: [{ type: "equals", field: "documentTypeId", value: documentTypeId }],
    associations: { documentType: {}, order: {} },
    sort: [{ field: "createdAt", order: "DESC" }],
    "total-count-mode": 1,
  };
  const data = await apiPost("/api/search/document", token, body);
  return data.data || [];
}

async function* allInvoiceDocuments(token, documentTypeId) {
  let page = 1;
  while (true) {
    const rows = await pageInvoiceDocuments(token, documentTypeId, page);
    for (const row of rows) yield row;
    if (rows.length < 200) return;
    page++;
  }
}
5

Decide, with one pure function

Keep the check in its own function that takes a document with its config.documentNumber, config.documentDate, and documentTypeTechnicalName, and returns true only when the type is invoice and either field is null, undefined, or an empty string. A pure function like this is easy to test with fixtures for a well formed document versus one truncated by the race condition.

decide.py
def _is_blank(value):
    return value is None or (isinstance(value, str) and value.strip() == "")

def is_orphaned_invoice_document(doc):
    if doc.get("documentTypeTechnicalName") != "invoice":
        return False
    config = doc.get("config") or {}
    return _is_blank(config.get("documentNumber")) or _is_blank(config.get("documentDate"))
decide.js
function isBlank(value) {
  return value === null || value === undefined || (typeof value === "string" && value.trim() === "");
}

export function isOrphanedInvoiceDocument(doc) {
  if (doc.documentTypeTechnicalName !== "invoice") return false;
  const config = doc.config || {};
  return isBlank(config.documentNumber) || isBlank(config.documentDate);
}
6

Report first, delete only when explicitly allowed

Collect the broken document ids and their order numbers and log every one, no matter what. Only when DRY_RUN is explicitly set to false does the loop call DELETE /api/document/{documentId} for each one. Staff then reopen the order and regenerate the invoice through Order Documents in the admin, which correctly waits for the full config before creating the document.

apply.py
def delete_document(token, document_id):
    r = requests.delete(
        f"{SHOPWARE_URL}/api/document/{document_id}",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
apply.js
async function deleteDocument(token, documentId) {
  const res = await fetch(`${SHOPWARE_URL}/api/document/${documentId}`, {
    method: "DELETE",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on document delete`);
}
Run it safe

Never write a documentNumber into the broken row by hand. A real invoice number can only come from the atomic NumberRangeValueGenerator behind document_invoice, so patching one in risks a collision with a number already issued elsewhere. Start with DRY_RUN=true, review the reported list of orphaned documents and order numbers, and only switch to false once a human has confirmed each one is safe to remove and regenerate.

The full code

Here is the complete script in one file for each language. It authenticates, resolves the invoice document type id, pages every invoice document with its order, checks each one with the pure function, reports every orphan, and deletes them only when DRY_RUN is off.

View this code on GitHub Full runnable folder with tests in the shopware-fixes repo.

find_orphaned_invoice_documents.py
"""Find Shopware 6 invoice documents created without a documentNumber or
documentDate, without ever writing a number into the broken row.

The admin Invoice modal first loads a default document config, including a
preview of the next number range value, and only after the merchant confirms
does POST /api/_action/order/{orderId}/document/invoice persist a document
entity and then fill its config with the real documentNumber and documentDate
through DocumentService, drawing the number from the document_invoice number
range through NumberRangeValueGenerator. Confirming before that initial config
request resolves still creates the document, just with an empty or missing
documentNumber and documentDate, a scenario tracked upstream as
shopware/shopware issue 745, closed as not planned.

This resolves the invoice document type id, pages every document of that
type with its order, and checks each one with a pure function. Every orphan
is logged with its document id and order number for manual review. The only
write, gated by DRY_RUN, is deleting the confirmed broken document so staff
can regenerate a correct one through Order Documents in the admin. Run on
demand. Safe to run again and again.
"""
import os
import logging
import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_orphaned_invoice_documents")

SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

PAGE_LIMIT = 200


def _is_blank(value):
    return value is None or (isinstance(value, str) and value.strip() == "")


def is_orphaned_invoice_document(doc):
    """Pure decision. No I/O. True only when the document type is invoice and
    either documentNumber or documentDate is null, undefined, or empty.
    """
    if doc.get("documentTypeTechnicalName") != "invoice":
        return False
    config = doc.get("config") or {}
    return _is_blank(config.get("documentNumber")) or _is_blank(config.get("documentDate"))


def get_token():
    r = requests.post(
        f"{SHOPWARE_URL}/api/oauth/token",
        json={"grant_type": "client_credentials", "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["access_token"]


def api_post(path, token, body=None):
    r = requests.post(
        f"{SHOPWARE_URL}{path}",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body or {},
        timeout=30,
    )
    r.raise_for_status()
    return r.json() if r.text else {}


def find_invoice_document_type_id(token):
    body = {
        "page": 1,
        "limit": 1,
        "filter": [{"type": "equals", "field": "technicalName", "value": "invoice"}],
        "total-count-mode": 1,
    }
    data = api_post("/api/search/document-type", token, body)
    rows = data.get("data") or []
    return rows[0]["id"] if rows else None


def page_invoice_documents(token, document_type_id, page):
    body = {
        "page": page,
        "limit": PAGE_LIMIT,
        "filter": [{"type": "equals", "field": "documentTypeId", "value": document_type_id}],
        "associations": {"documentType": {}, "order": {}},
        "sort": [{"field": "createdAt", "order": "DESC"}],
        "total-count-mode": 1,
    }
    data = api_post("/api/search/document", token, body)
    return data.get("data") or []


def all_invoice_documents(token, document_type_id):
    page = 1
    while True:
        rows = page_invoice_documents(token, document_type_id, page)
        for row in rows:
            yield row
        if len(rows) < PAGE_LIMIT:
            return
        page += 1


def to_check_shape(row):
    document_type = row.get("documentType") or {}
    order = row.get("order") or {}
    return {
        "id": row.get("id"),
        "orderNumber": order.get("orderNumber"),
        "documentTypeTechnicalName": document_type.get("technicalName"),
        "config": row.get("config") or {},
    }


def delete_document(token, document_id):
    r = requests.delete(
        f"{SHOPWARE_URL}/api/document/{document_id}",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
        timeout=30,
    )
    r.raise_for_status()


def run():
    token = get_token()
    document_type_id = find_invoice_document_type_id(token)
    if not document_type_id:
        log.warning("Could not resolve the invoice document type id, nothing to check.")
        return

    orphaned = []
    total = 0
    for row in all_invoice_documents(token, document_type_id):
        total += 1
        doc = to_check_shape(row)
        if is_orphaned_invoice_document(doc):
            orphaned.append(doc)

    if not orphaned:
        log.info("Done. 0 orphaned invoice document(s) found across %d invoice document(s).", total)
        return

    for doc in orphaned:
        log.warning(
            "Orphaned invoice document %s on order %s. %s",
            doc["id"], doc["orderNumber"] or "(unknown)",
            "would delete" if DRY_RUN else "deleting",
        )
        if not DRY_RUN:
            delete_document(token, doc["id"])

    log.info(
        "Done. %d orphaned invoice document(s) %s out of %d checked.",
        len(orphaned), "flagged" if DRY_RUN else "deleted", total,
    )


if __name__ == "__main__":
    run()
find-orphaned-invoice-documents.js
/**
 * Find Shopware 6 invoice documents created without a documentNumber or
 * documentDate, without ever writing a number into the broken row.
 *
 * The admin Invoice modal first loads a default document config, including a
 * preview of the next number range value, and only after the merchant
 * confirms does POST /api/_action/order/{orderId}/document/invoice persist a
 * document entity and then fill its config with the real documentNumber and
 * documentDate through DocumentService, drawing the number from the
 * document_invoice number range through NumberRangeValueGenerator.
 * Confirming before that initial config request resolves still creates the
 * document, just with an empty or missing documentNumber and documentDate, a
 * scenario tracked upstream as shopware/shopware issue 745, closed as not
 * planned.
 *
 * This resolves the invoice document type id, pages every document of that
 * type with its order, and checks each one with a pure function. Every
 * orphan is logged with its document id and order number for manual review.
 * The only write, gated by DRY_RUN, is deleting the confirmed broken
 * document so staff can regenerate a correct one through Order Documents in
 * the admin. Run on demand.
 *
 * Guide: https://www.allanninal.dev/shopware/invoice-document-missing-number/
 */
import { pathToFileURL } from "node:url";

const SHOPWARE_URL = (process.env.SHOPWARE_URL || "https://example.test").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID || "dummy-client-id";
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET || "dummy-secret";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const PAGE_LIMIT = 200;

function isBlank(value) {
  return value === null || value === undefined || (typeof value === "string" && value.trim() === "");
}

export function isOrphanedInvoiceDocument(doc) {
  if (doc.documentTypeTechnicalName !== "invoice") return false;
  const config = doc.config || {};
  return isBlank(config.documentNumber) || isBlank(config.documentDate);
}

async function getToken() {
  const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ grant_type: "client_credentials", client_id: CLIENT_ID, client_secret: CLIENT_SECRET }),
  });
  if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
  const body = await res.json();
  return body.access_token;
}

async function apiPost(path, token, body = {}) {
  const res = await fetch(`${SHOPWARE_URL}${path}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on ${path}`);
  const text = await res.text();
  return text ? JSON.parse(text) : {};
}

async function findInvoiceDocumentTypeId(token) {
  const body = {
    page: 1,
    limit: 1,
    filter: [{ type: "equals", field: "technicalName", value: "invoice" }],
    "total-count-mode": 1,
  };
  const data = await apiPost("/api/search/document-type", token, body);
  const rows = data.data || [];
  return rows.length ? rows[0].id : null;
}

async function pageInvoiceDocuments(token, documentTypeId, page) {
  const body = {
    page,
    limit: PAGE_LIMIT,
    filter: [{ type: "equals", field: "documentTypeId", value: documentTypeId }],
    associations: { documentType: {}, order: {} },
    sort: [{ field: "createdAt", order: "DESC" }],
    "total-count-mode": 1,
  };
  const data = await apiPost("/api/search/document", token, body);
  return data.data || [];
}

async function* allInvoiceDocuments(token, documentTypeId) {
  let page = 1;
  while (true) {
    const rows = await pageInvoiceDocuments(token, documentTypeId, page);
    for (const row of rows) yield row;
    if (rows.length < PAGE_LIMIT) return;
    page++;
  }
}

function toCheckShape(row) {
  const documentType = row.documentType || {};
  const order = row.order || {};
  return {
    id: row.id,
    orderNumber: order.orderNumber,
    documentTypeTechnicalName: documentType.technicalName,
    config: row.config || {},
  };
}

async function deleteDocument(token, documentId) {
  const res = await fetch(`${SHOPWARE_URL}/api/document/${documentId}`, {
    method: "DELETE",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on document delete`);
}

export async function run() {
  const token = await getToken();
  const documentTypeId = await findInvoiceDocumentTypeId(token);
  if (!documentTypeId) {
    console.warn("Could not resolve the invoice document type id, nothing to check.");
    return;
  }

  const orphaned = [];
  let total = 0;
  for await (const row of allInvoiceDocuments(token, documentTypeId)) {
    total++;
    const doc = toCheckShape(row);
    if (isOrphanedInvoiceDocument(doc)) orphaned.push(doc);
  }

  if (!orphaned.length) {
    console.log(`Done. 0 orphaned invoice document(s) found across ${total} invoice document(s).`);
    return;
  }

  for (const doc of orphaned) {
    console.warn(`Orphaned invoice document ${doc.id} on order ${doc.orderNumber || "(unknown)"}. ${DRY_RUN ? "would delete" : "deleting"}`);
    if (!DRY_RUN) await deleteDocument(token, doc.id);
  }

  console.log(`Done. ${orphaned.length} orphaned invoice document(s) ${DRY_RUN ? "flagged" : "deleted"} out of ${total} checked.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The decision function is the part most worth testing, because it decides which invoice documents get flagged as broken. Because is_orphaned_invoice_document is pure, the test needs no network and no live Shopware store, just plain fixture objects for a well formed invoice versus one truncated by the race condition.

test_invoice_orphaned_documents.py
from find_orphaned_invoice_documents import is_orphaned_invoice_document


def doc(**over):
    base = {
        "id": "doc-1",
        "documentTypeTechnicalName": "invoice",
        "config": {"documentNumber": "10042", "documentDate": "2026-07-10T00:00:00Z"},
    }
    base.update(over)
    return base


def test_well_formed_invoice_is_not_orphaned():
    assert is_orphaned_invoice_document(doc()) is False


def test_missing_document_number_is_orphaned():
    d = doc(config={"documentNumber": None, "documentDate": "2026-07-10T00:00:00Z"})
    assert is_orphaned_invoice_document(d) is True


def test_empty_string_document_number_is_orphaned():
    d = doc(config={"documentNumber": "", "documentDate": "2026-07-10T00:00:00Z"})
    assert is_orphaned_invoice_document(d) is True


def test_missing_document_date_is_orphaned():
    d = doc(config={"documentNumber": "10042", "documentDate": None})
    assert is_orphaned_invoice_document(d) is True


def test_both_missing_is_orphaned():
    d = doc(config={"documentNumber": None, "documentDate": None})
    assert is_orphaned_invoice_document(d) is True


def test_missing_config_entirely_is_orphaned():
    d = doc(config={})
    assert is_orphaned_invoice_document(d) is True


def test_non_invoice_document_type_is_never_orphaned():
    d = doc(documentTypeTechnicalName="credit_note", config={"documentNumber": None, "documentDate": None})
    assert is_orphaned_invoice_document(d) is False


def test_whitespace_only_document_number_is_orphaned():
    d = doc(config={"documentNumber": "   ", "documentDate": "2026-07-10T00:00:00Z"})
    assert is_orphaned_invoice_document(d) is True
invoice-orphaned-documents.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { isOrphanedInvoiceDocument } from "./find-orphaned-invoice-documents.js";

const doc = (over = {}) => ({
  id: "doc-1",
  documentTypeTechnicalName: "invoice",
  config: { documentNumber: "10042", documentDate: "2026-07-10T00:00:00Z" },
  ...over,
});

test("well formed invoice is not orphaned", () => {
  assert.equal(isOrphanedInvoiceDocument(doc()), false);
});

test("missing document number is orphaned", () => {
  const d = doc({ config: { documentNumber: null, documentDate: "2026-07-10T00:00:00Z" } });
  assert.equal(isOrphanedInvoiceDocument(d), true);
});

test("empty string document number is orphaned", () => {
  const d = doc({ config: { documentNumber: "", documentDate: "2026-07-10T00:00:00Z" } });
  assert.equal(isOrphanedInvoiceDocument(d), true);
});

test("missing document date is orphaned", () => {
  const d = doc({ config: { documentNumber: "10042", documentDate: null } });
  assert.equal(isOrphanedInvoiceDocument(d), true);
});

test("both missing is orphaned", () => {
  const d = doc({ config: { documentNumber: null, documentDate: null } });
  assert.equal(isOrphanedInvoiceDocument(d), true);
});

test("missing config entirely is orphaned", () => {
  const d = doc({ config: {} });
  assert.equal(isOrphanedInvoiceDocument(d), true);
});

test("non-invoice document type is never orphaned", () => {
  const d = doc({ documentTypeTechnicalName: "credit_note", config: { documentNumber: null, documentDate: null } });
  assert.equal(isOrphanedInvoiceDocument(d), false);
});

test("whitespace-only document number is orphaned", () => {
  const d = doc({ config: { documentNumber: "   ", documentDate: "2026-07-10T00:00:00Z" } });
  assert.equal(isOrphanedInvoiceDocument(d), true);
});

Case studies

Slow connection

The warehouse tablet on a weak signal

A logistics team printed invoices from a tablet at the loading dock, over a connection that was fine most of the day but crawled during shift change. Staff would open the Invoice modal and tap Create document immediately, out of habit, without waiting for the modal to finish loading its default config and number preview.

Weeks later, accounting found a handful of invoices with blank invoice numbers on the printed PDFs. Running the detection script surfaced eleven orphaned documents, all created during that same shift-change window, each one logged with its order number. The team deleted the eleven broken rows and regenerated every invoice properly through Order Documents, this time waiting for the modal to finish loading first.

Busy server

The Black Friday backlog of blank invoices

During a Black Friday traffic spike, the Shopware admin itself got sluggish under load, and support staff working through a backlog of manual invoice requests clicked through the modal faster than the backend could respond. A batch of invoices from that afternoon looked fine in the order list but threw a type error the moment anyone tried to open one.

The team ran the script in dry run first, got a clean list of every orphaned document and its order number, and confirmed with finance which ones were safe to discard. Only then did they flip DRY_RUN to false and let the script delete the broken rows, before regenerating each invoice by hand once traffic settled down.

What good looks like

After this runs, an orphaned invoice document stops being a surprise type error waiting to happen and becomes a reported, order-linked line item a human can act on deliberately. Nothing gets deleted without DRY_RUN explicitly off, and no number ever gets written in by hand, so every invoice that survives review keeps its legally valid, sequential number straight from the real number range.

FAQ

Why does a Shopware invoice have no document number or date?

The admin Invoice modal first loads a default document config, including a preview of the next number range value, then only fills the document's config with the real documentNumber and documentDate after you confirm and the create call finishes. If you confirm before that initial config request has resolved, Shopware still writes the document row, but its config is saved before the number and date were filled in, leaving both empty.

Is it safe to auto-generate the missing invoice number?

No. A legally valid, sequential invoice number can only come from the atomic NumberRangeValueGenerator behind the document_invoice number range. Writing a number into the broken document directly, or re-running generation against it, risks handing out a value that collides with one already issued elsewhere. The safe response is to flag the document for a human to review, not to patch a number into it.

How do I actually fix an orphaned invoice document once I find one?

Delete the broken document row with DELETE /api/document/{documentId} only after a human has confirmed it, then reopen the order and regenerate the invoice through Order Documents in the admin. That route reliably waits for the full config to load before it creates the document, so the replacement gets a proper number range value and a real date.

Related field notes

Citations

On the problem:

  1. Do not create invoices if input data is not provided. Issue 745, shopware/shopware. github.com/shopware/shopware/issues/745
  2. Adding invoice document to order not working. Shopware Community Forum. forum.shopware.com/t/adding-invoice-document-to-order-not-working/70294
  3. Shopware 6 Settings, Documents. docs.shopware.com/en/shopware-6-en/settings/documents

On the solution:

  1. Add Custom Document Type, Shopware Developer Documentation. developer.shopware.com/docs/guides/plugins/plugins/checkout/document/add-custom-document-type.html
  2. Number Ranges, Shopware Developer Documentation. developer.shopware.com/docs/guides/hosting/performance/number-ranges.html
  3. Admin API, Shopware Developer Documentation. developer.shopware.com/docs/concepts/api/admin-api.html

Stuck on a tricky one?

If you have a problem in Shopware orders, payments, number ranges, or documents that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this catch a blank invoice before it shipped?

If this saved you from a legally shaky invoice or an awkward accounting cleanup, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all Shopware field notes