Sales invoice duplication after error POS closing entry Probably caused by fiscalisation plugin

sales invoice duplication after error POS closing entry Probably caused by fiscalisation plugin

This is almost always an atomicity break, and it’s worth seeing why the retry makes a twin, because the fix is small.

Native consolidation only works while the whole batch is one transaction:

  • POS Closing Entry.on_submitconsolidate_pos_invoices → a POS Invoice Merge Log per customer → that creates + submits the consolidated Sales Invoice, then writes status = "Consolidated" back onto the POS Invoices last.

  • The only re-run guard (validate_pos_invoice_status) reads that Consolidated status — i.e. it checks a flag written in the last step to protect an invoice created in the first. Safe only because a failure rolls the batch back.

A fiscalisation plugin breaks exactly that. It hooks Sales Invoice.on_submit, calls the fiscal device, then frappe.db.commit()s inside the hook so the fiscal number can’t be lost to a rollback. Now:

  1. Consolidated SI #1 submits → plugin commits → it’s durable.

  2. A later invoice in the batch fails → frappe.db.rollback().

  3. Rollback can’t undo SI #1, but it undoes the status flags. Closing Entry → Failed.

  4. Operator hits Retry → those POS Invoices still look unconsolidated → a second consolidated Sales Invoice, same sales. (A killed RQ worker mid-job does the same.)

To confirm it’s this on your instance: list Sales Invoices with is_consolidated = 1 for the closing date and look for two sharing the same POS Invoice refs — then grep your fiscalisation app for frappe.db.commit() in any Sales Invoice submit hook. If it’s there, that’s your culprit.

The fix: make the guard a DB constraint, not a status flag — a unique idempotency key on the row you create, so a retry collides at INSERT instead of duplicating, no matter what any hook committed:

# unique field on the invoice you create — the index IS the guard
{"fieldname": "pos_idempotency_key", "fieldtype": "Data",
 "unique": 1, "read_only": 1, "no_copy": 1}

key = f"{pos_closing_entry}::{customer}"     # deterministic → a retry produces the same key

try:
    si.pos_idempotency_key = key
    si.insert(); si.submit()
except frappe.exceptions.DuplicateEntryError:
    si_name = frappe.db.get_value("Sales Invoice", {"pos_idempotency_key": key})
    # reuse the existing invoice; re-link the POS Invoices; mark the closing entry done

Avoid the intuitive if exists: … else: create — two workers can both pass the check before either inserts. The unique index is what makes it atomic; the try/except is just how you handle the collision.

If your till is offline-capable, generate the key as a client-side UUID at the point of sale and seal it into the queued transaction — then it’s stable across reconnects, retries, and even a device swap, and the same key guards the sale end-to-end.

(Disclosure: I build an offline-first ERPNext POS and this is exactly the pattern it uses — the client UUID is a unique:1 field; a replay just re-queries and returns the existing invoice, never re-books. Happy to go deeper.)