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_submit→consolidate_pos_invoices→ aPOS Invoice Merge Logper customer → that creates + submits the consolidated Sales Invoice, then writesstatus = "Consolidated"back onto the POS Invoices last. -
The only re-run guard (
validate_pos_invoice_status) reads thatConsolidatedstatus — 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:
-
Consolidated SI #1 submits → plugin commits → it’s durable.
-
A later invoice in the batch fails →
frappe.db.rollback(). -
Rollback can’t undo SI #1, but it undoes the status flags. Closing Entry → Failed.
-
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.)