@fayz-ai/plugin-fiscal-br 0.13.4 → 0.13.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/GapScreens-RDPPFZMG.js +3 -0
- package/dist/GapScreens-RDPPFZMG.js.map +1 -0
- package/dist/InboundNotesPage-R72EC5E4.js +5 -0
- package/dist/InboundNotesPage-R72EC5E4.js.map +1 -0
- package/dist/InvoiceNotesButton-3SMNHWMQ.js +5 -0
- package/dist/InvoiceNotesButton-3SMNHWMQ.js.map +1 -0
- package/dist/NoteReconcileView-3KVXZLCG.js +4 -0
- package/dist/NoteReconcileView-3KVXZLCG.js.map +1 -0
- package/dist/OrderNoteCard-H6P66R4J.js +5 -0
- package/dist/OrderNoteCard-H6P66R4J.js.map +1 -0
- package/dist/UnitFiscalWidgets-KCHSISBS.js +503 -0
- package/dist/UnitFiscalWidgets-KCHSISBS.js.map +1 -0
- package/dist/chunk-2GHTB3Z2.js +66 -0
- package/dist/chunk-2GHTB3Z2.js.map +1 -0
- package/dist/chunk-7NW3EBOQ.js +101 -0
- package/dist/chunk-7NW3EBOQ.js.map +1 -0
- package/dist/chunk-G7T5YMNY.js +241 -0
- package/dist/chunk-G7T5YMNY.js.map +1 -0
- package/dist/chunk-JRP5AZFJ.js +64 -0
- package/dist/chunk-JRP5AZFJ.js.map +1 -0
- package/dist/chunk-OGXYHL3Z.js +590 -0
- package/dist/chunk-OGXYHL3Z.js.map +1 -0
- package/dist/chunk-RZT2BO6E.js +64 -0
- package/dist/chunk-RZT2BO6E.js.map +1 -0
- package/dist/chunk-UYGGBJCR.js +2314 -0
- package/dist/chunk-UYGGBJCR.js.map +1 -0
- package/dist/chunk-WID54RUV.js +498 -0
- package/dist/chunk-WID54RUV.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +146 -9391
- package/dist/index.js.map +1 -1
- package/dist/migrations/main.browser.js +39 -0
- package/dist/migrations/main.browser.js.map +1 -0
- package/dist/migrations/main.js +4912 -0
- package/dist/migrations/main.js.map +1 -0
- package/dist/views/InboundNotesPage.d.ts.map +1 -1
- package/dist/views/inventoryView.d.ts.map +1 -1
- package/package.json +11 -5
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/migrations/index.ts"],"names":[],"mappings":";AAIO,IAAM,sBAAA,GAAyB,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA6jB/B,IAAM,0DAAA,GAA6D,CAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAqMnE,IAAM,+CAAA,GAAkD,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AA2WxD,IAAM,0CAAA,GAA6C,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAkPnD,IAAM,8CAAA,GAAiD,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAwBvD,IAAM,2CAAA,GAA8C,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA+MpD,IAAM,yCAAA,GAA4C,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAoBlD,IAAM,oDAAA,GAAuD,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAiC7D,IAAM,yDAAA,GAA4D,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AA4NlE,IAAM,gEAAA,GAAmE,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAsiBzE,IAAM,gEAAA,GAAmE,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAkOzE,IAAM,8CAAA,GAAiD,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAmFvD,IAAM,oDAAA,GAAuD,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AA8J7D,IAAM,oDAAA,GAAuD,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AA0J7D,IAAM,kDAAA,GAAqD,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAsK3D,IAAM,8CAAA,GAAiD,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AA4CvD,IAAM,yCAAA,GAA4C,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA6ElD,IAAM,4CAAA,GAA+C,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA4IrD,IAAM,0CAAA,GAA6C,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA0BnD,IAAM,+CAAA,GAAkD,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAmGxD,IAAM,gDAAA,GAAmD,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAkHzD,IAAM,4CAAA,GAA+C,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AA8CrD,IAAM,qCAAA,GAAwC,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAoB9C,IAAM,uDAAA,GAA0D,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AA2EhE,IAAM,oDAAA,GAAuD,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAsH7D,IAAM,+CAAA,GAAkD,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA0JxD,IAAM,8CAAA,GAAiD,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAsBvD,IAAM,0DAAA,GAA6D,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAwEnE,IAAM,4CAAA,GAA+C,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAgCrD,IAAM,kDAAA,GAAqD,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA6B3D,IAAM,8CAAA,GAAiD,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA2KvD,IAAM,yCAAA,GAA4C,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAoUlD,IAAM,oDAAA,GAAuD,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA8H7D,IAAM,UAAA,GAAiD;AAAA,EAC5D,EAAE,EAAA,EAAI,cAAA,EAAgB,GAAA,EAAK,sBAAA,EAAuB;AAAA,EAClD,EAAE,EAAA,EAAI,kDAAA,EAAoD,GAAA,EAAK,0DAAA,EAA2D;AAAA,EAC1H,EAAE,EAAA,EAAI,uCAAA,EAAyC,GAAA,EAAK,+CAAA,EAAgD;AAAA,EACpG,EAAE,EAAA,EAAI,kCAAA,EAAoC,GAAA,EAAK,0CAAA,EAA2C;AAAA,EAC1F,EAAE,EAAA,EAAI,sCAAA,EAAwC,GAAA,EAAK,8CAAA,EAA+C;AAAA,EAClG,EAAE,EAAA,EAAI,mCAAA,EAAqC,GAAA,EAAK,2CAAA,EAA4C;AAAA,EAC5F,EAAE,EAAA,EAAI,iCAAA,EAAmC,GAAA,EAAK,yCAAA,EAA0C;AAAA,EACxF,EAAE,EAAA,EAAI,4CAAA,EAA8C,GAAA,EAAK,oDAAA,EAAqD;AAAA,EAC9G,EAAE,EAAA,EAAI,iDAAA,EAAmD,GAAA,EAAK,yDAAA,EAA0D;AAAA,EACxH,EAAE,EAAA,EAAI,wDAAA,EAA0D,GAAA,EAAK,gEAAA,EAAiE;AAAA,EACtI,EAAE,EAAA,EAAI,wDAAA,EAA0D,GAAA,EAAK,gEAAA,EAAiE;AAAA,EACtI,EAAE,EAAA,EAAI,sCAAA,EAAwC,GAAA,EAAK,8CAAA,EAA+C;AAAA,EAClG,EAAE,EAAA,EAAI,4CAAA,EAA8C,GAAA,EAAK,oDAAA,EAAqD;AAAA,EAC9G,EAAE,EAAA,EAAI,4CAAA,EAA8C,GAAA,EAAK,oDAAA,EAAqD;AAAA,EAC9G,EAAE,EAAA,EAAI,0CAAA,EAA4C,GAAA,EAAK,kDAAA,EAAmD;AAAA,EAC1G,EAAE,EAAA,EAAI,sCAAA,EAAwC,GAAA,EAAK,8CAAA,EAA+C;AAAA,EAClG,EAAE,EAAA,EAAI,iCAAA,EAAmC,GAAA,EAAK,yCAAA,EAA0C;AAAA,EACxF,EAAE,EAAA,EAAI,oCAAA,EAAsC,GAAA,EAAK,4CAAA,EAA6C;AAAA,EAC9F,EAAE,EAAA,EAAI,kCAAA,EAAoC,GAAA,EAAK,0CAAA,EAA2C;AAAA,EAC1F,EAAE,EAAA,EAAI,uCAAA,EAAyC,GAAA,EAAK,+CAAA,EAAgD;AAAA,EACpG,EAAE,EAAA,EAAI,wCAAA,EAA0C,GAAA,EAAK,gDAAA,EAAiD;AAAA,EACtG,EAAE,EAAA,EAAI,oCAAA,EAAsC,GAAA,EAAK,4CAAA,EAA6C;AAAA,EAC9F,EAAE,EAAA,EAAI,6BAAA,EAA+B,GAAA,EAAK,qCAAA,EAAsC;AAAA,EAChF,EAAE,EAAA,EAAI,+CAAA,EAAiD,GAAA,EAAK,uDAAA,EAAwD;AAAA,EACpH,EAAE,EAAA,EAAI,4CAAA,EAA8C,GAAA,EAAK,oDAAA,EAAqD;AAAA,EAC9G,EAAE,EAAA,EAAI,uCAAA,EAAyC,GAAA,EAAK,+CAAA,EAAgD;AAAA,EACpG,EAAE,EAAA,EAAI,sCAAA,EAAwC,GAAA,EAAK,8CAAA,EAA+C;AAAA,EAClG,EAAE,EAAA,EAAI,kDAAA,EAAoD,GAAA,EAAK,0DAAA,EAA2D;AAAA,EAC1H,EAAE,EAAA,EAAI,oCAAA,EAAsC,GAAA,EAAK,4CAAA,EAA6C;AAAA,EAC9F,EAAE,EAAA,EAAI,0CAAA,EAA4C,GAAA,EAAK,kDAAA,EAAmD;AAAA,EAC1G,EAAE,EAAA,EAAI,sCAAA,EAAwC,GAAA,EAAK,8CAAA,EAA+C;AAAA,EAClG,EAAE,EAAA,EAAI,iCAAA,EAAmC,GAAA,EAAK,yCAAA,EAA0C;AAAA,EACxF,EAAE,EAAA,EAAI,4CAAA,EAA8C,GAAA,EAAK,oDAAA;AAC3D","file":"main.js","sourcesContent":["// AUTO-GENERATED from 000_baseline.sql, 001_preserve_terminal_results_and_recover_leases.sql, 002_a_nota_que_chega_tem_onde_esperar.sql, 003_a_caixa_de_entrada_atravessa.sql, 004_o_grupo_fiscal_do_v1_repete_nome.sql, 005_a_nota_emitida_e_um_documento.sql, 006_o_grupo_fiscal_ganha_o_rail.sql, 007_a_rls_do_documento_passa_a_ser_legivel.sql, 008_a_nota_recebida_guarda_a_decisao_de_entrada.sql, 009_aceitar_a_nota_escreve_estoque_e_titulo_de_uma_vez.sql, 010_a_data_de_emissao_e_instante_e_a_competencia_e_dia.sql, 011_o_imposto_do_produto_e_do_fiscal.sql, 012_quem_e_o_produto_se_pergunta_ao_indice.sql, 013_a_sugestao_sobrevive_a_fechar_a_janela.sql, 014_a_descricao_da_nota_tem_lixo_no_meio.sql, 015_o_movimento_diz_que_veio_da_nota.sql, 016_a_nota_guarda_o_proprio_xml.sql, 017_quem_emite_e_o_cnpj_nao_a_loja.sql, 018_a_nota_do_v1_alcanca_a_conta.sql, 019_a_nota_emitida_se_cancela_pelo_v2.sql, 020_a_nota_acha_o_fornecedor_pelo_cnpj.sql, 021_uso_e_consumo_entra_no_estoque.sql, 022_a_nota_do_v1_traz_o_xml.sql, 023_a_nota_aceita_sem_linhas_recebe_as_linhas.sql, 024_a_nota_nao_aponta_para_conta_que_sumiu.sql, 025_a_conta_da_nota_fecha_com_o_total.sql, 026_o_recebimento_parado_diz_por_que.sql, 027_a_nota_com_conta_e_sem_estoque_termina_no_v2.sql, 028_a_linha_de_estoque_tem_unidade.sql, 029_the_service_writes_the_intake_health.sql, 030_the_service_onboards_the_emitter.sql, 031_the_service_issues_the_note.sql, 032_a_delivered_note_carries_where_it_goes.sql — regenerate with scripts/embed-migrations.mjs\n// SQL files are the source of truth; this inline copy lets the manifest declare\n// migrations as data. Do not edit by hand — run the embed script instead.\n\nexport const MIGRATION_000_BASELINE = `-- Fiscal Brasil / PlugNotas baseline.\n--\n-- This addon does not create a second fiscal ledger. \\`plg_financial_nfe\\`\n-- remains the authoritative document owned by plugin-financial. The tables\n-- below describe delivery to an external issuer: durable commands, attempts,\n-- webhooks and emitter ownership.\n--\n-- The file is additive and idempotent because the current restaurant pool is\n-- live. Applying it is deliberately outside this draft PR.\n\nCREATE TABLE IF NOT EXISTS public.plg_fiscalbr_nfce_jobs (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n -- Logical reference to plugin-financial's fiscal ledger. Cross-plugin\n -- physical foreign keys are intentionally forbidden: installation and\n -- removal of this addon must never rewrite another plugin's schema.\n nota_id uuid NOT NULL,\n order_id uuid NOT NULL REFERENCES public.orders(id) ON DELETE RESTRICT,\n connection_id uuid NOT NULL REFERENCES public.connections(id) ON DELETE RESTRICT,\n operation text NOT NULL CHECK (operation IN ('issue', 'cancel')),\n status text NOT NULL DEFAULT 'queued'\n CHECK (status IN ('queued', 'running', 'waiting', 'succeeded', 'failed', 'manual_review')),\n idempotency_key text NOT NULL,\n provider_document_id text,\n integration_id text,\n request_snapshot jsonb NOT NULL DEFAULT '{}'::jsonb,\n provider_response jsonb NOT NULL DEFAULT '{}'::jsonb,\n attempts integer NOT NULL DEFAULT 0 CHECK (attempts >= 0),\n next_attempt_at timestamptz,\n locked_until timestamptz,\n last_error text,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n UNIQUE (tenant_id, idempotency_key)\n);\n\nCREATE INDEX IF NOT EXISTS plg_fiscalbr_nfce_jobs_recovery_idx\n ON public.plg_fiscalbr_nfce_jobs (status, next_attempt_at, locked_until)\n WHERE status IN ('queued', 'waiting');\n\nCREATE TABLE IF NOT EXISTS public.plg_fiscalbr_nfce_events (\n id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n nota_id uuid NOT NULL,\n job_id uuid REFERENCES public.plg_fiscalbr_nfce_jobs(id) ON DELETE SET NULL,\n event_type text NOT NULL,\n payload jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now()\n);\n\nCREATE INDEX IF NOT EXISTS plg_fiscalbr_nfce_events_document_idx\n ON public.plg_fiscalbr_nfce_events (tenant_id, nota_id, created_at DESC);\n\nCREATE TABLE IF NOT EXISTS public.plg_fiscalbr_webhook_receipts (\n id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n provider text NOT NULL DEFAULT 'plugnotas',\n provider_event_id text NOT NULL,\n tenant_id uuid REFERENCES public.tenants(id) ON DELETE SET NULL,\n nota_id uuid,\n payload_sha256 text NOT NULL,\n received_at timestamptz NOT NULL DEFAULT now(),\n processed_at timestamptz,\n processing_error text,\n UNIQUE (provider, provider_event_id)\n);\n\nCREATE TABLE IF NOT EXISTS public.plg_fiscalbr_emitter_ownership (\n emitter_cnpj text PRIMARY KEY CHECK (emitter_cnpj ~ '^[0-9]{14}$'),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n connection_id uuid NOT NULL REFERENCES public.connections(id) ON DELETE CASCADE,\n environment text NOT NULL CHECK (environment IN ('sandbox', 'homologation')),\n provider_emitter_id text,\n certificate_expires_at timestamptz,\n certificate_fingerprint text,\n csc_ready boolean NOT NULL DEFAULT false,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n UNIQUE (tenant_id, connection_id)\n);\n\nALTER TABLE public.plg_fiscalbr_nfce_jobs ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.plg_fiscalbr_nfce_events ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.plg_fiscalbr_webhook_receipts ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.plg_fiscalbr_emitter_ownership ENABLE ROW LEVEL SECURITY;\n\nDROP POLICY IF EXISTS plg_fiscalbr_nfce_jobs_read ON public.plg_fiscalbr_nfce_jobs;\nCREATE POLICY plg_fiscalbr_nfce_jobs_read ON public.plg_fiscalbr_nfce_jobs\n FOR SELECT TO authenticated\n USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.read', NULL))\n );\n\nDROP POLICY IF EXISTS plg_fiscalbr_nfce_events_read ON public.plg_fiscalbr_nfce_events;\nCREATE POLICY plg_fiscalbr_nfce_events_read ON public.plg_fiscalbr_nfce_events\n FOR SELECT TO authenticated\n USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.read', NULL))\n );\n\nDROP POLICY IF EXISTS plg_fiscalbr_emitter_ownership_read ON public.plg_fiscalbr_emitter_ownership;\nCREATE POLICY plg_fiscalbr_emitter_ownership_read ON public.plg_fiscalbr_emitter_ownership\n FOR SELECT TO authenticated\n USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.read', NULL))\n );\n\nREVOKE ALL ON public.plg_fiscalbr_nfce_jobs FROM anon, authenticated;\nREVOKE ALL ON public.plg_fiscalbr_nfce_events FROM anon, authenticated;\nREVOKE ALL ON public.plg_fiscalbr_webhook_receipts FROM anon, authenticated;\nREVOKE ALL ON public.plg_fiscalbr_emitter_ownership FROM anon, authenticated;\nGRANT SELECT ON public.plg_fiscalbr_nfce_jobs TO authenticated;\nGRANT SELECT ON public.plg_fiscalbr_nfce_events TO authenticated;\nGRANT SELECT ON public.plg_fiscalbr_emitter_ownership TO authenticated;\nGRANT ALL ON public.plg_fiscalbr_nfce_jobs TO service_role;\nGRANT ALL ON public.plg_fiscalbr_nfce_events TO service_role;\nGRANT ALL ON public.plg_fiscalbr_webhook_receipts TO service_role;\nGRANT ALL ON public.plg_fiscalbr_emitter_ownership TO service_role;\n\nCREATE OR REPLACE FUNCTION public.plugnotas_register_emitter(\n p_tenant_id uuid,\n p_connection_id uuid,\n p_emitter_cnpj text,\n p_environment text,\n p_provider_emitter_id text DEFAULT NULL,\n p_certificate_expires_at timestamptz DEFAULT NULL,\n p_certificate_fingerprint text DEFAULT NULL,\n p_csc_ready boolean DEFAULT false\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$\nDECLARE\n v_cnpj text := regexp_replace(coalesce(p_emitter_cnpj, ''), '[^0-9]', '', 'g');\nBEGIN\n IF v_cnpj !~ '^[0-9]{14}$' THEN\n RAISE EXCEPTION 'plugnotas_register_emitter: invalid CNPJ' USING ERRCODE = '22023';\n END IF;\n IF p_environment NOT IN ('sandbox', 'homologation') THEN\n RAISE EXCEPTION 'plugnotas_register_emitter: production is not enabled' USING ERRCODE = '22023';\n END IF;\n IF NOT EXISTS (\n SELECT 1 FROM public.connections c\n WHERE c.id = p_connection_id\n AND c.tenant_id = p_tenant_id\n AND c.connector_id = 'plugnotas'\n AND c.active\n ) THEN\n RAISE EXCEPTION 'plugnotas_register_emitter: active connection not found' USING ERRCODE = 'P0002';\n END IF;\n\n INSERT INTO public.plg_fiscalbr_emitter_ownership\n (emitter_cnpj, tenant_id, connection_id, environment, provider_emitter_id,\n certificate_expires_at, certificate_fingerprint, csc_ready, updated_at)\n VALUES\n (v_cnpj, p_tenant_id, p_connection_id, p_environment, p_provider_emitter_id,\n p_certificate_expires_at, p_certificate_fingerprint, p_csc_ready, now())\n ON CONFLICT (emitter_cnpj) DO UPDATE SET\n tenant_id = EXCLUDED.tenant_id,\n connection_id = EXCLUDED.connection_id,\n environment = EXCLUDED.environment,\n provider_emitter_id = EXCLUDED.provider_emitter_id,\n certificate_expires_at = EXCLUDED.certificate_expires_at,\n certificate_fingerprint = EXCLUDED.certificate_fingerprint,\n csc_ready = EXCLUDED.csc_ready,\n updated_at = now()\n WHERE public.plg_fiscalbr_emitter_ownership.tenant_id = EXCLUDED.tenant_id;\n\n IF NOT FOUND THEN\n RAISE EXCEPTION 'plugnotas_register_emitter: CNPJ belongs to another tenant' USING ERRCODE = '23505';\n END IF;\n\n UPDATE public.connections\n SET provider_account_key = v_cnpj,\n settings = settings || jsonb_build_object(\n 'environment', p_environment,\n 'certificateReady', p_certificate_expires_at IS NOT NULL,\n 'certificateExpiresAt', p_certificate_expires_at,\n 'certificateFingerprint', p_certificate_fingerprint,\n 'providerEmitterId', p_provider_emitter_id,\n 'cscReady', p_csc_ready\n ),\n status = CASE WHEN p_certificate_expires_at IS NOT NULL AND p_csc_ready THEN 'connected' ELSE 'pending' END,\n updated_at = now()\n WHERE id = p_connection_id AND tenant_id = p_tenant_id;\n\n RETURN jsonb_build_object('emitterCnpj', v_cnpj, 'environment', p_environment);\nEND $$;\n\nCREATE OR REPLACE FUNCTION public.plugnotas_disconnect_emitter(\n p_tenant_id uuid,\n p_connection_id uuid\n) RETURNS void\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$\nBEGIN\n DELETE FROM public.plg_fiscalbr_emitter_ownership\n WHERE tenant_id = p_tenant_id AND connection_id = p_connection_id;\n UPDATE public.connections\n SET active = false, status = 'revoked', settings = '{}'::jsonb,\n provider_state = '{}'::jsonb, updated_at = now()\n WHERE tenant_id = p_tenant_id AND id = p_connection_id AND connector_id = 'plugnotas';\nEND $$;\n\nCREATE OR REPLACE FUNCTION public.plugnotas_create_nfce_draft_for_order(\n p_tenant_id uuid,\n p_order_id uuid\n) RETURNS uuid\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$\nDECLARE\n v_order public.orders%ROWTYPE;\n v_nota_id uuid;\nBEGIN\n -- Serialize draft creation per tenant/order without adding a column or\n -- index to the financial plugin's table. The order itself remains the\n -- stable idempotency boundary for one NFC-e draft.\n PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || ':' || p_order_id::text, 0));\n\n SELECT * INTO v_order FROM public.orders\n WHERE tenant_id = p_tenant_id AND id = p_order_id;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'plugnotas_create_nfce_draft_for_order: order not found' USING ERRCODE = 'P0002';\n END IF;\n IF v_order.status NOT IN ('completed', 'paid', 'invoiced') THEN\n RAISE EXCEPTION 'plugnotas_create_nfce_draft_for_order: order is not closed' USING ERRCODE = '23514';\n END IF;\n IF NOT EXISTS (SELECT 1 FROM public.items i WHERE i.tenant_id = p_tenant_id AND i.order_id = p_order_id) THEN\n RAISE EXCEPTION 'plugnotas_create_nfce_draft_for_order: order has no items' USING ERRCODE = '23514';\n END IF;\n\n SELECT n.id INTO v_nota_id FROM public.plg_financial_nfe n\n WHERE n.tenant_id = p_tenant_id AND n.order_id = p_order_id AND n.model = 'nfce'\n ORDER BY n.created_at DESC LIMIT 1;\n IF v_nota_id IS NOT NULL THEN RETURN v_nota_id; END IF;\n\n INSERT INTO public.plg_financial_nfe\n (tenant_id, unit_id, model, status, order_id, contact_id, total_amount,\n emissor, metadata)\n VALUES\n (p_tenant_id, v_order.unit_id, 'nfce', 'draft', p_order_id, v_order.party_id,\n coalesce(v_order.total, 0), 'plugnotas',\n jsonb_build_object('source', 'order', 'orderKind', v_order.kind, 'orderChannel', v_order.channel))\n RETURNING id INTO v_nota_id;\n RETURN v_nota_id;\nEND $$;\n\nCREATE OR REPLACE FUNCTION public.plugnotas_prepare_nfce(\n p_tenant_id uuid,\n p_nota_id uuid,\n p_connection_id uuid,\n p_idempotency_key text\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$\nDECLARE\n v_nota public.plg_financial_nfe%ROWTYPE;\n v_job public.plg_fiscalbr_nfce_jobs%ROWTYPE;\n v_snapshot jsonb;\nBEGIN\n SELECT * INTO v_nota FROM public.plg_financial_nfe\n WHERE tenant_id = p_tenant_id AND id = p_nota_id FOR UPDATE;\n IF NOT FOUND OR v_nota.model <> 'nfce' OR v_nota.order_id IS NULL THEN\n RAISE EXCEPTION 'plugnotas_prepare_nfce: order-backed NFC-e not found' USING ERRCODE = 'P0002';\n END IF;\n IF v_nota.status NOT IN ('draft', 'error', 'processing') THEN\n RAISE EXCEPTION 'plugnotas_prepare_nfce: invalid document status %', v_nota.status USING ERRCODE = '23514';\n END IF;\n IF NOT EXISTS (\n SELECT 1 FROM public.plg_fiscalbr_emitter_ownership e\n WHERE e.tenant_id = p_tenant_id AND e.connection_id = p_connection_id\n AND e.csc_ready AND e.certificate_expires_at > now()\n ) THEN\n RAISE EXCEPTION 'plugnotas_prepare_nfce: emitter is not ready' USING ERRCODE = '23514';\n END IF;\n\n SELECT jsonb_build_object(\n 'nota', to_jsonb(v_nota),\n 'order', to_jsonb(o),\n 'items', coalesce((\n SELECT jsonb_agg(to_jsonb(i) ORDER BY i.sort_order, i.id)\n FROM public.items i\n WHERE i.tenant_id = p_tenant_id AND i.order_id = v_nota.order_id\n ), '[]'::jsonb),\n -- Product tax metadata must be frozen in the same transaction as the order\n -- lines. Looking products up in the Edge Function would let a retry emit\n -- with NCM/CFOP changed after the operator confirmed the document.\n 'products', coalesce((\n SELECT jsonb_agg(to_jsonb(p) ORDER BY p.id)\n FROM public.products p\n WHERE p.tenant_id = p_tenant_id\n AND p.id IN (\n SELECT i.product_id FROM public.items i\n WHERE i.tenant_id = p_tenant_id\n AND i.order_id = v_nota.order_id\n AND i.product_id IS NOT NULL\n )\n ), '[]'::jsonb)\n ) INTO v_snapshot\n FROM public.orders o\n WHERE o.tenant_id = p_tenant_id AND o.id = v_nota.order_id;\n\n IF jsonb_array_length(v_snapshot->'items') = 0 THEN\n RAISE EXCEPTION 'plugnotas_prepare_nfce: order has no items' USING ERRCODE = '23514';\n END IF;\n\n INSERT INTO public.plg_fiscalbr_nfce_jobs\n (tenant_id, nota_id, order_id, connection_id, operation, status,\n idempotency_key, request_snapshot, next_attempt_at, updated_at)\n VALUES\n (p_tenant_id, p_nota_id, v_nota.order_id, p_connection_id, 'issue', 'queued',\n p_idempotency_key, v_snapshot, now(), now())\n ON CONFLICT (tenant_id, idempotency_key) DO UPDATE\n SET updated_at = now()\n RETURNING * INTO v_job;\n\n UPDATE public.plg_financial_nfe\n SET status = 'processing', error_message = NULL,\n metadata = metadata || jsonb_build_object('plugnotas', jsonb_build_object(\n 'jobId', v_job.id, 'jobStatus', v_job.status, 'operation', 'issue'\n )), updated_at = now()\n WHERE id = p_nota_id AND tenant_id = p_tenant_id;\n\n INSERT INTO public.plg_fiscalbr_nfce_events (tenant_id, nota_id, job_id, event_type, payload)\n VALUES (p_tenant_id, p_nota_id, v_job.id, 'issue.queued', '{}'::jsonb);\n RETURN jsonb_build_object('jobId', v_job.id, 'snapshot', v_job.request_snapshot, 'replayed', v_job.attempts > 0);\nEND $$;\n\nCREATE OR REPLACE FUNCTION public.plugnotas_prepare_nfce_cancel(\n p_tenant_id uuid,\n p_nota_id uuid,\n p_connection_id uuid,\n p_reason text\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$\nDECLARE\n v_nota public.plg_financial_nfe%ROWTYPE;\n v_job_id uuid;\n v_reason text := btrim(coalesce(p_reason, ''));\nBEGIN\n IF char_length(v_reason) NOT BETWEEN 15 AND 255 THEN\n RAISE EXCEPTION 'plugnotas_prepare_nfce_cancel: reason must contain 15 to 255 characters' USING ERRCODE = '22023';\n END IF;\n SELECT * INTO v_nota FROM public.plg_financial_nfe\n WHERE tenant_id = p_tenant_id AND id = p_nota_id FOR UPDATE;\n IF NOT FOUND OR v_nota.status <> 'issued' OR v_nota.access_key IS NULL THEN\n RAISE EXCEPTION 'plugnotas_prepare_nfce_cancel: authorized NFC-e not found' USING ERRCODE = '23514';\n END IF;\n\n INSERT INTO public.plg_fiscalbr_nfce_jobs\n (tenant_id, nota_id, order_id, connection_id, operation, status,\n idempotency_key, provider_document_id, integration_id,\n request_snapshot, next_attempt_at)\n VALUES\n (p_tenant_id, p_nota_id, v_nota.order_id, p_connection_id, 'cancel', 'queued',\n 'nfce-cancel:' || p_nota_id::text,\n v_nota.metadata #>> '{plugnotas,providerDocumentId}',\n v_nota.metadata #>> '{plugnotas,integrationId}',\n jsonb_build_object('reason', v_reason), now())\n ON CONFLICT (tenant_id, idempotency_key) DO UPDATE SET\n provider_document_id = coalesce(\n public.plg_fiscalbr_nfce_jobs.provider_document_id,\n EXCLUDED.provider_document_id\n ),\n integration_id = coalesce(\n public.plg_fiscalbr_nfce_jobs.integration_id,\n EXCLUDED.integration_id\n ),\n updated_at = now()\n RETURNING id INTO v_job_id;\n\n UPDATE public.plg_financial_nfe\n SET metadata = metadata || jsonb_build_object('plugnotas', jsonb_build_object(\n 'jobId', v_job_id, 'jobStatus', 'queued', 'operation', 'cancel', 'cancellationPending', true\n )), updated_at = now()\n WHERE id = p_nota_id AND tenant_id = p_tenant_id;\n RETURN jsonb_build_object('jobId', v_job_id, 'reason', v_reason);\nEND $$;\n\nCREATE OR REPLACE FUNCTION public.plugnotas_record_nfce_attempt(\n p_tenant_id uuid,\n p_job_id uuid,\n p_status text,\n p_provider_response jsonb DEFAULT '{}'::jsonb,\n p_error text DEFAULT NULL,\n p_next_attempt_at timestamptz DEFAULT NULL\n) RETURNS void\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$\nBEGIN\n IF p_status NOT IN ('running', 'waiting', 'failed', 'manual_review') THEN\n RAISE EXCEPTION 'plugnotas_record_nfce_attempt: invalid status' USING ERRCODE = '22023';\n END IF;\n UPDATE public.plg_fiscalbr_nfce_jobs\n SET status = p_status,\n -- One provider dispatch is one attempt. Moving that same attempt from\n -- running to waiting/failed must not count it a second time.\n attempts = attempts + CASE WHEN p_status = 'running' THEN 1 ELSE 0 END,\n provider_response = coalesce(p_provider_response, '{}'::jsonb),\n last_error = p_error, next_attempt_at = p_next_attempt_at,\n locked_until = NULL, updated_at = now()\n WHERE tenant_id = p_tenant_id AND id = p_job_id;\nEND $$;\n\nCREATE OR REPLACE FUNCTION public.plugnotas_apply_nfce_result(\n p_tenant_id uuid,\n p_job_id uuid,\n p_status text,\n p_provider_document_id text DEFAULT NULL,\n p_integration_id text DEFAULT NULL,\n p_number text DEFAULT NULL,\n p_series text DEFAULT NULL,\n p_access_key text DEFAULT NULL,\n p_message text DEFAULT NULL,\n p_provider_response jsonb DEFAULT '{}'::jsonb\n) RETURNS uuid\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$\nDECLARE\n v_job public.plg_fiscalbr_nfce_jobs%ROWTYPE;\n v_document_status text;\nBEGIN\n SELECT * INTO v_job FROM public.plg_fiscalbr_nfce_jobs\n WHERE tenant_id = p_tenant_id AND id = p_job_id FOR UPDATE;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'plugnotas_apply_nfce_result: job not found' USING ERRCODE = 'P0002';\n END IF;\n IF p_status NOT IN ('processing', 'issued', 'cancelled', 'error') THEN\n RAISE EXCEPTION 'plugnotas_apply_nfce_result: invalid status' USING ERRCODE = '22023';\n END IF;\n\n v_document_status := CASE\n WHEN v_job.operation = 'cancel' AND p_status = 'processing' THEN 'issued'\n ELSE p_status\n END;\n UPDATE public.plg_fiscalbr_nfce_jobs\n SET status = CASE\n WHEN p_status IN ('issued', 'cancelled') THEN 'succeeded'\n WHEN p_status = 'error' THEN 'failed'\n ELSE 'waiting'\n END,\n provider_document_id = coalesce(p_provider_document_id, provider_document_id),\n integration_id = coalesce(p_integration_id, integration_id),\n provider_response = coalesce(p_provider_response, '{}'::jsonb),\n last_error = CASE WHEN p_status = 'error' THEN p_message ELSE NULL END,\n next_attempt_at = CASE WHEN p_status = 'processing' THEN now() + interval '2 minutes' ELSE NULL END,\n locked_until = NULL, updated_at = now()\n WHERE id = p_job_id;\n\n UPDATE public.plg_financial_nfe\n SET status = v_document_status,\n number = coalesce(p_number, number),\n series = coalesce(p_series, series),\n access_key = coalesce(p_access_key, access_key),\n issued_at = CASE WHEN p_status = 'issued' THEN coalesce(issued_at, now()) ELSE issued_at END,\n error_message = CASE WHEN p_status = 'error' THEN p_message ELSE NULL END,\n metadata = metadata || jsonb_build_object('plugnotas', jsonb_strip_nulls(jsonb_build_object(\n 'jobId', p_job_id,\n 'jobStatus', CASE WHEN p_status IN ('issued', 'cancelled') THEN 'succeeded' WHEN p_status = 'error' THEN 'failed' ELSE 'waiting' END,\n 'operation', v_job.operation,\n 'providerDocumentId', coalesce(p_provider_document_id, v_job.provider_document_id),\n 'integrationId', coalesce(p_integration_id, v_job.integration_id),\n 'cancellationPending', v_job.operation = 'cancel' AND p_status = 'processing'\n ))),\n updated_at = now()\n WHERE tenant_id = p_tenant_id AND id = v_job.nota_id;\n\n INSERT INTO public.plg_fiscalbr_nfce_events (tenant_id, nota_id, job_id, event_type, payload)\n VALUES (p_tenant_id, v_job.nota_id, p_job_id, v_job.operation || '.' || p_status,\n coalesce(p_provider_response, '{}'::jsonb));\n RETURN v_job.nota_id;\nEND $$;\n\nCREATE OR REPLACE FUNCTION public.plugnotas_apply_nfce_webhook(\n p_provider_event_id text,\n p_payload_sha256 text,\n p_provider_document_id text,\n p_status text,\n p_integration_id text DEFAULT NULL,\n p_number text DEFAULT NULL,\n p_series text DEFAULT NULL,\n p_access_key text DEFAULT NULL,\n p_message text DEFAULT NULL,\n p_payload jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$\nDECLARE\n v_job public.plg_fiscalbr_nfce_jobs%ROWTYPE;\n v_receipt_id bigint;\nBEGIN\n INSERT INTO public.plg_fiscalbr_webhook_receipts\n (provider_event_id, payload_sha256)\n VALUES (p_provider_event_id, p_payload_sha256)\n ON CONFLICT (provider, provider_event_id) DO NOTHING\n RETURNING id INTO v_receipt_id;\n IF v_receipt_id IS NULL THEN RETURN jsonb_build_object('duplicate', true); END IF;\n\n SELECT * INTO v_job FROM public.plg_fiscalbr_nfce_jobs\n WHERE provider_document_id = p_provider_document_id\n ORDER BY created_at DESC LIMIT 1;\n IF NOT FOUND THEN\n UPDATE public.plg_fiscalbr_webhook_receipts\n SET processed_at = now(), processing_error = 'job_not_found'\n WHERE id = v_receipt_id;\n RETURN jsonb_build_object('duplicate', false, 'matched', false);\n END IF;\n\n UPDATE public.plg_fiscalbr_webhook_receipts\n SET tenant_id = v_job.tenant_id, nota_id = v_job.nota_id, processed_at = now()\n WHERE id = v_receipt_id;\n PERFORM public.plugnotas_apply_nfce_result(\n v_job.tenant_id, v_job.id, p_status, p_provider_document_id,\n coalesce(p_integration_id, v_job.integration_id), p_number, p_series,\n p_access_key, p_message, p_payload\n );\n RETURN jsonb_build_object('duplicate', false, 'matched', true, 'jobId', v_job.id);\nEND $$;\n\nCREATE OR REPLACE FUNCTION public.plugnotas_get_artifact_locator(\n p_tenant_id uuid,\n p_nota_id uuid\n) RETURNS jsonb\nLANGUAGE sql SECURITY DEFINER SET search_path = '' STABLE AS $$\n SELECT jsonb_build_object(\n 'notaId', n.id,\n 'status', n.status,\n 'providerDocumentId', n.metadata #>> '{plugnotas,providerDocumentId}',\n 'integrationId', n.metadata #>> '{plugnotas,integrationId}'\n )\n FROM public.plg_financial_nfe n\n WHERE n.tenant_id = p_tenant_id AND n.id = p_nota_id\n AND n.status IN ('issued', 'cancelled');\n$$;\n\nCREATE OR REPLACE FUNCTION public.plugnotas_claim_nfce_recovery(\n p_limit integer DEFAULT 20\n) RETURNS SETOF public.plg_fiscalbr_nfce_jobs\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$\nBEGIN\n RETURN QUERY\n WITH candidates AS (\n SELECT j.id FROM public.plg_fiscalbr_nfce_jobs j\n WHERE j.status IN ('queued', 'waiting')\n AND coalesce(j.next_attempt_at, j.created_at) <= now()\n AND coalesce(j.locked_until, '-infinity'::timestamptz) < now()\n ORDER BY coalesce(j.next_attempt_at, j.created_at), j.created_at\n FOR UPDATE SKIP LOCKED\n LIMIT greatest(1, least(coalesce(p_limit, 20), 100))\n )\n UPDATE public.plg_fiscalbr_nfce_jobs j\n SET status = 'running', locked_until = now() + interval '2 minutes', updated_at = now()\n FROM candidates c WHERE j.id = c.id\n RETURNING j.*;\nEND $$;\n\nREVOKE ALL ON FUNCTION public.plugnotas_register_emitter(uuid, uuid, text, text, text, timestamptz, text, boolean) FROM PUBLIC, anon, authenticated;\nREVOKE ALL ON FUNCTION public.plugnotas_disconnect_emitter(uuid, uuid) FROM PUBLIC, anon, authenticated;\nREVOKE ALL ON FUNCTION public.plugnotas_create_nfce_draft_for_order(uuid, uuid) FROM PUBLIC, anon, authenticated;\nREVOKE ALL ON FUNCTION public.plugnotas_prepare_nfce(uuid, uuid, uuid, text) FROM PUBLIC, anon, authenticated;\nREVOKE ALL ON FUNCTION public.plugnotas_prepare_nfce_cancel(uuid, uuid, uuid, text) FROM PUBLIC, anon, authenticated;\nREVOKE ALL ON FUNCTION public.plugnotas_record_nfce_attempt(uuid, uuid, text, jsonb, text, timestamptz) FROM PUBLIC, anon, authenticated;\nREVOKE ALL ON FUNCTION public.plugnotas_apply_nfce_result(uuid, uuid, text, text, text, text, text, text, text, jsonb) FROM PUBLIC, anon, authenticated;\nREVOKE ALL ON FUNCTION public.plugnotas_apply_nfce_webhook(text, text, text, text, text, text, text, text, text, jsonb) FROM PUBLIC, anon, authenticated;\nREVOKE ALL ON FUNCTION public.plugnotas_get_artifact_locator(uuid, uuid) FROM PUBLIC, anon, authenticated;\nREVOKE ALL ON FUNCTION public.plugnotas_claim_nfce_recovery(integer) FROM PUBLIC, anon, authenticated;\n\nGRANT EXECUTE ON FUNCTION public.plugnotas_register_emitter(uuid, uuid, text, text, text, timestamptz, text, boolean) TO service_role;\nGRANT EXECUTE ON FUNCTION public.plugnotas_disconnect_emitter(uuid, uuid) TO service_role;\nGRANT EXECUTE ON FUNCTION public.plugnotas_create_nfce_draft_for_order(uuid, uuid) TO service_role;\nGRANT EXECUTE ON FUNCTION public.plugnotas_prepare_nfce(uuid, uuid, uuid, text) TO service_role;\nGRANT EXECUTE ON FUNCTION public.plugnotas_prepare_nfce_cancel(uuid, uuid, uuid, text) TO service_role;\nGRANT EXECUTE ON FUNCTION public.plugnotas_record_nfce_attempt(uuid, uuid, text, jsonb, text, timestamptz) TO service_role;\nGRANT EXECUTE ON FUNCTION public.plugnotas_apply_nfce_result(uuid, uuid, text, text, text, text, text, text, text, jsonb) TO service_role;\nGRANT EXECUTE ON FUNCTION public.plugnotas_apply_nfce_webhook(text, text, text, text, text, text, text, text, text, jsonb) TO service_role;\nGRANT EXECUTE ON FUNCTION public.plugnotas_get_artifact_locator(uuid, uuid) TO service_role;\nGRANT EXECUTE ON FUNCTION public.plugnotas_claim_nfce_recovery(integer) TO service_role;\n`\n\nexport const MIGRATION_001_PRESERVE_TERMINAL_RESULTS_AND_RECOVER_LEASES = `-- Incremental recovery fix. Preserve 000_baseline.sql and its recorded checksum.\n-- Only existing fiscal functions change; no parallel ledger/schema or backfill.\n-- CREATE OR REPLACE retains the service-role-only ACL from the baseline.\n\nCREATE OR REPLACE FUNCTION public.plugnotas_record_nfce_attempt(\n p_tenant_id uuid,\n p_job_id uuid,\n p_status text,\n p_provider_response jsonb DEFAULT '{}'::jsonb,\n p_error text DEFAULT NULL,\n p_next_attempt_at timestamptz DEFAULT NULL\n) RETURNS void\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$\nBEGIN\n IF p_status NOT IN ('running', 'waiting', 'failed', 'manual_review') THEN\n RAISE EXCEPTION 'plugnotas_record_nfce_attempt: invalid status' USING ERRCODE = '22023';\n END IF;\n UPDATE public.plg_fiscalbr_nfce_jobs\n SET status = p_status,\n -- One provider dispatch is one attempt. Moving that same attempt from\n -- running to waiting/failed must not count it a second time.\n attempts = attempts + CASE WHEN p_status = 'running' THEN 1 ELSE 0 END,\n provider_response = coalesce(p_provider_response, '{}'::jsonb),\n last_error = p_error, next_attempt_at = p_next_attempt_at,\n locked_until = CASE WHEN p_status = 'running'\n THEN greatest(coalesce(locked_until, now()), now() + interval '2 minutes') ELSE NULL END,\n updated_at = now()\n WHERE tenant_id = p_tenant_id AND id = p_job_id AND status <> 'succeeded';\nEND $$;\n\nCREATE OR REPLACE FUNCTION public.plugnotas_apply_nfce_result(\n p_tenant_id uuid,\n p_job_id uuid,\n p_status text,\n p_provider_document_id text DEFAULT NULL,\n p_integration_id text DEFAULT NULL,\n p_number text DEFAULT NULL,\n p_series text DEFAULT NULL,\n p_access_key text DEFAULT NULL,\n p_message text DEFAULT NULL,\n p_provider_response jsonb DEFAULT '{}'::jsonb\n) RETURNS uuid\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$\nDECLARE\n v_job public.plg_fiscalbr_nfce_jobs%ROWTYPE;\n v_document_status text;\n v_existing_status text;\n v_job_status text;\nBEGIN\n SELECT * INTO v_job FROM public.plg_fiscalbr_nfce_jobs\n WHERE tenant_id = p_tenant_id AND id = p_job_id;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'plugnotas_apply_nfce_result: job not found' USING ERRCODE = 'P0002';\n END IF;\n IF p_status NOT IN ('processing', 'issued', 'cancelled', 'error') THEN\n RAISE EXCEPTION 'plugnotas_apply_nfce_result: invalid status' USING ERRCODE = '22023';\n END IF;\n\n -- Match prepare/cancel's lock order: document, then job.\n SELECT status INTO v_existing_status FROM public.plg_financial_nfe\n WHERE tenant_id = p_tenant_id AND id = v_job.nota_id FOR UPDATE;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'plugnotas_apply_nfce_result: document not found' USING ERRCODE = 'P0002';\n END IF;\n SELECT * INTO v_job FROM public.plg_fiscalbr_nfce_jobs\n WHERE tenant_id = p_tenant_id AND id = p_job_id FOR UPDATE;\n\n -- Late polling must not regress a concluded document or an active cancellation.\n IF v_existing_status = 'cancelled'\n OR (v_job.operation = 'issue' AND v_existing_status = 'issued' AND p_status <> 'cancelled')\n OR (v_job.status = 'succeeded' AND p_status IN ('processing', 'error')) THEN\n -- Old/interrupted jobs may still be running after the document concluded.\n -- Settle their lease too, otherwise recovery would claim them forever.\n UPDATE public.plg_fiscalbr_nfce_jobs\n SET status = 'succeeded', locked_until = NULL, next_attempt_at = NULL, updated_at = now()\n WHERE tenant_id = p_tenant_id AND id = p_job_id;\n RETURN v_job.nota_id;\n END IF;\n\n v_job_status := CASE\n WHEN p_status = 'cancelled' OR (v_job.operation = 'issue' AND p_status = 'issued') THEN 'succeeded'\n WHEN p_status = 'error' THEN 'failed'\n ELSE 'waiting' END;\n v_document_status := CASE\n WHEN v_job.operation = 'cancel' AND p_status <> 'cancelled' THEN v_existing_status\n ELSE p_status\n END;\n UPDATE public.plg_fiscalbr_nfce_jobs\n SET status = v_job_status,\n provider_document_id = coalesce(p_provider_document_id, provider_document_id),\n integration_id = coalesce(p_integration_id, integration_id),\n provider_response = coalesce(p_provider_response, '{}'::jsonb),\n last_error = CASE WHEN p_status = 'error' THEN p_message ELSE NULL END,\n next_attempt_at = CASE WHEN v_job_status = 'waiting' THEN now() + interval '2 minutes' ELSE NULL END,\n locked_until = NULL, updated_at = now()\n WHERE id = p_job_id;\n\n UPDATE public.plg_financial_nfe\n SET status = v_document_status,\n number = coalesce(p_number, number),\n series = coalesce(p_series, series),\n access_key = coalesce(p_access_key, access_key),\n issued_at = CASE WHEN p_status = 'issued' THEN coalesce(issued_at, now()) ELSE issued_at END,\n error_message = CASE WHEN v_document_status = 'error' THEN p_message ELSE NULL END,\n metadata = metadata || jsonb_build_object('plugnotas', jsonb_strip_nulls(jsonb_build_object(\n 'jobId', p_job_id,\n 'jobStatus', v_job_status,\n 'operation', v_job.operation,\n 'providerDocumentId', coalesce(p_provider_document_id, v_job.provider_document_id),\n 'integrationId', coalesce(p_integration_id, v_job.integration_id),\n 'cancellationPending', v_job.operation = 'cancel' AND v_job_status = 'waiting'\n ))),\n updated_at = now()\n WHERE tenant_id = p_tenant_id AND id = v_job.nota_id;\n\n INSERT INTO public.plg_fiscalbr_nfce_events (tenant_id, nota_id, job_id, event_type, payload)\n VALUES (p_tenant_id, v_job.nota_id, p_job_id, v_job.operation || '.' || p_status,\n coalesce(p_provider_response, '{}'::jsonb));\n RETURN v_job.nota_id;\nEND $$;\n\nCREATE OR REPLACE FUNCTION public.plugnotas_apply_nfce_webhook(\n p_provider_event_id text,\n p_payload_sha256 text,\n p_provider_document_id text,\n p_status text,\n p_integration_id text DEFAULT NULL,\n p_number text DEFAULT NULL,\n p_series text DEFAULT NULL,\n p_access_key text DEFAULT NULL,\n p_message text DEFAULT NULL,\n p_payload jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$\nDECLARE\n v_job public.plg_fiscalbr_nfce_jobs%ROWTYPE;\n v_receipt_id bigint;\n v_receipt public.plg_fiscalbr_webhook_receipts%ROWTYPE;\nBEGIN\n INSERT INTO public.plg_fiscalbr_webhook_receipts\n (provider_event_id, payload_sha256)\n VALUES (p_provider_event_id, p_payload_sha256)\n ON CONFLICT (provider, provider_event_id) DO NOTHING\n RETURNING id INTO v_receipt_id;\n SELECT * INTO v_receipt FROM public.plg_fiscalbr_webhook_receipts\n WHERE provider = 'plugnotas' AND provider_event_id = p_provider_event_id FOR UPDATE;\n v_receipt_id := v_receipt.id;\n IF v_receipt.payload_sha256 IS DISTINCT FROM p_payload_sha256 THEN\n RAISE EXCEPTION 'plugnotas_apply_nfce_webhook: conflicting event payload' USING ERRCODE = '22023';\n END IF;\n IF v_receipt.processed_at IS NOT NULL AND v_receipt.processing_error IS NULL THEN\n RETURN jsonb_build_object('duplicate', true, 'matched', true);\n END IF;\n\n SELECT * INTO v_job FROM public.plg_fiscalbr_nfce_jobs\n WHERE provider_document_id = p_provider_document_id\n ORDER BY created_at DESC LIMIT 1;\n IF NOT FOUND THEN\n UPDATE public.plg_fiscalbr_webhook_receipts\n SET processed_at = NULL, processing_error = 'job_not_found'\n WHERE id = v_receipt_id;\n RETURN jsonb_build_object('duplicate', false, 'matched', false);\n END IF;\n\n UPDATE public.plg_fiscalbr_webhook_receipts\n SET tenant_id = v_job.tenant_id, nota_id = v_job.nota_id, processed_at = now(), processing_error = NULL\n WHERE id = v_receipt_id;\n PERFORM public.plugnotas_apply_nfce_result(\n v_job.tenant_id, v_job.id, p_status, p_provider_document_id,\n coalesce(p_integration_id, v_job.integration_id), p_number, p_series,\n p_access_key, p_message, p_payload\n );\n RETURN jsonb_build_object('duplicate', false, 'matched', true, 'jobId', v_job.id);\nEND $$;\n\nCREATE OR REPLACE FUNCTION public.plugnotas_claim_nfce_recovery(\n p_limit integer DEFAULT 20\n) RETURNS SETOF public.plg_fiscalbr_nfce_jobs\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$\nBEGIN\n RETURN QUERY\n WITH candidates AS (\n SELECT j.id FROM public.plg_fiscalbr_nfce_jobs j\n WHERE j.status IN ('queued', 'waiting', 'running')\n AND coalesce(j.next_attempt_at, j.created_at) <= now()\n AND coalesce(j.locked_until, '-infinity'::timestamptz) < now()\n ORDER BY coalesce(j.next_attempt_at, j.created_at), j.created_at\n FOR UPDATE SKIP LOCKED\n LIMIT greatest(1, least(coalesce(p_limit, 20), 100))\n )\n UPDATE public.plg_fiscalbr_nfce_jobs j\n SET status = 'running', locked_until = now() + interval '2 minutes', updated_at = now()\n FROM candidates c WHERE j.id = c.id\n RETURNING j.*;\nEND $$;\n`\n\nexport const MIGRATION_002_A_NOTA_QUE_CHEGA_TEM_ONDE_ESPERAR = `-- A nota que chega tem onde esperar.\n--\n-- O V1 recebe NF-e de fornecedor pela SEFAZ e guarda cada uma numa caixa de\n-- entrada antes de alguém decidir o que fazer com ela: manifestar, importar\n-- para o estoque, ou nada. Essa caixa não tinha destino no V2.\n--\n-- dfe_inbox 703 a nota recebida, com o XML inteiro\n-- dfe_inbox_items 3.215 o item da nota, com todo o tributo\n-- dfe_inbox_payments 739 a forma de pagamento declarada\n-- dfe_inbox_duplicatas 830 o vencimento da duplicata\n-- fiscal_groups 11 o grupo fiscal (CFOP, CST, alíquotas)\n--\n-- Os números somam os dois tenants: Espaço Facial e Pertinho do Céu. É o único\n-- domínio fiscal que os dois usam.\n--\n-- ── O XML é arquivo, e arquivo mora em public.documents ─────────────────────\n--\n-- \\`dfe_inbox.xml_content\\` é o XML inteiro numa coluna de texto, e\n-- \\`pdf_storage_path\\` aponta para o DANFE no storage do V1. Os dois são ARQUIVO,\n-- e o V2 já tem o arquétipo: \\`public.documents\\`, com kind, checksum, storage_*\n-- e a RLS de arquivo que uma coluna de texto nunca teria.\n--\n-- Então a extensão guarda um PONTEIRO — \\`document_id\\` — e os bytes vão para o\n-- arquétipo, pelo estágio de assets (\\`make mig-assets\\`), que é quem alcança\n-- object storage. Enquanto os bytes não chegam, \\`document_id\\` fica nulo e o\n-- caminho de origem fica em metadata: a linha é verdadeira nos dois momentos.\n--\n-- ── O tributo vem inteiro, e de propósito ───────────────────────────────────\n--\n-- 30 colunas de imposto por item (ICMS, ICMS-ST, PIS, COFINS, IPI, com base,\n-- alíquota e valor de cada). É muita coluna, e a tentação é jogar tudo num\n-- jsonb. Não: isto é documento fiscal recebido, e a pergunta que se faz dele é\n-- \"quanto de ICMS entrou neste mês\" — uma soma, sobre coluna, com índice. Um\n-- jsonb transformaria toda apuração numa varredura.\n--\n-- O que VAI para jsonb é o \\`raw_xml_item\\`, que já é jsonb na origem e existe\n-- para conferência, não para conta.\n--\n-- Idempotente.\n\nCREATE TABLE IF NOT EXISTS public.plg_fiscalbr_inbox_documents (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n unit_id uuid,\n -- O ponteiro para o arquétipo. Nulo enquanto os bytes não subiram — o\n -- caminho de origem fica em metadata até lá.\n document_id uuid,\n access_key text,\n nfe_number text,\n nfe_series text,\n nfe_model text,\n issue_date timestamp with time zone,\n received_at timestamp with time zone,\n status text DEFAULT 'received'::text NOT NULL,\n -- A manifestação do destinatário é outro eixo que o status: uma nota pode\n -- estar recebida e não manifestada, e é a manifestação que a SEFAZ cobra.\n manifestation_status text,\n manifested_at timestamp with time zone,\n manifestation_attempts integer DEFAULT 0 NOT NULL,\n manifestation_response jsonb,\n supplier_party_id uuid,\n supplier_cnpj text,\n supplier_name text,\n supplier_ie text,\n total_value numeric(14,2),\n total_products numeric(14,2),\n total_discount numeric(14,2),\n total_freight numeric(14,2),\n total_insurance numeric(14,2),\n total_other numeric(14,2),\n total_icms_base numeric(14,2),\n total_icms_value numeric(14,2),\n total_icms_st_base numeric(14,2),\n total_icms_st_value numeric(14,2),\n total_pis_value numeric(14,2),\n total_cofins_value numeric(14,2),\n total_ipi_value numeric(14,2),\n nfe_protocol text,\n nfe_authorized_at timestamp with time zone,\n carrier_name text,\n carrier_cnpj text,\n freight_mode_code text,\n additional_info text,\n reviewed_at timestamp with time zone,\n -- O que essa nota virou no estoque, quando virou. Nulo é nota recebida e\n -- não importada, que é a maioria.\n inventory_operation_id uuid,\n owner_id uuid,\n metadata jsonb DEFAULT '{}'::jsonb NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL\n);\n\nCOMMENT ON TABLE public.plg_fiscalbr_inbox_documents IS\n 'A NF-e de fornecedor recebida da SEFAZ, esperando manifestação ou importação. V1: dfe_inbox.';\nCOMMENT ON COLUMN public.plg_fiscalbr_inbox_documents.document_id IS\n 'V1: xml_content + pdf_storage_path. O XML e o DANFE são arquivo; os bytes moram em public.documents e aqui fica o ponteiro.';\n\nCREATE TABLE IF NOT EXISTS public.plg_fiscalbr_inbox_items (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n inbox_document_id uuid NOT NULL,\n item_number integer,\n supplier_product_code text,\n ean_barcode text,\n ean_taxable text,\n description text,\n ncm text,\n cest text,\n cfop text,\n unit_commercial text,\n unit_taxable text,\n quantity_commercial numeric(18,4),\n quantity_taxable numeric(18,4),\n unit_price_commercial numeric(18,6),\n unit_price_taxable numeric(18,6),\n total_price numeric(14,2),\n discount numeric(14,2),\n freight numeric(14,2),\n insurance numeric(14,2),\n other_expenses numeric(14,2),\n icms_cst text, icms_csosn text, icms_origin text,\n icms_base numeric(14,2), icms_rate numeric(9,4), icms_value numeric(14,2),\n icms_st_base numeric(14,2), icms_st_value numeric(14,2),\n pis_cst text, pis_base numeric(14,2), pis_rate numeric(9,4), pis_value numeric(14,2),\n cofins_cst text, cofins_base numeric(14,2), cofins_rate numeric(9,4), cofins_value numeric(14,2),\n ipi_cst text, ipi_rate numeric(9,4), ipi_value numeric(14,2),\n -- O produto do V2 que esta linha da nota representa. \\`reconciled\\` é a\n -- decisão de quem conferiu; o id é o resultado dela.\n product_id uuid,\n reconciled boolean DEFAULT false NOT NULL,\n raw_xml_item jsonb,\n metadata jsonb DEFAULT '{}'::jsonb NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL\n);\n\nCOMMENT ON TABLE public.plg_fiscalbr_inbox_items IS\n 'O item da NF-e recebida, com o tributo em coluna — a apuração soma coluna, não varre jsonb. V1: dfe_inbox_items.';\n\nCREATE TABLE IF NOT EXISTS public.plg_fiscalbr_inbox_payments (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n inbox_document_id uuid NOT NULL,\n payment_index integer,\n payment_indicator text,\n payment_type_code text,\n payment_type_label text,\n value numeric(14,2),\n change_given numeric(14,2),\n card_brand text,\n card_acquirer_cnpj text,\n card_auth_code text,\n metadata jsonb DEFAULT '{}'::jsonb NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS public.plg_fiscalbr_inbox_duplicates (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n inbox_document_id uuid NOT NULL,\n duplicate_index integer,\n number text,\n due_date date,\n value numeric(14,2),\n -- O que essa duplicata virou no contas a pagar, se alguém a lançou.\n installment_id uuid,\n metadata jsonb DEFAULT '{}'::jsonb NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL\n);\n\nCOMMENT ON TABLE public.plg_fiscalbr_inbox_duplicates IS\n 'O vencimento declarado na NF-e recebida. Vira parcela do contas a pagar quando alguém lança a nota. V1: dfe_inbox_duplicatas.';\n\nCREATE TABLE IF NOT EXISTS public.plg_fiscalbr_tax_groups (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n name text NOT NULL,\n cfop text,\n csosn text,\n cst text,\n icms_origin text,\n icms_rate numeric(9,4),\n icms_bc_mode text,\n pis_situation text,\n pis_rate numeric(9,4),\n cofins_situation text,\n cofins_rate numeric(9,4),\n -- A reforma tributária já está no cadastro do V1 e vem junto: deixar de\n -- fora obrigaria a recadastrar tudo no dia em que IBS/CBS valerem.\n ibs_cbs_situation text,\n ibs_cbs_classification text,\n base_calculation_mode text,\n benefit_code text,\n cest text,\n poverty_fund_rate numeric(9,4),\n is_active boolean DEFAULT true NOT NULL,\n metadata jsonb DEFAULT '{}'::jsonb NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL\n);\n\nCOMMENT ON TABLE public.plg_fiscalbr_tax_groups IS\n 'O grupo fiscal: CFOP, CST/CSOSN e alíquotas que o produto herda na emissão. V1: fiscal_groups. Traz IBS/CBS porque o cadastro do V1 já os tem.';\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- As chaves, os índices e a cerca\n-- ═══════════════════════════════════════════════════════════════════════════\n\nDO $$\nDECLARE t text;\nBEGIN\n FOREACH t IN ARRAY ARRAY['plg_fiscalbr_inbox_documents', 'plg_fiscalbr_inbox_items',\n 'plg_fiscalbr_inbox_payments', 'plg_fiscalbr_inbox_duplicates',\n 'plg_fiscalbr_tax_groups'] LOOP\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = ('public.' || t)::regclass AND contype = 'p') THEN\n EXECUTE format('ALTER TABLE ONLY public.%I ADD CONSTRAINT %I PRIMARY KEY (id)', t, t || '_pkey');\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = t || '_tenant_id_id_key') THEN\n EXECUTE format('ALTER TABLE ONLY public.%I ADD CONSTRAINT %I UNIQUE (tenant_id, id)', t, t || '_tenant_id_id_key');\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = t || '_tenant_fkey') THEN\n EXECUTE format('ALTER TABLE ONLY public.%I ADD CONSTRAINT %I FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE', t, t || '_tenant_fkey');\n END IF;\n END LOOP;\nEND $$;\n\nDO $$ BEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_fiscalbr_inbox_documents_unit_fkey') THEN\n ALTER TABLE ONLY public.plg_fiscalbr_inbox_documents ADD CONSTRAINT plg_fiscalbr_inbox_documents_unit_fkey\n FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n END IF;\n -- O arquivo pode ser apagado sem levar a nota junto: a nota fiscal existe\n -- mesmo sem o XML à mão, e é ela que a SEFAZ conhece.\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_fiscalbr_inbox_documents_document_fkey') THEN\n ALTER TABLE ONLY public.plg_fiscalbr_inbox_documents ADD CONSTRAINT plg_fiscalbr_inbox_documents_document_fkey\n FOREIGN KEY (document_id) REFERENCES public.documents(id) ON DELETE SET NULL;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_fiscalbr_inbox_documents_supplier_fkey') THEN\n ALTER TABLE ONLY public.plg_fiscalbr_inbox_documents ADD CONSTRAINT plg_fiscalbr_inbox_documents_supplier_fkey\n FOREIGN KEY (supplier_party_id) REFERENCES public.people(id) ON DELETE SET NULL;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_fiscalbr_inbox_documents_operation_fkey') THEN\n ALTER TABLE ONLY public.plg_fiscalbr_inbox_documents ADD CONSTRAINT plg_fiscalbr_inbox_documents_operation_fkey\n FOREIGN KEY (inventory_operation_id) REFERENCES public.plg_inventory_operations(id) ON DELETE SET NULL;\n END IF;\n\n -- Item, pagamento e duplicata não existem fora da nota: CASCADE.\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_fiscalbr_inbox_items_document_fkey') THEN\n ALTER TABLE ONLY public.plg_fiscalbr_inbox_items ADD CONSTRAINT plg_fiscalbr_inbox_items_document_fkey\n FOREIGN KEY (tenant_id, inbox_document_id) REFERENCES public.plg_fiscalbr_inbox_documents(tenant_id, id) ON DELETE CASCADE;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_fiscalbr_inbox_items_product_fkey') THEN\n ALTER TABLE ONLY public.plg_fiscalbr_inbox_items ADD CONSTRAINT plg_fiscalbr_inbox_items_product_fkey\n FOREIGN KEY (product_id) REFERENCES public.products(id) ON DELETE SET NULL;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_fiscalbr_inbox_payments_document_fkey') THEN\n ALTER TABLE ONLY public.plg_fiscalbr_inbox_payments ADD CONSTRAINT plg_fiscalbr_inbox_payments_document_fkey\n FOREIGN KEY (tenant_id, inbox_document_id) REFERENCES public.plg_fiscalbr_inbox_documents(tenant_id, id) ON DELETE CASCADE;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_fiscalbr_inbox_duplicates_document_fkey') THEN\n ALTER TABLE ONLY public.plg_fiscalbr_inbox_duplicates ADD CONSTRAINT plg_fiscalbr_inbox_duplicates_document_fkey\n FOREIGN KEY (tenant_id, inbox_document_id) REFERENCES public.plg_fiscalbr_inbox_documents(tenant_id, id) ON DELETE CASCADE;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_fiscalbr_inbox_duplicates_installment_fkey') THEN\n ALTER TABLE ONLY public.plg_fiscalbr_inbox_duplicates ADD CONSTRAINT plg_fiscalbr_inbox_duplicates_installment_fkey\n FOREIGN KEY (installment_id) REFERENCES public.plg_financial_invoice_installments(id) ON DELETE SET NULL;\n END IF;\nEND $$;\n\n-- A chave de acesso é única por casa: a mesma nota chegando duas vezes da SEFAZ\n-- é a mesma nota, e é isso que impede a importação em dobro.\nCREATE UNIQUE INDEX IF NOT EXISTS plg_fiscalbr_inbox_documents_access_key_uidx\n ON public.plg_fiscalbr_inbox_documents USING btree (tenant_id, access_key)\n WHERE access_key IS NOT NULL;\nCREATE INDEX IF NOT EXISTS plg_fiscalbr_inbox_documents_pending_idx\n ON public.plg_fiscalbr_inbox_documents USING btree (tenant_id, unit_id, issue_date DESC)\n WHERE inventory_operation_id IS NULL;\nCREATE INDEX IF NOT EXISTS plg_fiscalbr_inbox_items_document_idx\n ON public.plg_fiscalbr_inbox_items USING btree (tenant_id, inbox_document_id);\nCREATE INDEX IF NOT EXISTS plg_fiscalbr_inbox_items_product_idx\n ON public.plg_fiscalbr_inbox_items USING btree (tenant_id, product_id) WHERE product_id IS NOT NULL;\nCREATE INDEX IF NOT EXISTS plg_fiscalbr_inbox_payments_document_idx\n ON public.plg_fiscalbr_inbox_payments USING btree (tenant_id, inbox_document_id);\nCREATE INDEX IF NOT EXISTS plg_fiscalbr_inbox_duplicates_document_idx\n ON public.plg_fiscalbr_inbox_duplicates USING btree (tenant_id, inbox_document_id);\nCREATE UNIQUE INDEX IF NOT EXISTS plg_fiscalbr_tax_groups_name_uidx\n ON public.plg_fiscalbr_tax_groups USING btree (tenant_id, lower(name));\n\nDO $$\nDECLARE t text;\nBEGIN\n FOREACH t IN ARRAY ARRAY['plg_fiscalbr_inbox_documents', 'plg_fiscalbr_inbox_items',\n 'plg_fiscalbr_inbox_payments', 'plg_fiscalbr_inbox_duplicates',\n 'plg_fiscalbr_tax_groups'] LOOP\n EXECUTE format('DROP TRIGGER IF EXISTS %I ON public.%I', t || '_updated_at', t);\n EXECUTE format('CREATE TRIGGER %I BEFORE UPDATE ON public.%I FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at()', t || '_updated_at', t);\n END LOOP;\nEND $$;\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- Quem enxerga\n-- ═══════════════════════════════════════════════════════════════════════════\n--\n-- Nota fiscal é do FINANCEIRO, e a permissão é a que ele já usa. A caixa de\n-- entrada não tem unidade nos filhos — eles herdam a da nota —, então o escopo\n-- de unidade vale só no documento; o resto é do tenant, e é a CASCADE da FK que\n-- garante que ninguém veja filho de nota alheia.\n-- RLS, uma a uma e por extenso. O laço adiante já liga tudo isto — mas ele liga\n-- por \\`EXECUTE format(...)\\`, e o portão que revisa plugin lê o ARQUIVO, não o\n-- banco: para ele, tabela com tenant_id e sem um ENABLE literal é tabela\n-- aberta. Uma garantia que o revisor não consegue ler é uma garantia que\n-- ninguém confere.\nALTER TABLE public.plg_fiscalbr_inbox_documents ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.plg_fiscalbr_inbox_items ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.plg_fiscalbr_tax_groups ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.plg_fiscalbr_inbox_payments ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.plg_fiscalbr_inbox_duplicates ENABLE ROW LEVEL SECURITY;\n\nDO $$\nDECLARE\n t text;\n doc boolean;\nBEGIN\n FOREACH t IN ARRAY ARRAY['plg_fiscalbr_inbox_documents', 'plg_fiscalbr_inbox_items',\n 'plg_fiscalbr_inbox_payments', 'plg_fiscalbr_inbox_duplicates',\n 'plg_fiscalbr_tax_groups'] LOOP\n doc := t = 'plg_fiscalbr_inbox_documents';\n EXECUTE format('ALTER TABLE public.%I FORCE ROW LEVEL SECURITY', t);\n EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', t);\n EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.%I TO authenticated', t);\n EXECUTE format('GRANT ALL ON TABLE public.%I TO service_role', t);\n\n EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', t || '_authz_select', t);\n EXECUTE format($f$CREATE POLICY %I ON public.%I FOR SELECT TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (%s)\n AND (SELECT app.has_permission('financial.read', %s)))$f$,\n t || '_authz_select', t,\n CASE WHEN doc THEN format('unit_id IS NULL OR (SELECT app.has_unit(%I.unit_id))', t) ELSE 'true' END,\n CASE WHEN doc THEN format('%I.unit_id', t) ELSE 'NULL::uuid' END);\n\n EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', t || '_authz_write', t);\n EXECUTE format($f$CREATE POLICY %I ON public.%I FOR ALL TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.edit', %s)))\n WITH CHECK (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.edit', %s)))$f$,\n t || '_authz_write', t,\n CASE WHEN doc THEN format('%I.unit_id', t) ELSE 'NULL::uuid' END,\n CASE WHEN doc THEN format('%I.unit_id', t) ELSE 'NULL::uuid' END);\n END LOOP;\nEND $$;\n\nINSERT INTO public.scoped_resources (resource_table, unit_column, owner_column, shareable, default_visibility, label)\nVALUES ('plg_fiscalbr_inbox_documents', 'unit_id', 'owner_id', false, 'unit', 'Notas recebidas')\nON CONFLICT (resource_table) DO UPDATE\n SET unit_column = EXCLUDED.unit_column, default_visibility = EXCLUDED.default_visibility, label = EXCLUDED.label;\n`\n\nexport const MIGRATION_003_A_CAIXA_DE_ENTRADA_ATRAVESSA = `-- A caixa de entrada atravessa.\n--\n-- A 002 fez as cinco tabelas. Esta escreve o de-para. As cinco usam o writer\n-- genérico \\`by_shape\\` (spine 123): as colunas do destino foram desenhadas\n-- olhando a origem, então o que falta é dizer qual nome vira qual — não uma\n-- função de upsert por tabela.\n--\n-- ── O arquivo não vem por aqui ──────────────────────────────────────────────\n--\n-- \\`xml_content\\` guarda o XML inteiro e \\`pdf_storage_path\\` aponta para o DANFE.\n-- Os dois saem do de-para com \\`-\\`: arquivo atravessa pelo estágio de assets,\n-- que é quem alcança object storage, e vira linha de \\`public.documents\\`. O\n-- caminho de origem fica em metadata para o estágio achar.\n--\n-- ── O fornecedor fica para a costura ────────────────────────────────────────\n--\n-- \\`supplier_party_id\\` não é preenchido aqui. A origem guarda CNPJ e nome, não\n-- um id de contato, e casar CNPJ com \\`public.people\\` é uma decisão de\n-- identidade — a mesma que já pôs 438 clientes em quarentena. Fica nulo, com o\n-- CNPJ na coluna ao lado, e uma passada de costura resolve quando alguém\n-- decidir a regra.\n--\n-- Idempotente.\n\nSET LOCAL migration.allowlist_unlock = 'on';\n\nSELECT migration._allow(\n 'fiscal_groups', 'public.plg_fiscalbr_tax_groups', 'fiscal_br', 'by_shape',\n jsonb_build_object('target', 'public.plg_fiscalbr_tax_groups'),\n NULL::text[],\n jsonb_build_object(\n 'uuid', 'id',\n 'name', 'name',\n 'cfop', 'cfop',\n 'csosn', 'csosn',\n 'cst', 'cst',\n 'icms_origin', 'icms_origin',\n 'icms_rate', 'icms_rate',\n 'icms_modalidade_bc', 'icms_bc_mode',\n 'pis_situation', 'pis_situation',\n 'pis_rate', 'pis_rate',\n 'cofins_situation', 'cofins_situation',\n 'cofins_rate', 'cofins_rate',\n 'ibs_cbs_situation', 'ibs_cbs_situation',\n 'ibs_cbs_classification', 'ibs_cbs_classification',\n 'base_calculation_mode', 'base_calculation_mode',\n 'cbenef', 'benefit_code',\n 'cest', 'cest',\n 'poverty_fund_rate', 'poverty_fund_rate',\n 'active', 'is_active',\n 'created_at', 'created_at',\n 'updated_at', 'updated_at',\n 'tenant_id', '-',\n 'id', 'jsonb:fiscalbr.v1_ref'),\n 'O grupo fiscal: CFOP, CST/CSOSN e alíquotas que o produto herda na emissão. '\n 'IBS/CBS vêm junto porque o cadastro do V1 já os tem — deixar de fora '\n 'obrigaria a recadastrar tudo no dia em que a reforma valer.'\n);\n\nSELECT migration._allow(\n 'dfe_inbox', 'public.plg_fiscalbr_inbox_documents', 'fiscal_br', 'by_shape',\n jsonb_build_object('target', 'public.plg_fiscalbr_inbox_documents'),\n NULL::text[],\n jsonb_build_object(\n 'uuid', 'id',\n 'company_id', 'unit_id@companies',\n 'access_key', 'access_key',\n 'nfe_number', 'nfe_number',\n 'nfe_series', 'nfe_series',\n 'nfe_model', 'nfe_model',\n 'issue_date', 'issue_date',\n 'received_at', 'received_at',\n 'status', 'status',\n 'manifestation_status', 'manifestation_status',\n 'manifested_at', 'manifested_at',\n 'manifestation_attempts', 'manifestation_attempts',\n 'manifestation_response', 'manifestation_response',\n 'supplier_cnpj', 'supplier_cnpj',\n 'supplier_name', 'supplier_name',\n 'supplier_ie', 'supplier_ie',\n 'total_value', 'total_value',\n 'total_products', 'total_products',\n 'total_discount', 'total_discount',\n 'total_freight', 'total_freight',\n 'total_insurance', 'total_insurance',\n 'total_other', 'total_other',\n 'total_icms_base', 'total_icms_base',\n 'total_icms_value', 'total_icms_value',\n 'total_icms_st_base', 'total_icms_st_base',\n 'total_icms_st_value', 'total_icms_st_value',\n 'total_pis_value', 'total_pis_value',\n 'total_cofins_value', 'total_cofins_value',\n 'total_ipi_value', 'total_ipi_value',\n 'nfe_protocol', 'nfe_protocol') ||\n -- \\`jsonb_build_object\\` aceita no máximo 100 argumentos, e este de-para tem\n -- 59 pares. Dois objetos concatenados, e não uma tabela a menos.\n jsonb_build_object(\n 'nfe_authorized_at', 'nfe_authorized_at',\n 'carrier_name', 'carrier_name',\n 'carrier_cnpj', 'carrier_cnpj',\n 'freight_mode_code', 'freight_mode_code',\n 'additional_info', 'additional_info',\n 'reviewed_at', 'reviewed_at',\n 'created_at', 'created_at',\n 'updated_at', 'updated_at',\n 'tenant_id', '-',\n 'reviewed_by', '-',\n 'xml_content', '-',\n 'pdf_storage_path', 'jsonb:fiscalbr.v1_pdf_path',\n 'pdf_fetched_at', 'jsonb:fiscalbr.v1_pdf_fetched_at',\n 'danfe_import_id', 'jsonb:fiscalbr.v1_danfe_import_ref',\n 'plugnotas_payload', 'jsonb:fiscalbr.v1_plugnotas_payload',\n 'plugnotas_received_at', 'jsonb:fiscalbr.v1_plugnotas_received_at',\n 'nfe_cnf', 'jsonb:fiscalbr.nfe.cnf',\n 'nfe_natureza_operacao', 'jsonb:fiscalbr.nfe.natureza_operacao',\n 'nfe_finalidade', 'jsonb:fiscalbr.nfe.finalidade',\n 'nfe_tipo_operacao', 'jsonb:fiscalbr.nfe.tipo_operacao',\n 'nfe_destination', 'jsonb:fiscalbr.nfe.destination',\n 'supplier_address', 'jsonb:fiscalbr.supplier.street',\n 'supplier_number', 'jsonb:fiscalbr.supplier.number',\n 'supplier_complement', 'jsonb:fiscalbr.supplier.complement',\n 'supplier_neighborhood', 'jsonb:fiscalbr.supplier.neighborhood',\n 'supplier_city', 'jsonb:fiscalbr.supplier.city',\n 'supplier_state', 'jsonb:fiscalbr.supplier.state',\n 'supplier_postal_code', 'jsonb:fiscalbr.supplier.postal_code',\n 'supplier_phone', 'jsonb:fiscalbr.supplier.phone',\n 'supplier_email', 'jsonb:fiscalbr.supplier.email',\n 'id', 'jsonb:fiscalbr.v1_ref'),\n 'A NF-e de fornecedor recebida da SEFAZ. \\`xml_content\\` sai com \\`-\\`: arquivo '\n 'atravessa pelo estágio de assets e vira public.documents, e o caminho do '\n 'DANFE fica em metadata para o estágio achar. O endereço do fornecedor vai '\n 'para metadata porque o fornecedor é \\`public.people\\`, e duplicá-lo em coluna '\n 'daria duas respostas para \"onde ele fica\".'\n);\n\nSELECT migration._allow(\n 'dfe_inbox_items', 'public.plg_fiscalbr_inbox_items', 'fiscal_br', 'by_shape',\n jsonb_build_object('target', 'public.plg_fiscalbr_inbox_items'),\n NULL::text[],\n jsonb_build_object(\n 'uuid', 'id',\n 'dfe_inbox_id', 'inbox_document_id@dfe_inbox',\n 'item_number', 'item_number',\n 'product_code_supplier', 'supplier_product_code',\n 'ean_barcode', 'ean_barcode',\n 'ean_trib', 'ean_taxable',\n 'description', 'description',\n 'ncm', 'ncm',\n 'cest', 'cest',\n 'cfop', 'cfop',\n 'unit_commercial', 'unit_commercial',\n 'unit_taxable', 'unit_taxable',\n 'quantity_commercial', 'quantity_commercial',\n 'quantity_taxable', 'quantity_taxable',\n 'unit_price_commercial', 'unit_price_commercial',\n 'unit_price_taxable', 'unit_price_taxable',\n 'total_price', 'total_price',\n 'discount', 'discount',\n 'freight', 'freight',\n 'insurance', 'insurance',\n 'other_expenses', 'other_expenses',\n 'icms_cst', 'icms_cst',\n 'icms_csosn', 'icms_csosn',\n 'icms_origin', 'icms_origin',\n 'icms_base', 'icms_base',\n 'icms_rate', 'icms_rate',\n 'icms_value', 'icms_value',\n 'icms_st_base', 'icms_st_base',\n 'icms_st_value', 'icms_st_value',\n 'pis_cst', 'pis_cst',\n 'pis_base', 'pis_base',\n 'pis_rate', 'pis_rate',\n 'pis_value', 'pis_value',\n 'cofins_cst', 'cofins_cst',\n 'cofins_base', 'cofins_base',\n 'cofins_rate', 'cofins_rate',\n 'cofins_value', 'cofins_value',\n 'ipi_cst', 'ipi_cst',\n 'ipi_rate', 'ipi_rate',\n 'ipi_value', 'ipi_value',\n 'linked_product_id', 'product_id@products',\n 'reconciled', 'reconciled',\n 'raw_xml_item', 'raw_xml_item',\n 'created_at', 'created_at',\n 'updated_at', 'updated_at',\n 'tenant_id', '-',\n 'id', 'jsonb:fiscalbr.v1_ref'),\n 'O item da nota, com o tributo em COLUNA — a apuração soma coluna, não varre '\n 'jsonb. \\`raw_xml_item\\` é a exceção: já é jsonb na origem e serve para '\n 'conferência, não para conta.'\n);\n\nSELECT migration._allow(\n 'dfe_inbox_payments', 'public.plg_fiscalbr_inbox_payments', 'fiscal_br', 'by_shape',\n jsonb_build_object('target', 'public.plg_fiscalbr_inbox_payments'),\n NULL::text[],\n jsonb_build_object(\n 'uuid', 'id',\n 'dfe_inbox_id', 'inbox_document_id@dfe_inbox',\n 'payment_index', 'payment_index',\n 'indicador_pagamento', 'payment_indicator',\n 'tipo_pagamento_code', 'payment_type_code',\n 'tipo_pagamento_label', 'payment_type_label',\n 'value', 'value',\n 'troco', 'change_given',\n 'card_flag', 'card_brand',\n 'card_acquirer_cnpj', 'card_acquirer_cnpj',\n 'card_auth_code', 'card_auth_code',\n 'created_at', 'created_at',\n 'updated_at', 'updated_at',\n 'tenant_id', '-',\n 'id', 'jsonb:fiscalbr.v1_ref'),\n 'A forma de pagamento declarada na nota recebida.'\n);\n\nSELECT migration._allow(\n 'dfe_inbox_duplicatas', 'public.plg_fiscalbr_inbox_duplicates', 'fiscal_br', 'by_shape',\n jsonb_build_object('target', 'public.plg_fiscalbr_inbox_duplicates'),\n NULL::text[],\n jsonb_build_object(\n 'uuid', 'id',\n 'dfe_inbox_id', 'inbox_document_id@dfe_inbox',\n 'duplicata_index', 'duplicate_index',\n 'numero', 'number',\n 'vencimento', 'due_date',\n 'valor', 'value',\n 'created_at', 'created_at',\n 'updated_at', 'updated_at',\n 'tenant_id', '-',\n 'id', 'jsonb:fiscalbr.v1_ref'),\n 'O vencimento declarado na nota. Vira parcela do contas a pagar quando alguém '\n 'lança a nota — \\`installment_id\\` fica nulo até lá, porque a parcela é '\n 'decisão de quem lança, não da SEFAZ.'\n);\n\nDELETE FROM migration.excluded_tables\n WHERE source_table IN ('dfe_inbox', 'dfe_inbox_items', 'dfe_inbox_payments',\n 'dfe_inbox_duplicatas', 'fiscal_groups');\n\nRESET migration.allowlist_unlock;\n`\n\nexport const MIGRATION_004_O_GRUPO_FISCAL_DO_V1_REPETE_NOME = `-- O grupo fiscal do V1 repete nome.\n--\n-- A 002 criou índice ÚNICO em \\`(tenant_id, lower(name))\\`. O V1 tem, no tenant\n-- 8, \"Bebidas Alcoólicas\", \"Bebidas Não Alcoólicas\" e \"Vinhos e Espumantes\"\n-- DUAS vezes cada — grupos fiscais diferentes com o mesmo rótulo, e o V1\n-- permite. Três das onze linhas foram para a quarentena por uma regra que eu\n-- inventei, não que a origem tenha.\n--\n-- O índice vira comum: serve para buscar, não para proibir. Quem quiser proibir\n-- decide isso na tela, com o dado já dentro — e sabendo quais três são.\n--\n-- Esta correção nasceu dentro de \\`financial/083\\` e mudou de arquivo: o\n-- financeiro roda ANTES do fiscal_br na corrente, e um arquivo do financeiro\n-- que mexe numa tabela do fiscal_br falha do zero com\n-- \\`relation \"public.plg_fiscalbr_tax_groups\" does not exist\\`. Cada plugin mexe\n-- na tabela que é dele.\n--\n-- Idempotente.\n\nDROP INDEX IF EXISTS public.plg_fiscalbr_tax_groups_name_uidx;\nCREATE INDEX IF NOT EXISTS plg_fiscalbr_tax_groups_name_idx\n ON public.plg_fiscalbr_tax_groups USING btree (tenant_id, lower(name));\n`\n\nexport const MIGRATION_005_A_NOTA_EMITIDA_E_UM_DOCUMENTO = `-- A nota emitida é um DOCUMENTO, e o documento já tem arquétipo.\n--\n-- O V1 do Pertinho do Céu emitiu 496 notas (todas NFC-e: 440 concluídas, 51\n-- rejeitadas, 5 com erro) e nenhuma delas tem onde morar no V2. O que existe\n-- hoje aqui é a caixa de ENTRADA (\\`plg_fiscalbr_inbox_*\\`, a nota que chega do\n-- fornecedor) e o JOB de saída (\\`plg_fiscalbr_nfce_jobs\\`, a tentativa de\n-- emitir). Falta o resultado: a nota que a casa emitiu, com chave, protocolo e\n-- XML.\n--\n-- ── Por que pendurar em \\`public.documents\\` e não numa tabela solta ─────────\n--\n-- Porque a pergunta que o operador faz não é \"quais notas fiscais existem\",\n-- é \"o que foi emitido sobre ESTE pedido\". Hoje a resposta está espalhada:\n-- o anexo do título está em \\`documents\\`, o formulário está em \\`documents\\`, a\n-- foto do ponto está em \\`documents\\` — e a nota fiscal ficaria de fora, sendo\n-- o documento mais documento de todos.\n--\n-- \\`documents\\` é o arquétipo: \\`kind\\` diz a espécie, \\`subject_type\\`/\\`subject_id\\`\n-- dizem sobre o quê, e o plugin pendura o que só ele entende numa extensão 1:1\n-- pela chave — exatamente como \\`plg_forms_documents\\` já faz. Esta migração\n-- segue esse padrão, não inventa outro.\n--\n-- ── O que entra e o que NÃO entra ─────────────────────────────────────────\n--\n-- ENTRA o artefato legal e a trilha:\n-- xml_content 113 de 496 preenchidos, 618 kB no total (5,6 kB cada).\n-- É a prova da emissão e não se regenera. Fica.\n-- os quatro payloads 108 kB para as 496 notas inteiras. É a conversa com a\n-- SEFAZ, e é ela que explica as 51 rejeições.\n-- pdf_storage_path 84 de 496. Vira \\`documents.storage_path\\` com\n-- \\`storage_provider = 'v1-pending'\\`, a mesma convenção\n-- que \\`upsert_document\\` usa: o REGISTRO chegou, os bytes\n-- ainda não.\n--\n-- NÃO entra nada que o V2 já saiba responder por conta própria, e a emissão\n-- nova do V2 continua nascendo por \\`plg_fiscalbr_nfce_jobs\\` — este arquivo é o\n-- destino do resultado, não um segundo caminho de emissão.\n--\n-- ── Os dois ponteiros da nota do V1, que não são o mesmo ──────────────────\n--\n-- fiscal_documents.order_id 496 de 496 — a COMANDA (\\`table_orders\\`)\n-- fiscal_documents.invoice_id 118 de 496 — a VENDA (\\`invoices\\`)\n--\n-- Os dois chegam em \\`public.orders\\` no V2, e às vezes na MESMA linha (a\n-- comanda que virou nota adota a venda). Guardar os dois na mesma coluna\n-- perderia justamente a distinção: \\`order_id\\` na extensão é a comanda; a venda\n-- vai para \\`plg_fiscalbr_document_orders\\`, que é N:N porque uma nota pode\n-- juntar várias (511 vínculos para 496 notas).\n--\n-- Idempotente.\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 1. A espécie entra no vocabulário\n-- ═══════════════════════════════════════════════════════════════════════════\n-- \\`migration.upsert_document\\` RECUSA espécie que não esteja aqui, e faz certo:\n-- é o registro que impede \\`kind\\` de virar campo livre.\n\nINSERT INTO public.document_kinds (kind, plugin, description, statuses, status)\nVALUES ('fiscal_document', 'fiscal_br',\n 'A nota fiscal que a casa EMITIU (NFC-e, NF-e, NFS-e), com chave, protocolo e XML',\n ARRAY['draft','processing','authorized','rejected','error','cancelled','substituted'],\n 'declared')\nON CONFLICT (kind) DO UPDATE\n SET plugin = EXCLUDED.plugin,\n description = EXCLUDED.description,\n statuses = EXCLUDED.statuses,\n updated_at = now();\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 2. A extensão: o que só o fiscal entende\n-- ═══════════════════════════════════════════════════════════════════════════\n\nCREATE TABLE IF NOT EXISTS public.plg_fiscalbr_documents (\n -- A chave É o documento. Não há id próprio: a nota e o documento são a mesma\n -- coisa vista de dois lugares, e um segundo id só criaria a chance de eles\n -- discordarem.\n document_id uuid PRIMARY KEY REFERENCES public.documents(id) ON DELETE CASCADE,\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n unit_id uuid,\n -- A comanda/pedido que originou a emissão.\n order_id uuid REFERENCES public.orders(id) ON DELETE SET NULL,\n model text NOT NULL,\n environment text,\n status text NOT NULL,\n series text,\n number text,\n -- 44 dígitos. Sem CHECK de formato: uma nota REJEITADA não tem chave, e uma\n -- chave que a SEFAZ devolveu torta é informação, não erro de digitação.\n access_key text,\n verification_code text,\n protocol text,\n provider text,\n provider_document_id text,\n provider_integration_id text,\n total_value numeric(14,2),\n iss_value numeric(14,2),\n recipient_snapshot jsonb,\n request_payload jsonb,\n response_payload jsonb,\n status_payload jsonb,\n rejection_payload jsonb,\n source_snapshot jsonb,\n -- O artefato legal. 5,6 kB por nota nas 113 que o têm; não se regenera.\n xml text,\n authorized_at timestamptz,\n last_polled_at timestamptz,\n emitted_by_user_id uuid,\n cancelled_at timestamptz,\n cancel_reason text,\n cancelled_by_user_id uuid,\n -- A cadeia de substituição. Auto-referência, então SET NULL: apagar a nota\n -- substituída não pode arrastar a que a substituiu.\n substitutes_document_id uuid REFERENCES public.plg_fiscalbr_documents(document_id) ON DELETE SET NULL,\n substituted_by_document_id uuid REFERENCES public.plg_fiscalbr_documents(document_id) ON DELETE SET NULL,\n observations text,\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\n\nDO $$ BEGIN\n ALTER TABLE public.plg_fiscalbr_documents\n ADD CONSTRAINT plg_fiscalbr_documents_unit_fkey\n FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\nEXCEPTION WHEN duplicate_object THEN NULL; END $$;\n\n-- A chave de acesso é única POR INQUILINO e só quando existe: 56 das 496 notas\n-- do Pertinho não têm chave nenhuma (rejeitadas e com erro), e um UNIQUE\n-- comum as empilharia todas num conflito só.\nCREATE UNIQUE INDEX IF NOT EXISTS plg_fiscalbr_documents_chave_idx\n ON public.plg_fiscalbr_documents (tenant_id, access_key) WHERE access_key IS NOT NULL;\nCREATE INDEX IF NOT EXISTS plg_fiscalbr_documents_order_idx\n ON public.plg_fiscalbr_documents (tenant_id, order_id);\nCREATE INDEX IF NOT EXISTS plg_fiscalbr_documents_status_idx\n ON public.plg_fiscalbr_documents (tenant_id, unit_id, status, authorized_at DESC);\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 3. A trilha da emissão\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 1.886 eventos para 496 notas — quase quatro por nota. É aqui que a rejeição\n-- diz o motivo, e é o único lugar em que ela diz.\n\nCREATE TABLE IF NOT EXISTS public.plg_fiscalbr_document_events (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n unit_id uuid,\n document_id uuid NOT NULL REFERENCES public.plg_fiscalbr_documents(document_id) ON DELETE CASCADE,\n event_type text NOT NULL,\n status text,\n justification text,\n request_payload jsonb,\n response_payload jsonb,\n created_by_user_id uuid,\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now()\n);\n\nCREATE INDEX IF NOT EXISTS plg_fiscalbr_document_events_doc_idx\n ON public.plg_fiscalbr_document_events (document_id, created_at DESC);\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 4. Quais vendas a nota cobre\n-- ═══════════════════════════════════════════════════════════════════════════\n\nCREATE TABLE IF NOT EXISTS public.plg_fiscalbr_document_orders (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n document_id uuid NOT NULL REFERENCES public.plg_fiscalbr_documents(document_id) ON DELETE CASCADE,\n order_id uuid NOT NULL REFERENCES public.orders(id) ON DELETE CASCADE,\n created_at timestamptz NOT NULL DEFAULT now(),\n UNIQUE (document_id, order_id)\n);\n\nCREATE INDEX IF NOT EXISTS plg_fiscalbr_document_orders_order_idx\n ON public.plg_fiscalbr_document_orders (tenant_id, order_id);\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 5. updated_at e a cerca\n-- ═══════════════════════════════════════════════════════════════════════════\n\nDO $$ BEGIN\n CREATE TRIGGER plg_fiscalbr_documents_updated_at BEFORE UPDATE\n ON public.plg_fiscalbr_documents FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\nEXCEPTION WHEN duplicate_object THEN NULL; END $$;\n\n-- \\`app.current_tenant_id()\\` e não \\`user_tenant_ids()\\`, pela razão que a 013 do\n-- plugin-tables teve de consertar em produção: a segunda devolve a UNIÃO das\n-- contas do chamador e mostraria a nota fiscal de um cliente para outro.\nDO $$\nDECLARE t text;\nBEGIN\n FOREACH t IN ARRAY ARRAY['plg_fiscalbr_documents','plg_fiscalbr_document_events',\n 'plg_fiscalbr_document_orders'] LOOP\n EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', t);\n EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', t || '_select', t);\n EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', t || '_insert', t);\n EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', t || '_update', t);\n EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', t || '_delete', t);\n EXECUTE format('CREATE POLICY %I ON public.%I FOR SELECT USING (tenant_id = (select app.current_tenant_id()))', t || '_select', t);\n EXECUTE format('CREATE POLICY %I ON public.%I FOR INSERT WITH CHECK (tenant_id = (select app.current_tenant_id()))', t || '_insert', t);\n EXECUTE format('CREATE POLICY %I ON public.%I FOR UPDATE USING (tenant_id = (select app.current_tenant_id()))', t || '_update', t);\n EXECUTE format('CREATE POLICY %I ON public.%I FOR DELETE USING (tenant_id = (select app.current_tenant_id()))', t || '_delete', t);\n EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO authenticated', t);\n END LOOP;\nEND $$;\n`\n\nexport const MIGRATION_006_O_GRUPO_FISCAL_GANHA_O_RAIL = `-- O grupo fiscal ganha observação e etiqueta.\n--\n-- A tabela existe desde a 002 e nunca teve TELA: o grupo fiscal de toda conta\n-- migrada estava no banco sem porta. Estas duas colunas são o que falta para\n-- ele ter o mesmo rail de todo cadastro do produto — e a etiqueta tem uso\n-- imediato aqui, porque «Simples», «monofásico» e «substituição tributária»\n-- são recortes que nenhum campo do grupo responde e que decidem qual grupo\n-- alguém procura.\n--\n-- Idempotente.\n\nALTER TABLE public.plg_fiscalbr_tax_groups\n ADD COLUMN IF NOT EXISTS notes text,\n ADD COLUMN IF NOT EXISTS tags text[] DEFAULT '{}'::text[],\n ADD COLUMN IF NOT EXISTS unit_id uuid;\n\nCREATE INDEX IF NOT EXISTS plg_fiscalbr_tax_groups_tags_gin\n ON public.plg_fiscalbr_tax_groups USING gin (tags);\n`\n\nexport const MIGRATION_007_A_RLS_DO_DOCUMENTO_PASSA_A_SER_LEGIVEL = `-- ---------------------------------------------------------------------------\n-- 007_a_rls_do_documento_passa_a_ser_legivel.sql\n--\n-- MESMO caso da 036 do plugin-tables, e a mesma medição antes de escrever.\n--\n-- \\`check-plugin-review\\` reprovou estas tabelas com *«cria public.X com\n-- tenant_id e nunca liga row security»*. Fui ao banco: **todas têm RLS ligada,\n-- com políticas** — medido no cluster do Pertinho em 29-09:\n--\n-- plg_fiscalbr_documents ........ rls=t, 4 políticas\n-- plg_fiscalbr_document_events .. rls=t, 4\n-- plg_fiscalbr_document_orders .. rls=t, 4\n--\n-- Não há vazamento. O \\`ENABLE\\` dessas tabelas mora dentro de um\n-- \\`DO $$ … EXECUTE format(...)\\`, e o portão lê o ARQUIVO, não o banco — uma\n-- garantia que o revisor não consegue ler é uma garantia que ninguém confere.\n--\n-- As migrations que as criam JÁ FORAM APLICADAS; reescrevê-las trocaria o\n-- checksum, que é a deriva de ledger pela qual este repositório para o mundo.\n-- Então a legibilidade entra por arquivo NOVO — o portão pergunta ao SQL\n-- INTEIRO do plugin.\n--\n-- Ligar RLS já ligada é no-op: isto não muda comportamento, torna conferível o\n-- que já era verdade.\n--\n-- Idempotente e replay-safe.\n-- ---------------------------------------------------------------------------\n\nALTER TABLE public.plg_fiscalbr_documents ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.plg_fiscalbr_document_events ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.plg_fiscalbr_document_orders ENABLE ROW LEVEL SECURITY;\n`\n\nexport const MIGRATION_008_A_NOTA_RECEBIDA_GUARDA_A_DECISAO_DE_ENTRADA = `-- ---------------------------------------------------------------------------\n-- 008_a_nota_recebida_guarda_a_decisao_de_entrada.sql\n--\n-- A 002 trouxe a nota; esta guarda o que se DECIDE sobre ela.\n--\n-- O inbox do V2 já sabe tudo que o XML diz: fornecedor, item, NCM, CFOP, os 30\n-- campos de tributo. O que não tem onde morar é a decisão que o operador toma\n-- em cima da nota, e é ela que transforma documento em estoque e em dinheiro:\n--\n-- a qual PRODUTO esta linha corresponde (product_id, já existia)\n-- que TIPO de entrada ela é (entry_type)\n-- em que UNIDADE de estoque, com que FATOR (stock_unit_id, conversion_factor)\n-- em que PLANO DE CONTAS ela cai (chart_account_id)\n-- com que CUSTO o produto fica (cost_mode, unit_cost_new)\n--\n-- Sem essas colunas a tela de conciliação não tem onde salvar, e \"importar nota\n-- fiscal\" viraria um leitor de XML — que é exatamente o que o V2 tinha.\n--\n-- ── O vocabulário vem do V1 medido, não de um enum inventado ────────────────\n--\n-- \\`danfe_import_items.entry_type\\` no V1 de produção (29-09-2026), 3.402 linhas:\n--\n-- insumo 2.303 revenda 546 patrimonio 280\n-- despesa 262 servico 11 (null) 5\n--\n-- São cinco, e duas delas — \\`despesa\\` e \\`servico\\` — NÃO viram estoque. É por\n-- isso que o tipo é coluna da linha e não da nota: a mesma NF-e traz o insumo\n-- que entra no estoque e a taxa de entrega que só vira despesa.\n--\n-- \\`cost_mode\\` tem UM valor em 3.407 linhas: \\`novo\\`. A média ponderada existe na\n-- tela do V1 e ninguém nunca escolheu. Fica no CHECK por paridade — tirar a\n-- opção é decisão de produto, e o dado não autoriza ninguém a tomá-la por aqui.\n--\n-- ── Por que \\`stock_movement_id\\` na linha, e não só na nota ──────────────────\n--\n-- Uma nota pode virar estoque PARCIALMENTE: o V1 tem os status \\`partial\\` e\n-- \\`partial_stocked\\` justamente porque o operador concilia 1 de 6 itens hoje e o\n-- resto amanhã. Guardar o movimento só no cabeçalho responderia \"esta nota\n-- entrou?\" e nunca \"esta LINHA entrou?\" — e é a segunda pergunta que decide se\n-- aceitar de novo duplica saldo.\n--\n-- ── O de-para é o que faz a segunda nota ser rápida ─────────────────────────\n--\n-- \\`supplier_product_mappings\\` no V1: (CNPJ do fornecedor, código do produto no\n-- fornecedor) → produto, tipo, plano, unidade, fator. 66 linhas na Artorius, e é\n-- o que faz a nota seguinte do mesmo fornecedor chegar já vinculada. Sem ele,\n-- toda importação recomeça do zero e o ganho de importar XML desaparece.\n--\n-- A chave é (tenant, CNPJ, código) e NÃO inclui unidade: o de-para é\n-- conhecimento sobre o catálogo do fornecedor, não sobre uma loja.\n--\n-- Aditivo e idempotente. Nada aqui apaga, renomeia ou reescreve dado existente.\n-- ---------------------------------------------------------------------------\n\n-- ── A decisão, linha a linha ────────────────────────────────────────────────\n\nALTER TABLE public.plg_fiscalbr_inbox_items\n ADD COLUMN IF NOT EXISTS entry_type text,\n ADD COLUMN IF NOT EXISTS chart_account_id uuid,\n ADD COLUMN IF NOT EXISTS stock_unit_id uuid,\n -- A quantidade que entra no estoque, já convertida. Derivada\n -- (quantity_commercial × conversion_factor), mas GRAVADA: é o número que o\n -- operador viu na tela antes de aceitar, e recalcular depois com um fator que\n -- alguém editou daria outra resposta para a mesma entrada.\n ADD COLUMN IF NOT EXISTS stock_quantity numeric(18,4),\n ADD COLUMN IF NOT EXISTS conversion_factor numeric(14,4) NOT NULL DEFAULT 1,\n ADD COLUMN IF NOT EXISTS cost_mode text,\n ADD COLUMN IF NOT EXISTS unit_cost_new numeric(18,6),\n -- O movimento que ESTA linha produziu. Nulo = não entrou.\n ADD COLUMN IF NOT EXISTS stock_movement_id uuid,\n -- Divergência: chegou menos do que a nota diz. O V1 guarda quantidade, motivo\n -- e observação, e a nota continua válida — quem responde pela diferença é o\n -- fornecedor, não o estoque.\n ADD COLUMN IF NOT EXISTS shortage_quantity numeric(18,4),\n ADD COLUMN IF NOT EXISTS shortage_reason text,\n ADD COLUMN IF NOT EXISTS shortage_note text;\n\nDO $$\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_fiscalbr_inbox_items_entry_type_chk') THEN\n ALTER TABLE public.plg_fiscalbr_inbox_items\n ADD CONSTRAINT plg_fiscalbr_inbox_items_entry_type_chk\n CHECK (entry_type IS NULL OR entry_type IN ('insumo','revenda','patrimonio','despesa','servico'));\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_fiscalbr_inbox_items_cost_mode_chk') THEN\n ALTER TABLE public.plg_fiscalbr_inbox_items\n ADD CONSTRAINT plg_fiscalbr_inbox_items_cost_mode_chk\n CHECK (cost_mode IS NULL OR cost_mode IN ('novo','media'));\n END IF;\n -- Um fator zero ou negativo não converte nada: transformaria a quantidade da\n -- nota em zero estoque sem ninguém acusar. É o defeito silencioso que o\n -- FLOW.md do V1 nomeia («fator de conversão errado é silencioso»), e aqui ele\n -- deixa de ser possível.\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_fiscalbr_inbox_items_factor_chk') THEN\n ALTER TABLE public.plg_fiscalbr_inbox_items\n ADD CONSTRAINT plg_fiscalbr_inbox_items_factor_chk CHECK (conversion_factor > 0);\n END IF;\nEND $$;\n\n-- FK lógica para o plano de contas e a unidade: as duas são de OUTROS plugins\n-- (financial e inventory), e a 000 deste plugin já estabelece que FK física\n-- entre plugins é proibida — instalar ou remover um addon não reescreve o\n-- schema do vizinho. A integridade é verificada na RPC, que é quem escreve.\nCOMMENT ON COLUMN public.plg_fiscalbr_inbox_items.chart_account_id IS\n 'Logical reference to plg_financial_chart_of_accounts.id — validated by the accept RPC, not by a cross-plugin FK.';\nCOMMENT ON COLUMN public.plg_fiscalbr_inbox_items.stock_unit_id IS\n 'Logical reference to plg_inventory_measurement_units.id — validated by the accept RPC.';\nCOMMENT ON COLUMN public.plg_fiscalbr_inbox_items.stock_quantity IS\n 'quantity_commercial x conversion_factor, stored as shown on screen before accepting.';\n\nCREATE INDEX IF NOT EXISTS plg_fiscalbr_inbox_items_pending_idx\n ON public.plg_fiscalbr_inbox_items (tenant_id, inbox_document_id)\n WHERE reconciled IS NOT TRUE;\n\n-- ── A decisão no cabeçalho da nota ──────────────────────────────────────────\n\nALTER TABLE public.plg_fiscalbr_inbox_documents\n -- «itens sem plano herdarão este» — o plano da nota é padrão com HERANÇA, não\n -- obrigação por item. A frase é do V1 e a coluna existe para ela.\n ADD COLUMN IF NOT EXISTS default_chart_account_id uuid,\n -- O título a pagar que esta nota gerou. Mesma ideia do inventory_operation_id\n -- que a 002 já trouxe para o estoque: a nota aponta para as duas travessias.\n ADD COLUMN IF NOT EXISTS invoice_id uuid,\n ADD COLUMN IF NOT EXISTS stocked_at timestamptz,\n -- A natureza da operação declarada na NF-e («REM BONIFICACAO NAO\n -- CONTRIBUINTE»). O bloco de situação fiscal da conciliação mostra isso, e\n -- sem a coluna a tela mostraria um campo vazio.\n ADD COLUMN IF NOT EXISTS nature_operation text,\n ADD COLUMN IF NOT EXISTS default_location_id uuid;\n\nCOMMENT ON COLUMN public.plg_fiscalbr_inbox_documents.invoice_id IS\n 'Logical reference to plg_financial_invoices.id created by fiscalbr_accept_inbox_document.';\n\n-- ── A parcela sabe como se paga ─────────────────────────────────────────────\n--\n-- A duplicata do XML traz número, vencimento e valor. O que o operador acrescenta\n-- na tela é COMO ela se paga — e o boleto que ele anexa carrega o código de\n-- barras, que é o que a automação do V1 lê para preencher a parcela.\n\nALTER TABLE public.plg_fiscalbr_inbox_duplicates\n ADD COLUMN IF NOT EXISTS payment_type text,\n ADD COLUMN IF NOT EXISTS barcode text,\n ADD COLUMN IF NOT EXISTS pix_key text,\n ADD COLUMN IF NOT EXISTS payment_method_id uuid;\n\n-- O vocabulário é o do V1, medido em \\`danfe_payments.payment_type\\` (1.069 linhas):\n-- boleto 660 · sem_pagamento 108 · outro 107 · dinheiro 65 ·\n-- duplicata_mercantil 45 · pix 35 · cartao_credito 35 · cartao_debito 9 ·\n-- credito_loja 4 · vale_presente 1\nDO $$\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_fiscalbr_inbox_duplicates_payment_type_chk') THEN\n ALTER TABLE public.plg_fiscalbr_inbox_duplicates\n ADD CONSTRAINT plg_fiscalbr_inbox_duplicates_payment_type_chk\n CHECK (payment_type IS NULL OR payment_type IN (\n 'boleto','pix','dinheiro','cartao_credito','cartao_debito',\n 'duplicata_mercantil','credito_loja','vale_presente','sem_pagamento','outro'));\n END IF;\nEND $$;\n\n-- ── O de-para fornecedor × produto ──────────────────────────────────────────\n\nCREATE TABLE IF NOT EXISTS public.plg_fiscalbr_supplier_product_map (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n -- Só dígitos, normalizado na escrita: o XML traz o CNPJ cru e a tela do V1\n -- aceitava pontuação, então duas grafias do mesmo fornecedor viravam dois\n -- de-para e a segunda nota não achava o primeiro.\n supplier_cnpj text NOT NULL,\n supplier_product_code text NOT NULL,\n product_id uuid NOT NULL REFERENCES public.products(id) ON DELETE CASCADE,\n entry_type text,\n chart_account_id uuid,\n stock_unit_id uuid,\n conversion_factor numeric(14,4) NOT NULL DEFAULT 1,\n cost_mode text,\n -- Quantas vezes este de-para acertou. Serve para a tela mostrar de onde veio o\n -- vínculo («já usado 12 vezes») em vez de um produto que apareceu sozinho.\n hit_count integer NOT NULL DEFAULT 0,\n last_used_at timestamptz,\n owner_id uuid,\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n CONSTRAINT plg_fiscalbr_supplier_product_map_factor_chk CHECK (conversion_factor > 0),\n CONSTRAINT plg_fiscalbr_supplier_product_map_entry_type_chk\n CHECK (entry_type IS NULL OR entry_type IN ('insumo','revenda','patrimonio','despesa','servico')),\n CONSTRAINT plg_fiscalbr_supplier_product_map_cost_mode_chk\n CHECK (cost_mode IS NULL OR cost_mode IN ('novo','media'))\n);\n\nCREATE UNIQUE INDEX IF NOT EXISTS plg_fiscalbr_supplier_product_map_uq\n ON public.plg_fiscalbr_supplier_product_map (tenant_id, supplier_cnpj, supplier_product_code);\n\nALTER TABLE public.plg_fiscalbr_supplier_product_map ENABLE ROW LEVEL SECURITY;\n\n-- Mesmas políticas das tabelas do inbox (002): o de-para é conhecimento do\n-- financeiro sobre o fornecedor, então lê com \\`financial.read\\` e escreve com\n-- \\`financial.edit\\`. Não tem unit_id — é catálogo do fornecedor, não da loja.\nDROP POLICY IF EXISTS plg_fiscalbr_supplier_product_map_authz_select ON public.plg_fiscalbr_supplier_product_map;\nCREATE POLICY plg_fiscalbr_supplier_product_map_authz_select\n ON public.plg_fiscalbr_supplier_product_map FOR SELECT TO authenticated\n USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.read', NULL::uuid)));\n\nDROP POLICY IF EXISTS plg_fiscalbr_supplier_product_map_authz_write ON public.plg_fiscalbr_supplier_product_map;\nCREATE POLICY plg_fiscalbr_supplier_product_map_authz_write\n ON public.plg_fiscalbr_supplier_product_map FOR ALL TO authenticated\n USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.edit', NULL::uuid)))\n WITH CHECK (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.edit', NULL::uuid)));\n\nCOMMENT ON TABLE public.plg_fiscalbr_supplier_product_map IS\n 'Supplier catalogue memory: (CNPJ, supplier product code) -> the entry decision, so the next note from the same supplier arrives already bound. Mirrors V1 supplier_product_mappings.';\n`\n\nexport const MIGRATION_009_ACEITAR_A_NOTA_ESCREVE_ESTOQUE_E_TITULO_DE_UMA_VEZ = `-- ---------------------------------------------------------------------------\n-- 009_aceitar_a_nota_escreve_estoque_e_titulo_de_uma_vez.sql\n--\n-- É o arquivo que fecha o porte: «Aceitar» na conciliação dá entrada no estoque\n-- E cria a conta a pagar, numa transação só.\n--\n-- Medido no V1 de produção (tenant 12 da Artorius, 29-09-2026): **72 de 72**\n-- movimentos de estoque nasceram com \\`danfe_import_item_id\\`. Nenhum foi digitado.\n-- Dar ao V2 uma tela de movimentação manual e chamar isso de paridade seria\n-- entregar a tela que eles nunca abriram.\n--\n-- ── Não reimplementa estoque nem título. CHAMA quem já faz ──────────────────\n--\n-- O V2 já tem as duas primitivas, e as duas já são idempotentes:\n--\n-- public.inventory_receive(location, lines, document, idempotency_key)\n-- public.financial_create_invoice(direction, total, counterparty, …, key)\n--\n-- Reescrever INSERT em \\`plg_inventory_stock_movements\\` aqui daria uma segunda\n-- resposta para «como entra estoque» — e a posição é derivada por gatilho, então\n-- a segunda resposta sairia com saldo certo e sem ninguém acusar. A entrada de\n-- nota é mais um CHAMADOR dessas portas, como a requisição e a contagem.\n--\n-- Idempotência vem de graça daí: a chave é derivada da chave de acesso da NF-e,\n-- que é única por documento fiscal no Brasil. Aceitar duas vezes devolve o mesmo\n-- resultado sem escrever de novo — o AC7 do plano.\n--\n-- ── O total do título é a soma das PARCELAS ────────────────────────────────\n--\n-- Regra herdada da \\`plugin-financial/084\\`, que mediu: em 6,3% das notas a soma\n-- das duplicatas não bate com o total da nota. \\`plg_financial_invoices\\` é\n-- imutável depois de emitido, então um título aberto com o total errado fica\n-- com saldo errado para sempre em toda tela que faz «total − pago».\n-- O total da nota continua legível no \\`metadata\\` e na caixa de entrada.\n--\n-- E a MESMA chave de idempotência da 084 (\\`danfe:<chave>\\`): uma nota que o porte\n-- histórico já transformou em título não pode ganhar um segundo aqui.\n--\n-- ── Despesa e serviço não viram estoque ────────────────────────────────────\n--\n-- \\`entry_type\\` decide por LINHA. A mesma NF-e traz o insumo que entra no estoque\n-- e a taxa de entrega que só vira despesa — e as duas precisam ir para o mesmo\n-- título a pagar. Por isso a lista de linhas do estoque é um FILTRO sobre os\n-- itens, não os itens.\n--\n-- Idempotente. Só cria função.\n-- ---------------------------------------------------------------------------\n\n-- ── Ajudantes ──────────────────────────────────────────────────────────────\n\n-- O XML traz o CNPJ cru e a tela aceita pontuação. Duas grafias do mesmo\n-- fornecedor viravam dois de-para no V1, e a segunda nota não achava o primeiro.\nCREATE OR REPLACE FUNCTION public.plg_fiscalbr_digits(p_value text)\nRETURNS text LANGUAGE sql IMMUTABLE SET search_path TO '' AS $$\n SELECT regexp_replace(coalesce(p_value, ''), '[^0-9]', '', 'g')\n$$;\n\n-- ── Receber a nota: o XML já lido vira linhas ──────────────────────────────\n--\n-- O XML é decomposto no NAVEGADOR e chega aqui como jsonb. Parsear NF-e em\n-- PL/pgSQL seria escrever um leitor de XML dentro do banco, sem teste unitário\n-- possível e sem a mensagem de erro que a tela precisa dar para um arquivo que\n-- não é uma nota.\n\nCREATE OR REPLACE FUNCTION public.fiscalbr_stage_inbox_document(p_note jsonb)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $function$\nDECLARE\n v_tenant uuid := public.plg_financial_require_tenant();\n v_key text := btrim(coalesce(p_note->>'access_key', ''));\n v_cnpj text := public.plg_fiscalbr_digits(p_note->'supplier'->>'cnpj');\n v_doc public.plg_fiscalbr_inbox_documents%ROWTYPE;\n v_unit uuid := nullif(p_note->>'unit_id', '')::uuid;\n v_party uuid;\n v_item jsonb;\n v_map public.plg_fiscalbr_supplier_product_map%ROWTYPE;\n v_n integer := 0;\n v_matched integer := 0;\nBEGIN\n PERFORM public.plg_financial_require_permission('financial.create', v_unit);\n\n IF length(v_key) <> 44 THEN\n RAISE EXCEPTION 'fiscalbr: chave de acesso inválida (esperado 44 dígitos, veio %)', length(v_key)\n USING ERRCODE = '22023';\n END IF;\n\n SELECT * INTO v_doc FROM public.plg_fiscalbr_inbox_documents\n WHERE tenant_id = v_tenant AND access_key = v_key;\n\n -- Nota já aceita não é reimportada: o XML é o mesmo documento fiscal, e\n -- reescrever os itens apagaria a decisão que virou estoque e título.\n IF FOUND AND (v_doc.stocked_at IS NOT NULL OR v_doc.invoice_id IS NOT NULL) THEN\n RETURN jsonb_build_object(\n 'document_id', v_doc.id, 'access_key', v_key, 'already_accepted', true,\n 'invoice_id', v_doc.invoice_id, 'items', 0, 'matched', 0);\n END IF;\n\n -- O fornecedor é uma PESSOA, não um nome. A 094 do financial documenta o custo\n -- de não ter o ponteiro: praticamente todo título nascido de DANFE ficou sem\n -- contraparte. Casar por CNPJ acerta; casar por nome acertaria 137 de 554.\n IF v_cnpj <> '' THEN\n SELECT id INTO v_party FROM public.people\n WHERE tenant_id = v_tenant AND public.plg_fiscalbr_digits(document_number) = v_cnpj\n LIMIT 1;\n END IF;\n\n INSERT INTO public.plg_fiscalbr_inbox_documents (\n tenant_id, unit_id, access_key, nfe_number, nfe_series, nfe_model, issue_date,\n received_at, status, nature_operation, supplier_party_id, supplier_cnpj,\n supplier_name, supplier_ie, total_value, total_products, total_discount,\n total_freight, total_insurance, total_other, additional_info, metadata)\n VALUES (\n v_tenant, v_unit, v_key, p_note->>'nfe_number', p_note->>'nfe_series',\n coalesce(p_note->>'nfe_model', '55'), nullif(p_note->>'issue_date', '')::date,\n now(), 'pending', p_note->>'nature_operation', v_party, nullif(v_cnpj, ''),\n p_note->'supplier'->>'name', p_note->'supplier'->>'ie',\n (p_note->'totals'->>'total')::numeric, (p_note->'totals'->>'products')::numeric,\n (p_note->'totals'->>'discount')::numeric, (p_note->'totals'->>'freight')::numeric,\n (p_note->'totals'->>'insurance')::numeric, (p_note->'totals'->>'other')::numeric,\n p_note->>'additional_info',\n jsonb_build_object('source', coalesce(p_note->>'source', 'xml_upload')))\n -- O índice único da 002 é PARCIAL (\\`WHERE access_key IS NOT NULL\\`), e a\n -- inferência do ON CONFLICT só o alcança repetindo o predicado. Sem ele o\n -- INSERT levanta «no unique or exclusion constraint matching» — e levantaria\n -- na primeira reimportação, não na aplicação.\n ON CONFLICT (tenant_id, access_key) WHERE access_key IS NOT NULL DO UPDATE SET\n -- Reimportar o mesmo XML corrige o cabeçalho; a contraparte só é preenchida\n -- quando está vazia, para não desfazer uma costura feita à mão.\n nfe_number = EXCLUDED.nfe_number,\n nature_operation = coalesce(EXCLUDED.nature_operation, public.plg_fiscalbr_inbox_documents.nature_operation),\n supplier_party_id = coalesce(public.plg_fiscalbr_inbox_documents.supplier_party_id, EXCLUDED.supplier_party_id),\n supplier_name = EXCLUDED.supplier_name,\n total_value = EXCLUDED.total_value,\n updated_at = now()\n RETURNING * INTO v_doc;\n\n -- Os itens e as duplicatas são do DOCUMENTO, não do operador: vêm do XML e\n -- podem ser reescritos enquanto a nota não foi aceita.\n DELETE FROM public.plg_fiscalbr_inbox_items WHERE tenant_id = v_tenant AND inbox_document_id = v_doc.id;\n DELETE FROM public.plg_fiscalbr_inbox_duplicates WHERE tenant_id = v_tenant AND inbox_document_id = v_doc.id;\n DELETE FROM public.plg_fiscalbr_inbox_payments WHERE tenant_id = v_tenant AND inbox_document_id = v_doc.id;\n\n FOR v_item IN SELECT * FROM jsonb_array_elements(coalesce(p_note->'items', '[]'::jsonb)) LOOP\n v_n := v_n + 1;\n\n -- O de-para: a segunda nota do mesmo fornecedor chega já vinculada. É o que\n -- faz importar XML valer a pena — sem ele toda nota recomeça do zero.\n SELECT * INTO v_map FROM public.plg_fiscalbr_supplier_product_map\n WHERE tenant_id = v_tenant AND supplier_cnpj = v_cnpj\n AND supplier_product_code = coalesce(v_item->>'supplier_product_code', '');\n IF FOUND THEN v_matched := v_matched + 1; END IF;\n\n INSERT INTO public.plg_fiscalbr_inbox_items (\n tenant_id, inbox_document_id, item_number, supplier_product_code, ean_barcode,\n description, ncm, cest, cfop, unit_commercial, quantity_commercial,\n unit_price_commercial, total_price, discount, freight, other_expenses,\n icms_cst, icms_csosn, icms_base, icms_rate, icms_value,\n pis_value, cofins_value, ipi_value, raw_xml_item,\n product_id, entry_type, chart_account_id, stock_unit_id, conversion_factor,\n cost_mode, reconciled, stock_quantity, unit_cost_new)\n VALUES (\n v_tenant, v_doc.id, coalesce((v_item->>'item_number')::int, v_n),\n v_item->>'supplier_product_code', v_item->>'ean_barcode',\n v_item->>'description', v_item->>'ncm', v_item->>'cest', v_item->>'cfop',\n v_item->>'unit', (v_item->>'quantity')::numeric,\n (v_item->>'unit_price')::numeric, (v_item->>'total')::numeric,\n (v_item->>'discount')::numeric, (v_item->>'freight')::numeric,\n (v_item->>'other')::numeric,\n v_item->>'icms_cst', v_item->>'icms_csosn', (v_item->>'icms_base')::numeric,\n (v_item->>'icms_rate')::numeric, (v_item->>'icms_value')::numeric,\n (v_item->>'pis_value')::numeric, (v_item->>'cofins_value')::numeric,\n (v_item->>'ipi_value')::numeric, v_item,\n v_map.product_id, v_map.entry_type, v_map.chart_account_id, v_map.stock_unit_id,\n coalesce(v_map.conversion_factor, 1), coalesce(v_map.cost_mode, 'novo'),\n v_map.product_id IS NOT NULL,\n -- A quantidade de estoque já sai calculada quando o de-para deu o fator: é\n -- o número que a tela mostra antes de aceitar.\n CASE WHEN v_map.product_id IS NOT NULL\n THEN (v_item->>'quantity')::numeric * coalesce(v_map.conversion_factor, 1) END,\n CASE WHEN v_map.product_id IS NOT NULL AND coalesce(v_map.conversion_factor, 1) > 0\n THEN (v_item->>'unit_price')::numeric / coalesce(v_map.conversion_factor, 1) END);\n END LOOP;\n\n INSERT INTO public.plg_fiscalbr_inbox_duplicates (\n tenant_id, inbox_document_id, duplicate_index, number, due_date, value, payment_type)\n SELECT v_tenant, v_doc.id, coalesce((d->>'index')::int, ord), d->>'number',\n nullif(d->>'due_date', '')::date, (d->>'value')::numeric,\n nullif(d->>'payment_type', '')\n FROM jsonb_array_elements(coalesce(p_note->'duplicates', '[]'::jsonb)) WITH ORDINALITY AS t(d, ord);\n\n INSERT INTO public.plg_fiscalbr_inbox_payments (\n tenant_id, inbox_document_id, payment_index, payment_indicator,\n payment_type_code, payment_type_label, value)\n SELECT v_tenant, v_doc.id, coalesce((p->>'index')::int, ord), p->>'indicator',\n p->>'code', p->>'label', (p->>'value')::numeric\n FROM jsonb_array_elements(coalesce(p_note->'payments', '[]'::jsonb)) WITH ORDINALITY AS t(p, ord);\n\n RETURN jsonb_build_object(\n 'document_id', v_doc.id, 'access_key', v_key, 'already_accepted', false,\n 'supplier_party_id', v_party, 'items', v_n, 'matched', v_matched);\nEND $function$;\n\nREVOKE ALL ON FUNCTION public.fiscalbr_stage_inbox_document(jsonb) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.fiscalbr_stage_inbox_document(jsonb) TO authenticated;\n\n-- ── Salvar a conciliação sem aceitar ───────────────────────────────────────\n--\n-- Conciliar e estocar são duas coisas no V1 — daí as duas colunas na lista, e\n-- daí a fila de 55 notas conciliadas e não estocadas. Quem salva o vínculo não\n-- está mandando dar entrada.\n\nCREATE OR REPLACE FUNCTION public.fiscalbr_save_inbox_decision(\n p_document_id uuid,\n p_items jsonb,\n p_default_chart_account_id uuid DEFAULT NULL,\n p_default_location_id uuid DEFAULT NULL,\n p_duplicates jsonb DEFAULT NULL)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $function$\nDECLARE\n v_tenant uuid := public.plg_financial_require_tenant();\n v_doc public.plg_fiscalbr_inbox_documents%ROWTYPE;\n v_row jsonb;\n v_done integer := 0;\n v_total integer;\nBEGIN\n SELECT * INTO v_doc FROM public.plg_fiscalbr_inbox_documents\n WHERE tenant_id = v_tenant AND id = p_document_id;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'fiscalbr: nota % não encontrada neste tenant', p_document_id USING ERRCODE = 'P0002';\n END IF;\n PERFORM public.plg_financial_require_permission('financial.edit', v_doc.unit_id);\n\n IF v_doc.stocked_at IS NOT NULL OR v_doc.invoice_id IS NOT NULL THEN\n RAISE EXCEPTION 'fiscalbr: nota % já foi aceita — a decisão não muda depois do estoque', p_document_id\n USING ERRCODE = '23514';\n END IF;\n\n UPDATE public.plg_fiscalbr_inbox_documents SET\n default_chart_account_id = coalesce(p_default_chart_account_id, default_chart_account_id),\n default_location_id = coalesce(p_default_location_id, default_location_id),\n reviewed_at = now(), updated_at = now()\n WHERE id = v_doc.id;\n\n FOR v_row IN SELECT * FROM jsonb_array_elements(coalesce(p_items, '[]'::jsonb)) LOOP\n UPDATE public.plg_fiscalbr_inbox_items i SET\n product_id = nullif(v_row->>'product_id', '')::uuid,\n entry_type = nullif(v_row->>'entry_type', ''),\n chart_account_id = nullif(v_row->>'chart_account_id', '')::uuid,\n stock_unit_id = nullif(v_row->>'stock_unit_id', '')::uuid,\n conversion_factor = coalesce(nullif(v_row->>'conversion_factor', '')::numeric, 1),\n cost_mode = coalesce(nullif(v_row->>'cost_mode', ''), 'novo'),\n shortage_quantity = nullif(v_row->>'shortage_quantity', '')::numeric,\n shortage_reason = nullif(v_row->>'shortage_reason', ''),\n shortage_note = nullif(v_row->>'shortage_note', ''),\n -- Derivadas, gravadas: é o que o operador viu.\n stock_quantity = i.quantity_commercial * coalesce(nullif(v_row->>'conversion_factor', '')::numeric, 1)\n - coalesce(nullif(v_row->>'shortage_quantity', '')::numeric, 0),\n unit_cost_new = CASE WHEN coalesce(nullif(v_row->>'conversion_factor', '')::numeric, 1) > 0\n THEN i.unit_price_commercial / coalesce(nullif(v_row->>'conversion_factor', '')::numeric, 1) END,\n -- Uma linha está conciliada quando sabe o que é. Despesa e serviço não\n -- pedem produto: elas nunca vão virar estoque.\n reconciled = CASE\n WHEN nullif(v_row->>'entry_type', '') IN ('despesa','servico') THEN true\n ELSE nullif(v_row->>'product_id', '') IS NOT NULL\n END,\n updated_at = now()\n WHERE i.tenant_id = v_tenant AND i.inbox_document_id = v_doc.id\n AND i.id = (v_row->>'id')::uuid;\n v_done := v_done + 1;\n END LOOP;\n\n IF p_duplicates IS NOT NULL THEN\n DELETE FROM public.plg_fiscalbr_inbox_duplicates\n WHERE tenant_id = v_tenant AND inbox_document_id = v_doc.id;\n INSERT INTO public.plg_fiscalbr_inbox_duplicates (\n tenant_id, inbox_document_id, duplicate_index, number, due_date, value,\n payment_type, barcode, pix_key, payment_method_id)\n SELECT v_tenant, v_doc.id, coalesce((d->>'index')::int, ord), d->>'number',\n nullif(d->>'due_date', '')::date, (d->>'value')::numeric,\n nullif(d->>'payment_type', ''), nullif(d->>'barcode', ''),\n nullif(d->>'pix_key', ''), nullif(d->>'payment_method_id', '')::uuid\n FROM jsonb_array_elements(p_duplicates) WITH ORDINALITY AS t(d, ord);\n END IF;\n\n SELECT count(*) INTO v_total FROM public.plg_fiscalbr_inbox_items\n WHERE tenant_id = v_tenant AND inbox_document_id = v_doc.id;\n\n UPDATE public.plg_fiscalbr_inbox_documents SET status = CASE\n WHEN (SELECT count(*) FROM public.plg_fiscalbr_inbox_items\n WHERE tenant_id = v_tenant AND inbox_document_id = v_doc.id AND reconciled) = v_total\n THEN 'accepted' ELSE 'partial' END\n WHERE id = v_doc.id;\n\n RETURN jsonb_build_object('document_id', v_doc.id, 'saved', v_done, 'items', v_total,\n 'reconciled', (SELECT count(*) FROM public.plg_fiscalbr_inbox_items\n WHERE tenant_id = v_tenant AND inbox_document_id = v_doc.id AND reconciled));\nEND $function$;\n\nREVOKE ALL ON FUNCTION public.fiscalbr_save_inbox_decision(uuid, jsonb, uuid, uuid, jsonb) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.fiscalbr_save_inbox_decision(uuid, jsonb, uuid, uuid, jsonb) TO authenticated;\n\n-- ── Aceitar: estoque e título, de uma vez ──────────────────────────────────\n\nCREATE OR REPLACE FUNCTION public.fiscalbr_accept_inbox_document(\n p_document_id uuid,\n p_location_id uuid DEFAULT NULL)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $function$\nDECLARE\n v_tenant uuid := public.plg_financial_require_tenant();\n v_doc public.plg_fiscalbr_inbox_documents%ROWTYPE;\n v_loc uuid;\n v_lines jsonb := '[]'::jsonb;\n v_insts jsonb := '[]'::jsonb;\n v_recv jsonb;\n v_inv jsonb;\n v_it record;\n v_no integer := 0;\n v_total numeric := 0;\n v_stock_n integer := 0;\n v_key text;\n v_invoice uuid;\n -- Posição na lista de linhas → id do item da nota. \\`line_no\\` do movimento\n -- indexa este array direto.\n v_item_ids uuid[] := '{}';\nBEGIN\n SELECT * INTO v_doc FROM public.plg_fiscalbr_inbox_documents\n WHERE tenant_id = v_tenant AND id = p_document_id;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'fiscalbr: nota % não encontrada neste tenant', p_document_id USING ERRCODE = 'P0002';\n END IF;\n PERFORM public.plg_financial_require_permission('financial.create', v_doc.unit_id);\n\n -- Já aceita devolve o que aceitou. Não é erro: é a segunda vez que o operador\n -- clicou, e o AC7 diz que isso não pode dobrar nada.\n IF v_doc.stocked_at IS NOT NULL OR v_doc.invoice_id IS NOT NULL THEN\n RETURN jsonb_build_object('document_id', v_doc.id, 'already_accepted', true,\n 'invoice_id', v_doc.invoice_id, 'operation_id', v_doc.inventory_operation_id);\n END IF;\n\n v_loc := coalesce(p_location_id, v_doc.default_location_id);\n\n -- Toda linha tem de saber o que é. Aceitar uma nota com item sem tipo gravaria\n -- um título cujo rateio ninguém consegue explicar depois.\n IF EXISTS (SELECT 1 FROM public.plg_fiscalbr_inbox_items\n WHERE tenant_id = v_tenant AND inbox_document_id = v_doc.id\n AND (entry_type IS NULL OR entry_type = '')) THEN\n RAISE EXCEPTION 'fiscalbr: há item sem tipo de registro — conciliar antes de aceitar'\n USING ERRCODE = '23514';\n END IF;\n\n -- As linhas que viram estoque. Despesa e serviço ficam fora daqui e entram só\n -- no título.\n FOR v_it IN\n SELECT * FROM public.plg_fiscalbr_inbox_items\n WHERE tenant_id = v_tenant AND inbox_document_id = v_doc.id\n AND entry_type IN ('insumo','revenda','patrimonio')\n ORDER BY item_number\n LOOP\n IF v_it.product_id IS NULL THEN\n RAISE EXCEPTION 'fiscalbr: item % é % e não tem produto vinculado', v_it.item_number, v_it.entry_type\n USING ERRCODE = '23514';\n END IF;\n v_no := v_no + 1;\n -- A ordem desta lista É o \\`line_no\\` que o movimento vai carregar. Guardo o\n -- id da linha na mesma posição para devolver o movimento ao item sem\n -- reconsultar — e sem depender de casar por produto, que repete quando a\n -- mesma mercadoria vem em duas linhas da nota.\n v_item_ids := v_item_ids || v_it.id;\n v_lines := v_lines || jsonb_build_object(\n 'product_id', v_it.product_id,\n 'quantity', coalesce(v_it.stock_quantity, v_it.quantity_commercial * v_it.conversion_factor),\n 'unit_cost', coalesce(v_it.unit_cost_new, v_it.unit_price_commercial / v_it.conversion_factor),\n 'measurement_unit_id', v_it.stock_unit_id,\n 'notes', v_it.description);\n END LOOP;\n v_stock_n := v_no;\n\n IF v_stock_n > 0 THEN\n IF v_loc IS NULL THEN\n RAISE EXCEPTION 'fiscalbr: a nota tem % linha(s) de estoque e nenhum local de estoque escolhido', v_stock_n\n USING ERRCODE = '22023';\n END IF;\n -- A chave de acesso é única por documento fiscal no Brasil: é a chave de\n -- idempotência natural, e é ela que faz o segundo clique não dobrar saldo.\n v_recv := public.inventory_receive(\n v_loc, v_lines,\n jsonb_build_object(\n 'supplier_id', v_doc.supplier_party_id,\n 'date', v_doc.issue_date,\n 'type', 'nfe',\n 'ref', 'NF-e ' || coalesce(v_doc.nfe_number, '?'),\n 'notes', 'Entrada via DANFE NF-e ' || coalesce(v_doc.nfe_number, '?')),\n 'nfe:' || v_doc.access_key);\n\n -- Cada movimento volta para a SUA linha. Guardar só no cabeçalho responderia\n -- «esta nota entrou?» e nunca «esta linha entrou?» — e é a segunda pergunta\n -- que a conciliação parcial faz.\n --\n -- \\`line_no\\` do movimento é a posição na lista que montei acima, e\n -- \\`v_item_ids\\` está na mesma ordem: um join, não uma varredura por posição.\n UPDATE public.plg_fiscalbr_inbox_items i\n SET stock_movement_id = m.movement_id, updated_at = now()\n FROM (\n SELECT v_item_ids[(mov->>'line_no')::int] AS item_id, (mov->>'id')::uuid AS movement_id\n FROM jsonb_array_elements(coalesce(v_recv->'movements', '[]'::jsonb)) AS t(mov)\n WHERE (mov->>'line_no') IS NOT NULL\n ) m\n WHERE i.tenant_id = v_tenant AND i.id = m.item_id;\n END IF;\n\n -- ── O título ─────────────────────────────────────────────────────────────\n -- Total = soma das PARCELAS (regra da financial/084). Nota sem duplicata vira\n -- uma parcela só, no vencimento que a nota declarar ou na emissão.\n SELECT coalesce(sum(value), 0),\n coalesce(jsonb_agg(jsonb_build_object(\n 'number', coalesce(duplicate_index, 1),\n 'due_date', coalesce(due_date, v_doc.issue_date, current_date),\n 'amount', round(value, 2),\n 'metadata', jsonb_strip_nulls(jsonb_build_object(\n 'barcode', barcode, 'pixKey', pix_key, 'paymentType', payment_type,\n 'danfeNumber', number))) ORDER BY duplicate_index), '[]'::jsonb)\n INTO v_total, v_insts\n FROM public.plg_fiscalbr_inbox_duplicates\n WHERE tenant_id = v_tenant AND inbox_document_id = v_doc.id;\n\n IF jsonb_array_length(v_insts) = 0 THEN\n v_total := round(coalesce(v_doc.total_value, 0), 2);\n v_insts := jsonb_build_array(jsonb_build_object(\n 'number', 1, 'due_date', coalesce(v_doc.issue_date, current_date), 'amount', v_total));\n ELSE\n v_total := round(v_total, 2);\n END IF;\n\n -- A MESMA chave da 084: nota já transformada em título pelo porte histórico\n -- não ganha um segundo aqui.\n v_key := 'danfe:' || v_doc.access_key;\n\n v_inv := public.financial_create_invoice(\n 'payable', v_total, v_doc.supplier_party_id, NULL, v_doc.unit_id, v_insts,\n v_doc.issue_date,\n 'NF-e ' || coalesce(v_doc.nfe_number, '?') || ' · ' || coalesce(v_doc.supplier_name, 'fornecedor'),\n NULL,\n jsonb_build_object(\n 'source', 'danfe',\n 'danfe', jsonb_strip_nulls(jsonb_build_object(\n 'accessKey', v_doc.access_key, 'number', v_doc.nfe_number,\n 'series', v_doc.nfe_series, 'supplierName', v_doc.supplier_name,\n 'supplierCnpj', v_doc.supplier_cnpj,\n -- O total da NOTA continua legível, mesmo quando difere do total do\n -- título. É a diferença que a 084 mediu em 6,3% das notas.\n 'noteTotal', v_doc.total_value,\n 'inboxDocumentId', v_doc.id))),\n v_key);\n v_invoice := (v_inv->>'invoice_id')::uuid;\n\n -- As linhas do título: TODOS os itens, inclusive despesa e serviço. É o que\n -- responde «de que essa conta é feita».\n INSERT INTO public.items (tenant_id, invoice_id, name, quantity, unit_price, total, sort_order, metadata)\n SELECT v_tenant, v_invoice, i.description, i.quantity_commercial, i.unit_price_commercial,\n round(coalesce(i.total_price, 0), 2), i.item_number,\n jsonb_strip_nulls(jsonb_build_object(\n 'itemKind', i.entry_type, 'referenceId', i.product_id,\n 'chartAccountId', i.chart_account_id, 'ncm', i.ncm, 'cfop', i.cfop,\n 'supplierProductCode', i.supplier_product_code))\n FROM public.plg_fiscalbr_inbox_items i\n WHERE i.tenant_id = v_tenant AND i.inbox_document_id = v_doc.id\n ORDER BY i.item_number\n ON CONFLICT DO NOTHING;\n\n UPDATE public.plg_fiscalbr_inbox_documents SET\n invoice_id = v_invoice,\n inventory_operation_id = coalesce((v_recv->>'operation_id')::uuid, inventory_operation_id),\n stocked_at = CASE WHEN v_stock_n > 0 THEN now() ELSE stocked_at END,\n status = 'accepted', updated_at = now()\n WHERE id = v_doc.id;\n\n -- ── O de-para aprende ────────────────────────────────────────────────────\n -- A decisão desta nota é o que faz a próxima do mesmo fornecedor chegar\n -- pronta. Guardar isto é o que separa «importei XML» de «o sistema sabe o meu\n -- fornecedor».\n INSERT INTO public.plg_fiscalbr_supplier_product_map (\n tenant_id, supplier_cnpj, supplier_product_code, product_id, entry_type,\n chart_account_id, stock_unit_id, conversion_factor, cost_mode, hit_count, last_used_at)\n SELECT v_tenant, v_doc.supplier_cnpj, i.supplier_product_code, i.product_id,\n i.entry_type, coalesce(i.chart_account_id, v_doc.default_chart_account_id),\n i.stock_unit_id, i.conversion_factor, i.cost_mode, 1, now()\n FROM public.plg_fiscalbr_inbox_items i\n WHERE i.tenant_id = v_tenant AND i.inbox_document_id = v_doc.id\n AND i.product_id IS NOT NULL\n AND coalesce(v_doc.supplier_cnpj, '') <> ''\n AND coalesce(i.supplier_product_code, '') <> ''\n ON CONFLICT (tenant_id, supplier_cnpj, supplier_product_code) DO UPDATE SET\n product_id = EXCLUDED.product_id,\n entry_type = EXCLUDED.entry_type,\n chart_account_id = coalesce(EXCLUDED.chart_account_id, public.plg_fiscalbr_supplier_product_map.chart_account_id),\n stock_unit_id = coalesce(EXCLUDED.stock_unit_id, public.plg_fiscalbr_supplier_product_map.stock_unit_id),\n conversion_factor = EXCLUDED.conversion_factor,\n cost_mode = EXCLUDED.cost_mode,\n hit_count = public.plg_fiscalbr_supplier_product_map.hit_count + 1,\n last_used_at = now(),\n updated_at = now();\n\n RETURN jsonb_build_object(\n 'document_id', v_doc.id, 'already_accepted', false,\n 'invoice_id', v_invoice, 'invoice_total', v_total,\n 'installments', jsonb_array_length(v_insts),\n 'operation_id', v_recv->>'operation_id',\n 'stock_lines', v_stock_n,\n 'movements', coalesce(v_recv->'movements', '[]'::jsonb));\nEND $function$;\n\nREVOKE ALL ON FUNCTION public.fiscalbr_accept_inbox_document(uuid, uuid) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.fiscalbr_accept_inbox_document(uuid, uuid) TO authenticated;\n\n-- ── Rejeitar ───────────────────────────────────────────────────────────────\n-- O rodapé do diálogo do V1 tem dois botões, e o outro também precisa existir:\n-- recusar a nota é dizer que ela não vira nada aqui — sem apagar o documento,\n-- que é fiscal e tem de continuar legível.\n\nCREATE OR REPLACE FUNCTION public.fiscalbr_reject_inbox_document(\n p_document_id uuid, p_reason text DEFAULT NULL)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $function$\nDECLARE\n v_tenant uuid := public.plg_financial_require_tenant();\n v_doc public.plg_fiscalbr_inbox_documents%ROWTYPE;\nBEGIN\n SELECT * INTO v_doc FROM public.plg_fiscalbr_inbox_documents\n WHERE tenant_id = v_tenant AND id = p_document_id;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'fiscalbr: nota % não encontrada neste tenant', p_document_id USING ERRCODE = 'P0002';\n END IF;\n PERFORM public.plg_financial_require_permission('financial.edit', v_doc.unit_id);\n\n IF v_doc.stocked_at IS NOT NULL OR v_doc.invoice_id IS NOT NULL THEN\n RAISE EXCEPTION 'fiscalbr: nota % já virou estoque ou título — recusar não desfaz', p_document_id\n USING ERRCODE = '23514';\n END IF;\n\n UPDATE public.plg_fiscalbr_inbox_documents SET\n status = 'rejected',\n metadata = metadata || jsonb_strip_nulls(jsonb_build_object('rejectReason', p_reason)),\n reviewed_at = now(), updated_at = now()\n WHERE id = v_doc.id;\n\n RETURN jsonb_build_object('document_id', v_doc.id, 'status', 'rejected');\nEND $function$;\n\nREVOKE ALL ON FUNCTION public.fiscalbr_reject_inbox_document(uuid, text) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.fiscalbr_reject_inbox_document(uuid, text) TO authenticated;\n`\n\nexport const MIGRATION_010_A_DATA_DE_EMISSAO_E_INSTANTE_E_A_COMPETENCIA_E_DIA = `-- ---------------------------------------------------------------------------\n-- 010_a_data_de_emissao_e_instante_e_a_competencia_e_dia.sql\n--\n-- A 009 não conseguia aceitar nota nenhuma. Descoberto no primeiro teste com\n-- usuário logado de verdade, que é onde ela ia ser usada:\n--\n-- POST rpc/fiscalbr_accept_inbox_document → 404\n-- function public.financial_create_invoice(unknown, numeric, uuid, unknown,\n-- uuid, jsonb, timestamp with time zone, text, unknown, jsonb, text)\n-- does not exist\n--\n-- \\`plg_fiscalbr_inbox_documents.issue_date\\` é **timestamptz** — a NF-e declara um\n-- INSTANTE (\\`dhEmi\\`, com fuso), e a 002 preservou isso. Eu escrevi a chamada\n-- supondo \\`date\\`, e \\`financial_create_invoice\\` pede \\`p_competence_date date\\`.\n--\n-- Nenhum teste pegou porque nenhum podia: o SQL aplica, a função compila (o corpo\n-- de PL/pgSQL não resolve assinatura em CREATE), e o erro só existe na chamada.\n-- Foi preciso um usuário logado percorrendo o caminho.\n--\n-- ── Duas correções, não uma ────────────────────────────────────────────────\n--\n-- 1. \\`issue_date::date\\` onde a competência é pedida. Instante é quando a nota foi\n-- emitida; competência é o DIA a que o título pertence, e o mês do título vem\n-- dela — um \\`timestamptz\\` viajando para lá levaria fuso para dentro de uma\n-- conta contábil.\n--\n-- 2. **Todo argumento tipado na chamada.** A chamada posicional passava \\`'payable'\\`\n-- e \\`NULL\\` sem tipo, e o Postgres tem de adivinhar entre as sobrecargas. Adivinhar\n-- certo hoje e errado quando alguém acrescentar uma sobrecarga é o defeito que\n-- volta meses depois sem ninguém mexer neste arquivo. Agora vai \\`::text\\`,\n-- \\`::uuid\\`, \\`::date\\` em tudo.\n--\n-- Idempotente: só substitui o corpo de uma função.\n-- ---------------------------------------------------------------------------\n\nCREATE OR REPLACE FUNCTION public.fiscalbr_accept_inbox_document(\n p_document_id uuid,\n p_location_id uuid DEFAULT NULL)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $function$\nDECLARE\n v_tenant uuid := public.plg_financial_require_tenant();\n v_doc public.plg_fiscalbr_inbox_documents%ROWTYPE;\n v_loc uuid;\n v_lines jsonb := '[]'::jsonb;\n v_insts jsonb := '[]'::jsonb;\n v_recv jsonb;\n v_inv jsonb;\n v_it record;\n v_no integer := 0;\n v_total numeric := 0;\n v_stock_n integer := 0;\n v_key text;\n v_invoice uuid;\n v_item_ids uuid[] := '{}';\n -- A competência: o DIA da emissão, não o instante.\n v_competence date;\nBEGIN\n SELECT * INTO v_doc FROM public.plg_fiscalbr_inbox_documents\n WHERE tenant_id = v_tenant AND id = p_document_id;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'fiscalbr: nota % não encontrada neste tenant', p_document_id USING ERRCODE = 'P0002';\n END IF;\n PERFORM public.plg_financial_require_permission('financial.create', v_doc.unit_id);\n\n IF v_doc.stocked_at IS NOT NULL OR v_doc.invoice_id IS NOT NULL THEN\n RETURN jsonb_build_object('document_id', v_doc.id, 'already_accepted', true,\n 'invoice_id', v_doc.invoice_id, 'operation_id', v_doc.inventory_operation_id);\n END IF;\n\n v_loc := coalesce(p_location_id, v_doc.default_location_id);\n v_competence := (v_doc.issue_date)::date;\n\n IF EXISTS (SELECT 1 FROM public.plg_fiscalbr_inbox_items\n WHERE tenant_id = v_tenant AND inbox_document_id = v_doc.id\n AND (entry_type IS NULL OR entry_type = '')) THEN\n RAISE EXCEPTION 'fiscalbr: há item sem tipo de registro — conciliar antes de aceitar'\n USING ERRCODE = '23514';\n END IF;\n\n FOR v_it IN\n SELECT * FROM public.plg_fiscalbr_inbox_items\n WHERE tenant_id = v_tenant AND inbox_document_id = v_doc.id\n AND entry_type IN ('insumo','revenda','patrimonio')\n ORDER BY item_number\n LOOP\n IF v_it.product_id IS NULL THEN\n RAISE EXCEPTION 'fiscalbr: item % é % e não tem produto vinculado', v_it.item_number, v_it.entry_type\n USING ERRCODE = '23514';\n END IF;\n v_no := v_no + 1;\n v_item_ids := v_item_ids || v_it.id;\n v_lines := v_lines || jsonb_build_object(\n 'product_id', v_it.product_id,\n 'quantity', coalesce(v_it.stock_quantity, v_it.quantity_commercial * v_it.conversion_factor),\n 'unit_cost', coalesce(v_it.unit_cost_new, v_it.unit_price_commercial / v_it.conversion_factor),\n 'measurement_unit_id', v_it.stock_unit_id,\n 'notes', v_it.description);\n END LOOP;\n v_stock_n := v_no;\n\n IF v_stock_n > 0 THEN\n IF v_loc IS NULL THEN\n RAISE EXCEPTION 'fiscalbr: a nota tem % linha(s) de estoque e nenhum local de estoque escolhido', v_stock_n\n USING ERRCODE = '22023';\n END IF;\n v_recv := public.inventory_receive(\n v_loc::uuid,\n v_lines::jsonb,\n jsonb_build_object(\n 'supplier_id', v_doc.supplier_party_id,\n -- O documento do estoque também quer o DIA.\n 'date', v_competence,\n 'type', 'nfe',\n 'ref', 'NF-e ' || coalesce(v_doc.nfe_number, '?'),\n 'notes', 'Entrada via DANFE NF-e ' || coalesce(v_doc.nfe_number, '?')),\n ('nfe:' || v_doc.access_key)::text);\n\n UPDATE public.plg_fiscalbr_inbox_items i\n SET stock_movement_id = m.movement_id, updated_at = now()\n FROM (\n SELECT v_item_ids[(mov->>'line_no')::int] AS item_id, (mov->>'id')::uuid AS movement_id\n FROM jsonb_array_elements(coalesce(v_recv->'movements', '[]'::jsonb)) AS t(mov)\n WHERE (mov->>'line_no') IS NOT NULL\n ) m\n WHERE i.tenant_id = v_tenant AND i.id = m.item_id;\n END IF;\n\n SELECT coalesce(sum(value), 0),\n coalesce(jsonb_agg(jsonb_build_object(\n 'number', coalesce(duplicate_index, 1),\n 'due_date', coalesce(due_date, v_competence, current_date),\n 'amount', round(value, 2),\n 'metadata', jsonb_strip_nulls(jsonb_build_object(\n 'barcode', barcode, 'pixKey', pix_key, 'paymentType', payment_type,\n 'danfeNumber', number))) ORDER BY duplicate_index), '[]'::jsonb)\n INTO v_total, v_insts\n FROM public.plg_fiscalbr_inbox_duplicates\n WHERE tenant_id = v_tenant AND inbox_document_id = v_doc.id;\n\n IF jsonb_array_length(v_insts) = 0 THEN\n v_total := round(coalesce(v_doc.total_value, 0), 2);\n v_insts := jsonb_build_array(jsonb_build_object(\n 'number', 1, 'due_date', coalesce(v_competence, current_date), 'amount', v_total));\n ELSE\n v_total := round(v_total, 2);\n END IF;\n\n v_key := 'danfe:' || v_doc.access_key;\n\n -- TODO argumento tipado. A chamada posicional sem tipo obrigava o Postgres a\n -- adivinhar entre sobrecargas, e foi assim que esta função nasceu quebrada.\n v_inv := public.financial_create_invoice(\n 'payable'::text,\n v_total::numeric,\n v_doc.supplier_party_id::uuid,\n NULL::uuid,\n v_doc.unit_id::uuid,\n v_insts::jsonb,\n v_competence::date,\n ('NF-e ' || coalesce(v_doc.nfe_number, '?') || ' · ' || coalesce(v_doc.supplier_name, 'fornecedor'))::text,\n NULL::text,\n jsonb_build_object(\n 'source', 'danfe',\n 'danfe', jsonb_strip_nulls(jsonb_build_object(\n 'accessKey', v_doc.access_key, 'number', v_doc.nfe_number,\n 'series', v_doc.nfe_series, 'supplierName', v_doc.supplier_name,\n 'supplierCnpj', v_doc.supplier_cnpj,\n 'noteTotal', v_doc.total_value,\n 'inboxDocumentId', v_doc.id)))::jsonb,\n v_key::text);\n v_invoice := (v_inv->>'invoice_id')::uuid;\n\n INSERT INTO public.items (tenant_id, invoice_id, name, quantity, unit_price, total, sort_order, metadata)\n SELECT v_tenant, v_invoice, i.description, i.quantity_commercial, i.unit_price_commercial,\n round(coalesce(i.total_price, 0), 2), i.item_number,\n jsonb_strip_nulls(jsonb_build_object(\n 'itemKind', i.entry_type, 'referenceId', i.product_id,\n 'chartAccountId', i.chart_account_id, 'ncm', i.ncm, 'cfop', i.cfop,\n 'supplierProductCode', i.supplier_product_code))\n FROM public.plg_fiscalbr_inbox_items i\n WHERE i.tenant_id = v_tenant AND i.inbox_document_id = v_doc.id\n ORDER BY i.item_number\n ON CONFLICT DO NOTHING;\n\n UPDATE public.plg_fiscalbr_inbox_documents SET\n invoice_id = v_invoice,\n inventory_operation_id = coalesce((v_recv->>'operation_id')::uuid, inventory_operation_id),\n stocked_at = CASE WHEN v_stock_n > 0 THEN now() ELSE stocked_at END,\n status = 'accepted', updated_at = now()\n WHERE id = v_doc.id;\n\n INSERT INTO public.plg_fiscalbr_supplier_product_map (\n tenant_id, supplier_cnpj, supplier_product_code, product_id, entry_type,\n chart_account_id, stock_unit_id, conversion_factor, cost_mode, hit_count, last_used_at)\n SELECT v_tenant, v_doc.supplier_cnpj, i.supplier_product_code, i.product_id,\n i.entry_type, coalesce(i.chart_account_id, v_doc.default_chart_account_id),\n i.stock_unit_id, i.conversion_factor, i.cost_mode, 1, now()\n FROM public.plg_fiscalbr_inbox_items i\n WHERE i.tenant_id = v_tenant AND i.inbox_document_id = v_doc.id\n AND i.product_id IS NOT NULL\n AND coalesce(v_doc.supplier_cnpj, '') <> ''\n AND coalesce(i.supplier_product_code, '') <> ''\n ON CONFLICT (tenant_id, supplier_cnpj, supplier_product_code) DO UPDATE SET\n product_id = EXCLUDED.product_id,\n entry_type = EXCLUDED.entry_type,\n chart_account_id = coalesce(EXCLUDED.chart_account_id, public.plg_fiscalbr_supplier_product_map.chart_account_id),\n stock_unit_id = coalesce(EXCLUDED.stock_unit_id, public.plg_fiscalbr_supplier_product_map.stock_unit_id),\n conversion_factor = EXCLUDED.conversion_factor,\n cost_mode = EXCLUDED.cost_mode,\n hit_count = public.plg_fiscalbr_supplier_product_map.hit_count + 1,\n last_used_at = now(),\n updated_at = now();\n\n RETURN jsonb_build_object(\n 'document_id', v_doc.id, 'already_accepted', false,\n 'invoice_id', v_invoice, 'invoice_total', v_total,\n 'installments', jsonb_array_length(v_insts),\n 'operation_id', v_recv->>'operation_id',\n 'stock_lines', v_stock_n,\n 'movements', coalesce(v_recv->'movements', '[]'::jsonb));\nEND $function$;\n\nREVOKE ALL ON FUNCTION public.fiscalbr_accept_inbox_document(uuid, uuid) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.fiscalbr_accept_inbox_document(uuid, uuid) TO authenticated;\n`\n\nexport const MIGRATION_011_O_IMPOSTO_DO_PRODUTO_E_DO_FISCAL = `-- O imposto do PRODUTO mora no fiscal, não no estoque.\n--\n-- No V1 as dezessete colunas de tributo (NCM, CFOP, CST, PIS, COFINS, CEST, FCP\n-- e os dois campos novos da reforma) ficam numa tabela do módulo de estoque,\n-- ao lado de quantidade mínima e temperatura de armazenamento. O salão que\n-- nunca emitiu uma nota carrega as dezessete do mesmo jeito.\n--\n-- Aqui elas ficam onde o assunto é: \\`plg_fiscalbr_tax_groups\\` já guarda a\n-- mesma lista para o GRUPO fiscal, e esta tabela é o que um produto tem de\n-- diferente do grupo dele. Produto sem nada de fiscal não tem linha nenhuma, e\n-- o estoque de quem não emite nota não muda de forma.\n--\n-- \\`tax_group_id\\` é a herança e as outras colunas são a exceção: NULL aqui quer\n-- dizer «vale o que o grupo diz», não «zero». Por isso nenhuma tem DEFAULT 0 —\n-- um zero gravado afirmaria alíquota zero e sobrescreveria o grupo em silêncio.\n\nCREATE TABLE IF NOT EXISTS public.plg_fiscalbr_product_tax_info (\n product_id uuid PRIMARY KEY REFERENCES public.products(id) ON DELETE CASCADE,\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n tax_group_id uuid REFERENCES public.plg_fiscalbr_tax_groups(id) ON DELETE SET NULL,\n\n -- Nomenclatura Comum do Mercosul: é do PRODUTO, nunca do grupo. É o campo que\n -- o V1 preenche mesmo quando nenhum outro está preenchido.\n ncm text,\n cest text,\n icms_origin text,\n icms_rate numeric(9,4),\n cfop text,\n cst text,\n pis_situation text,\n pis_rate numeric(9,4),\n cofins_situation text,\n cofins_rate numeric(9,4),\n base_calculation_mode text,\n benefit_code text,\n poverty_fund_rate numeric(9,4),\n\n -- Reforma tributária. Entram agora pelo mesmo motivo que entraram no grupo:\n -- deixar de fora obrigaria a recadastrar o catálogo inteiro no dia em que\n -- IBS e CBS valerem.\n ibs_cbs_situation text,\n ibs_cbs_classification text,\n\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\n\nCREATE INDEX IF NOT EXISTS plg_fiscalbr_product_tax_info_tenant_idx\n ON public.plg_fiscalbr_product_tax_info (tenant_id);\nCREATE INDEX IF NOT EXISTS plg_fiscalbr_product_tax_info_group_idx\n ON public.plg_fiscalbr_product_tax_info (tax_group_id);\n\nALTER TABLE public.plg_fiscalbr_product_tax_info ENABLE ROW LEVEL SECURITY;\n\n-- Quem lê e escreve é quem cuida do CADASTRO do produto, não quem emite a\n-- nota: a aba Fiscal do V1 vive dentro do formulário do produto, e exigir\n-- permissão de financeiro ali esconderia a seção do estoquista que digita o\n-- NCM da mercadoria que acabou de chegar.\nDROP POLICY IF EXISTS plg_fiscalbr_product_tax_info_authz_select ON public.plg_fiscalbr_product_tax_info;\nCREATE POLICY plg_fiscalbr_product_tax_info_authz_select\n ON public.plg_fiscalbr_product_tax_info FOR SELECT TO authenticated\n USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('inventory.read', NULL::uuid)));\n\nDROP POLICY IF EXISTS plg_fiscalbr_product_tax_info_authz_write ON public.plg_fiscalbr_product_tax_info;\nCREATE POLICY plg_fiscalbr_product_tax_info_authz_write\n ON public.plg_fiscalbr_product_tax_info FOR ALL TO authenticated\n USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('inventory.edit', NULL::uuid)))\n WITH CHECK (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('inventory.edit', NULL::uuid)));\n\nGRANT ALL ON TABLE public.plg_fiscalbr_product_tax_info TO authenticated;\nGRANT ALL ON TABLE public.plg_fiscalbr_product_tax_info TO service_role;\n\nCOMMENT ON TABLE public.plg_fiscalbr_product_tax_info IS\n 'Tributos do produto — a exceção ao grupo fiscal. Espelha a aba Fiscal do cadastro de produto do V1 (product_fiscal_info). NULL herda do grupo.';\n`\n\nexport const MIGRATION_012_QUEM_E_O_PRODUTO_SE_PERGUNTA_AO_INDICE = `-- ---------------------------------------------------------------------------\n-- Quem é o produto desta linha da nota: pergunta-se ao índice, não ao modelo.\n-- ---------------------------------------------------------------------------\n-- A sugestão de IA da entrada de notas mandava o CATÁLOGO para o modelo. O V1\n-- mandava 400 produtos em todo turno (\\`.slice(0, 400)\\` sobre um \\`.limit(800)\\`\n-- sem \\`order by\\` — o corte era arbitrário, e acima de 400 produtos a function\n-- respondia «crie este produto» para produto cadastrado, sem aviso).\n--\n-- O custo disso é \\`catálogo × notas\\`. Num tenant de 171 produtos são ~3,5k\n-- tokens por nota; num distribuidor de 8.000 são ~160k e não cabe no turno. E a\n-- varredura de catálogo não é trabalho de modelo: é trabalho de índice.\n--\n-- Esta função é a pré-seleção, em TRÊS fases, e a primeira que acerta encerra a\n-- linha:\n--\n-- supplier_map o de-para do fornecedor. O que aquele CNPJ já ensinou não se\n-- pergunta de novo — e numa nota repetida isso resolve a nota\n-- inteira, então a IA nem roda.\n-- ean código de barras contra o cadastro. Igualdade, não palpite.\n-- text semelhança de texto, com os candidatos que sobraram.\n--\n-- \\`reason\\` é o que a tela usa para decidir: \\`supplier_map\\` e \\`ean\\` são VÍNCULO\n-- (liga sozinho), \\`text\\` é CANDIDATO (vai para o modelo desambiguar). Sem essa\n-- coluna a tela trataria um EAN exato como um chute de trigrama.\n--\n-- A fase de texto NÃO reimplementa busca: ela chama \\`public.global_search\\`, que\n-- já tem índice GIN de trigrama, \\`fayz_norm()\\` (tira acento, minúscula, colapsa\n-- pontuação) e a escada de pontuação da casa. Reescrever isso pior era o erro\n-- que o \\`ILIKE\\` da descrição fatiada em 24 caracteres já estava cometendo do\n-- lado do cliente — \\`name.ilike.%SACO PLASTICO 10X15 TRAN%\\` não casa acento,\n-- ordem de palavra nem abreviação, e o resultado mais comum era zero candidatos.\n--\n-- O RPC foi renomeado na 137 (\\`fayz_global_search\\` → \\`global_search\\`) e há pool\n-- em cada lado. Aqui se descobre o nome em tempo de execução, como o cliente já\n-- faz em \\`@fayz-ai/core/search\\`.\n--\n-- NÃO é SECURITY DEFINER. Roda como quem chamou, a RLS de \\`products\\` vale, e o\n-- tenant vem de \\`app.current_tenant_id()\\` — o cliente não passa tenant e por\n-- isso não pode passar o de outra conta.\n--\n-- \\`SET search_path\\` é explícito porque \\`similarity\\`/\\`word_similarity\\` moram em\n-- \\`extensions\\`: corpo com search_path vazio passa no DDL e estoura na primeira\n-- chamada.\n-- ---------------------------------------------------------------------------\n\nCREATE OR REPLACE FUNCTION public.fiscalbr_match_product_candidates(\n -- [{ \"ref\": \"<id do item>\", \"text\": \"SACO PLASTICO 10X15\", \"ean\": \"789…\", \"code\": \"ABC1\" }]\n p_queries jsonb,\n p_supplier_cnpj text DEFAULT NULL,\n p_per_query integer DEFAULT 8\n) RETURNS TABLE (\n ref text,\n product_id uuid,\n name text,\n sku text,\n unit text,\n cost numeric,\n score real,\n reason text\n)\nLANGUAGE plpgsql\nSTABLE\nSET search_path TO 'public', 'extensions', 'pg_temp'\nAS $fn$\nDECLARE\n v_tenant uuid := (SELECT app.current_tenant_id());\n v_cnpj text := public.fayz_digits(coalesce(p_supplier_cnpj, ''));\n v_barcodes boolean := to_regclass('public.plg_inventory_product_barcodes') IS NOT NULL;\n v_map boolean := to_regclass('public.plg_fiscalbr_supplier_product_map') IS NOT NULL;\n v_search text;\n v_q record;\n v_found uuid;\nBEGIN\n IF v_tenant IS NULL THEN RETURN; END IF;\n IF p_queries IS NULL OR jsonb_typeof(p_queries) <> 'array' THEN RETURN; END IF;\n p_per_query := least(greatest(coalesce(p_per_query, 8), 1), 50);\n\n v_search := CASE\n WHEN to_regprocedure('public.global_search(text,uuid,text[],integer,integer)') IS NOT NULL\n THEN 'public.global_search'\n WHEN to_regprocedure('public.fayz_global_search(text,uuid,text[],integer,integer)') IS NOT NULL\n THEN 'public.fayz_global_search'\n ELSE NULL\n END;\n\n FOR v_q IN\n SELECT e->>'ref' AS ref,\n coalesce(e->>'text', '') AS text,\n public.fayz_digits(coalesce(e->>'ean', '')) AS ean,\n nullif(btrim(coalesce(e->>'code', '')), '') AS code\n FROM jsonb_array_elements(p_queries) AS e\n WHERE e->>'ref' IS NOT NULL\n LOOP\n v_found := NULL;\n\n -- ① O de-para do fornecedor. Aprendizado da casa, e ele ganha de tudo.\n IF v_map AND v_cnpj <> '' AND v_q.code IS NOT NULL THEN\n SELECT p.id INTO v_found\n FROM public.plg_fiscalbr_supplier_product_map m\n JOIN public.products p ON p.id = m.product_id\n WHERE m.supplier_cnpj = v_cnpj\n AND m.supplier_product_code = v_q.code\n AND p.is_active\n ORDER BY m.hit_count DESC NULLS LAST, m.last_used_at DESC NULLS LAST\n LIMIT 1;\n\n IF v_found IS NOT NULL THEN\n RETURN QUERY\n SELECT v_q.ref, p.id, p.name, p.sku, p.unit, p.cost, 1.0::real, 'supplier_map'::text\n FROM public.products p WHERE p.id = v_found;\n CONTINUE;\n END IF;\n END IF;\n\n -- ② O código de barras. O produto guarda o principal em \\`metadata->>barcode\\`\n -- (no V1 era coluna) e os demais na tabela de códigos do estoque.\n IF length(v_q.ean) >= 8 THEN\n SELECT p.id INTO v_found\n FROM public.products p\n WHERE p.is_active\n AND (\n public.fayz_digits(coalesce(p.metadata->>'barcode', '')) = v_q.ean\n OR (v_barcodes AND EXISTS (\n SELECT 1 FROM public.plg_inventory_product_barcodes b\n WHERE b.product_id = p.id\n AND public.fayz_digits(coalesce(b.barcode, '')) = v_q.ean))\n )\n LIMIT 1;\n\n IF v_found IS NOT NULL THEN\n RETURN QUERY\n SELECT v_q.ref, p.id, p.name, p.sku, p.unit, p.cost, 1.0::real, 'ean'::text\n FROM public.products p WHERE p.id = v_found;\n CONTINUE;\n END IF;\n END IF;\n\n -- ③ Semelhança de texto, pela busca indexada que já existe.\n CONTINUE WHEN v_search IS NULL OR length(public.fayz_norm(v_q.text)) < 3;\n\n RETURN QUERY EXECUTE format($q$\n SELECT $4::text, p.id, p.name, p.sku, p.unit, p.cost, g.score, 'text'::text\n FROM %s($1, $2, ARRAY['product']::text[], $3, $3) g\n JOIN public.products p ON p.id = g.record_id::uuid\n WHERE p.is_active\n ORDER BY g.score DESC\n $q$, v_search)\n USING v_q.text, v_tenant, p_per_query, v_q.ref;\n END LOOP;\nEND\n$fn$;\n\nGRANT EXECUTE ON FUNCTION public.fiscalbr_match_product_candidates(jsonb, text, integer) TO authenticated;\n\nCOMMENT ON FUNCTION public.fiscalbr_match_product_candidates(jsonb, text, integer) IS\n 'Pré-seleção de produtos para a sugestão de IA da entrada de notas: de-para do fornecedor e EAN resolvem sozinhos (reason supplier_map/ean, vínculo direto); o resto volta como candidatos de texto (reason text) para o modelo desambiguar. Uma viagem para a nota inteira — substitui o despejo de 400 produtos do V1 e o ILIKE da descrição fatiada.';\n`\n\nexport const MIGRATION_013_A_SUGESTAO_SOBREVIVE_A_FECHAR_A_JANELA = `-- ---------------------------------------------------------------------------\n-- A sugestão sobrevive a fechar a janela — e diz QUEM escreveu no campo.\n-- ---------------------------------------------------------------------------\n-- A conciliação virou modal para a lista não ser desmontada. O efeito colateral\n-- é que o modal fecha com clique fora e com Esc, e a sugestão só existia em\n-- memória até alguém clicar em «Salvar conciliação»:\n--\n-- sugerir (30s de IA) → revisar 7 de 11 itens → clique fora → perdeu os 11\n--\n-- No V1 fechar não custava nada, porque a edge function já tinha gravado. Então\n-- a V2 grava também, logo depois da sugestão, pelo caminho que já existia\n-- (\\`fiscalbr_save_inbox_decision\\`, que não marca \\`reconciled\\`) — nenhum segundo\n-- caminho de escrita, nenhuma coluna \\`ai_*\\` paralela aos campos de verdade.\n--\n-- O que faltava era a MEMÓRIA DE AUTORIA. Depois de recarregar, a tela via um\n-- fator 22,056 e não sabia se foi a Laissa que digitou ou a máquina que chutou —\n-- e as duas coisas se conferem de maneiras diferentes.\n--\n-- O V1 guardava um booleano, \\`ai_suggested\\`. Aqui é texto, porque a V2 tem TRÊS\n-- autores de máquina e eles não merecem o mesmo selo:\n--\n-- supplier_map o de-para deste fornecedor. Igualdade, e aprendizado da casa.\n-- ean código de barras. Igualdade.\n-- ai semelhança julgada por modelo. É o único que se confere item\n-- a item, e \\`suggestion_confidence\\` diz o quanto.\n--\n-- NULL é a resposta mais importante: significa «uma pessoa escolheu isto». O\n-- cliente limpa a coluna em qualquer edição do campo, então o selo nunca mente\n-- sobre um valor que alguém corrigiu à mão.\n-- ---------------------------------------------------------------------------\n\nALTER TABLE public.plg_fiscalbr_inbox_items\n ADD COLUMN IF NOT EXISTS suggested_by text,\n ADD COLUMN IF NOT EXISTS suggestion_confidence numeric;\n\nDO $$\nBEGIN\n IF NOT EXISTS (\n SELECT 1 FROM pg_constraint\n WHERE conname = 'plg_fiscalbr_inbox_items_suggested_by_check'\n AND conrelid = 'public.plg_fiscalbr_inbox_items'::regclass\n ) THEN\n ALTER TABLE public.plg_fiscalbr_inbox_items\n ADD CONSTRAINT plg_fiscalbr_inbox_items_suggested_by_check\n CHECK (suggested_by IS NULL OR suggested_by IN ('ai', 'ean', 'supplier_map'));\n END IF;\nEND $$;\n\nCOMMENT ON COLUMN public.plg_fiscalbr_inbox_items.suggested_by IS\n 'Quem escreveu a decisão desta linha: ai (modelo, confira), ean / supplier_map (igualdade), NULL (uma pessoa). O cliente limpa em qualquer edição manual.';\nCOMMENT ON COLUMN public.plg_fiscalbr_inbox_items.suggestion_confidence IS\n '0..1, só para suggested_by = ai. Abaixo de 0,6 a tela marca a linha como «confira».';\n\n-- ── A gravação da decisão passa a carregar a autoria ───────────────────────\n-- Mesma assinatura, então é \\`CREATE OR REPLACE\\` e nenhum chamador muda. O corpo\n-- é o da 009 com duas linhas a mais: o resto fica palavra por palavra, porque\n-- esta função é o único caminho de escrita da decisão e reescrevê-la de memória\n-- é como se perde uma regra (a de \\`reconciled\\`, por exemplo).\n\nCREATE OR REPLACE FUNCTION public.fiscalbr_save_inbox_decision(\n p_document_id uuid,\n p_items jsonb,\n p_default_chart_account_id uuid DEFAULT NULL,\n p_default_location_id uuid DEFAULT NULL,\n p_duplicates jsonb DEFAULT NULL)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $function$\nDECLARE\n v_tenant uuid := public.plg_financial_require_tenant();\n v_doc public.plg_fiscalbr_inbox_documents%ROWTYPE;\n v_row jsonb;\n v_done integer := 0;\n v_total integer;\nBEGIN\n SELECT * INTO v_doc FROM public.plg_fiscalbr_inbox_documents\n WHERE tenant_id = v_tenant AND id = p_document_id;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'fiscalbr: nota % não encontrada neste tenant', p_document_id USING ERRCODE = 'P0002';\n END IF;\n PERFORM public.plg_financial_require_permission('financial.edit', v_doc.unit_id);\n\n IF v_doc.stocked_at IS NOT NULL OR v_doc.invoice_id IS NOT NULL THEN\n RAISE EXCEPTION 'fiscalbr: nota % já foi aceita — a decisão não muda depois do estoque', p_document_id\n USING ERRCODE = '23514';\n END IF;\n\n UPDATE public.plg_fiscalbr_inbox_documents SET\n default_chart_account_id = coalesce(p_default_chart_account_id, default_chart_account_id),\n default_location_id = coalesce(p_default_location_id, default_location_id),\n reviewed_at = now(), updated_at = now()\n WHERE id = v_doc.id;\n\n FOR v_row IN SELECT * FROM jsonb_array_elements(coalesce(p_items, '[]'::jsonb)) LOOP\n UPDATE public.plg_fiscalbr_inbox_items i SET\n product_id = nullif(v_row->>'product_id', '')::uuid,\n entry_type = nullif(v_row->>'entry_type', ''),\n chart_account_id = nullif(v_row->>'chart_account_id', '')::uuid,\n stock_unit_id = nullif(v_row->>'stock_unit_id', '')::uuid,\n conversion_factor = coalesce(nullif(v_row->>'conversion_factor', '')::numeric, 1),\n cost_mode = coalesce(nullif(v_row->>'cost_mode', ''), 'novo'),\n shortage_quantity = nullif(v_row->>'shortage_quantity', '')::numeric,\n shortage_reason = nullif(v_row->>'shortage_reason', ''),\n shortage_note = nullif(v_row->>'shortage_note', ''),\n -- Quem escreveu. Ausente na chave é NULL, que é «uma pessoa escolheu» — e é\n -- o que acontece em todo salvamento comum, sem a IA ter passado por aqui.\n suggested_by = nullif(v_row->>'suggested_by', ''),\n suggestion_confidence = nullif(v_row->>'suggestion_confidence', '')::numeric,\n -- Derivadas, gravadas: é o que o operador viu.\n stock_quantity = i.quantity_commercial * coalesce(nullif(v_row->>'conversion_factor', '')::numeric, 1)\n - coalesce(nullif(v_row->>'shortage_quantity', '')::numeric, 0),\n unit_cost_new = CASE WHEN coalesce(nullif(v_row->>'conversion_factor', '')::numeric, 1) > 0\n THEN i.unit_price_commercial / coalesce(nullif(v_row->>'conversion_factor', '')::numeric, 1) END,\n -- Uma linha está conciliada quando sabe o que é. Despesa e serviço não\n -- pedem produto: elas nunca vão virar estoque.\n reconciled = CASE\n WHEN nullif(v_row->>'entry_type', '') IN ('despesa','servico') THEN true\n ELSE nullif(v_row->>'product_id', '') IS NOT NULL\n END,\n updated_at = now()\n WHERE i.tenant_id = v_tenant AND i.inbox_document_id = v_doc.id\n AND i.id = (v_row->>'id')::uuid;\n v_done := v_done + 1;\n END LOOP;\n\n IF p_duplicates IS NOT NULL THEN\n DELETE FROM public.plg_fiscalbr_inbox_duplicates\n WHERE tenant_id = v_tenant AND inbox_document_id = v_doc.id;\n INSERT INTO public.plg_fiscalbr_inbox_duplicates (\n tenant_id, inbox_document_id, duplicate_index, number, due_date, value,\n payment_type, barcode, pix_key, payment_method_id)\n SELECT v_tenant, v_doc.id, coalesce((d->>'index')::int, ord), d->>'number',\n nullif(d->>'due_date', '')::date, (d->>'value')::numeric,\n nullif(d->>'payment_type', ''), nullif(d->>'barcode', ''),\n nullif(d->>'pix_key', ''), nullif(d->>'payment_method_id', '')::uuid\n FROM jsonb_array_elements(p_duplicates) WITH ORDINALITY AS t(d, ord);\n END IF;\n\n SELECT count(*) INTO v_total FROM public.plg_fiscalbr_inbox_items\n WHERE tenant_id = v_tenant AND inbox_document_id = v_doc.id;\n\n UPDATE public.plg_fiscalbr_inbox_documents SET status = CASE\n WHEN (SELECT count(*) FROM public.plg_fiscalbr_inbox_items\n WHERE tenant_id = v_tenant AND inbox_document_id = v_doc.id AND reconciled) = v_total\n THEN 'accepted' ELSE 'partial' END\n WHERE id = v_doc.id;\n\n RETURN jsonb_build_object('document_id', v_doc.id, 'saved', v_done, 'items', v_total,\n 'reconciled', (SELECT count(*) FROM public.plg_fiscalbr_inbox_items\n WHERE tenant_id = v_tenant AND inbox_document_id = v_doc.id AND reconciled));\nEND $function$;\n\nREVOKE ALL ON FUNCTION public.fiscalbr_save_inbox_decision(uuid, jsonb, uuid, uuid, jsonb) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.fiscalbr_save_inbox_decision(uuid, jsonb, uuid, uuid, jsonb) TO authenticated;\n`\n\nexport const MIGRATION_014_A_DESCRICAO_DA_NOTA_TEM_LIXO_NO_MEIO = `-- ---------------------------------------------------------------------------\n-- A descrição da nota tem lixo no meio, e um token de lixo matava a busca.\n-- ---------------------------------------------------------------------------\n-- Caso real, primeira nota de verdade: o item «20X35 AR VACUO» voltou SEM\n-- candidato, e o produto \\`EMBALAGEM VACUO 20X35\\` está cadastrado.\n--\n-- \\`public.global_search\\` exige que TODOS os tokens apareçam no haystack — é a\n-- regra certa para gente digitando («joão silva» não deve trazer todos os joãos)\n-- e é a regra errada para descrição de NF-e. Os tokens de \\`20X35 AR VACUO\\` são\n-- \\`20x35\\`, \\`ar\\`, \\`vacuo\\`; o produto é \\`embalagem vacuo 20x35\\`. Dois casam. \\`ar\\`\n-- não aparece em lugar nenhum, e o AND derruba a linha inteira.\n--\n-- A segunda fase (\\`word_similarity\\`) também não salva: ela compara a frase\n-- inteira contra o haystack, e com um terço da frase sendo ruído a semelhança\n-- fica abaixo do limiar.\n--\n-- Descrição de nota fiscal é assim por natureza: abreviada, em ordem própria, com\n-- sigla de unidade, micragem e palavra solta no meio. Então o que entra na busca\n-- deixa de ser a frase e passa a ser O QUE NELA DISTINGUE:\n--\n-- ① tokens de 3 letras ou mais. «ar», «kg», «un», «c/», «p/» saem. «20x35»\n-- e «vacuo» ficam — e são eles que identificam o produto.\n-- ② se mesmo assim não vier nada, UMA última tentativa com o token mais longo\n-- sozinho. É o recuo que acha «embalagem a vácuo 15 x 25» a partir de\n-- «VACUO», para a pessoa escolher entre poucos em vez de nenhum.\n--\n-- Não se mexe em \\`global_search\\`: lá a regra está certa para quem ela serve.\n-- ---------------------------------------------------------------------------\n\nCREATE OR REPLACE FUNCTION public.fiscalbr_match_product_candidates(\n p_queries jsonb,\n p_supplier_cnpj text DEFAULT NULL,\n p_per_query integer DEFAULT 8\n) RETURNS TABLE (\n ref text,\n product_id uuid,\n name text,\n sku text,\n unit text,\n cost numeric,\n score real,\n reason text\n)\nLANGUAGE plpgsql\nSTABLE\nSET search_path TO 'public', 'extensions', 'pg_temp'\nAS $fn$\nDECLARE\n v_tenant uuid := (SELECT app.current_tenant_id());\n v_cnpj text := public.fayz_digits(coalesce(p_supplier_cnpj, ''));\n v_barcodes boolean := to_regclass('public.plg_inventory_product_barcodes') IS NOT NULL;\n v_map boolean := to_regclass('public.plg_fiscalbr_supplier_product_map') IS NOT NULL;\n v_search text;\n v_q record;\n v_found uuid;\n v_tokens text[];\n v_strong text[];\n v_busca text;\n v_recuo text;\nBEGIN\n IF v_tenant IS NULL THEN RETURN; END IF;\n IF p_queries IS NULL OR jsonb_typeof(p_queries) <> 'array' THEN RETURN; END IF;\n p_per_query := least(greatest(coalesce(p_per_query, 8), 1), 50);\n\n v_search := CASE\n WHEN to_regprocedure('public.global_search(text,uuid,text[],integer,integer)') IS NOT NULL\n THEN 'public.global_search'\n WHEN to_regprocedure('public.fayz_global_search(text,uuid,text[],integer,integer)') IS NOT NULL\n THEN 'public.fayz_global_search'\n ELSE NULL\n END;\n\n FOR v_q IN\n SELECT e->>'ref' AS ref,\n coalesce(e->>'text', '') AS text,\n public.fayz_digits(coalesce(e->>'ean', '')) AS ean,\n nullif(btrim(coalesce(e->>'code', '')), '') AS code\n FROM jsonb_array_elements(p_queries) AS e\n WHERE e->>'ref' IS NOT NULL\n LOOP\n v_found := NULL;\n\n -- ① O de-para do fornecedor. Aprendizado da casa, e ele ganha de tudo.\n IF v_map AND v_cnpj <> '' AND v_q.code IS NOT NULL THEN\n SELECT p.id INTO v_found\n FROM public.plg_fiscalbr_supplier_product_map m\n JOIN public.products p ON p.id = m.product_id\n WHERE m.supplier_cnpj = v_cnpj\n AND m.supplier_product_code = v_q.code\n AND p.is_active\n ORDER BY m.hit_count DESC NULLS LAST, m.last_used_at DESC NULLS LAST\n LIMIT 1;\n\n IF v_found IS NOT NULL THEN\n RETURN QUERY\n SELECT v_q.ref, p.id, p.name, p.sku, p.unit, p.cost, 1.0::real, 'supplier_map'::text\n FROM public.products p WHERE p.id = v_found;\n CONTINUE;\n END IF;\n END IF;\n\n -- ② O código de barras. «SEM GTIN» é o que a NF-e escreve quando não há — e\n -- \\`fayz_digits\\` o reduz a vazio, então o teste de comprimento já o barra.\n IF length(v_q.ean) >= 8 THEN\n SELECT p.id INTO v_found\n FROM public.products p\n WHERE p.is_active\n AND (\n public.fayz_digits(coalesce(p.metadata->>'barcode', '')) = v_q.ean\n OR (v_barcodes AND EXISTS (\n SELECT 1 FROM public.plg_inventory_product_barcodes b\n WHERE b.product_id = p.id\n AND public.fayz_digits(coalesce(b.barcode, '')) = v_q.ean))\n )\n LIMIT 1;\n\n IF v_found IS NOT NULL THEN\n RETURN QUERY\n SELECT v_q.ref, p.id, p.name, p.sku, p.unit, p.cost, 1.0::real, 'ean'::text\n FROM public.products p WHERE p.id = v_found;\n CONTINUE;\n END IF;\n END IF;\n\n -- ③ Semelhança de texto, com o que DISTINGUE em vez da frase inteira.\n CONTINUE WHEN v_search IS NULL;\n\n v_tokens := array_remove(string_to_array(public.fayz_norm(v_q.text), ' '), '');\n SELECT array_agg(t ORDER BY length(t) DESC) INTO v_strong\n FROM unnest(v_tokens) t WHERE length(t) >= 3;\n\n v_busca := array_to_string(coalesce(v_strong, v_tokens), ' ');\n -- O recuo é o token mais longo sozinho: numa descrição de nota ele é quase\n -- sempre o nome da coisa, e os outros são medida, micragem ou conjunção.\n v_recuo := coalesce(v_strong[1], '');\n\n CONTINUE WHEN length(v_busca) < 3;\n\n RETURN QUERY EXECUTE format($q$\n WITH forte AS (\n SELECT g.record_id, g.score FROM %1$s($1, $2, ARRAY['product']::text[], $3, $3) g\n ),\n -- Só quando a busca forte não achou nada: um recuo que rodasse sempre\n -- encheria a lista de parecidos e empurraria o certo para fora do teto.\n fraco AS (\n SELECT g.record_id, g.score * 0.7 AS score\n FROM %1$s($4, $2, ARRAY['product']::text[], $3, $3) g\n WHERE $4 <> '' AND $4 <> $1 AND NOT EXISTS (SELECT 1 FROM forte)\n )\n SELECT $5::text, p.id, p.name, p.sku, p.unit, p.cost, g.score::real, 'text'::text\n FROM (SELECT * FROM forte UNION ALL SELECT * FROM fraco) g\n JOIN public.products p ON p.id = g.record_id::uuid\n WHERE p.is_active\n ORDER BY g.score DESC\n $q$, v_search)\n USING v_busca, v_tenant, p_per_query, v_recuo, v_q.ref;\n END LOOP;\nEND\n$fn$;\n\nGRANT EXECUTE ON FUNCTION public.fiscalbr_match_product_candidates(jsonb, text, integer) TO authenticated;\n\nCOMMENT ON FUNCTION public.fiscalbr_match_product_candidates(jsonb, text, integer) IS\n 'Pré-seleção de produtos para a sugestão de IA da entrada de notas: de-para do fornecedor e EAN resolvem sozinhos (reason supplier_map/ean); o resto volta como candidatos de texto. A busca usa os tokens de 3+ letras da descrição, não a frase — «20X35 AR VACUO» achava nada porque o AND de global_search exige o token «ar», que não existe em «embalagem vacuo 20x35».';\n`\n\nexport const MIGRATION_015_O_MOVIMENTO_DIZ_QUE_VEIO_DA_NOTA = `-- ---------------------------------------------------------------------------\n-- O movimento de estoque diz, por escrito, que veio desta nota.\n-- ---------------------------------------------------------------------------\n-- A aceitação já mandava a frase para \\`inventory_receive\\`, na chave \\`notes\\` do\n-- cabeçalho — e ela nunca chegava: lá dentro o \\`coalesce(v_ln.notes, …)\\` dá a vez\n-- à anotação DA LINHA, que aqui é sempre a descrição do item. A tela de\n-- Movimentações então escrevia «Sem motivo registrado» sobre a entrada mais\n-- rastreável que existe.\n--\n-- \\`inventory/021\\` abriu a chave \\`reason\\` no cabeçalho. Esta migration passa a\n-- usá-la, com a mesma frase que as linhas migradas do V1 carregam — para que a\n-- entrada de ontem e a de hoje se leiam igual no mesmo razão.\n--\n-- Só a chamada muda. O resto do corpo é o da 009, palavra por palavra: esta\n-- função escreve estoque e título na mesma transação, e reescrevê-la de memória\n-- é como se perde uma regra.\n-- ---------------------------------------------------------------------------\n\nDO $m$\nDECLARE\n v_def text;\n v_antes text := E' ''ref'', ''NF-e '' || coalesce(v_doc.nfe_number, ''?''),\\\\n ''notes'', ''Entrada via DANFE NF-e '' || coalesce(v_doc.nfe_number, ''?'')),';\n v_depois text := E' ''ref'', ''NF-e '' || coalesce(v_doc.nfe_number, ''?''),\\\\n ''notes'', ''Entrada via DANFE NF-e '' || coalesce(v_doc.nfe_number, ''?''),\\\\n ''reason'', ''Entrada via DANFE NF-e '' || coalesce(v_doc.nfe_number, ''?'')),';\nBEGIN\n SELECT pg_get_functiondef(p.oid) INTO v_def\n FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace\n WHERE n.nspname = 'public' AND p.proname = 'fiscalbr_accept_inbox_document';\n\n IF v_def IS NULL THEN\n RAISE NOTICE 'fiscalbr: accept ainda não existe neste pool — 009 roda antes e já traz o reason';\n RETURN;\n END IF;\n\n -- Já aplicada: sair sem tocar em nada é o que torna esta migration repetível.\n IF position('''reason'', ''Entrada via DANFE' in v_def) > 0 THEN RETURN; END IF;\n\n IF position(v_antes in v_def) = 0 THEN\n RAISE EXCEPTION 'fiscalbr/015: o corpo de fiscalbr_accept_inbox_document não é o esperado — confira à mão em vez de deixar passar calado';\n END IF;\n\n EXECUTE replace(v_def, v_antes, v_depois);\nEND $m$;\n`\n\nexport const MIGRATION_016_A_NOTA_GUARDA_O_PROPRIO_XML = `-- ---------------------------------------------------------------------------\n-- A nota guarda o próprio XML, em vez de lê-lo e jogar fora.\n-- ---------------------------------------------------------------------------\n-- O V1 abre o XML e o DANFE direto da lista de notas. A V2 mostrava os dois\n-- botões desligados porque não tinha o arquivo: das 889 notas deste cluster,\n-- ZERO têm \\`document_id\\`, zero têm bytes em \\`documents\\`, zero têm nada no\n-- metadata.\n--\n-- E não é que o arquivo nunca tenha passado por aqui — ele passa INTEIRO pela\n-- mão do estágio: o navegador lê o \\`.xml\\` que a pessoa arrastou, o parser extrai\n-- itens, parcelas e totais, e os bytes são descartados no fim da função. A nota\n-- fiscal é documento com guarda legal de cinco anos, e a única cópia ficava na\n-- pasta de downloads de quem importou.\n--\n-- \\`xml_content text\\` é o mesmo nome e o mesmo tipo do \\`dfe_inbox.xml_content\\` do\n-- V1 — a ponte da 003 já citava essa coluna, e repetir o nome é o que faz uma\n-- futura migração de dados ser um \\`INSERT ... SELECT\\` em vez de um de-para.\n--\n-- Guardar no banco e não no bucket, de propósito: são 10–50 KB de texto por nota\n-- e são o ÚNICO conteúdo que torna a linha auditável. Storage é outro sistema,\n-- com outra vida útil e outra política de acesso, e um XML fiscal que vive em\n-- bucket é um XML que alguém apaga limpando bucket.\n--\n-- Isto NÃO preenche as 889 notas antigas: o que não foi guardado não se inventa.\n-- A tela desliga o botão por nota, não por feature.\n-- ---------------------------------------------------------------------------\n\nALTER TABLE public.plg_fiscalbr_inbox_documents\n ADD COLUMN IF NOT EXISTS xml_content text;\n\nCOMMENT ON COLUMN public.plg_fiscalbr_inbox_documents.xml_content IS\n 'O XML autorizado da NF-e, como chegou. Nulo nas notas importadas antes desta coluna existir — o que não foi guardado não se inventa.';\n\n-- ── O estágio passa a guardar o que já tinha em mão ────────────────────────\n-- Patch sobre o corpo que está no cluster, e não reescrita: esta função insere\n-- documento, itens, duplicatas e pagamentos numa transação só, e reescrevê-la de\n-- memória é como se perde uma regra.\n\nDO $m$\nDECLARE\n v_def text;\n v_cols_antes text := E' total_freight, total_insurance, total_other, additional_info, metadata)';\n v_cols_depois text := E' total_freight, total_insurance, total_other, additional_info, metadata, xml_content)';\n v_vals_antes text := E' jsonb_build_object(''source'', coalesce(p_note->>''source'', ''xml_upload'')))';\n v_vals_depois text := E' jsonb_build_object(''source'', coalesce(p_note->>''source'', ''xml_upload'')),' || E'\\\\n' ||\n E' nullif(p_note->>''xml'', ''''))';\n v_upd_antes text := E' total_value = EXCLUDED.total_value,';\n v_upd_depois text := E' total_value = EXCLUDED.total_value,' || E'\\\\n' ||\n E' -- Reimportar com o arquivo preenche o que faltava; reimportar sem ele' || E'\\\\n' ||\n E' -- não apaga o que já estava guardado.' || E'\\\\n' ||\n E' xml_content = coalesce(EXCLUDED.xml_content, public.plg_fiscalbr_inbox_documents.xml_content),';\nBEGIN\n SELECT pg_get_functiondef(p.oid) INTO v_def\n FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace\n WHERE n.nspname = 'public' AND p.proname = 'fiscalbr_stage_inbox_document';\n\n IF v_def IS NULL THEN\n RAISE NOTICE 'fiscalbr/016: o estágio ainda não existe neste pool — a 009 roda antes';\n RETURN;\n END IF;\n\n -- Já aplicada: sair sem tocar em nada é o que torna esta migration repetível.\n IF position('xml_content' in v_def) > 0 THEN RETURN; END IF;\n\n IF position(v_cols_antes in v_def) = 0\n OR position(v_vals_antes in v_def) = 0\n OR position(v_upd_antes in v_def) = 0 THEN\n RAISE EXCEPTION 'fiscalbr/016: o corpo de fiscalbr_stage_inbox_document não é o esperado — confira à mão em vez de deixar passar calado';\n END IF;\n\n v_def := replace(v_def, v_cols_antes, v_cols_depois);\n v_def := replace(v_def, v_vals_antes, v_vals_depois);\n v_def := replace(v_def, v_upd_antes, v_upd_depois);\n EXECUTE v_def;\nEND $m$;\n`\n\nexport const MIGRATION_017_QUEM_EMITE_E_O_CNPJ_NAO_A_LOJA = `-- Quem emite é o CNPJ, não a loja.\n--\n-- As abas \"Certificado\" e \"Emissão Fiscal\" do cadastro de empresa do V1\n-- guardam, em companies, como aquela pessoa jurídica emite NFS-e e NFC-e e se\n-- recebe DANFes da SEFAZ. No V2 a pessoa jurídica é\n-- public.company_legal_entities (base), e a filial chega nela por\n-- app.units.company_legal_entity_id (168). Esta tabela é uma linha por pessoa\n-- jurídica:\n--\n-- unidade → app.units.company_legal_entity_id → company_legal_entities.id\n-- → plg_fiscalbr_emitter_settings.legal_entity_id (UNIQUE)\n--\n-- Duas filiais com o mesmo CNPJ dividem a mesma configuração, como no V1.\n--\n-- O certificado A1 NÃO mora aqui. O .pfx e a senha vão direto ao provedor\n-- (PlugNotas) e o que fica é o recibo: id no provedor, titular e validade. O\n-- CSC da NFC-e é segredo — o navegador grava, mas não lê de volta: a coluna\n-- fica fora do GRANT SELECT e a tela vê só \\`nfce_csc_token_set\\`.\n--\n-- Plano aprovado em docs/port/empresa.schema.json. Idempotente.\n\nCREATE TABLE IF NOT EXISTS public.plg_fiscalbr_emitter_settings (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n legal_entity_id uuid NOT NULL,\n\n nfse_enabled boolean NOT NULL DEFAULT false,\n nfse_production boolean NOT NULL DEFAULT false,\n nfse_national boolean NOT NULL DEFAULT false,\n nfse_service_code_national text,\n nfse_service_code_municipal text,\n nfse_nbs_code text,\n nfse_cnae text,\n nfse_iss_rate numeric(5,2) CHECK (nfse_iss_rate IS NULL OR (nfse_iss_rate >= 0 AND nfse_iss_rate <= 100)),\n nfse_iss_taxation smallint,\n nfse_iss_exigibility smallint,\n nfse_ibscbs_cst text,\n nfse_ibscbs_classification text,\n nfse_simples_regime smallint,\n nfse_net_value boolean NOT NULL DEFAULT false,\n nfse_additional_info text,\n nfse_city_features jsonb,\n\n nfce_enabled boolean NOT NULL DEFAULT false,\n nfce_production boolean NOT NULL DEFAULT false,\n nfce_series integer CHECK (nfce_series IS NULL OR nfce_series > 0),\n nfce_csc_id text,\n nfce_csc_token text,\n nfce_csc_token_set boolean GENERATED ALWAYS AS (nfce_csc_token IS NOT NULL AND nfce_csc_token <> '') STORED,\n nfce_generic_item jsonb,\n\n nfe_enabled boolean NOT NULL DEFAULT false,\n\n certificate_provider_id text,\n certificate_cn text,\n certificate_expires_at timestamptz,\n certificate_uploaded_at timestamptz,\n\n dfe_sync_enabled boolean NOT NULL DEFAULT false,\n dfe_sync_cursor text,\n dfe_last_sync_at timestamptz,\n provider_synced_at timestamptz,\n\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n UNIQUE (tenant_id, id)\n);\n\nDO $$\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_fiscalbr_emitter_settings_legal_entity_fkey') THEN\n -- company_legal_entities é da base, não de outro plugin: a FK física é\n -- permitida (a regra do 000 proíbe FK entre PLUGINS).\n ALTER TABLE ONLY public.plg_fiscalbr_emitter_settings\n ADD CONSTRAINT plg_fiscalbr_emitter_settings_legal_entity_fkey\n FOREIGN KEY (legal_entity_id) REFERENCES public.company_legal_entities(id) ON DELETE CASCADE;\n END IF;\nEND $$;\n\nCREATE UNIQUE INDEX IF NOT EXISTS plg_fiscalbr_emitter_settings_legal_entity_uidx\n ON public.plg_fiscalbr_emitter_settings USING btree (legal_entity_id);\n\nCOMMENT ON TABLE public.plg_fiscalbr_emitter_settings IS\n 'V1: companies (abas Certificado + Emissão Fiscal). Como uma pessoa jurídica emite NFS-e/NFC-e e recebe DANFes. Uma linha por CNPJ; a filial chega por app.units.company_legal_entity_id.';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.legal_entity_id IS 'A chave que conecta: company_legal_entities.id. UNIQUE.';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.nfse_production IS 'V1: companies.nfse_producao — false = homologação.';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.nfse_national IS 'V1: companies.nfse_nacional — padrão nacional × municipal.';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.nfse_service_code_national IS 'V1: companies.nfse_codigo_tributacao_nacional';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.nfse_service_code_municipal IS 'V1: companies.nfse_codigo_tributacao_municipio';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.nfse_nbs_code IS 'V1: companies.nfse_codigo_nbs';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.nfse_cnae IS 'V1: companies.nfse_cnae — CNAE do serviço, pode diferir do principal.';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.nfse_iss_rate IS 'V1: companies.nfse_iss_aliquota (%)';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.nfse_iss_taxation IS 'V1: companies.nfse_iss_tipo_tributacao';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.nfse_iss_exigibility IS 'V1: companies.nfse_iss_exigibilidade';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.nfse_simples_regime IS 'V1: companies.nfse_regime_apuracao_sn — só para optante do Simples.';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.nfse_net_value IS 'V1: companies.nfse_valor_liquido — emitir pelo valor líquido.';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.nfse_city_features IS 'V1: companies.nfse_city_features — o que a prefeitura aceita, devolvido pelo provedor.';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.nfce_csc_token IS 'V1: companies.nfce_csc_token — SEGREDO. Fora do GRANT SELECT de authenticated.';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.nfce_generic_item IS 'V1: companies.nfce_item_generico — {ncm, cfop, csosn}.';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.certificate_provider_id IS 'V1: companies.certificate_plugnotas_id — recibo; o .pfx não fica no banco.';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.dfe_sync_enabled IS 'V1: companies.dfe_sync_enabled — receber DANFes automaticamente.';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.dfe_sync_cursor IS 'V1: companies.dfe_sync_cursor — NSU onde a busca na SEFAZ parou.';\n\nALTER TABLE public.plg_fiscalbr_emitter_settings ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.plg_fiscalbr_emitter_settings FORCE ROW LEVEL SECURITY;\n\n-- O segredo fica fora do SELECT: grava-se, não se lê.\nREVOKE ALL ON TABLE public.plg_fiscalbr_emitter_settings FROM anon, authenticated;\nGRANT INSERT, UPDATE, DELETE ON TABLE public.plg_fiscalbr_emitter_settings TO authenticated;\nGRANT SELECT (\n id, tenant_id, legal_entity_id,\n nfse_enabled, nfse_production, nfse_national, nfse_service_code_national, nfse_service_code_municipal,\n nfse_nbs_code, nfse_cnae, nfse_iss_rate, nfse_iss_taxation, nfse_iss_exigibility, nfse_ibscbs_cst,\n nfse_ibscbs_classification, nfse_simples_regime, nfse_net_value, nfse_additional_info, nfse_city_features,\n nfce_enabled, nfce_production, nfce_series, nfce_csc_id, nfce_csc_token_set, nfce_generic_item,\n nfe_enabled, certificate_provider_id, certificate_cn, certificate_expires_at, certificate_uploaded_at,\n dfe_sync_enabled, dfe_sync_cursor, dfe_last_sync_at, provider_synced_at, created_at, updated_at\n) ON public.plg_fiscalbr_emitter_settings TO authenticated;\nGRANT ALL ON TABLE public.plg_fiscalbr_emitter_settings TO service_role;\n\nDROP POLICY IF EXISTS plg_fiscalbr_emitter_settings_authz_select ON public.plg_fiscalbr_emitter_settings;\nCREATE POLICY plg_fiscalbr_emitter_settings_authz_select ON public.plg_fiscalbr_emitter_settings\n FOR SELECT TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.read', NULL::uuid)));\n\nDROP POLICY IF EXISTS plg_fiscalbr_emitter_settings_authz_write ON public.plg_fiscalbr_emitter_settings;\nCREATE POLICY plg_fiscalbr_emitter_settings_authz_write ON public.plg_fiscalbr_emitter_settings\n FOR ALL TO authenticated\n USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('settings.manage', NULL::uuid)))\n WITH CHECK (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('settings.manage', NULL::uuid))\n AND EXISTS (SELECT 1 FROM public.company_legal_entities le\n WHERE le.id = plg_fiscalbr_emitter_settings.legal_entity_id\n AND le.tenant_id = plg_fiscalbr_emitter_settings.tenant_id));\n`\n\nexport const MIGRATION_018_A_NOTA_DO_V1_ALCANCA_A_CONTA = `-- A nota que veio do V1 alcança a conta que a gerou.\n--\n-- A conta a receber pergunta \"que notas eu tenho?\" pelo pedido dela:\n-- plg_financial_invoices.order_id → plg_fiscalbr_document_orders → a nota.\n-- A importação só preencheu esse vínculo a partir da tabela N:N do V1\n-- (fiscal_document_invoice_sources). A NFS-e do V1 nunca usou essa tabela: ela\n-- aponta a conta por fiscal_documents.invoice_id, que chegou aqui apenas como\n-- metadata.v1.invoice_ref. Medido em 06-10-2026 no cluster do ChefControl: 488\n-- NFS-e e 122 NFC-e sem caminho até a conta.\n--\n-- A conta importada guarda o próprio id do V1 em\n-- metadata.migration.source_numeric_id, então o par se resolve sem adivinhar.\n-- Num pool sem dado do V1 isto não casa nada. Aditivo e idempotente.\n\nINSERT INTO public.plg_fiscalbr_document_orders (tenant_id, document_id, order_id)\nSELECT DISTINCT f.tenant_id, f.document_id, i.order_id\n FROM public.plg_fiscalbr_documents f\n JOIN public.plg_financial_invoices i\n ON i.tenant_id = f.tenant_id\n AND i.metadata -> 'migration' ->> 'source_table' = 'invoices'\n AND i.metadata -> 'migration' ->> 'source_numeric_id' = f.metadata -> 'v1' ->> 'invoice_ref'\n JOIN public.orders o ON o.id = i.order_id\n WHERE f.metadata -> 'v1' ->> 'invoice_ref' IS NOT NULL\nON CONFLICT (document_id, order_id) DO NOTHING;\n`\n\nexport const MIGRATION_019_A_NOTA_EMITIDA_SE_CANCELA_PELO_V2 = `-- A nota emitida se consulta e se cancela pelo V2.\n--\n-- Quem fala com a PlugNotas é a função \\`fiscalbr-plugnotas\\` (a chave só existe\n-- no servidor), mas ela roda com o JWT de quem clicou e não grava nada direto:\n-- pergunta aqui se a pessoa pode, e devolve o resultado aqui. Uma porta de\n-- leitura, uma de escrita, a permissão conferida nas duas.\n--\n-- consultar e baixar o PDF → financial.read na unidade da nota\n-- cancelar → financial.edit na unidade da nota\n--\n-- A espelho em \\`documents.status\\` anda junto: as duas tabelas são a mesma nota\n-- vista de dois lugares (005), e um status divergente entre elas é a nota\n-- dizendo duas coisas. Idempotente.\n\nCREATE OR REPLACE FUNCTION public.fiscalbr_note_for_provider(p_document_id uuid, p_action text)\nRETURNS jsonb\nLANGUAGE plpgsql\nSTABLE SECURITY DEFINER\nSET search_path TO ''\nAS $$\nDECLARE\n v public.plg_fiscalbr_documents%ROWTYPE;\nBEGIN\n SELECT * INTO v FROM public.plg_fiscalbr_documents\n WHERE document_id = p_document_id AND tenant_id = app.current_tenant_id();\n IF NOT FOUND THEN\n RAISE EXCEPTION 'fiscal: note not found' USING ERRCODE = 'P0002';\n END IF;\n PERFORM public.plg_financial_require_permission(\n CASE WHEN p_action = 'cancel' THEN 'financial.edit' ELSE 'financial.read' END, v.unit_id);\n RETURN jsonb_build_object(\n 'document_id', v.document_id, 'model', v.model, 'status', v.status,\n 'environment', v.environment, 'provider_document_id', v.provider_document_id,\n 'cancelled_at', v.cancelled_at, 'access_key', v.access_key, 'number', v.number);\nEND $$;\n\nCREATE OR REPLACE FUNCTION public.fiscalbr_note_apply(\n p_document_id uuid,\n p_action text,\n p_patch jsonb,\n p_event jsonb DEFAULT NULL\n)\nRETURNS void\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path TO ''\nAS $$\nDECLARE\n v public.plg_fiscalbr_documents%ROWTYPE;\n v_status text := p_patch ->> 'status';\nBEGIN\n SELECT * INTO v FROM public.plg_fiscalbr_documents\n WHERE document_id = p_document_id AND tenant_id = app.current_tenant_id()\n FOR UPDATE;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'fiscal: note not found' USING ERRCODE = 'P0002';\n END IF;\n PERFORM public.plg_financial_require_permission(\n CASE WHEN p_action = 'cancel' THEN 'financial.edit' ELSE 'financial.read' END, v.unit_id);\n IF v_status IS NOT NULL AND v_status NOT IN ('processing','authorized','rejected','error','cancelled','substituted') THEN\n RAISE EXCEPTION 'fiscal: unknown status %', v_status USING ERRCODE = '22023';\n END IF;\n\n -- Only what the provider answers; identity (model, unit, order) never moves.\n UPDATE public.plg_fiscalbr_documents SET\n status = COALESCE(v_status, status),\n number = COALESCE(p_patch ->> 'number', number),\n series = COALESCE(p_patch ->> 'series', series),\n access_key = COALESCE(p_patch ->> 'access_key', access_key),\n verification_code = COALESCE(p_patch ->> 'verification_code', verification_code),\n status_payload = COALESCE(p_patch -> 'status_payload', status_payload),\n rejection_payload = CASE WHEN p_patch ? 'rejection_payload' THEN p_patch -> 'rejection_payload' ELSE rejection_payload END,\n authorized_at = COALESCE((p_patch ->> 'authorized_at')::timestamptz, authorized_at),\n last_polled_at = CASE WHEN p_action = 'poll' THEN now() ELSE last_polled_at END,\n cancelled_at = CASE WHEN p_patch ? 'cancelled_at' THEN (p_patch ->> 'cancelled_at')::timestamptz ELSE cancelled_at END,\n cancel_reason = CASE WHEN p_patch ? 'cancel_reason' THEN p_patch ->> 'cancel_reason' ELSE cancel_reason END,\n cancelled_by_user_id = CASE WHEN p_action = 'cancel' THEN auth.uid()\n WHEN p_patch ? 'cancelled_at' AND p_patch -> 'cancelled_at' = 'null'::jsonb THEN NULL\n ELSE cancelled_by_user_id END\n WHERE document_id = p_document_id;\n\n IF v_status IS NOT NULL THEN\n UPDATE public.documents SET status = v_status WHERE id = p_document_id AND status IS DISTINCT FROM v_status;\n END IF;\n\n IF p_event IS NOT NULL THEN\n INSERT INTO public.plg_fiscalbr_document_events\n (tenant_id, unit_id, document_id, event_type, status, justification, response_payload, created_by_user_id)\n VALUES (v.tenant_id, v.unit_id, p_document_id, p_event ->> 'event_type', p_event ->> 'status',\n p_event ->> 'justification', p_event -> 'response_payload', auth.uid());\n END IF;\nEND $$;\n\nREVOKE ALL ON FUNCTION public.fiscalbr_note_for_provider(uuid, text) FROM PUBLIC, anon;\nREVOKE ALL ON FUNCTION public.fiscalbr_note_apply(uuid, text, jsonb, jsonb) FROM PUBLIC, anon;\nGRANT EXECUTE ON FUNCTION public.fiscalbr_note_for_provider(uuid, text) TO authenticated;\nGRANT EXECUTE ON FUNCTION public.fiscalbr_note_apply(uuid, text, jsonb, jsonb) TO authenticated;\n`\n\nexport const MIGRATION_020_A_NOTA_ACHA_O_FORNECEDOR_PELO_CNPJ = `-- A nota acha o fornecedor pelo CNPJ — e, se não achar, cadastra.\n--\n-- O estágio (009) só casava o CNPJ no momento do import. As notas que vieram\n-- do V1 atravessaram sem o vínculo (003 deixou para \"a costura\"), e um\n-- fornecedor cadastrado depois nunca era religado: 1029 notas com o fornecedor\n-- no cadastro e a tela dizendo \"não está no cadastro\", e o título aceito no V2\n-- nascendo sem contraparte (o traço em Contas a Pagar).\n--\n-- O V1 nunca pediu cadastro: ao aceitar, \\`resolveSupplierId\\` procura o\n-- fornecedor pelo CNPJ e, sem achar, cria com o nome e o CNPJ da nota. Esta\n-- função é esse gesto, com duas pontas:\n--\n-- p_create = false só casa (ao abrir a nota)\n-- p_create = true casa ou cria (antes de aceitar)\n--\n-- O título da nota, se já existe e está SEM contraparte, recebe a mesma pessoa.\n-- Nunca troca um vínculo gravado. Idempotente.\n\nCREATE OR REPLACE FUNCTION public.plg_fiscalbr_supplier_for_cnpj(p_tenant uuid, p_cnpj text)\nRETURNS uuid\nLANGUAGE sql\nSTABLE\nSET search_path TO ''\nAS $$\n SELECT p.id FROM public.people p\n WHERE p.tenant_id = p_tenant\n AND p_cnpj <> ''\n AND public.plg_fiscalbr_digits(p.document_number) = p_cnpj\n ORDER BY (p.kind = 'supplier') DESC, p.created_at\n LIMIT 1\n$$;\n\nCREATE OR REPLACE FUNCTION public.fiscalbr_link_inbox_supplier(p_document_id uuid, p_create boolean DEFAULT false)\nRETURNS jsonb\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path TO ''\nAS $$\nDECLARE\n v_tenant uuid := public.plg_financial_require_tenant();\n v_doc public.plg_fiscalbr_inbox_documents%ROWTYPE;\n v_cnpj text;\n v_party uuid;\n v_created boolean := false;\nBEGIN\n SELECT * INTO v_doc FROM public.plg_fiscalbr_inbox_documents\n WHERE id = p_document_id AND tenant_id = v_tenant;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'fiscal: inbox note not found' USING ERRCODE = 'P0002';\n END IF;\n PERFORM public.plg_financial_require_permission(\n CASE WHEN p_create THEN 'financial.create' ELSE 'financial.read' END, v_doc.unit_id);\n\n v_party := v_doc.supplier_party_id;\n v_cnpj := public.plg_fiscalbr_digits(v_doc.supplier_cnpj);\n\n IF v_party IS NULL THEN\n v_party := public.plg_fiscalbr_supplier_for_cnpj(v_tenant, v_cnpj);\n END IF;\n\n -- Without a CNPJ there is nothing to match on; a name-only person would be a\n -- duplicate waiting to happen, so creation needs the document.\n IF v_party IS NULL AND p_create AND v_cnpj <> '' THEN\n INSERT INTO public.people (tenant_id, kind, name, document_number)\n VALUES (v_tenant, 'supplier', coalesce(nullif(trim(v_doc.supplier_name), ''), 'Fornecedor ' || v_cnpj), v_cnpj)\n RETURNING id INTO v_party;\n v_created := true;\n END IF;\n\n IF v_party IS NOT NULL AND v_doc.supplier_party_id IS NULL THEN\n UPDATE public.plg_fiscalbr_inbox_documents\n SET supplier_party_id = v_party\n WHERE id = v_doc.id AND supplier_party_id IS NULL;\n END IF;\n\n IF v_party IS NOT NULL AND v_doc.invoice_id IS NOT NULL THEN\n UPDATE public.plg_financial_invoices\n SET counterparty_person_id = v_party\n WHERE id = v_doc.invoice_id AND tenant_id = v_tenant AND counterparty_person_id IS NULL;\n END IF;\n\n RETURN jsonb_build_object('supplier_party_id', v_party, 'created', v_created);\nEND;\n$$;\n\nREVOKE ALL ON FUNCTION public.fiscalbr_link_inbox_supplier(uuid, boolean) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.fiscalbr_link_inbox_supplier(uuid, boolean) TO authenticated;\n\n-- Backfill: match only, NULLs only. Nothing is created here — creating a person\n-- is the operator's gesture (accepting), not a migration's.\nWITH p AS (\n SELECT DISTINCT ON (tenant_id, dg) tenant_id, dg, id\n FROM (SELECT tenant_id, id, kind, created_at, public.plg_fiscalbr_digits(document_number) AS dg\n FROM public.people\n WHERE document_number IS NOT NULL) x\n WHERE length(dg) = 14\n ORDER BY tenant_id, dg, (kind = 'supplier') DESC, created_at\n)\nUPDATE public.plg_fiscalbr_inbox_documents d\n SET supplier_party_id = p.id\n FROM p\n WHERE d.supplier_party_id IS NULL\n AND p.tenant_id = d.tenant_id\n AND p.dg = public.plg_fiscalbr_digits(d.supplier_cnpj);\n\nUPDATE public.plg_financial_invoices i\n SET counterparty_person_id = d.supplier_party_id\n FROM public.plg_fiscalbr_inbox_documents d\n WHERE d.invoice_id = i.id\n AND d.tenant_id = i.tenant_id\n AND d.supplier_party_id IS NOT NULL\n AND i.counterparty_person_id IS NULL;\n`\n\nexport const MIGRATION_021_USO_E_CONSUMO_ENTRA_NO_ESTOQUE = `-- \"Materiais de Uso e Consumo\" vira um tipo de registro da nota recebida.\n--\n-- É o que a casa gasta sem vender nem transformar — luva, descartável, material\n-- de limpeza — e que ainda assim se conta no estoque. Entra no estoque como o\n-- insumo: precisa de produto vinculado e gera movimento ao aceitar.\n--\n-- Aditivo e idempotente. A função de aceite é REMENDADA, não reescrita (mesmo\n-- padrão da 015): copiar o corpo da 010 apagaria o \\`reason\\` que a 015 enxertou.\n\nALTER TABLE public.plg_fiscalbr_inbox_items\n DROP CONSTRAINT IF EXISTS plg_fiscalbr_inbox_items_entry_type_chk;\nALTER TABLE public.plg_fiscalbr_inbox_items\n ADD CONSTRAINT plg_fiscalbr_inbox_items_entry_type_chk\n CHECK (entry_type IS NULL OR entry_type IN ('insumo','revenda','patrimonio','uso_consumo','despesa','servico'));\n\nALTER TABLE public.plg_fiscalbr_supplier_product_map\n DROP CONSTRAINT IF EXISTS plg_fiscalbr_supplier_product_map_entry_type_chk;\nALTER TABLE public.plg_fiscalbr_supplier_product_map\n ADD CONSTRAINT plg_fiscalbr_supplier_product_map_entry_type_chk\n CHECK (entry_type IS NULL OR entry_type IN ('insumo','revenda','patrimonio','uso_consumo','despesa','servico'));\n\nDO $m$\nDECLARE\n v_def text;\n v_antes text := $q$entry_type IN ('insumo','revenda','patrimonio')$q$;\n v_depois text := $q$entry_type IN ('insumo','revenda','patrimonio','uso_consumo')$q$;\nBEGIN\n SELECT pg_get_functiondef(p.oid) INTO v_def\n FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace\n WHERE n.nspname = 'public' AND p.proname = 'fiscalbr_accept_inbox_document';\n\n IF v_def IS NULL THEN\n RAISE EXCEPTION 'fiscalbr/021: fiscalbr_accept_inbox_document não existe — aplicar 009/010 antes';\n END IF;\n\n IF position('uso_consumo' in v_def) > 0 THEN RETURN; END IF;\n\n IF position(v_antes in v_def) = 0 THEN\n RAISE EXCEPTION 'fiscalbr/021: o corpo de fiscalbr_accept_inbox_document não é o esperado — confira à mão em vez de deixar passar calado';\n END IF;\n\n EXECUTE replace(v_def, v_antes, v_depois);\nEND\n$m$;\n`\n\nexport const MIGRATION_022_A_NOTA_DO_V1_TRAZ_O_XML = `-- ---------------------------------------------------------------------------\n-- The V1 bridge carries the note's XML.\n-- ---------------------------------------------------------------------------\n-- 003 dropped \\`xml_content\\` because, back then, V2 had no column for it (016\n-- added one). Without the file a note copied from the V1 cannot be downloaded,\n-- and a note the V1 never broke into lines arrives with zero items and no way\n-- to rebuild them. Only the mapping changes; rows already copied are not touched.\n-- ---------------------------------------------------------------------------\n\n-- The row is already frozen, and trg_allowlist_frozen refuses a plain write;\n-- from zero this file stopped the rest of fiscal_br. A migration file is the\n-- one writer the hatch exists for, and SET LOCAL ends with the transaction.\nSET LOCAL migration.allowlist_unlock = 'on';\n\nUPDATE migration.allowlist\n SET columns = columns || jsonb_build_object('xml_content', 'xml_content')\n WHERE source_table = 'dfe_inbox'\n AND columns->>'xml_content' IS DISTINCT FROM 'xml_content';\n`\n\nexport const MIGRATION_023_A_NOTA_ACEITA_SEM_LINHAS_RECEBE_AS_LINHAS = `-- ---------------------------------------------------------------------------\n-- An accepted note that has no lines gets its lines, and nothing else.\n-- ---------------------------------------------------------------------------\n-- Notes copied from the V1 can carry a payable (or stock) and zero lines: the\n-- V1 never split their XML. Restaging is refused for them on purpose (it would\n-- rewrite the decision that became stock and payable), so they showed 0/0\n-- forever. This writes ONLY the missing lines, from the stored XML, and only\n-- while the note has none; the document, its payable and its instalments stay.\n-- ---------------------------------------------------------------------------\n\nCREATE OR REPLACE FUNCTION public.fiscalbr_fill_inbox_lines(p_document_id uuid, p_items jsonb)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $function$\nDECLARE\n v_tenant uuid := public.plg_financial_require_tenant();\n v_doc public.plg_fiscalbr_inbox_documents%ROWTYPE;\n v_cnpj text;\n v_item jsonb;\n v_map public.plg_fiscalbr_supplier_product_map%ROWTYPE;\n v_n integer := 0;\nBEGIN\n SELECT * INTO v_doc FROM public.plg_fiscalbr_inbox_documents\n WHERE tenant_id = v_tenant AND id = p_document_id\n FOR UPDATE; -- two opens of the same note must not both see \"no lines\"\n IF NOT FOUND THEN\n RAISE EXCEPTION 'fiscalbr: nota não encontrada' USING ERRCODE = 'P0002';\n END IF;\n PERFORM public.plg_financial_require_permission('financial.create', v_doc.unit_id);\n\n IF EXISTS (SELECT 1 FROM public.plg_fiscalbr_inbox_items\n WHERE tenant_id = v_tenant AND inbox_document_id = v_doc.id) THEN\n RETURN jsonb_build_object('document_id', v_doc.id, 'items', 0, 'already_had_lines', true);\n END IF;\n\n v_cnpj := public.plg_fiscalbr_digits(v_doc.supplier_cnpj);\n\n FOR v_item IN SELECT * FROM jsonb_array_elements(coalesce(p_items, '[]'::jsonb)) LOOP\n v_n := v_n + 1;\n v_map := NULL;\n SELECT * INTO v_map FROM public.plg_fiscalbr_supplier_product_map\n WHERE tenant_id = v_tenant AND supplier_cnpj = v_cnpj\n AND supplier_product_code = coalesce(v_item->>'supplier_product_code', '');\n\n INSERT INTO public.plg_fiscalbr_inbox_items (\n tenant_id, inbox_document_id, item_number, supplier_product_code, ean_barcode,\n description, ncm, cest, cfop, unit_commercial, quantity_commercial,\n unit_price_commercial, total_price, discount, freight, other_expenses,\n icms_cst, icms_csosn, icms_base, icms_rate, icms_value,\n pis_value, cofins_value, ipi_value, raw_xml_item,\n product_id, entry_type, chart_account_id, stock_unit_id, conversion_factor,\n cost_mode, reconciled)\n VALUES (\n v_tenant, v_doc.id, coalesce((v_item->>'item_number')::int, v_n),\n v_item->>'supplier_product_code', v_item->>'ean_barcode',\n v_item->>'description', v_item->>'ncm', v_item->>'cest', v_item->>'cfop',\n v_item->>'unit', (v_item->>'quantity')::numeric,\n (v_item->>'unit_price')::numeric, (v_item->>'total')::numeric,\n (v_item->>'discount')::numeric, (v_item->>'freight')::numeric,\n (v_item->>'other')::numeric,\n v_item->>'icms_cst', v_item->>'icms_csosn', (v_item->>'icms_base')::numeric,\n (v_item->>'icms_rate')::numeric, (v_item->>'icms_value')::numeric,\n (v_item->>'pis_value')::numeric, (v_item->>'cofins_value')::numeric,\n (v_item->>'ipi_value')::numeric, v_item,\n v_map.product_id, v_map.entry_type, v_map.chart_account_id, v_map.stock_unit_id,\n coalesce(v_map.conversion_factor, 1), coalesce(v_map.cost_mode, 'novo'),\n v_map.product_id IS NOT NULL);\n END LOOP;\n\n RETURN jsonb_build_object('document_id', v_doc.id, 'items', v_n, 'already_had_lines', false);\nEND;\n$function$;\n\nREVOKE ALL ON FUNCTION public.fiscalbr_fill_inbox_lines(uuid, jsonb) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.fiscalbr_fill_inbox_lines(uuid, jsonb) TO authenticated;\n`\n\nexport const MIGRATION_024_A_NOTA_NAO_APONTA_PARA_CONTA_QUE_SUMIU = `-- ---------------------------------------------------------------------------\n-- A note never points at a payable (or stock movement) that no longer exists.\n-- ---------------------------------------------------------------------------\n-- invoice_id and stock_movement_id cross into other plugins, so they carry no\n-- foreign key. When a re-copy rebuilt a tenant's payables under new ids, the\n-- notes kept the old ones: the screen read \"already accepted\" and hid every\n-- action, while Contas a Pagar had nothing. This heals a note: a dead payable\n-- link is re-pointed to the live payable of the same NF-e when exactly one\n-- exists, otherwise cleared; a dead movement link is cleared, and the note\n-- stops counting as stocked only when that dead movement was all it had. A\n-- cleared note can be accepted again, which writes the payable and the stock.\n-- ---------------------------------------------------------------------------\n\nCREATE OR REPLACE FUNCTION public.plg_fiscalbr_heal_note_links(p_tenant uuid, p_document_id uuid)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $function$\nDECLARE\n v_doc public.plg_fiscalbr_inbox_documents%ROWTYPE;\n v_invoice uuid;\n v_matches integer;\n v_dead_mv integer := 0;\n v_relinked boolean := false;\n v_released boolean := false;\n v_unstocked boolean := false;\nBEGIN\n SELECT * INTO v_doc FROM public.plg_fiscalbr_inbox_documents\n WHERE tenant_id = p_tenant AND id = p_document_id\n FOR UPDATE;\n IF NOT FOUND THEN\n RETURN jsonb_build_object('document_id', p_document_id, 'changed', false);\n END IF;\n\n IF v_doc.invoice_id IS NOT NULL AND NOT EXISTS (\n SELECT 1 FROM public.plg_financial_invoices f WHERE f.id = v_doc.invoice_id) THEN\n -- Same NF-e: the key this plugin writes, or the V1 conciliation's text for the same supplier.\n SELECT count(*), min(f.id::text)::uuid INTO v_matches, v_invoice\n FROM public.plg_financial_invoices f\n WHERE f.tenant_id = p_tenant AND f.direction = 'payable' AND f.cancelled_at IS NULL\n AND (f.idempotency_key = 'danfe:' || v_doc.access_key\n OR (v_doc.supplier_party_id IS NOT NULL\n AND f.counterparty_person_id = v_doc.supplier_party_id\n AND f.description ~ ('NF-e 0*' || ltrim(v_doc.nfe_number, '0') || '(\\\\D|$)')))\n AND NOT EXISTS (SELECT 1 FROM public.plg_fiscalbr_inbox_documents o\n WHERE o.tenant_id = p_tenant AND o.invoice_id = f.id AND o.id <> v_doc.id);\n IF v_matches = 1 THEN\n v_relinked := true;\n ELSE\n v_invoice := NULL;\n v_released := true;\n END IF;\n UPDATE public.plg_fiscalbr_inbox_documents SET invoice_id = v_invoice, updated_at = now()\n WHERE id = v_doc.id;\n END IF;\n\n UPDATE public.plg_fiscalbr_inbox_items i SET stock_movement_id = NULL, updated_at = now()\n WHERE i.tenant_id = p_tenant AND i.inbox_document_id = v_doc.id\n AND i.stock_movement_id IS NOT NULL\n AND NOT EXISTS (SELECT 1 FROM public.plg_inventory_stock_movements m WHERE m.id = i.stock_movement_id);\n GET DIAGNOSTICS v_dead_mv = ROW_COUNT;\n\n IF v_dead_mv > 0 AND v_doc.stocked_at IS NOT NULL\n AND v_doc.inventory_operation_id IS NULL\n AND NOT EXISTS (SELECT 1 FROM public.plg_fiscalbr_inbox_items i\n WHERE i.tenant_id = p_tenant AND i.inbox_document_id = v_doc.id\n AND i.stock_movement_id IS NOT NULL) THEN\n UPDATE public.plg_fiscalbr_inbox_documents SET stocked_at = NULL, updated_at = now()\n WHERE id = v_doc.id;\n v_unstocked := true;\n END IF;\n\n RETURN jsonb_build_object('document_id', v_doc.id,\n 'changed', v_relinked OR v_released OR v_dead_mv > 0,\n 'relinked', v_relinked, 'released', v_released,\n 'dead_movements', v_dead_mv, 'unstocked', v_unstocked);\nEND;\n$function$;\n\nREVOKE ALL ON FUNCTION public.plg_fiscalbr_heal_note_links(uuid, uuid) FROM PUBLIC, anon, authenticated;\n\n-- The screen heals the note it opens, so a future re-copy cannot strand one again.\nCREATE OR REPLACE FUNCTION public.fiscalbr_heal_inbox_note(p_document_id uuid)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $function$\nDECLARE\n v_tenant uuid := public.plg_financial_require_tenant();\n v_unit uuid;\nBEGIN\n SELECT unit_id INTO v_unit FROM public.plg_fiscalbr_inbox_documents\n WHERE tenant_id = v_tenant AND id = p_document_id;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'fiscalbr: nota não encontrada' USING ERRCODE = 'P0002';\n END IF;\n PERFORM public.plg_financial_require_permission('financial.create', v_unit);\n RETURN public.plg_fiscalbr_heal_note_links(v_tenant, p_document_id);\nEND;\n$function$;\n\nREVOKE ALL ON FUNCTION public.fiscalbr_heal_inbox_note(uuid) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.fiscalbr_heal_inbox_note(uuid) TO authenticated;\n\n-- Heal what is already stranded.\nDO $backfill$\nDECLARE r record;\nBEGIN\n FOR r IN\n SELECT DISTINCT d.tenant_id, d.id\n FROM public.plg_fiscalbr_inbox_documents d\n LEFT JOIN public.plg_fiscalbr_inbox_items i\n ON i.tenant_id = d.tenant_id AND i.inbox_document_id = d.id\n WHERE (d.invoice_id IS NOT NULL\n AND NOT EXISTS (SELECT 1 FROM public.plg_financial_invoices f WHERE f.id = d.invoice_id))\n OR (i.stock_movement_id IS NOT NULL\n AND NOT EXISTS (SELECT 1 FROM public.plg_inventory_stock_movements m WHERE m.id = i.stock_movement_id))\n LOOP\n PERFORM public.plg_fiscalbr_heal_note_links(r.tenant_id, r.id);\n END LOOP;\nEND\n$backfill$;\n`\n\nexport const MIGRATION_025_A_CONTA_DA_NOTA_FECHA_COM_O_TOTAL = `-- ---------------------------------------------------------------------------\n-- The payable a note opens lists products as products, and adds up to its total.\n-- ---------------------------------------------------------------------------\n-- Two defects in the lines fiscalbr_accept_inbox_document wrote:\n-- 1. \\`itemKind\\` carried the fiscal entry type ('insumo', 'revenda'…), which the\n-- financial screens do not know, so a linked product showed as \"Insumo\"\n-- with no product. The kind is 'product' when the line has one; the entry\n-- type moves to \\`entryType\\`, and product/chart account go to their columns.\n-- 2. Only the product lines were written, so freight, insurance, other\n-- expenses, IPI, ICMS-ST and discount were in the total but in no line\n-- (NF-e 1430116: R$ 480,47 owed, R$ 478,47 listed). Each becomes a line,\n-- and whatever still differs from the instalments becomes one more.\n-- ---------------------------------------------------------------------------\n\nCREATE OR REPLACE FUNCTION public.plg_fiscalbr_note_charge_lines(\n p_tenant uuid, p_document_id uuid, p_invoice_id uuid, p_total numeric)\nRETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $function$\nDECLARE\n v_doc public.plg_fiscalbr_inbox_documents%ROWTYPE;\n v_sum numeric;\n v_rest numeric;\n v_n integer := 0;\n v_c record;\nBEGIN\n SELECT * INTO v_doc FROM public.plg_fiscalbr_inbox_documents\n WHERE tenant_id = p_tenant AND id = p_document_id;\n IF NOT FOUND OR p_invoice_id IS NULL THEN RETURN 0; END IF;\n IF EXISTS (SELECT 1 FROM public.items\n WHERE tenant_id = p_tenant AND invoice_id = p_invoice_id\n AND metadata ? 'noteCharge') THEN\n RETURN 0;\n END IF;\n -- Only a payable that already lists the note's products: a V1 copy with no lines\n -- would get its whole total as one \"difference\" line.\n IF NOT EXISTS (SELECT 1 FROM public.items\n WHERE tenant_id = p_tenant AND invoice_id = p_invoice_id\n AND metadata ? 'cfop') THEN\n RETURN 0;\n END IF;\n\n FOR v_c IN\n SELECT * FROM (VALUES\n (1, 'freight', 'Frete', round(coalesce(v_doc.total_freight, 0), 2)),\n (2, 'insurance', 'Seguro', round(coalesce(v_doc.total_insurance, 0), 2)),\n (3, 'other', 'Outras despesas da nota', round(coalesce(v_doc.total_other, 0), 2)),\n (4, 'ipi', 'IPI', round(coalesce(v_doc.total_ipi_value, 0), 2)),\n (5, 'icms_st', 'ICMS-ST', round(coalesce(v_doc.total_icms_st_value, 0), 2)),\n (6, 'discount', 'Desconto da nota', -round(coalesce(v_doc.total_discount, 0), 2))\n ) AS c(ord, code, label, amount)\n WHERE amount <> 0\n ORDER BY ord\n LOOP\n INSERT INTO public.items (tenant_id, invoice_id, name, quantity, unit_price, total, sort_order, metadata)\n VALUES (p_tenant, p_invoice_id, v_c.label, 1, v_c.amount, v_c.amount, 1000 + v_c.ord,\n jsonb_build_object('itemKind', 'other', 'noteCharge', v_c.code));\n v_n := v_n + 1;\n END LOOP;\n\n SELECT coalesce(sum(total), 0) INTO v_sum FROM public.items\n WHERE tenant_id = p_tenant AND invoice_id = p_invoice_id AND cancelled_at IS NULL;\n v_rest := round(coalesce(p_total, 0) - v_sum, 2);\n IF v_rest <> 0 THEN\n INSERT INTO public.items (tenant_id, invoice_id, name, quantity, unit_price, total, sort_order, metadata)\n VALUES (p_tenant, p_invoice_id, 'Diferença entre a nota e as parcelas', 1, v_rest, v_rest, 1099,\n jsonb_build_object('itemKind', 'other', 'noteCharge', 'instalment_difference'));\n v_n := v_n + 1;\n END IF;\n RETURN v_n;\nEND;\n$function$;\n\nREVOKE ALL ON FUNCTION public.plg_fiscalbr_note_charge_lines(uuid, uuid, uuid, numeric) FROM PUBLIC, anon, authenticated;\n\nDO $m$\nDECLARE\n v_def text;\n v_start integer;\n v_end integer;\n v_marker text := 'INSERT INTO public.items (tenant_id, invoice_id, name, quantity, unit_price, total, sort_order, metadata)';\n v_close text := 'ON CONFLICT DO NOTHING;';\n v_new text := $q$INSERT INTO public.items (tenant_id, invoice_id, product_id, chart_account_id, name, quantity, unit_price, total, sort_order, metadata)\n SELECT v_tenant, v_invoice, i.product_id, coalesce(i.chart_account_id, v_doc.default_chart_account_id),\n i.description, i.quantity_commercial, i.unit_price_commercial,\n round(coalesce(i.total_price, 0), 2), i.item_number,\n jsonb_strip_nulls(jsonb_build_object(\n 'itemKind', CASE WHEN i.product_id IS NOT NULL THEN 'product' ELSE 'other' END,\n 'referenceId', i.product_id, 'entryType', i.entry_type,\n 'chartAccountId', coalesce(i.chart_account_id, v_doc.default_chart_account_id),\n 'ncm', i.ncm, 'cfop', i.cfop,\n 'supplierProductCode', i.supplier_product_code))\n FROM public.plg_fiscalbr_inbox_items i\n WHERE i.tenant_id = v_tenant AND i.inbox_document_id = v_doc.id\n ORDER BY i.item_number\n ON CONFLICT DO NOTHING;\n PERFORM public.plg_fiscalbr_note_charge_lines(v_tenant, v_doc.id, v_invoice, v_total);$q$;\nBEGIN\n SELECT pg_get_functiondef(p.oid) INTO v_def\n FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace\n WHERE n.nspname = 'public' AND p.proname = 'fiscalbr_accept_inbox_document';\n IF v_def IS NULL THEN\n RAISE EXCEPTION 'fiscalbr/025: fiscalbr_accept_inbox_document does not exist';\n END IF;\n IF position('plg_fiscalbr_note_charge_lines' in v_def) > 0 THEN RETURN; END IF;\n\n v_start := position(v_marker in v_def);\n IF v_start = 0 THEN\n RAISE EXCEPTION 'fiscalbr/025: unexpected body of fiscalbr_accept_inbox_document — check by hand';\n END IF;\n v_end := v_start + position(v_close in substr(v_def, v_start)) - 1 + length(v_close);\n EXECUTE substr(v_def, 1, v_start - 1) || v_new || substr(v_def, v_end);\nEND\n$m$;\n\n-- The payables already opened by notes: same kind/product fix, and the missing charge lines.\nALTER TABLE public.items DISABLE TRIGGER items_invoice_line_immutable;\n\nUPDATE public.items it\n SET product_id = coalesce(it.product_id, (it.metadata->>'referenceId')::uuid),\n metadata = jsonb_set(it.metadata || jsonb_build_object('entryType', it.metadata->>'itemKind'),\n '{itemKind}', '\"product\"'::jsonb, true),\n updated_at = now()\n FROM public.plg_financial_invoices f\n WHERE f.id = it.invoice_id\n AND f.metadata ? 'danfe'\n AND it.metadata->>'itemKind' IN ('insumo','revenda','patrimonio','uso_consumo','despesa','servico')\n AND it.metadata->>'referenceId' IS NOT NULL\n AND EXISTS (SELECT 1 FROM public.products p WHERE p.id = (it.metadata->>'referenceId')::uuid);\n\nUPDATE public.items it\n SET metadata = jsonb_set(it.metadata || jsonb_build_object('entryType', it.metadata->>'itemKind'),\n '{itemKind}', '\"other\"'::jsonb, true),\n updated_at = now()\n FROM public.plg_financial_invoices f\n WHERE f.id = it.invoice_id\n AND f.metadata ? 'danfe'\n AND it.metadata->>'itemKind' IN ('insumo','revenda','patrimonio','uso_consumo','despesa','servico');\n\nALTER TABLE public.items ENABLE TRIGGER items_invoice_line_immutable;\n\nDO $backfill$\nDECLARE r record;\nBEGIN\n FOR r IN\n SELECT d.tenant_id, d.id, f.id AS invoice_id, f.total\n FROM public.plg_fiscalbr_inbox_documents d\n JOIN public.plg_financial_invoices f ON f.id = d.invoice_id AND f.tenant_id = d.tenant_id\n WHERE f.metadata ? 'danfe' AND f.cancelled_at IS NULL\n LOOP\n PERFORM public.plg_fiscalbr_note_charge_lines(r.tenant_id, r.id, r.invoice_id, r.total);\n END LOOP;\nEND\n$backfill$;\n`\n\nexport const MIGRATION_026_O_RECEBIMENTO_PARADO_DIZ_POR_QUE = `-- ---------------------------------------------------------------------------\n-- When notes stop arriving, the emitter row says why.\n-- ---------------------------------------------------------------------------\n-- The SEFAZ pull fails per CNPJ (expired A1 certificate, provider refusal) and\n-- the V1 records only the last failure (integration_sync_state.last_error). The\n-- V2 kept nothing, so a tenant could go two weeks without a note and no screen\n-- would say so. These two columns hold that last failure; the notes list warns\n-- from them and from a stale dfe_last_sync_at.\n-- ---------------------------------------------------------------------------\n\nALTER TABLE public.plg_fiscalbr_emitter_settings\n ADD COLUMN IF NOT EXISTS dfe_last_error text,\n ADD COLUMN IF NOT EXISTS dfe_last_error_at timestamptz;\n\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.dfe_last_error IS\n 'V1: integration_sync_state.last_error (source = dfe_inbox) — last failure pulling notes from SEFAZ; null when the last pull worked.';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.dfe_last_error_at IS\n 'V1: integration_sync_state.last_synced_at when last_status = error.';\n\nGRANT SELECT (dfe_last_error, dfe_last_error_at) ON public.plg_fiscalbr_emitter_settings TO authenticated;\n`\n\nexport const MIGRATION_027_A_NOTA_COM_CONTA_E_SEM_ESTOQUE_TERMINA_NO_V2 = `-- ---------------------------------------------------------------------------\n-- A note that has its payable but never reached stock can be finished in V2.\n-- ---------------------------------------------------------------------------\n-- In the V1, saving the payment plan of a DANFE import opened the payable\n-- before anyone reconciled the lines; many imports stopped there. Copied over,\n-- those notes carry invoice_id and no stock, and both RPCs read invoice_id as\n-- \"accepted\": nothing could be reconciled and the goods never entered stock\n-- (Artorius 18, Espaço Facial 72, Pertinho 17 on 14/10/2026).\n--\n-- \"Settled\" is now: stocked, or a payable with no stock line left to receive.\n-- Saving decisions is allowed until then, and accepting a note that already has\n-- its payable receives the stock against it instead of opening a second one.\n-- ---------------------------------------------------------------------------\n\nDO $m$\nDECLARE\n v_def text;\n v_old text;\n v_new text;\n v_start integer;\n v_end integer;\n v_pending constant text := $q$NOT EXISTS (SELECT 1 FROM public.plg_fiscalbr_inbox_items x\n WHERE x.tenant_id = v_tenant AND x.inbox_document_id = v_doc.id\n AND x.stock_movement_id IS NULL\n AND (x.entry_type IS NULL OR x.entry_type = '' OR x.entry_type IN ('insumo','revenda','patrimonio','uso_consumo')))$q$;\nBEGIN\n -- 1. Saving decisions.\n SELECT pg_get_functiondef(p.oid) INTO v_def FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace\n WHERE n.nspname = 'public' AND p.proname = 'fiscalbr_save_inbox_decision';\n v_old := 'IF v_doc.stocked_at IS NOT NULL OR v_doc.invoice_id IS NOT NULL THEN';\n IF v_def IS NULL THEN RAISE EXCEPTION 'fiscalbr/027: fiscalbr_save_inbox_decision does not exist'; END IF;\n IF position(v_old in v_def) > 0 THEN\n v_def := replace(v_def, v_old, 'IF v_doc.stocked_at IS NOT NULL OR (v_doc.invoice_id IS NOT NULL AND ' || v_pending || ') THEN');\n -- v_tenant is that function's own variable name too.\n EXECUTE v_def;\n ELSIF position('x.stock_movement_id IS NULL' in v_def) = 0 THEN\n RAISE EXCEPTION 'fiscalbr/027: unexpected body of fiscalbr_save_inbox_decision — check by hand';\n END IF;\n\n -- 2. Accepting.\n SELECT pg_get_functiondef(p.oid) INTO v_def FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace\n WHERE n.nspname = 'public' AND p.proname = 'fiscalbr_accept_inbox_document';\n IF v_def IS NULL THEN RAISE EXCEPTION 'fiscalbr/027: fiscalbr_accept_inbox_document does not exist'; END IF;\n IF position('Half-done in the V1' in v_def) > 0 THEN RETURN; END IF;\n\n IF position(v_old in v_def) = 0 THEN\n RAISE EXCEPTION 'fiscalbr/027: unexpected guard in fiscalbr_accept_inbox_document — check by hand';\n END IF;\n v_def := replace(v_def, v_old, 'IF v_doc.stocked_at IS NOT NULL OR (v_doc.invoice_id IS NOT NULL AND ' || v_pending || ') THEN');\n\n v_start := position(' SELECT coalesce(sum(value), 0),' in v_def);\n v_new := 'PERFORM public.plg_fiscalbr_note_charge_lines(v_tenant, v_doc.id, v_invoice, v_total);';\n v_end := position(v_new in v_def);\n IF v_start = 0 OR v_end = 0 OR v_end < v_start THEN\n RAISE EXCEPTION 'fiscalbr/027: unexpected payable block in fiscalbr_accept_inbox_document — check by hand';\n END IF;\n v_end := v_end + length(v_new);\n v_def := substr(v_def, 1, v_start - 1)\n || $q$ IF v_doc.invoice_id IS NOT NULL THEN\n -- Half-done in the V1: the payable exists; only the stock is missing.\n v_invoice := v_doc.invoice_id;\n SELECT total INTO v_total FROM public.plg_financial_invoices WHERE id = v_invoice;\n v_insts := '[]'::jsonb;\n ELSE\n$q$ || substr(v_def, v_start, v_end - v_start) || $q$\n END IF;$q$\n || substr(v_def, v_end);\n EXECUTE v_def;\nEND\n$m$;\n`\n\nexport const MIGRATION_028_A_LINHA_DE_ESTOQUE_TEM_UNIDADE = `-- ---------------------------------------------------------------------------\n-- A stock line enters with its unit.\n-- ---------------------------------------------------------------------------\n-- Accepting let a stock line through with no stock unit; the movement then\n-- carried a bare quantity that every screen reads as \"unidade\" (Artorius, NF-e\n-- 222879: 20 kg of copa lombo entered as 20 units). The screen now suggests the\n-- unit and refuses without one; this is the same rule where it cannot be\n-- skipped.\n-- ---------------------------------------------------------------------------\n\nDO $m$\nDECLARE\n v_def text;\n v_old constant text := $q$ IF v_it.product_id IS NULL THEN$q$;\n v_new constant text := $q$ IF v_it.stock_unit_id IS NULL THEN\n RAISE EXCEPTION 'fiscalbr: item % entra no estoque sem unidade — escolha a unidade de estoque', v_it.item_number\n USING ERRCODE = '23514';\n END IF;\n IF v_it.product_id IS NULL THEN$q$;\nBEGIN\n SELECT pg_get_functiondef(p.oid) INTO v_def FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace\n WHERE n.nspname = 'public' AND p.proname = 'fiscalbr_accept_inbox_document';\n IF v_def IS NULL THEN RAISE EXCEPTION 'fiscalbr/028: fiscalbr_accept_inbox_document does not exist'; END IF;\n IF position('entra no estoque sem unidade' in v_def) > 0 THEN RETURN; END IF;\n IF position(v_old in v_def) = 0 THEN\n RAISE EXCEPTION 'fiscalbr/028: unexpected body of fiscalbr_accept_inbox_document — check by hand';\n END IF;\n EXECUTE replace(v_def, v_old, v_new);\nEND\n$m$;\n`\n\nexport const MIGRATION_029_THE_SERVICE_WRITES_THE_INTAKE_HEALTH = `-- ============================================================================\n-- 029_the_service_writes_the_intake_health — the DF-e pull moves to the\n-- integration service (ADR 0003 phase 2).\n--\n-- The pull reads a tenant's emitter rows AS the caller (RLS decides which), and\n-- then records each CNPJ's outcome in three health columns, even for a reader\n-- without settings.manage, so the notes list can say \"intake stopped\". The\n-- edge function did that with service_role; the service gets exactly those\n-- three columns, by row id, and nothing else on the table.\n--\n-- Guarded: the role is an operator step. Replay-safe.\n-- ============================================================================\n\nDO $$\nBEGIN\n IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fayz_integration')\n AND to_regclass('public.plg_fiscalbr_emitter_settings') IS NOT NULL THEN\n GRANT SELECT (id) ON public.plg_fiscalbr_emitter_settings TO fayz_integration;\n GRANT UPDATE (dfe_last_sync_at, dfe_last_error, dfe_last_error_at) ON public.plg_fiscalbr_emitter_settings TO fayz_integration;\n DROP POLICY IF EXISTS plg_fiscalbr_emitter_settings_service_health ON public.plg_fiscalbr_emitter_settings;\n CREATE POLICY plg_fiscalbr_emitter_settings_service_health ON public.plg_fiscalbr_emitter_settings\n FOR UPDATE TO fayz_integration USING (true) WITH CHECK (true);\n DROP POLICY IF EXISTS plg_fiscalbr_emitter_settings_service_read ON public.plg_fiscalbr_emitter_settings;\n CREATE POLICY plg_fiscalbr_emitter_settings_service_read ON public.plg_fiscalbr_emitter_settings\n FOR SELECT TO fayz_integration USING (true);\n END IF;\nEND $$;\n`\n\nexport const MIGRATION_030_THE_SERVICE_ONBOARDS_THE_EMITTER = `-- ============================================================================\n-- 030 — the integration service onboards the emitter\n--\n-- The integration service registers the company at PlugNotas (certificate,\n-- company, NF-e and NFC-e configuration, webhook) from the integration screen\n-- (control-sdk-backend ADR 0008). The emitter's facts stay here: the legal\n-- entity (public.company_legal_entities) and its settings\n-- (plg_fiscalbr_emitter_settings, one row per CNPJ). These are the doors the\n-- service reads and writes them through, one function per action, the\n-- tenant explicit, open only to \\`fayz_integration\\`.\n--\n-- NF-e gets what NFC-e already had: environment, series and the next number\n-- (numbering continues from the previous system, so no number is skipped or\n-- used twice). The CSC token stays write-only for the screen; the service\n-- reads it here because PlugNotas needs it to sign NFC-e.\n-- ============================================================================\n\nALTER TABLE public.plg_fiscalbr_emitter_settings\n ADD COLUMN IF NOT EXISTS nfe_production boolean NOT NULL DEFAULT false,\n ADD COLUMN IF NOT EXISTS nfe_series integer CHECK (nfe_series IS NULL OR nfe_series > 0),\n ADD COLUMN IF NOT EXISTS nfe_next_number bigint CHECK (nfe_next_number IS NULL OR nfe_next_number > 0),\n ADD COLUMN IF NOT EXISTS nfce_next_number bigint CHECK (nfce_next_number IS NULL OR nfce_next_number > 0),\n ADD COLUMN IF NOT EXISTS provider_webhook_at timestamptz,\n -- PlugNotas' codes. The legal entity's tax_regime is free text copied from\n -- the V1 ('simples_nacional', 'other'); guessing a regime from 'other' would\n -- register the company wrong, so the store states it once.\n ADD COLUMN IF NOT EXISTS tax_regime_code smallint CHECK (tax_regime_code IS NULL OR tax_regime_code BETWEEN 1 AND 3),\n ADD COLUMN IF NOT EXISTS special_tax_regime_code smallint NOT NULL DEFAULT 0 CHECK (special_tax_regime_code BETWEEN 0 AND 11);\n\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.nfe_next_number IS\n 'The next NF-e number PlugNotas will use for nfe_series; set when the store moves from another system.';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.tax_regime_code IS\n '1 Simples Nacional, 2 Simples Nacional over the sublimit, 3 normal regime (presumed or real profit).';\nCOMMENT ON COLUMN public.plg_fiscalbr_emitter_settings.provider_webhook_at IS\n 'When the integration service registered its webhook for this CNPJ at PlugNotas.';\n\n-- One emitter as the service needs it: the legal entity and its settings,\n-- CSC token included. NULL when the legal entity is not the tenant's.\nCREATE OR REPLACE FUNCTION public.fiscalbr_emitter_for_service(p_tenant uuid, p_legal_entity uuid)\nRETURNS jsonb\nLANGUAGE sql STABLE SECURITY DEFINER\nSET search_path TO ''\nAS $$\n SELECT jsonb_build_object(\n 'legalEntityId', l.id,\n 'tenantId', l.tenant_id,\n 'cnpj', regexp_replace(coalesce(l.document_number, ''), '\\\\D', '', 'g'),\n 'legalName', l.legal_name,\n 'tradeName', l.trade_name,\n 'stateRegistration', l.state_registration,\n 'municipalRegistration', l.municipal_registration,\n 'taxRegime', l.tax_regime,\n -- Stated on the emitter, else read from a regime the text names unambiguously.\n 'taxRegimeCode', coalesce(s.tax_regime_code, CASE WHEN l.tax_regime = 'simples_nacional' THEN 1 END),\n 'specialTaxRegimeCode', coalesce(s.special_tax_regime_code, 0),\n 'cnae', l.cnae,\n 'email', coalesce(nullif(l.fiscal_email, ''), l.email),\n 'phone', l.phone,\n -- The address is a row of public.addresses (142); the legal entity's own\n -- columns are the retired shape, read only when no address row exists.\n 'address', CASE WHEN a.id IS NOT NULL THEN jsonb_build_object(\n 'street', a.street, 'number', a.number, 'complement', a.complement,\n 'district', a.district, 'city', a.city, 'state', a.state, 'postalCode', a.postal_code,\n 'ibgeCityCode', l.ibge_city_code, 'country', a.country\n ) ELSE jsonb_build_object(\n 'street', l.address, 'number', l.address_number, 'complement', l.address_complement,\n 'district', l.district, 'city', l.city, 'state', l.state, 'postalCode', l.postal_code,\n 'ibgeCityCode', l.ibge_city_code, 'country', l.country\n ) END,\n 'settings', CASE WHEN s.id IS NULL THEN NULL ELSE jsonb_build_object(\n 'nfe', jsonb_build_object('enabled', s.nfe_enabled, 'production', s.nfe_production, 'series', s.nfe_series, 'nextNumber', s.nfe_next_number),\n 'nfce', jsonb_build_object('enabled', s.nfce_enabled, 'production', s.nfce_production, 'series', s.nfce_series, 'nextNumber', s.nfce_next_number,\n 'cscId', s.nfce_csc_id, 'cscToken', s.nfce_csc_token, 'genericItem', s.nfce_generic_item),\n 'certificate', jsonb_build_object('providerId', s.certificate_provider_id, 'holder', s.certificate_cn,\n 'expiresAt', s.certificate_expires_at, 'uploadedAt', s.certificate_uploaded_at),\n 'providerSyncedAt', s.provider_synced_at,\n 'providerWebhookAt', s.provider_webhook_at\n ) END\n )\n FROM public.company_legal_entities l\n LEFT JOIN public.plg_fiscalbr_emitter_settings s ON s.legal_entity_id = l.id AND s.tenant_id = l.tenant_id\n LEFT JOIN LATERAL (\n SELECT * FROM public.addresses x\n WHERE x.tenant_id = l.tenant_id AND x.owner_type = 'company_legal_entity' AND x.owner_id = l.id\n ORDER BY (x.kind = 'fiscal') DESC, x.is_default DESC, x.created_at\n LIMIT 1\n ) a ON true\n WHERE l.id = p_legal_entity AND l.tenant_id = p_tenant\n$$;\n\n-- Every legal entity of the tenant that a unit points at: the emitters a\n-- screen can set up.\nCREATE OR REPLACE FUNCTION public.fiscalbr_emitters_for_service(p_tenant uuid)\nRETURNS SETOF jsonb\nLANGUAGE sql STABLE SECURITY DEFINER\nSET search_path TO ''\nAS $$\n SELECT public.fiscalbr_emitter_for_service(p_tenant, l.id)\n FROM public.company_legal_entities l\n WHERE l.tenant_id = p_tenant\n AND EXISTS (SELECT 1 FROM app.units u WHERE u.tenant_id = p_tenant AND u.company_legal_entity_id = l.id)\n ORDER BY l.legal_name\n$$;\n\n-- Records what the service set at PlugNotas. Only the keys below move; the\n-- row is created on first write.\nCREATE OR REPLACE FUNCTION public.fiscalbr_emitter_record(p_tenant uuid, p_legal_entity uuid, p_patch jsonb)\nRETURNS void\nLANGUAGE plpgsql SECURITY DEFINER\nSET search_path TO ''\nAS $$\nDECLARE\n v_unknown text;\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM public.company_legal_entities WHERE id = p_legal_entity AND tenant_id = p_tenant) THEN\n RAISE EXCEPTION 'fiscalbr_emitter_record: legal entity % is not of tenant %', p_legal_entity, p_tenant USING ERRCODE = 'P0002';\n END IF;\n SELECT k INTO v_unknown FROM jsonb_object_keys(p_patch) k\n WHERE k NOT IN ('nfe_enabled', 'nfe_production', 'nfe_series', 'nfe_next_number',\n 'nfce_enabled', 'nfce_production', 'nfce_series', 'nfce_next_number', 'nfce_csc_id', 'nfce_csc_token',\n 'certificate_provider_id', 'certificate_cn', 'certificate_expires_at', 'certificate_uploaded_at',\n 'provider_synced_at', 'provider_webhook_at', 'tax_regime_code', 'special_tax_regime_code')\n LIMIT 1;\n IF v_unknown IS NOT NULL THEN\n RAISE EXCEPTION 'fiscalbr_emitter_record: % is not recorded here', v_unknown USING ERRCODE = '22023';\n END IF;\n\n INSERT INTO public.plg_fiscalbr_emitter_settings (tenant_id, legal_entity_id)\n VALUES (p_tenant, p_legal_entity)\n ON CONFLICT (legal_entity_id) DO NOTHING;\n\n UPDATE public.plg_fiscalbr_emitter_settings s SET\n nfe_enabled = CASE WHEN p_patch ? 'nfe_enabled' THEN (p_patch ->> 'nfe_enabled')::boolean ELSE s.nfe_enabled END,\n nfe_production = CASE WHEN p_patch ? 'nfe_production' THEN (p_patch ->> 'nfe_production')::boolean ELSE s.nfe_production END,\n nfe_series = CASE WHEN p_patch ? 'nfe_series' THEN (p_patch ->> 'nfe_series')::integer ELSE s.nfe_series END,\n nfe_next_number = CASE WHEN p_patch ? 'nfe_next_number' THEN (p_patch ->> 'nfe_next_number')::bigint ELSE s.nfe_next_number END,\n nfce_enabled = CASE WHEN p_patch ? 'nfce_enabled' THEN (p_patch ->> 'nfce_enabled')::boolean ELSE s.nfce_enabled END,\n nfce_production = CASE WHEN p_patch ? 'nfce_production' THEN (p_patch ->> 'nfce_production')::boolean ELSE s.nfce_production END,\n nfce_series = CASE WHEN p_patch ? 'nfce_series' THEN (p_patch ->> 'nfce_series')::integer ELSE s.nfce_series END,\n nfce_next_number = CASE WHEN p_patch ? 'nfce_next_number' THEN (p_patch ->> 'nfce_next_number')::bigint ELSE s.nfce_next_number END,\n nfce_csc_id = CASE WHEN p_patch ? 'nfce_csc_id' THEN p_patch ->> 'nfce_csc_id' ELSE s.nfce_csc_id END,\n nfce_csc_token = CASE WHEN p_patch ? 'nfce_csc_token' THEN p_patch ->> 'nfce_csc_token' ELSE s.nfce_csc_token END,\n certificate_provider_id = CASE WHEN p_patch ? 'certificate_provider_id' THEN p_patch ->> 'certificate_provider_id' ELSE s.certificate_provider_id END,\n certificate_cn = CASE WHEN p_patch ? 'certificate_cn' THEN p_patch ->> 'certificate_cn' ELSE s.certificate_cn END,\n certificate_expires_at = CASE WHEN p_patch ? 'certificate_expires_at' THEN (p_patch ->> 'certificate_expires_at')::timestamptz ELSE s.certificate_expires_at END,\n certificate_uploaded_at = CASE WHEN p_patch ? 'certificate_uploaded_at' THEN (p_patch ->> 'certificate_uploaded_at')::timestamptz ELSE s.certificate_uploaded_at END,\n provider_synced_at = CASE WHEN p_patch ? 'provider_synced_at' THEN (p_patch ->> 'provider_synced_at')::timestamptz ELSE s.provider_synced_at END,\n provider_webhook_at = CASE WHEN p_patch ? 'provider_webhook_at' THEN (p_patch ->> 'provider_webhook_at')::timestamptz ELSE s.provider_webhook_at END,\n tax_regime_code = CASE WHEN p_patch ? 'tax_regime_code' THEN (p_patch ->> 'tax_regime_code')::smallint ELSE s.tax_regime_code END,\n special_tax_regime_code = CASE WHEN p_patch ? 'special_tax_regime_code' THEN coalesce((p_patch ->> 'special_tax_regime_code')::smallint, 0) ELSE s.special_tax_regime_code END,\n updated_at = now()\n WHERE s.legal_entity_id = p_legal_entity AND s.tenant_id = p_tenant;\nEND\n$$;\n\nREVOKE ALL ON FUNCTION public.fiscalbr_emitter_for_service(uuid, uuid) FROM PUBLIC, anon, authenticated;\nREVOKE ALL ON FUNCTION public.fiscalbr_emitters_for_service(uuid) FROM PUBLIC, anon, authenticated;\nREVOKE ALL ON FUNCTION public.fiscalbr_emitter_record(uuid, uuid, jsonb) FROM PUBLIC, anon, authenticated;\n\nDO $$\nBEGIN\n IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fayz_integration') THEN\n GRANT USAGE ON SCHEMA public TO fayz_integration;\n GRANT EXECUTE ON FUNCTION public.fiscalbr_emitter_for_service(uuid, uuid) TO fayz_integration;\n GRANT EXECUTE ON FUNCTION public.fiscalbr_emitters_for_service(uuid) TO fayz_integration;\n GRANT EXECUTE ON FUNCTION public.fiscalbr_emitter_record(uuid, uuid, jsonb) TO fayz_integration;\n END IF;\nEND\n$$;\n`\n\nexport const MIGRATION_031_THE_SERVICE_ISSUES_THE_NOTE = `-- ============================================================================\n-- 031 — the integration service issues the order's note\n--\n-- control-sdk-backend ADR 0008, step 2: an operator clicks \"Emitir NFC-e\" on\n-- an order and the integration service builds the note with the V1's rules,\n-- sends it to PlugNotas and records what comes back. The note is the record\n-- 005 already defined (public.documents + plg_fiscalbr_documents, linked to\n-- the order by plg_fiscalbr_document_orders); these are the service's doors to\n-- it, one function per action, the tenant explicit, open only to\n-- \\`fayz_integration\\`.\n--\n-- fiscalbr_order_for_note_for_service what the note is made of\n-- fiscalbr_note_open_for_service the note, opened before the call\n-- fiscalbr_note_apply_for_service what PlugNotas answered\n-- fiscalbr_notes_for_service an order's notes\n-- fiscalbr_note_for_service one note\n-- fiscalbr_notes_to_follow_for_service notes still waiting on PlugNotas\n-- fiscalbr_note_by_integration_for_service where a webhook belongs\n--\n-- A note is opened BEFORE PlugNotas is called, with the idIntegracao it will\n-- carry, so a call whose answer is lost is found again by that id instead of\n-- being sent twice (the V1's FISCAL_PROVIDER_RESULT_UNCERTAIN rule).\n-- ============================================================================\n\n-- What the note of an order is made of: the order, its lines with their tax\n-- data, what was paid and how, who bought and where it goes. The service\n-- applies the rules; this only gathers.\nCREATE OR REPLACE FUNCTION public.fiscalbr_order_for_note_for_service(p_tenant uuid, p_order uuid)\nRETURNS jsonb\nLANGUAGE plpgsql STABLE SECURITY DEFINER\nSET search_path TO ''\nAS $$\nDECLARE\n v_checkout jsonb;\nBEGIN\n -- The shop is optional: a site order says how it was paid on its checkout,\n -- read only where the shop's table exists.\n IF to_regclass('public.plg_shop_checkout_sessions') IS NOT NULL THEN\n EXECUTE $q$\n SELECT jsonb_build_object(\n 'paymentMethod', s.payment_method, 'provider', s.provider, 'total', s.total, 'status', s.status,\n 'customerName', s.customer_name, 'customerDocument', s.customer_document,\n 'destinationState', s.shipping_address ->> 'state', 'destinationCity', s.shipping_address ->> 'city')\n FROM public.plg_shop_checkout_sessions s\n WHERE s.order_id = $1 AND s.tenant_id = $2 AND s.status = 'paid'\n ORDER BY s.updated_at DESC LIMIT 1 $q$\n INTO v_checkout USING p_order, p_tenant;\n END IF;\n\n RETURN (SELECT jsonb_build_object(\n 'orderId', o.id,\n 'tenantId', o.tenant_id,\n 'unitId', o.unit_id,\n 'kind', o.kind,\n 'status', o.status,\n 'channel', o.channel,\n 'createdAt', o.created_at,\n 'subtotal', o.subtotal,\n 'discount', o.discount,\n 'shipping', o.shipping,\n 'total', o.total,\n 'legalEntityId', u.company_legal_entity_id,\n 'items', coalesce((\n SELECT jsonb_agg(jsonb_build_object(\n 'itemId', i.id,\n 'productId', i.product_id,\n 'code', coalesce(nullif(p.sku, ''), i.product_id::text, i.id::text),\n 'name', i.name,\n 'quantity', i.quantity,\n 'unitPrice', i.unit_price,\n 'discount', i.discount,\n 'total', i.total,\n 'cancelled', i.cancelled_at IS NOT NULL,\n -- The product's own value wins, field by field, over its group's (V1).\n 'tax', CASE WHEN t.product_id IS NULL AND g.id IS NULL THEN NULL ELSE jsonb_build_object(\n 'ncm', t.ncm,\n 'cest', coalesce(t.cest, g.cest),\n 'cfop', coalesce(nullif(t.cfop, ''), g.cfop),\n 'cst', coalesce(nullif(t.cst, ''), g.cst),\n 'csosn', g.csosn,\n 'origin', coalesce(nullif(t.icms_origin, ''), g.icms_origin),\n 'icmsRate', CASE WHEN coalesce(t.icms_rate, 0) > 0 THEN t.icms_rate ELSE g.icms_rate END,\n 'icmsBaseMode', g.icms_bc_mode,\n 'pisSituation', coalesce(nullif(t.pis_situation, ''), g.pis_situation),\n 'pisRate', CASE WHEN coalesce(t.pis_rate, 0) > 0 THEN t.pis_rate ELSE g.pis_rate END,\n 'cofinsSituation', coalesce(nullif(t.cofins_situation, ''), g.cofins_situation),\n 'cofinsRate', CASE WHEN coalesce(t.cofins_rate, 0) > 0 THEN t.cofins_rate ELSE g.cofins_rate END,\n 'ibsCbsSituation', coalesce(nullif(t.ibs_cbs_situation, ''), g.ibs_cbs_situation),\n 'ibsCbsClassification', coalesce(nullif(t.ibs_cbs_classification, ''), g.ibs_cbs_classification),\n 'group', g.name\n ) END\n ) ORDER BY i.sort_order, i.created_at)\n FROM public.items i\n LEFT JOIN public.products p ON p.id = i.product_id AND p.tenant_id = i.tenant_id\n LEFT JOIN public.plg_fiscalbr_product_tax_info t ON t.product_id = i.product_id AND t.tenant_id = i.tenant_id\n LEFT JOIN public.plg_fiscalbr_tax_groups g ON g.id = t.tax_group_id AND g.tenant_id = i.tenant_id\n WHERE i.order_id = o.id AND i.tenant_id = o.tenant_id\n ), '[]'::jsonb),\n -- Anything that is not a site order is paid through the movements the\n -- order received, net of reversals, by method.\n 'checkout', v_checkout,\n 'payments', coalesce((\n SELECT jsonb_agg(jsonb_build_object('method', x.method, 'methodType', x.method_type, 'amount', x.amount))\n FROM (\n SELECT pm.name AS method, pt.name AS method_type,\n sum(CASE WHEN m.direction = 'credit' THEN m.paid_amount ELSE -m.paid_amount END) AS amount\n FROM public.plg_financial_movements m\n LEFT JOIN public.plg_financial_payment_methods pm ON pm.id = m.payment_method_id\n LEFT JOIN public.plg_financial_payment_method_types pt ON pt.id = pm.payment_method_type_id\n WHERE m.tenant_id = o.tenant_id AND m.movement_kind = 'payment' AND m.status = 'paid'\n AND (m.invoice_id = o.id OR m.financial_invoice_id IN (\n SELECT fi.id FROM public.plg_financial_invoices fi WHERE fi.order_id = o.id AND fi.tenant_id = o.tenant_id))\n GROUP BY pm.name, pt.name\n ) x WHERE x.amount <> 0\n ), '[]'::jsonb),\n 'notes', coalesce((\n SELECT jsonb_agg(jsonb_build_object('documentId', d.document_id, 'model', d.model, 'status', d.status))\n FROM public.plg_fiscalbr_documents d\n WHERE d.tenant_id = o.tenant_id AND d.order_id = o.id\n ), '[]'::jsonb)\n )\n FROM public.orders o\n LEFT JOIN app.units u ON u.id = o.unit_id AND u.tenant_id = o.tenant_id\n WHERE o.id = p_order AND o.tenant_id = p_tenant);\nEND\n$$;\n\n-- The note, opened before PlugNotas is called. One live note per order and\n-- model: a second \"Emitir\" while one is processing or authorized is refused\n-- (P0001 note_exists), so a double click never issues twice.\nCREATE OR REPLACE FUNCTION public.fiscalbr_note_open_for_service(\n p_tenant uuid, p_order uuid, p_model text, p_input jsonb\n)\nRETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER\nSET search_path TO ''\nAS $$\nDECLARE\n v_order public.orders%ROWTYPE;\n v_doc uuid := gen_random_uuid();\n v_integration text;\n v_existing uuid;\nBEGIN\n IF p_model NOT IN ('nfce', 'nfe') THEN\n RAISE EXCEPTION 'fiscalbr_note_open: model % is not issued here', p_model USING ERRCODE = '22023';\n END IF;\n SELECT * INTO v_order FROM public.orders WHERE id = p_order AND tenant_id = p_tenant FOR UPDATE;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'fiscalbr_note_open: order % is not of tenant %', p_order, p_tenant USING ERRCODE = 'P0002';\n END IF;\n SELECT document_id INTO v_existing FROM public.plg_fiscalbr_documents\n WHERE tenant_id = p_tenant AND order_id = p_order AND model = p_model AND status IN ('processing', 'authorized')\n LIMIT 1;\n IF v_existing IS NOT NULL THEN\n RAISE EXCEPTION 'note_exists' USING ERRCODE = 'P0001', DETAIL = v_existing::text;\n END IF;\n\n -- Ours, and unique per company at PlugNotas: how a lost answer is found again.\n v_integration := 'csb-' || v_doc::text;\n\n INSERT INTO public.documents (id, tenant_id, kind, title, status, unit_id, subject_type, subject_id, created_by, metadata)\n VALUES (v_doc, p_tenant, 'fiscal_document', upper(p_model) || ' do pedido', 'processing', v_order.unit_id,\n 'order', p_order, nullif(p_input ->> 'userId', '')::uuid, jsonb_build_object('source', 'integration-service'));\n\n INSERT INTO public.plg_fiscalbr_documents (\n document_id, tenant_id, unit_id, order_id, model, environment, status, series, provider,\n provider_integration_id, total_value, recipient_snapshot, request_payload, source_snapshot, emitted_by_user_id, metadata)\n VALUES (\n v_doc, p_tenant, v_order.unit_id, p_order, p_model, p_input ->> 'environment', 'processing', p_input ->> 'series', 'plugnotas',\n v_integration, (p_input ->> 'total')::numeric, p_input -> 'recipient', p_input -> 'request', p_input -> 'source',\n nullif(p_input ->> 'userId', '')::uuid, jsonb_build_object('emitterCnpj', p_input ->> 'emitterCnpj'));\n\n INSERT INTO public.plg_fiscalbr_document_orders (tenant_id, document_id, order_id)\n VALUES (p_tenant, v_doc, p_order);\n\n INSERT INTO public.plg_fiscalbr_document_events (tenant_id, unit_id, document_id, event_type, status, created_by_user_id)\n VALUES (p_tenant, v_order.unit_id, v_doc, 'issue_requested', 'processing', nullif(p_input ->> 'userId', '')::uuid);\n\n RETURN jsonb_build_object('documentId', v_doc, 'integrationId', v_integration);\nEND\n$$;\n\n-- What PlugNotas answered, on the note and on its public.documents row (the\n-- same note seen from two places, 005). Identity (model, unit, order) never moves.\nCREATE OR REPLACE FUNCTION public.fiscalbr_note_apply_for_service(\n p_tenant uuid, p_document uuid, p_patch jsonb, p_event jsonb DEFAULT NULL\n)\nRETURNS void\nLANGUAGE plpgsql SECURITY DEFINER\nSET search_path TO ''\nAS $$\nDECLARE\n v public.plg_fiscalbr_documents%ROWTYPE;\n v_status text := p_patch ->> 'status';\nBEGIN\n SELECT * INTO v FROM public.plg_fiscalbr_documents\n WHERE document_id = p_document AND tenant_id = p_tenant FOR UPDATE;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'fiscalbr_note_apply: note % is not of tenant %', p_document, p_tenant USING ERRCODE = 'P0002';\n END IF;\n IF v_status IS NOT NULL AND v_status NOT IN ('processing', 'authorized', 'rejected', 'error', 'cancelled', 'substituted') THEN\n RAISE EXCEPTION 'fiscalbr_note_apply: unknown status %', v_status USING ERRCODE = '22023';\n END IF;\n\n UPDATE public.plg_fiscalbr_documents SET\n status = coalesce(v_status, status),\n number = coalesce(p_patch ->> 'number', number),\n series = coalesce(p_patch ->> 'series', series),\n access_key = coalesce(p_patch ->> 'access_key', access_key),\n protocol = coalesce(p_patch ->> 'protocol', protocol),\n provider_document_id = coalesce(p_patch ->> 'provider_document_id', provider_document_id),\n response_payload = coalesce(p_patch -> 'response_payload', response_payload),\n status_payload = coalesce(p_patch -> 'status_payload', status_payload),\n rejection_payload = CASE WHEN p_patch ? 'rejection_payload' THEN p_patch -> 'rejection_payload' ELSE rejection_payload END,\n xml = coalesce(p_patch ->> 'xml', xml),\n authorized_at = coalesce((p_patch ->> 'authorized_at')::timestamptz, authorized_at),\n last_polled_at = CASE WHEN p_patch ? 'polled' THEN now() ELSE last_polled_at END,\n cancelled_at = CASE WHEN p_patch ? 'cancelled_at' THEN (p_patch ->> 'cancelled_at')::timestamptz ELSE cancelled_at END,\n cancel_reason = CASE WHEN p_patch ? 'cancel_reason' THEN p_patch ->> 'cancel_reason' ELSE cancel_reason END,\n cancelled_by_user_id = CASE WHEN p_patch ? 'cancelled_by_user_id' THEN nullif(p_patch ->> 'cancelled_by_user_id', '')::uuid ELSE cancelled_by_user_id END,\n metadata = metadata || coalesce(p_patch -> 'metadata', '{}'::jsonb)\n WHERE document_id = p_document;\n\n IF v_status IS NOT NULL THEN\n UPDATE public.documents SET status = v_status WHERE id = p_document AND status IS DISTINCT FROM v_status;\n END IF;\n\n IF p_event IS NOT NULL THEN\n INSERT INTO public.plg_fiscalbr_document_events\n (tenant_id, unit_id, document_id, event_type, status, justification, response_payload, created_by_user_id)\n VALUES (v.tenant_id, v.unit_id, p_document, p_event ->> 'event_type', p_event ->> 'status',\n p_event ->> 'justification', p_event -> 'response_payload', nullif(p_event ->> 'userId', '')::uuid);\n END IF;\nEND\n$$;\n\nCREATE OR REPLACE FUNCTION public.fiscalbr_note_for_service(p_tenant uuid, p_document uuid)\nRETURNS jsonb\nLANGUAGE sql STABLE SECURITY DEFINER\nSET search_path TO ''\nAS $$\n SELECT jsonb_build_object(\n 'documentId', d.document_id, 'tenantId', d.tenant_id, 'unitId', d.unit_id, 'orderId', d.order_id,\n 'model', d.model, 'environment', d.environment, 'status', d.status, 'series', d.series, 'number', d.number,\n 'accessKey', d.access_key, 'protocol', d.protocol, 'provider', d.provider,\n 'providerDocumentId', d.provider_document_id, 'integrationId', d.provider_integration_id,\n 'total', d.total_value, 'recipient', d.recipient_snapshot, 'rejection', d.rejection_payload,\n 'hasXml', d.xml IS NOT NULL, 'authorizedAt', d.authorized_at, 'lastPolledAt', d.last_polled_at,\n 'cancelledAt', d.cancelled_at, 'cancelReason', d.cancel_reason, 'createdAt', d.created_at,\n 'emitterCnpj', d.metadata ->> 'emitterCnpj', 'metadata', d.metadata)\n FROM public.plg_fiscalbr_documents d\n WHERE d.document_id = p_document AND d.tenant_id = p_tenant\n$$;\n\nCREATE OR REPLACE FUNCTION public.fiscalbr_notes_for_service(p_tenant uuid, p_order uuid)\nRETURNS SETOF jsonb\nLANGUAGE sql STABLE SECURITY DEFINER\nSET search_path TO ''\nAS $$\n SELECT public.fiscalbr_note_for_service(p_tenant, d.document_id)\n FROM public.plg_fiscalbr_documents d\n WHERE d.tenant_id = p_tenant AND d.order_id = p_order\n ORDER BY d.created_at DESC\n$$;\n\n-- The XML is large and read only on download.\nCREATE OR REPLACE FUNCTION public.fiscalbr_note_xml_for_service(p_tenant uuid, p_document uuid)\nRETURNS text\nLANGUAGE sql STABLE SECURITY DEFINER\nSET search_path TO ''\nAS $$\n SELECT d.xml FROM public.plg_fiscalbr_documents d WHERE d.document_id = p_document AND d.tenant_id = p_tenant\n$$;\n\n-- Where a PlugNotas webhook belongs: our idIntegracao names the note.\nCREATE OR REPLACE FUNCTION public.fiscalbr_note_by_integration_for_service(p_integration text)\nRETURNS jsonb\nLANGUAGE sql STABLE SECURITY DEFINER\nSET search_path TO ''\nAS $$\n SELECT jsonb_build_object('tenantId', d.tenant_id, 'documentId', d.document_id)\n FROM public.plg_fiscalbr_documents d\n WHERE d.provider_integration_id = p_integration AND d.provider = 'plugnotas'\n LIMIT 1\n$$;\n\n-- Notes this service sent that PlugNotas has not settled: the fallback when a\n-- webhook does not arrive. Oldest poll first.\nCREATE OR REPLACE FUNCTION public.fiscalbr_notes_to_follow_for_service(p_limit integer DEFAULT 25)\nRETURNS SETOF jsonb\nLANGUAGE sql STABLE SECURITY DEFINER\nSET search_path TO ''\nAS $$\n SELECT public.fiscalbr_note_for_service(d.tenant_id, d.document_id)\n FROM public.plg_fiscalbr_documents d\n WHERE d.provider = 'plugnotas' AND d.status = 'processing' AND d.provider_integration_id LIKE 'csb-%'\n AND d.created_at > now() - interval '7 days'\n ORDER BY coalesce(d.last_polled_at, d.created_at)\n LIMIT greatest(1, least(p_limit, 100))\n$$;\n\nDO $$\nDECLARE\n f text;\nBEGIN\n FOREACH f IN ARRAY ARRAY[\n 'public.fiscalbr_order_for_note_for_service(uuid, uuid)',\n 'public.fiscalbr_note_open_for_service(uuid, uuid, text, jsonb)',\n 'public.fiscalbr_note_apply_for_service(uuid, uuid, jsonb, jsonb)',\n 'public.fiscalbr_note_for_service(uuid, uuid)',\n 'public.fiscalbr_notes_for_service(uuid, uuid)',\n 'public.fiscalbr_note_xml_for_service(uuid, uuid)',\n 'public.fiscalbr_notes_to_follow_for_service(integer)',\n 'public.fiscalbr_note_by_integration_for_service(text)'\n ] LOOP\n EXECUTE format('REVOKE ALL ON FUNCTION %s FROM PUBLIC, anon, authenticated', f);\n IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fayz_integration') THEN\n EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO fayz_integration', f);\n END IF;\n END LOOP;\nEND\n$$;\n`\n\nexport const MIGRATION_032_A_DELIVERED_NOTE_CARRIES_WHERE_IT_GOES = `-- ============================================================================\n-- 032 — a home-delivery NFC-e carries where it goes\n--\n-- The SEFAZ rejected Artorius' NFC-e delivered at home with \"NFC-e de entrega\n-- a domicilio sem o endereco do destinatario\": indPres 4 requires the\n-- recipient's address. The order's source for its note now carries the\n-- checkout's delivery address and the customer's email; the integration\n-- service adds the city's IBGE code (control-sdk-backend ADR 0008).\n-- ============================================================================\n\nCREATE OR REPLACE FUNCTION public.fiscalbr_order_for_note_for_service(p_tenant uuid, p_order uuid)\nRETURNS jsonb\nLANGUAGE plpgsql STABLE SECURITY DEFINER\nSET search_path TO ''\nAS $$\nDECLARE\n v_checkout jsonb;\nBEGIN\n -- The shop is optional: a site order says how it was paid on its checkout,\n -- read only where the shop's table exists.\n IF to_regclass('public.plg_shop_checkout_sessions') IS NOT NULL THEN\n EXECUTE $q$\n SELECT jsonb_build_object(\n 'paymentMethod', s.payment_method, 'provider', s.provider, 'total', s.total, 'status', s.status,\n 'customerName', s.customer_name, 'customerDocument', s.customer_document,\n 'destinationState', s.shipping_address ->> 'state', 'destinationCity', s.shipping_address ->> 'city',\n 'customerEmail', s.customer_email,\n 'shippingAddress', CASE WHEN s.shipping_address IS NULL THEN NULL ELSE jsonb_build_object(\n 'street', s.shipping_address ->> 'street', 'number', s.shipping_address ->> 'number',\n 'complement', s.shipping_address ->> 'complement', 'district', s.shipping_address ->> 'district',\n 'city', s.shipping_address ->> 'city', 'state', s.shipping_address ->> 'state',\n 'postalCode', coalesce(s.shipping_address ->> 'postalCode', s.shipping_address ->> 'postal_code')) END)\n FROM public.plg_shop_checkout_sessions s\n WHERE s.order_id = $1 AND s.tenant_id = $2 AND s.status = 'paid'\n ORDER BY s.updated_at DESC LIMIT 1 $q$\n INTO v_checkout USING p_order, p_tenant;\n END IF;\n\n RETURN (SELECT jsonb_build_object(\n 'orderId', o.id,\n 'tenantId', o.tenant_id,\n 'unitId', o.unit_id,\n 'kind', o.kind,\n 'status', o.status,\n 'channel', o.channel,\n 'createdAt', o.created_at,\n 'subtotal', o.subtotal,\n 'discount', o.discount,\n 'shipping', o.shipping,\n 'total', o.total,\n 'legalEntityId', u.company_legal_entity_id,\n 'items', coalesce((\n SELECT jsonb_agg(jsonb_build_object(\n 'itemId', i.id,\n 'productId', i.product_id,\n 'code', coalesce(nullif(p.sku, ''), i.product_id::text, i.id::text),\n 'name', i.name,\n 'quantity', i.quantity,\n 'unitPrice', i.unit_price,\n 'discount', i.discount,\n 'total', i.total,\n 'cancelled', i.cancelled_at IS NOT NULL,\n -- The product's own value wins, field by field, over its group's (V1).\n 'tax', CASE WHEN t.product_id IS NULL AND g.id IS NULL THEN NULL ELSE jsonb_build_object(\n 'ncm', t.ncm,\n 'cest', coalesce(t.cest, g.cest),\n 'cfop', coalesce(nullif(t.cfop, ''), g.cfop),\n 'cst', coalesce(nullif(t.cst, ''), g.cst),\n 'csosn', g.csosn,\n 'origin', coalesce(nullif(t.icms_origin, ''), g.icms_origin),\n 'icmsRate', CASE WHEN coalesce(t.icms_rate, 0) > 0 THEN t.icms_rate ELSE g.icms_rate END,\n 'icmsBaseMode', g.icms_bc_mode,\n 'pisSituation', coalesce(nullif(t.pis_situation, ''), g.pis_situation),\n 'pisRate', CASE WHEN coalesce(t.pis_rate, 0) > 0 THEN t.pis_rate ELSE g.pis_rate END,\n 'cofinsSituation', coalesce(nullif(t.cofins_situation, ''), g.cofins_situation),\n 'cofinsRate', CASE WHEN coalesce(t.cofins_rate, 0) > 0 THEN t.cofins_rate ELSE g.cofins_rate END,\n 'ibsCbsSituation', coalesce(nullif(t.ibs_cbs_situation, ''), g.ibs_cbs_situation),\n 'ibsCbsClassification', coalesce(nullif(t.ibs_cbs_classification, ''), g.ibs_cbs_classification),\n 'group', g.name\n ) END\n ) ORDER BY i.sort_order, i.created_at)\n FROM public.items i\n LEFT JOIN public.products p ON p.id = i.product_id AND p.tenant_id = i.tenant_id\n LEFT JOIN public.plg_fiscalbr_product_tax_info t ON t.product_id = i.product_id AND t.tenant_id = i.tenant_id\n LEFT JOIN public.plg_fiscalbr_tax_groups g ON g.id = t.tax_group_id AND g.tenant_id = i.tenant_id\n WHERE i.order_id = o.id AND i.tenant_id = o.tenant_id\n ), '[]'::jsonb),\n -- Anything that is not a site order is paid through the movements the\n -- order received, net of reversals, by method.\n 'checkout', v_checkout,\n 'payments', coalesce((\n SELECT jsonb_agg(jsonb_build_object('method', x.method, 'methodType', x.method_type, 'amount', x.amount))\n FROM (\n SELECT pm.name AS method, pt.name AS method_type,\n sum(CASE WHEN m.direction = 'credit' THEN m.paid_amount ELSE -m.paid_amount END) AS amount\n FROM public.plg_financial_movements m\n LEFT JOIN public.plg_financial_payment_methods pm ON pm.id = m.payment_method_id\n LEFT JOIN public.plg_financial_payment_method_types pt ON pt.id = pm.payment_method_type_id\n WHERE m.tenant_id = o.tenant_id AND m.movement_kind = 'payment' AND m.status = 'paid'\n AND (m.invoice_id = o.id OR m.financial_invoice_id IN (\n SELECT fi.id FROM public.plg_financial_invoices fi WHERE fi.order_id = o.id AND fi.tenant_id = o.tenant_id))\n GROUP BY pm.name, pt.name\n ) x WHERE x.amount <> 0\n ), '[]'::jsonb),\n 'notes', coalesce((\n SELECT jsonb_agg(jsonb_build_object('documentId', d.document_id, 'model', d.model, 'status', d.status))\n FROM public.plg_fiscalbr_documents d\n WHERE d.tenant_id = o.tenant_id AND d.order_id = o.id\n ), '[]'::jsonb)\n )\n FROM public.orders o\n LEFT JOIN app.units u ON u.id = o.unit_id AND u.tenant_id = o.tenant_id\n WHERE o.id = p_order AND o.tenant_id = p_tenant);\nEND\n$$;\n\nREVOKE ALL ON FUNCTION public.fiscalbr_order_for_note_for_service(uuid, uuid) FROM PUBLIC, anon, authenticated;\nDO $$\nBEGIN\n IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fayz_integration') THEN\n GRANT EXECUTE ON FUNCTION public.fiscalbr_order_for_note_for_service(uuid, uuid) TO fayz_integration;\n END IF;\nEND\n$$;\n`\n\nexport const MIGRATIONS: Array<{ id: string; sql: string }> = [\n { id: \"000_baseline\", sql: MIGRATION_000_BASELINE },\n { id: \"001_preserve_terminal_results_and_recover_leases\", sql: MIGRATION_001_PRESERVE_TERMINAL_RESULTS_AND_RECOVER_LEASES },\n { id: \"002_a_nota_que_chega_tem_onde_esperar\", sql: MIGRATION_002_A_NOTA_QUE_CHEGA_TEM_ONDE_ESPERAR },\n { id: \"003_a_caixa_de_entrada_atravessa\", sql: MIGRATION_003_A_CAIXA_DE_ENTRADA_ATRAVESSA },\n { id: \"004_o_grupo_fiscal_do_v1_repete_nome\", sql: MIGRATION_004_O_GRUPO_FISCAL_DO_V1_REPETE_NOME },\n { id: \"005_a_nota_emitida_e_um_documento\", sql: MIGRATION_005_A_NOTA_EMITIDA_E_UM_DOCUMENTO },\n { id: \"006_o_grupo_fiscal_ganha_o_rail\", sql: MIGRATION_006_O_GRUPO_FISCAL_GANHA_O_RAIL },\n { id: \"007_a_rls_do_documento_passa_a_ser_legivel\", sql: MIGRATION_007_A_RLS_DO_DOCUMENTO_PASSA_A_SER_LEGIVEL },\n { id: \"008_a_nota_recebida_guarda_a_decisao_de_entrada\", sql: MIGRATION_008_A_NOTA_RECEBIDA_GUARDA_A_DECISAO_DE_ENTRADA },\n { id: \"009_aceitar_a_nota_escreve_estoque_e_titulo_de_uma_vez\", sql: MIGRATION_009_ACEITAR_A_NOTA_ESCREVE_ESTOQUE_E_TITULO_DE_UMA_VEZ },\n { id: \"010_a_data_de_emissao_e_instante_e_a_competencia_e_dia\", sql: MIGRATION_010_A_DATA_DE_EMISSAO_E_INSTANTE_E_A_COMPETENCIA_E_DIA },\n { id: \"011_o_imposto_do_produto_e_do_fiscal\", sql: MIGRATION_011_O_IMPOSTO_DO_PRODUTO_E_DO_FISCAL },\n { id: \"012_quem_e_o_produto_se_pergunta_ao_indice\", sql: MIGRATION_012_QUEM_E_O_PRODUTO_SE_PERGUNTA_AO_INDICE },\n { id: \"013_a_sugestao_sobrevive_a_fechar_a_janela\", sql: MIGRATION_013_A_SUGESTAO_SOBREVIVE_A_FECHAR_A_JANELA },\n { id: \"014_a_descricao_da_nota_tem_lixo_no_meio\", sql: MIGRATION_014_A_DESCRICAO_DA_NOTA_TEM_LIXO_NO_MEIO },\n { id: \"015_o_movimento_diz_que_veio_da_nota\", sql: MIGRATION_015_O_MOVIMENTO_DIZ_QUE_VEIO_DA_NOTA },\n { id: \"016_a_nota_guarda_o_proprio_xml\", sql: MIGRATION_016_A_NOTA_GUARDA_O_PROPRIO_XML },\n { id: \"017_quem_emite_e_o_cnpj_nao_a_loja\", sql: MIGRATION_017_QUEM_EMITE_E_O_CNPJ_NAO_A_LOJA },\n { id: \"018_a_nota_do_v1_alcanca_a_conta\", sql: MIGRATION_018_A_NOTA_DO_V1_ALCANCA_A_CONTA },\n { id: \"019_a_nota_emitida_se_cancela_pelo_v2\", sql: MIGRATION_019_A_NOTA_EMITIDA_SE_CANCELA_PELO_V2 },\n { id: \"020_a_nota_acha_o_fornecedor_pelo_cnpj\", sql: MIGRATION_020_A_NOTA_ACHA_O_FORNECEDOR_PELO_CNPJ },\n { id: \"021_uso_e_consumo_entra_no_estoque\", sql: MIGRATION_021_USO_E_CONSUMO_ENTRA_NO_ESTOQUE },\n { id: \"022_a_nota_do_v1_traz_o_xml\", sql: MIGRATION_022_A_NOTA_DO_V1_TRAZ_O_XML },\n { id: \"023_a_nota_aceita_sem_linhas_recebe_as_linhas\", sql: MIGRATION_023_A_NOTA_ACEITA_SEM_LINHAS_RECEBE_AS_LINHAS },\n { id: \"024_a_nota_nao_aponta_para_conta_que_sumiu\", sql: MIGRATION_024_A_NOTA_NAO_APONTA_PARA_CONTA_QUE_SUMIU },\n { id: \"025_a_conta_da_nota_fecha_com_o_total\", sql: MIGRATION_025_A_CONTA_DA_NOTA_FECHA_COM_O_TOTAL },\n { id: \"026_o_recebimento_parado_diz_por_que\", sql: MIGRATION_026_O_RECEBIMENTO_PARADO_DIZ_POR_QUE },\n { id: \"027_a_nota_com_conta_e_sem_estoque_termina_no_v2\", sql: MIGRATION_027_A_NOTA_COM_CONTA_E_SEM_ESTOQUE_TERMINA_NO_V2 },\n { id: \"028_a_linha_de_estoque_tem_unidade\", sql: MIGRATION_028_A_LINHA_DE_ESTOQUE_TEM_UNIDADE },\n { id: \"029_the_service_writes_the_intake_health\", sql: MIGRATION_029_THE_SERVICE_WRITES_THE_INTAKE_HEALTH },\n { id: \"030_the_service_onboards_the_emitter\", sql: MIGRATION_030_THE_SERVICE_ONBOARDS_THE_EMITTER },\n { id: \"031_the_service_issues_the_note\", sql: MIGRATION_031_THE_SERVICE_ISSUES_THE_NOTE },\n { id: \"032_a_delivered_note_carries_where_it_goes\", sql: MIGRATION_032_A_DELIVERED_NOTE_CARRIES_WHERE_IT_GOES },\n]\n"]}
|