@open-mercato/core 0.6.7-develop.6676.1.7dad6df292 → 0.6.7-develop.6685.1.77c0a5591b
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/.turbo/turbo-build.log +1 -1
- package/dist/generated/entities/gateway_payment_operation/index.js +2 -0
- package/dist/generated/entities/gateway_payment_operation/index.js.map +2 -2
- package/dist/generated/entities/gateway_transaction/index.js +2 -0
- package/dist/generated/entities/gateway_transaction/index.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +2 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/payment_gateways/api/capture/route.js +1 -1
- package/dist/modules/payment_gateways/api/capture/route.js.map +2 -2
- package/dist/modules/payment_gateways/data/entities.js +7 -0
- package/dist/modules/payment_gateways/data/entities.js.map +2 -2
- package/dist/modules/payment_gateways/lib/capture-ledger.js +183 -0
- package/dist/modules/payment_gateways/lib/capture-ledger.js.map +7 -0
- package/dist/modules/payment_gateways/lib/gateway-service.js +70 -8
- package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
- package/dist/modules/payment_gateways/lib/payment-operation-idempotency.js +1 -0
- package/dist/modules/payment_gateways/lib/payment-operation-idempotency.js.map +2 -2
- package/dist/modules/payment_gateways/migrations/Migration20260725101500_payment_gateways.js +16 -0
- package/dist/modules/payment_gateways/migrations/Migration20260725101500_payment_gateways.js.map +7 -0
- package/dist/modules/query_index/lib/indexer.js +12 -2
- package/dist/modules/query_index/lib/indexer.js.map +2 -2
- package/dist/modules/workflows/backend/definitions/visual-editor/page.js +5 -2
- package/dist/modules/workflows/backend/definitions/visual-editor/page.js.map +2 -2
- package/dist/modules/workflows/components/ActivitiesEditor.js +67 -11
- package/dist/modules/workflows/components/ActivitiesEditor.js.map +2 -2
- package/dist/modules/workflows/components/TransitionsEditor.js +205 -163
- package/dist/modules/workflows/components/TransitionsEditor.js.map +2 -2
- package/dist/modules/workflows/data/validators.js +34 -1
- package/dist/modules/workflows/data/validators.js.map +3 -3
- package/dist/modules/workflows/lib/activity-executor.js +18 -2
- package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
- package/dist/modules/workflows/lib/format-validation-error.js +46 -1
- package/dist/modules/workflows/lib/format-validation-error.js.map +2 -2
- package/generated/entities/gateway_payment_operation/index.ts +1 -0
- package/generated/entities/gateway_transaction/index.ts +1 -0
- package/generated/entity-fields-registry.ts +2 -0
- package/package.json +7 -7
- package/src/modules/attachments/i18n/de.json +12 -0
- package/src/modules/attachments/i18n/en.json +12 -0
- package/src/modules/attachments/i18n/es.json +12 -0
- package/src/modules/attachments/i18n/pl.json +12 -0
- package/src/modules/auth/i18n/de.json +1 -0
- package/src/modules/auth/i18n/en.json +1 -0
- package/src/modules/auth/i18n/es.json +1 -0
- package/src/modules/auth/i18n/pl.json +1 -0
- package/src/modules/catalog/i18n/de.json +18 -0
- package/src/modules/catalog/i18n/en.json +18 -0
- package/src/modules/catalog/i18n/es.json +18 -0
- package/src/modules/catalog/i18n/pl.json +18 -0
- package/src/modules/customers/i18n/de.json +44 -0
- package/src/modules/customers/i18n/en.json +44 -0
- package/src/modules/customers/i18n/es.json +44 -0
- package/src/modules/customers/i18n/pl.json +44 -0
- package/src/modules/payment_gateways/api/capture/route.ts +1 -1
- package/src/modules/payment_gateways/data/entities.ts +8 -2
- package/src/modules/payment_gateways/lib/capture-ledger.ts +260 -0
- package/src/modules/payment_gateways/lib/gateway-service.ts +83 -10
- package/src/modules/payment_gateways/lib/payment-operation-idempotency.ts +1 -0
- package/src/modules/payment_gateways/migrations/.snapshot-open-mercato.json +34 -0
- package/src/modules/payment_gateways/migrations/Migration20260725101500_payment_gateways.ts +21 -0
- package/src/modules/query_index/lib/indexer.ts +25 -2
- package/src/modules/workflows/backend/definitions/visual-editor/page.tsx +12 -3
- package/src/modules/workflows/components/ActivitiesEditor.tsx +96 -12
- package/src/modules/workflows/components/TransitionsEditor.tsx +75 -17
- package/src/modules/workflows/data/validators.ts +52 -1
- package/src/modules/workflows/i18n/de.json +2 -0
- package/src/modules/workflows/i18n/en.json +2 -0
- package/src/modules/workflows/i18n/es.json +2 -0
- package/src/modules/workflows/i18n/pl.json +2 -0
- package/src/modules/workflows/lib/activity-executor.ts +38 -3
- package/src/modules/workflows/lib/format-validation-error.ts +60 -0
|
@@ -90,6 +90,7 @@
|
|
|
90
90
|
"customers.ai.actions.translate": "Translate",
|
|
91
91
|
"customers.ai.comingSoon": "Coming soon",
|
|
92
92
|
"customers.ai.prefix": "IA:",
|
|
93
|
+
"customers.ai_assistant.agents.account.description": "Explora personas, empresas, oportunidades y actividades.",
|
|
93
94
|
"customers.ai_assistant.agents.account.label": "CRM Assistant",
|
|
94
95
|
"customers.ai_assistant.context.matchingDeals": "{count} negocios en vista",
|
|
95
96
|
"customers.ai_assistant.context.matchingPeople": "{count} contacts in view",
|
|
@@ -104,31 +105,47 @@
|
|
|
104
105
|
"customers.ai_assistant.popover.heading": "AI assistants",
|
|
105
106
|
"customers.ai_assistant.sheet.composerPlaceholder": "Pregunta sobre personas, empresas, oportunidades...",
|
|
106
107
|
"customers.ai_assistant.sheet.description": "Asistente de solo lectura. Pregunta sobre personas, empresas, oportunidades y actividades del alcance de esta lista.",
|
|
108
|
+
"customers.ai_assistant.sheet.descriptionWithSelection": "Trabajando con {count} contactos seleccionados. Pregunta por sus datos, oportunidades, empresas y actividades.",
|
|
109
|
+
"customers.ai_assistant.sheet.descriptionWithSelectionCompanies": "Trabajando con {count} empresas seleccionadas. Pregunta por sus oportunidades, contactos y actividades.",
|
|
107
110
|
"customers.ai_assistant.sheet.descriptionWithSelectionDeals": "Trabajando con {count} negocios seleccionados. Pregunta por su etapa, valor y fechas de cierre.",
|
|
108
111
|
"customers.ai_assistant.sheet.dock": "Dock to side",
|
|
109
112
|
"customers.ai_assistant.sheet.selectionPill": "Actuando sobre {count} seleccionados",
|
|
110
113
|
"customers.ai_assistant.sheet.title": "Asistente AI de clientes",
|
|
114
|
+
"customers.ai_assistant.sheet.welcomeDescriptionAll": "Pregúntame lo que quieras sobre tus clientes, empresas y oportunidades:",
|
|
115
|
+
"customers.ai_assistant.sheet.welcomeDescriptionAllCompanies": "Pregúntame lo que quieras sobre tus empresas, oportunidades y actividades:",
|
|
111
116
|
"customers.ai_assistant.sheet.welcomeDescriptionAllDeals": "Pregunta por negocios, pipeline o fechas de cierre:",
|
|
117
|
+
"customers.ai_assistant.sheet.welcomeDescriptionSelection": "Listo para explorar tus {count} contactos seleccionados:",
|
|
118
|
+
"customers.ai_assistant.sheet.welcomeDescriptionSelectionCompanies": "Listo para explorar tus {count} empresas seleccionadas:",
|
|
112
119
|
"customers.ai_assistant.sheet.welcomeDescriptionSelectionDeals": "Listo para explorar tus {count} negocios seleccionados:",
|
|
113
120
|
"customers.ai_assistant.sheet.welcomeTitle": "CRM Assistant",
|
|
114
121
|
"customers.ai_assistant.suggestions.activityOverview": "Activity overview",
|
|
115
122
|
"customers.ai_assistant.suggestions.atRiskSelectedDeals": "Negocios seleccionados en riesgo",
|
|
123
|
+
"customers.ai_assistant.suggestions.companiesActivityOverview": "Resumen de actividad",
|
|
116
124
|
"customers.ai_assistant.suggestions.companiesForSelectedDeals": "Empresas en negocios seleccionados",
|
|
125
|
+
"customers.ai_assistant.suggestions.companiesWithoutContacts": "Empresas sin contactos",
|
|
117
126
|
"customers.ai_assistant.suggestions.dealsAtRisk": "Negocios en riesgo de retraso",
|
|
118
127
|
"customers.ai_assistant.suggestions.dealsClosingThisMonth": "Negocios que cierran este mes",
|
|
128
|
+
"customers.ai_assistant.suggestions.dealsForSelectedCompanies": "Mostrar oportunidades de las empresas seleccionadas",
|
|
119
129
|
"customers.ai_assistant.suggestions.findCompanies": "Find related companies",
|
|
120
130
|
"customers.ai_assistant.suggestions.findDeals": "Show deals for selected people",
|
|
131
|
+
"customers.ai_assistant.suggestions.peopleAtSelectedCompanies": "Listar personas de las empresas seleccionadas",
|
|
121
132
|
"customers.ai_assistant.suggestions.recentDeals": "Show recent deals",
|
|
133
|
+
"customers.ai_assistant.suggestions.searchCompanies": "Buscar una empresa",
|
|
122
134
|
"customers.ai_assistant.suggestions.searchDeals": "Buscar un negocio",
|
|
123
135
|
"customers.ai_assistant.suggestions.searchPeople": "Search for a contact",
|
|
124
136
|
"customers.ai_assistant.suggestions.summarizeSelected": "Summarize selected contacts",
|
|
137
|
+
"customers.ai_assistant.suggestions.summarizeSelectedCompanies": "Resumir las empresas seleccionadas",
|
|
125
138
|
"customers.ai_assistant.suggestions.summarizeSelectedDeals": "Resumir negocios seleccionados",
|
|
126
139
|
"customers.ai_assistant.suggestions.topCompanies": "List top companies",
|
|
140
|
+
"customers.ai_assistant.suggestions.topCompaniesByDeals": "Principales empresas por valor de oportunidades",
|
|
127
141
|
"customers.ai_assistant.suggestions.topDealsByValue": "Mejores negocios por valor",
|
|
128
142
|
"customers.ai_assistant.suggestions.totalOpenDealValue": "Valor total de negocios abiertos",
|
|
129
143
|
"customers.ai_assistant.trigger.ariaLabel": "Abrir asistente AI para personas",
|
|
144
|
+
"customers.ai_assistant.trigger.ariaLabelCompanies": "Abrir el asistente de IA para empresas",
|
|
130
145
|
"customers.ai_assistant.trigger.ariaLabelDeals": "Abrir asistente AI para negocios",
|
|
131
146
|
"customers.ai_assistant.trigger.label": "AI",
|
|
147
|
+
"customers.ai_assistant.trigger.moreAgentsAriaLabel": "Elegir un asistente de IA",
|
|
148
|
+
"customers.assignableStaff.forbidden": "Permisos insuficientes para cargar el personal asignable.",
|
|
132
149
|
"customers.assignableStaff.loadError": "No se pudieron cargar los miembros del equipo. Verifica tus permisos y vuelve a intentarlo.",
|
|
133
150
|
"customers.audit.activities.create": "Crear actividad",
|
|
134
151
|
"customers.audit.activities.delete": "Eliminar actividad",
|
|
@@ -747,6 +764,7 @@
|
|
|
747
764
|
"customers.companies.list.bulkDelete.failedGroup": "No se pudieron eliminar {count} empresas: {message}",
|
|
748
765
|
"customers.companies.list.bulkDelete.operationLabel": "Eliminar {count} empresas",
|
|
749
766
|
"customers.companies.list.bulkDelete.partial": "{deleted} de {total} empresas eliminadas; {failed} fallaron",
|
|
767
|
+
"customers.companies.list.bulkDelete.progressDescription": "{count} empresas seleccionadas para eliminar",
|
|
750
768
|
"customers.companies.list.bulkDelete.progressName": "Eliminar empresas seleccionadas",
|
|
751
769
|
"customers.companies.list.bulkDelete.success": "{count} companies deleted",
|
|
752
770
|
"customers.companies.list.bulkDelete.title": "Delete {count} companies?",
|
|
@@ -885,10 +903,24 @@
|
|
|
885
903
|
"customers.countries.it": "Italia",
|
|
886
904
|
"customers.countries.pl": "Polonia",
|
|
887
905
|
"customers.countries.us": "Estados Unidos",
|
|
906
|
+
"customers.deal_analyzer.context.dealsInView": "{count} oportunidades en la vista",
|
|
907
|
+
"customers.deal_analyzer.context.selectedDeals": "{count} oportunidades seleccionadas",
|
|
888
908
|
"customers.deal_analyzer.dock.subtitle": "Negocios",
|
|
909
|
+
"customers.deal_analyzer.sheet.composerPlaceholder": "Analiza oportunidades estancadas, propón cambios de etapa...",
|
|
910
|
+
"customers.deal_analyzer.sheet.description": "Analizador de estado de oportunidades en varios pasos. Detecta oportunidades estancadas y propone transiciones de etapa para su aprobación.",
|
|
911
|
+
"customers.deal_analyzer.sheet.descriptionWithSelection": "Analizando {count} oportunidades seleccionadas para evaluar el pipeline y proponer cambios de etapa.",
|
|
889
912
|
"customers.deal_analyzer.sheet.dock": "Anclar al lado",
|
|
913
|
+
"customers.deal_analyzer.sheet.selectionPill": "Actuando sobre {count} oportunidades",
|
|
890
914
|
"customers.deal_analyzer.sheet.title": "Analizador de negocios",
|
|
915
|
+
"customers.deal_analyzer.sheet.welcomeDescriptionAll": "Analiza el estado del pipeline y propón cambios de etapa:",
|
|
916
|
+
"customers.deal_analyzer.sheet.welcomeDescriptionSelection": "Listo para analizar tus {count} oportunidades seleccionadas:",
|
|
891
917
|
"customers.deal_analyzer.sheet.welcomeTitle": "Analizador de negocios",
|
|
918
|
+
"customers.deal_analyzer.suggestions.analyzeSelected": "Analizar las oportunidades seleccionadas",
|
|
919
|
+
"customers.deal_analyzer.suggestions.analyzeStalledDeals": "Analizar oportunidades estancadas",
|
|
920
|
+
"customers.deal_analyzer.suggestions.overviewByStage": "Resumen del estado de las oportunidades",
|
|
921
|
+
"customers.deal_analyzer.suggestions.proposeStageMove": "Proponer cambios de etapa para las oportunidades seleccionadas",
|
|
922
|
+
"customers.deal_analyzer.suggestions.showAtRiskPipeline": "Mostrar el pipeline en riesgo",
|
|
923
|
+
"customers.deal_analyzer.trigger.ariaLabel": "Abrir el agente de IA Analizador de Oportunidades",
|
|
892
924
|
"customers.deals.create.associations.companiesPlaceholder": "Buscar empresas por nombre o dominio…",
|
|
893
925
|
"customers.deals.create.associations.peoplePlaceholder": "Buscar personas por nombre o correo…",
|
|
894
926
|
"customers.deals.create.back": "Volver a oportunidades",
|
|
@@ -973,7 +1005,10 @@
|
|
|
973
1005
|
"customers.deals.detail.highlights": "Aspectos destacados",
|
|
974
1006
|
"customers.deals.detail.linkedEntities.clearAll": "Clear",
|
|
975
1007
|
"customers.deals.detail.linkedEntities.clearVisible": "Limpiar visibles",
|
|
1008
|
+
"customers.deals.detail.linkedEntities.confirmButton": "Vincular {{entity}}",
|
|
1009
|
+
"customers.deals.detail.linkedEntities.dialogSubtitle": "Vincula un registro existente de {{entity}} a esta oportunidad",
|
|
976
1010
|
"customers.deals.detail.linkedEntities.dialogTitle": "Manage linked {{entity}}",
|
|
1011
|
+
"customers.deals.detail.linkedEntities.dialogTitleShort": "Vincular {{entity}}",
|
|
977
1012
|
"customers.deals.detail.linkedEntities.empty": "No linked records yet.",
|
|
978
1013
|
"customers.deals.detail.linkedEntities.loadMore": "Load more",
|
|
979
1014
|
"customers.deals.detail.linkedEntities.loading": "Loading linked records…",
|
|
@@ -986,6 +1021,7 @@
|
|
|
986
1021
|
"customers.deals.detail.linkedEntities.searchAll": "Search all {{entity}}…",
|
|
987
1022
|
"customers.deals.detail.linkedEntities.searchResults": "Search results",
|
|
988
1023
|
"customers.deals.detail.linkedEntities.searching": "Searching…",
|
|
1024
|
+
"customers.deals.detail.linkedEntities.sectionLabel": "{{entity}} COINCIDENTES",
|
|
989
1025
|
"customers.deals.detail.linkedEntities.selectVisible": "Seleccionar visibles",
|
|
990
1026
|
"customers.deals.detail.linkedEntities.selectedTitle": "Selected {{entity}}",
|
|
991
1027
|
"customers.deals.detail.linkedEntities.summary": "{{count}} linked {{entity}}",
|
|
@@ -1067,6 +1103,7 @@
|
|
|
1067
1103
|
"customers.deals.detail.title": "Detalles del deal",
|
|
1068
1104
|
"customers.deals.detail.unsavedCancel": "Keep editing",
|
|
1069
1105
|
"customers.deals.detail.unsavedConfirm": "Discard changes",
|
|
1106
|
+
"customers.deals.detail.unsavedDescription": "Tienes cambios sin guardar en esta oportunidad. Guárdalos primero o continúa para descartarlos.",
|
|
1070
1107
|
"customers.deals.detail.unsavedTitle": "Discard unsaved changes?",
|
|
1071
1108
|
"customers.deals.detail.untitled": "Deal sin título",
|
|
1072
1109
|
"customers.deals.detail.updateSuccess": "Deal updated.",
|
|
@@ -1374,6 +1411,7 @@
|
|
|
1374
1411
|
"customers.deals.list.bulkDelete.failedGroup": "No se pudieron eliminar {count} deals: {message}",
|
|
1375
1412
|
"customers.deals.list.bulkDelete.operationLabel": "Eliminar {count} deals",
|
|
1376
1413
|
"customers.deals.list.bulkDelete.partial": "{deleted} de {total} deals eliminados; {failed} fallaron",
|
|
1414
|
+
"customers.deals.list.bulkDelete.progressDescription": "{count} oportunidades seleccionadas para eliminar",
|
|
1377
1415
|
"customers.deals.list.bulkDelete.progressName": "Eliminar negocios seleccionados",
|
|
1378
1416
|
"customers.deals.list.bulkDelete.success": "{count} deals deleted",
|
|
1379
1417
|
"customers.deals.list.bulkDelete.title": "Delete {count} deals?",
|
|
@@ -1646,6 +1684,7 @@
|
|
|
1646
1684
|
"customers.linking.deal.addNew": "Añadir nueva oportunidad",
|
|
1647
1685
|
"customers.linking.deal.confirmButton": "Vincular oportunidad",
|
|
1648
1686
|
"customers.linking.deal.dialogDescription": "Busca oportunidades existentes para vincularlas a este registro.",
|
|
1687
|
+
"customers.linking.deal.dialogSubtitle": "Vincula una oportunidad existente a este registro",
|
|
1649
1688
|
"customers.linking.deal.dialogTitle": "Vincular oportunidades",
|
|
1650
1689
|
"customers.linking.deal.orphanWarning": "Esta oportunidad no tiene otros elementos vinculados. Si la desvinculas más tarde, quedará inaccesible.",
|
|
1651
1690
|
"customers.linking.deal.orphanWarningTitle": "Oportunidad sin empresa",
|
|
@@ -1660,8 +1699,11 @@
|
|
|
1660
1699
|
"customers.linking.pagination.previous": "Página anterior",
|
|
1661
1700
|
"customers.linking.pagination.previousShort": "Anterior",
|
|
1662
1701
|
"customers.linking.person.addNew": "Añadir nueva persona",
|
|
1702
|
+
"customers.linking.person.addNewSubtitle": "La empresa se rellenará automáticamente",
|
|
1663
1703
|
"customers.linking.person.confirmButton": "Vincular persona",
|
|
1664
1704
|
"customers.linking.person.dialogDescription": "Busca personas existentes y vincúlalas a este registro sin salir de la página.",
|
|
1705
|
+
"customers.linking.person.dialogSubtitle": "Vincula un contacto existente a esta empresa",
|
|
1706
|
+
"customers.linking.person.dialogSubtitleFor": "Vincula un contacto existente a {{name}}",
|
|
1665
1707
|
"customers.linking.person.dialogTitle": "Vincular personas",
|
|
1666
1708
|
"customers.linking.person.searchEmpty": "No se encontraron personas.",
|
|
1667
1709
|
"customers.linking.person.searchPlaceholder": "Buscar todas las personas…",
|
|
@@ -2290,6 +2332,7 @@
|
|
|
2290
2332
|
"customers.people.list.bulkDelete.failedGroup": "No se pudieron eliminar {count} personas: {message}",
|
|
2291
2333
|
"customers.people.list.bulkDelete.operationLabel": "Eliminar {count} personas",
|
|
2292
2334
|
"customers.people.list.bulkDelete.partial": "{deleted} de {total} personas eliminadas; {failed} fallaron",
|
|
2335
|
+
"customers.people.list.bulkDelete.progressDescription": "{count} personas seleccionadas para eliminar",
|
|
2293
2336
|
"customers.people.list.bulkDelete.progressName": "Eliminar personas seleccionadas",
|
|
2294
2337
|
"customers.people.list.bulkDelete.success": "{count} people deleted",
|
|
2295
2338
|
"customers.people.list.bulkDelete.title": "Delete {count} people?",
|
|
@@ -2465,6 +2508,7 @@
|
|
|
2465
2508
|
"customers.roles.dialog.team.unassigned": "No team",
|
|
2466
2509
|
"customers.roles.dialog.teamLabel": "Select a team member",
|
|
2467
2510
|
"customers.roles.dialog.title": "Assign role",
|
|
2511
|
+
"customers.roles.dialog.visibleCount": "Mostrando {{shown}} de {{total}} miembros del equipo",
|
|
2468
2512
|
"customers.roles.email": "Send email",
|
|
2469
2513
|
"customers.roles.emailUnavailable": "Email unavailable",
|
|
2470
2514
|
"customers.roles.emptySlot": "Not assigned",
|
|
@@ -90,6 +90,7 @@
|
|
|
90
90
|
"customers.ai.actions.translate": "Przetłumacz",
|
|
91
91
|
"customers.ai.comingSoon": "Wkrótce",
|
|
92
92
|
"customers.ai.prefix": "AI:",
|
|
93
|
+
"customers.ai_assistant.agents.account.description": "Przeglądaj osoby, firmy, szanse i aktywności.",
|
|
93
94
|
"customers.ai_assistant.agents.account.label": "Asystent CRM",
|
|
94
95
|
"customers.ai_assistant.context.matchingDeals": "{count} transakcji w widoku",
|
|
95
96
|
"customers.ai_assistant.context.matchingPeople": "Kontaktów w widoku: {count}",
|
|
@@ -104,31 +105,47 @@
|
|
|
104
105
|
"customers.ai_assistant.popover.heading": "Asystenci AI",
|
|
105
106
|
"customers.ai_assistant.sheet.composerPlaceholder": "Zapytaj o osoby, firmy, transakcje...",
|
|
106
107
|
"customers.ai_assistant.sheet.description": "Asystent tylko do odczytu. Zadawaj pytania o osoby, firmy, transakcje i aktywności w zasięgu tej listy.",
|
|
108
|
+
"customers.ai_assistant.sheet.descriptionWithSelection": "Praca na {count} wybranych kontaktach. Pytaj o ich dane, szanse, firmy i aktywności.",
|
|
109
|
+
"customers.ai_assistant.sheet.descriptionWithSelectionCompanies": "Praca na {count} wybranych firmach. Pytaj o ich szanse, kontakty i aktywności.",
|
|
107
110
|
"customers.ai_assistant.sheet.descriptionWithSelectionDeals": "Pracujesz na {count} zaznaczonych transakcjach. Zadawaj pytania o etap, wartość i daty zamknięcia.",
|
|
108
111
|
"customers.ai_assistant.sheet.dock": "Przypnij z boku",
|
|
109
112
|
"customers.ai_assistant.sheet.selectionPill": "Działam na {count} zaznaczonych",
|
|
110
113
|
"customers.ai_assistant.sheet.title": "Asystent AI dla klientów",
|
|
114
|
+
"customers.ai_assistant.sheet.welcomeDescriptionAll": "Zapytaj mnie o cokolwiek dotyczącego Twoich klientów, firm i szans:",
|
|
115
|
+
"customers.ai_assistant.sheet.welcomeDescriptionAllCompanies": "Zapytaj mnie o cokolwiek dotyczącego Twoich firm, szans i aktywności:",
|
|
111
116
|
"customers.ai_assistant.sheet.welcomeDescriptionAllDeals": "Zadaj pytanie o transakcje, pipeline lub daty zamknięcia:",
|
|
117
|
+
"customers.ai_assistant.sheet.welcomeDescriptionSelection": "Gotowe do przeglądu Twoich {count} wybranych kontaktów:",
|
|
118
|
+
"customers.ai_assistant.sheet.welcomeDescriptionSelectionCompanies": "Gotowe do przeglądu Twoich {count} wybranych firm:",
|
|
112
119
|
"customers.ai_assistant.sheet.welcomeDescriptionSelectionDeals": "Możemy przejrzeć Twoje {count} zaznaczonych transakcji:",
|
|
113
120
|
"customers.ai_assistant.sheet.welcomeTitle": "Asystent CRM",
|
|
114
121
|
"customers.ai_assistant.suggestions.activityOverview": "Przegląd aktywności",
|
|
115
122
|
"customers.ai_assistant.suggestions.atRiskSelectedDeals": "Zaznaczone transakcje zagrożone",
|
|
123
|
+
"customers.ai_assistant.suggestions.companiesActivityOverview": "Przegląd aktywności",
|
|
116
124
|
"customers.ai_assistant.suggestions.companiesForSelectedDeals": "Firmy w zaznaczonych transakcjach",
|
|
125
|
+
"customers.ai_assistant.suggestions.companiesWithoutContacts": "Firmy bez kontaktów",
|
|
117
126
|
"customers.ai_assistant.suggestions.dealsAtRisk": "Transakcje zagrożone poślizgiem",
|
|
118
127
|
"customers.ai_assistant.suggestions.dealsClosingThisMonth": "Transakcje zamykające się w tym miesiącu",
|
|
128
|
+
"customers.ai_assistant.suggestions.dealsForSelectedCompanies": "Pokaż szanse dla wybranych firm",
|
|
119
129
|
"customers.ai_assistant.suggestions.findCompanies": "Znajdź powiązane firmy",
|
|
120
130
|
"customers.ai_assistant.suggestions.findDeals": "Pokaż szanse dla zaznaczonych osób",
|
|
131
|
+
"customers.ai_assistant.suggestions.peopleAtSelectedCompanies": "Wypisz osoby z wybranych firm",
|
|
121
132
|
"customers.ai_assistant.suggestions.recentDeals": "Pokaż ostatnie szanse",
|
|
133
|
+
"customers.ai_assistant.suggestions.searchCompanies": "Wyszukaj firmę",
|
|
122
134
|
"customers.ai_assistant.suggestions.searchDeals": "Wyszukaj transakcję",
|
|
123
135
|
"customers.ai_assistant.suggestions.searchPeople": "Wyszukaj kontakt",
|
|
124
136
|
"customers.ai_assistant.suggestions.summarizeSelected": "Podsumuj zaznaczone kontakty",
|
|
137
|
+
"customers.ai_assistant.suggestions.summarizeSelectedCompanies": "Podsumuj wybrane firmy",
|
|
125
138
|
"customers.ai_assistant.suggestions.summarizeSelectedDeals": "Podsumuj zaznaczone transakcje",
|
|
126
139
|
"customers.ai_assistant.suggestions.topCompanies": "Wypisz najważniejsze firmy",
|
|
140
|
+
"customers.ai_assistant.suggestions.topCompaniesByDeals": "Najlepsze firmy wg wartości szans",
|
|
127
141
|
"customers.ai_assistant.suggestions.topDealsByValue": "Najlepsze transakcje wg wartości",
|
|
128
142
|
"customers.ai_assistant.suggestions.totalOpenDealValue": "Łączna wartość otwartych transakcji",
|
|
129
143
|
"customers.ai_assistant.trigger.ariaLabel": "Otwórz asystenta AI dla osób",
|
|
144
|
+
"customers.ai_assistant.trigger.ariaLabelCompanies": "Otwórz asystenta AI dla firm",
|
|
130
145
|
"customers.ai_assistant.trigger.ariaLabelDeals": "Otwórz asystenta AI dla transakcji",
|
|
131
146
|
"customers.ai_assistant.trigger.label": "AI",
|
|
147
|
+
"customers.ai_assistant.trigger.moreAgentsAriaLabel": "Wybierz asystenta AI",
|
|
148
|
+
"customers.assignableStaff.forbidden": "Brak wystarczających uprawnień do wczytania pracowników możliwych do przypisania.",
|
|
132
149
|
"customers.assignableStaff.loadError": "Nie udało się załadować pracowników. Sprawdź uprawnienia i spróbuj ponownie.",
|
|
133
150
|
"customers.audit.activities.create": "Utwórz aktywność",
|
|
134
151
|
"customers.audit.activities.delete": "Usuń aktywność",
|
|
@@ -747,6 +764,7 @@
|
|
|
747
764
|
"customers.companies.list.bulkDelete.failedGroup": "Nie udało się usunąć {count} firm: {message}",
|
|
748
765
|
"customers.companies.list.bulkDelete.operationLabel": "Usuń {count} firm",
|
|
749
766
|
"customers.companies.list.bulkDelete.partial": "Usunięto {deleted} z {total} firm; {failed} nie powiodło się",
|
|
767
|
+
"customers.companies.list.bulkDelete.progressDescription": "Wybrano {count} firm do usunięcia",
|
|
750
768
|
"customers.companies.list.bulkDelete.progressName": "Usuń zaznaczone firmy",
|
|
751
769
|
"customers.companies.list.bulkDelete.success": "Usunięto {count} firm",
|
|
752
770
|
"customers.companies.list.bulkDelete.title": "Usunąć {count} firm?",
|
|
@@ -885,10 +903,24 @@
|
|
|
885
903
|
"customers.countries.it": "Włochy",
|
|
886
904
|
"customers.countries.pl": "Polska",
|
|
887
905
|
"customers.countries.us": "Stany Zjednoczone",
|
|
906
|
+
"customers.deal_analyzer.context.dealsInView": "{count} szans w widoku",
|
|
907
|
+
"customers.deal_analyzer.context.selectedDeals": "Wybrano {count} szans",
|
|
888
908
|
"customers.deal_analyzer.dock.subtitle": "Transakcje",
|
|
909
|
+
"customers.deal_analyzer.sheet.composerPlaceholder": "Przeanalizuj zastane szanse, zaproponuj zmiany etapu...",
|
|
910
|
+
"customers.deal_analyzer.sheet.description": "Wieloetapowy analizator kondycji szans. Wskazuje zastane szanse i proponuje zmiany etapu do zatwierdzenia.",
|
|
911
|
+
"customers.deal_analyzer.sheet.descriptionWithSelection": "Analiza {count} wybranych szans pod kątem kondycji lejka i propozycji zmian etapu.",
|
|
889
912
|
"customers.deal_analyzer.sheet.dock": "Zadokuj z boku",
|
|
913
|
+
"customers.deal_analyzer.sheet.selectionPill": "Działanie na {count} szansach",
|
|
890
914
|
"customers.deal_analyzer.sheet.title": "Analizator transakcji",
|
|
915
|
+
"customers.deal_analyzer.sheet.welcomeDescriptionAll": "Przeanalizuj kondycję lejka sprzedaży i zaproponuj zmiany etapu:",
|
|
916
|
+
"customers.deal_analyzer.sheet.welcomeDescriptionSelection": "Gotowe do analizy Twoich {count} wybranych szans:",
|
|
891
917
|
"customers.deal_analyzer.sheet.welcomeTitle": "Analizator transakcji",
|
|
918
|
+
"customers.deal_analyzer.suggestions.analyzeSelected": "Przeanalizuj wybrane szanse",
|
|
919
|
+
"customers.deal_analyzer.suggestions.analyzeStalledDeals": "Przeanalizuj zastane szanse",
|
|
920
|
+
"customers.deal_analyzer.suggestions.overviewByStage": "Przegląd kondycji szans",
|
|
921
|
+
"customers.deal_analyzer.suggestions.proposeStageMove": "Zaproponuj zmiany etapu dla wybranych szans",
|
|
922
|
+
"customers.deal_analyzer.suggestions.showAtRiskPipeline": "Pokaż zagrożony lejek",
|
|
923
|
+
"customers.deal_analyzer.trigger.ariaLabel": "Otwórz agenta AI do analizy szans",
|
|
892
924
|
"customers.deals.create.associations.companiesPlaceholder": "Szukaj firm po nazwie lub domenie…",
|
|
893
925
|
"customers.deals.create.associations.peoplePlaceholder": "Szukaj osób po nazwie lub e-mailu…",
|
|
894
926
|
"customers.deals.create.back": "Powrót do szans sprzedaży",
|
|
@@ -973,7 +1005,10 @@
|
|
|
973
1005
|
"customers.deals.detail.highlights": "Kluczowe informacje",
|
|
974
1006
|
"customers.deals.detail.linkedEntities.clearAll": "Wyczyść",
|
|
975
1007
|
"customers.deals.detail.linkedEntities.clearVisible": "Wyczyść widoczne",
|
|
1008
|
+
"customers.deals.detail.linkedEntities.confirmButton": "Powiąż {{entity}}",
|
|
1009
|
+
"customers.deals.detail.linkedEntities.dialogSubtitle": "Powiąż istniejący rekord {{entity}} z tą szansą",
|
|
976
1010
|
"customers.deals.detail.linkedEntities.dialogTitle": "Zarządzaj powiązanymi — {{entity}}",
|
|
1011
|
+
"customers.deals.detail.linkedEntities.dialogTitleShort": "Powiąż {{entity}}",
|
|
977
1012
|
"customers.deals.detail.linkedEntities.empty": "Brak powiązanych rekordów.",
|
|
978
1013
|
"customers.deals.detail.linkedEntities.loadMore": "Wczytaj więcej",
|
|
979
1014
|
"customers.deals.detail.linkedEntities.loading": "Ładowanie powiązanych rekordów…",
|
|
@@ -986,6 +1021,7 @@
|
|
|
986
1021
|
"customers.deals.detail.linkedEntities.searchAll": "Szukaj we wszystkich — {{entity}}…",
|
|
987
1022
|
"customers.deals.detail.linkedEntities.searchResults": "Wyniki wyszukiwania",
|
|
988
1023
|
"customers.deals.detail.linkedEntities.searching": "Wyszukiwanie…",
|
|
1024
|
+
"customers.deals.detail.linkedEntities.sectionLabel": "PASUJĄCE {{entity}}",
|
|
989
1025
|
"customers.deals.detail.linkedEntities.selectVisible": "Zaznacz widoczne",
|
|
990
1026
|
"customers.deals.detail.linkedEntities.selectedTitle": "Wybrane — {{entity}}",
|
|
991
1027
|
"customers.deals.detail.linkedEntities.summary": "Powiązane: {{count}} {{entity}}",
|
|
@@ -1067,6 +1103,7 @@
|
|
|
1067
1103
|
"customers.deals.detail.title": "Szczegóły szansy",
|
|
1068
1104
|
"customers.deals.detail.unsavedCancel": "Kontynuuj edycję",
|
|
1069
1105
|
"customers.deals.detail.unsavedConfirm": "Odrzuć zmiany",
|
|
1106
|
+
"customers.deals.detail.unsavedDescription": "Masz niezapisane zmiany w tej szansie. Zapisz je najpierw albo kontynuuj, aby je odrzucić.",
|
|
1070
1107
|
"customers.deals.detail.unsavedTitle": "Odrzucić niezapisane zmiany?",
|
|
1071
1108
|
"customers.deals.detail.untitled": "Szansa bez nazwy",
|
|
1072
1109
|
"customers.deals.detail.updateSuccess": "Zaktualizowano szansę.",
|
|
@@ -1374,6 +1411,7 @@
|
|
|
1374
1411
|
"customers.deals.list.bulkDelete.failedGroup": "Nie udało się usunąć {count} transakcji: {message}",
|
|
1375
1412
|
"customers.deals.list.bulkDelete.operationLabel": "Usuń {count} transakcji",
|
|
1376
1413
|
"customers.deals.list.bulkDelete.partial": "Usunięto {deleted} z {total} transakcji; {failed} nie powiodło się",
|
|
1414
|
+
"customers.deals.list.bulkDelete.progressDescription": "Wybrano {count} szans do usunięcia",
|
|
1377
1415
|
"customers.deals.list.bulkDelete.progressName": "Usuń zaznaczone transakcje",
|
|
1378
1416
|
"customers.deals.list.bulkDelete.success": "Usunięto {count} szans",
|
|
1379
1417
|
"customers.deals.list.bulkDelete.title": "Usunąć {count} szans?",
|
|
@@ -1646,6 +1684,7 @@
|
|
|
1646
1684
|
"customers.linking.deal.addNew": "Dodaj nową szansę",
|
|
1647
1685
|
"customers.linking.deal.confirmButton": "Powiąż szansę",
|
|
1648
1686
|
"customers.linking.deal.dialogDescription": "Wyszukaj istniejące szanse, aby powiązać je z tym rekordem.",
|
|
1687
|
+
"customers.linking.deal.dialogSubtitle": "Powiąż istniejącą szansę z tym rekordem",
|
|
1649
1688
|
"customers.linking.deal.dialogTitle": "Powiąż szanse",
|
|
1650
1689
|
"customers.linking.deal.orphanWarning": "Ta szansa nie ma innych powiązanych obiektów. Jeśli ją odepniesz, stanie się niedostępna.",
|
|
1651
1690
|
"customers.linking.deal.orphanWarningTitle": "Szansa bez firmy",
|
|
@@ -1660,8 +1699,11 @@
|
|
|
1660
1699
|
"customers.linking.pagination.previous": "Poprzednia strona",
|
|
1661
1700
|
"customers.linking.pagination.previousShort": "Wstecz",
|
|
1662
1701
|
"customers.linking.person.addNew": "Dodaj nową osobę",
|
|
1702
|
+
"customers.linking.person.addNewSubtitle": "Firma zostanie uzupełniona automatycznie",
|
|
1663
1703
|
"customers.linking.person.confirmButton": "Powiąż osobę",
|
|
1664
1704
|
"customers.linking.person.dialogDescription": "Wyszukaj istniejące osoby i przypisz je do tego rekordu bez opuszczania strony.",
|
|
1705
|
+
"customers.linking.person.dialogSubtitle": "Powiąż istniejący kontakt z tą firmą",
|
|
1706
|
+
"customers.linking.person.dialogSubtitleFor": "Powiąż istniejący kontakt z {{name}}",
|
|
1665
1707
|
"customers.linking.person.dialogTitle": "Powiąż osoby",
|
|
1666
1708
|
"customers.linking.person.searchEmpty": "Nie znaleziono pasujących osób.",
|
|
1667
1709
|
"customers.linking.person.searchPlaceholder": "Szukaj wszystkich osób…",
|
|
@@ -2290,6 +2332,7 @@
|
|
|
2290
2332
|
"customers.people.list.bulkDelete.failedGroup": "Nie udało się usunąć {count} osób: {message}",
|
|
2291
2333
|
"customers.people.list.bulkDelete.operationLabel": "Usuń {count} osób",
|
|
2292
2334
|
"customers.people.list.bulkDelete.partial": "Usunięto {deleted} z {total} osób; {failed} nie powiodło się",
|
|
2335
|
+
"customers.people.list.bulkDelete.progressDescription": "Wybrano {count} osób do usunięcia",
|
|
2293
2336
|
"customers.people.list.bulkDelete.progressName": "Usuń zaznaczone osoby",
|
|
2294
2337
|
"customers.people.list.bulkDelete.success": "Usunięto {count} osób",
|
|
2295
2338
|
"customers.people.list.bulkDelete.title": "Usunąć {count} osób?",
|
|
@@ -2465,6 +2508,7 @@
|
|
|
2465
2508
|
"customers.roles.dialog.team.unassigned": "Brak zespołu",
|
|
2466
2509
|
"customers.roles.dialog.teamLabel": "Wybierz członka zespołu",
|
|
2467
2510
|
"customers.roles.dialog.title": "Przypisz rolę",
|
|
2511
|
+
"customers.roles.dialog.visibleCount": "Wyświetlane {{shown}} z {{total}} członków zespołu",
|
|
2468
2512
|
"customers.roles.email": "Wyślij e-mail",
|
|
2469
2513
|
"customers.roles.emailUnavailable": "E-mail niedostępny",
|
|
2470
2514
|
"customers.roles.emptySlot": "Nieprzypisane",
|
|
@@ -92,7 +92,7 @@ export const openApi = {
|
|
|
92
92
|
tags: [paymentGatewaysTag],
|
|
93
93
|
responses: [
|
|
94
94
|
{ status: 200, description: 'Payment captured' },
|
|
95
|
-
{ status: 409, description: 'Invalid payment status transition' },
|
|
95
|
+
{ status: 409, description: 'Invalid payment status transition, cumulative capture ceiling exceeded, or conflicting capture operation' },
|
|
96
96
|
{ status: 422, description: 'Invalid payload' },
|
|
97
97
|
{ status: 502, description: 'Gateway provider error' },
|
|
98
98
|
],
|
|
@@ -6,7 +6,7 @@ import { Entity, Index, PrimaryKey, Property, Unique } from '@mikro-orm/decorato
|
|
|
6
6
|
@Index({ properties: ['providerKey', 'providerSessionId', 'organizationId'] })
|
|
7
7
|
@Index({ properties: ['organizationId', 'tenantId', 'unifiedStatus'] })
|
|
8
8
|
export class GatewayTransaction {
|
|
9
|
-
[OptionalProps]?: 'unifiedStatus' | 'gatewayStatus' | 'providerSessionId' | 'gatewayPaymentId' | 'gatewayRefundId' | 'redirectUrl' | 'clientSecret' | 'gatewayMetadata' | 'webhookLog' | 'lastWebhookAt' | 'lastPolledAt' | 'expiresAt' | 'createdAt' | 'updatedAt' | 'deletedAt'
|
|
9
|
+
[OptionalProps]?: 'unifiedStatus' | 'gatewayStatus' | 'providerSessionId' | 'gatewayPaymentId' | 'gatewayRefundId' | 'redirectUrl' | 'clientSecret' | 'capturedAmount' | 'gatewayMetadata' | 'webhookLog' | 'lastWebhookAt' | 'lastPolledAt' | 'expiresAt' | 'createdAt' | 'updatedAt' | 'deletedAt'
|
|
10
10
|
|
|
11
11
|
@PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
|
|
12
12
|
id!: string
|
|
@@ -41,6 +41,9 @@ export class GatewayTransaction {
|
|
|
41
41
|
@Property({ name: 'amount', type: 'numeric', precision: 18, scale: 4 })
|
|
42
42
|
amount!: string
|
|
43
43
|
|
|
44
|
+
@Property({ name: 'captured_amount', type: 'numeric', precision: 18, scale: 4, default: '0' })
|
|
45
|
+
capturedAmount: string = '0'
|
|
46
|
+
|
|
44
47
|
@Property({ name: 'currency_code', type: 'text' })
|
|
45
48
|
currencyCode!: string
|
|
46
49
|
|
|
@@ -83,7 +86,7 @@ export class GatewayTransaction {
|
|
|
83
86
|
@Index({ properties: ['transactionId', 'operationType', 'organizationId', 'tenantId'] })
|
|
84
87
|
@Index({ properties: ['status', 'leaseExpiresAt'] })
|
|
85
88
|
export class GatewayPaymentOperation {
|
|
86
|
-
[OptionalProps]?: 'status' | 'attemptCount' | 'result' | 'leaseExpiresAt' | 'createdAt' | 'updatedAt'
|
|
89
|
+
[OptionalProps]?: 'status' | 'attemptCount' | 'result' | 'reservedAmount' | 'leaseExpiresAt' | 'createdAt' | 'updatedAt'
|
|
87
90
|
|
|
88
91
|
@PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
|
|
89
92
|
id!: string
|
|
@@ -118,6 +121,9 @@ export class GatewayPaymentOperation {
|
|
|
118
121
|
@Property({ name: 'result', type: 'jsonb', nullable: true })
|
|
119
122
|
result?: Record<string, unknown> | null
|
|
120
123
|
|
|
124
|
+
@Property({ name: 'reserved_amount', type: 'numeric', precision: 18, scale: 4, nullable: true })
|
|
125
|
+
reservedAmount?: string | null
|
|
126
|
+
|
|
121
127
|
@Property({ name: 'lease_expires_at', type: Date, nullable: true })
|
|
122
128
|
leaseExpiresAt?: Date | null
|
|
123
129
|
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
3
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
4
|
+
import type { UnifiedPaymentStatus } from '@open-mercato/shared/modules/payment_gateways/types'
|
|
5
|
+
import { GatewayPaymentOperation, GatewayTransaction } from '../data/entities'
|
|
6
|
+
|
|
7
|
+
type Scope = { organizationId: string; tenantId: string }
|
|
8
|
+
|
|
9
|
+
const AMOUNT_SCALE = 4
|
|
10
|
+
const AMOUNT_UNITS_PER_WHOLE = 10n ** BigInt(AMOUNT_SCALE)
|
|
11
|
+
const DECIMAL_PATTERN = /^(-?)(\d*)(?:\.(\d*))?$/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Money in this module lives in `numeric(18,4)` columns and is read back as strings. Every
|
|
15
|
+
* comparison and every sum runs on integer minor units (1 unit = 10^-4) so partial captures
|
|
16
|
+
* never accumulate floating-point drift. Extra precision beyond four decimals is rounded
|
|
17
|
+
* half-up, matching what Postgres would store in the column anyway.
|
|
18
|
+
*/
|
|
19
|
+
export function parseAmountUnits(value: string | number | null | undefined): bigint {
|
|
20
|
+
if (value === null || value === undefined) return 0n
|
|
21
|
+
const text = typeof value === 'number' ? value.toFixed(AMOUNT_SCALE) : value.trim()
|
|
22
|
+
const match = DECIMAL_PATTERN.exec(text)
|
|
23
|
+
const whole = match?.[2] ?? ''
|
|
24
|
+
const fraction = match?.[3] ?? ''
|
|
25
|
+
if (!match || (whole === '' && fraction === '')) {
|
|
26
|
+
throw new Error(`[internal] Unsupported gateway amount value: ${String(value)}`)
|
|
27
|
+
}
|
|
28
|
+
const keptFraction = fraction.slice(0, AMOUNT_SCALE).padEnd(AMOUNT_SCALE, '0')
|
|
29
|
+
const nextDigit = fraction.charAt(AMOUNT_SCALE)
|
|
30
|
+
let units = BigInt(whole === '' ? '0' : whole) * AMOUNT_UNITS_PER_WHOLE + BigInt(keptFraction)
|
|
31
|
+
if (nextDigit !== '' && Number(nextDigit) >= 5) units += 1n
|
|
32
|
+
return match[1] === '-' ? -units : units
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function formatAmountUnits(units: bigint): string {
|
|
36
|
+
const negative = units < 0n
|
|
37
|
+
const absolute = negative ? -units : units
|
|
38
|
+
const fraction = (absolute % AMOUNT_UNITS_PER_WHOLE).toString().padStart(AMOUNT_SCALE, '0')
|
|
39
|
+
return `${negative ? '-' : ''}${absolute / AMOUNT_UNITS_PER_WHOLE}.${fraction}`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function formatAmountLabel(units: bigint): string {
|
|
43
|
+
return formatAmountUnits(units).replace(/\.?0+$/, '')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function captureConflict(code: string, message: string): CrudHttpError {
|
|
47
|
+
return new CrudHttpError(409, { error: message, code })
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type CaptureAmounts = {
|
|
51
|
+
authorizedUnits: bigint
|
|
52
|
+
capturedUnits: bigint
|
|
53
|
+
remainingUnits: bigint
|
|
54
|
+
requestedUnits: bigint
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolves what a capture request means for the transaction's running total. An omitted
|
|
59
|
+
* amount captures everything that is still authorized, not the original full amount.
|
|
60
|
+
*/
|
|
61
|
+
export function resolveCaptureAmounts(transaction: GatewayTransaction, amount: number | undefined): CaptureAmounts {
|
|
62
|
+
const authorizedUnits = parseAmountUnits(transaction.amount)
|
|
63
|
+
const capturedUnits = parseAmountUnits(transaction.capturedAmount)
|
|
64
|
+
const remainingUnits = authorizedUnits - capturedUnits
|
|
65
|
+
return {
|
|
66
|
+
authorizedUnits,
|
|
67
|
+
capturedUnits,
|
|
68
|
+
remainingUnits,
|
|
69
|
+
requestedUnits: amount === undefined ? remainingUnits : parseAmountUnits(amount),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Rejects a capture whose amount would push the captured-to-date total past the authorized
|
|
75
|
+
* amount. This is the cumulative ceiling: each individual request may look small, but the
|
|
76
|
+
* sum of all captures against one authorization can never exceed it (#4487).
|
|
77
|
+
*/
|
|
78
|
+
export function assertCaptureWithinRemaining(
|
|
79
|
+
transaction: GatewayTransaction,
|
|
80
|
+
amount: number | undefined,
|
|
81
|
+
): CaptureAmounts {
|
|
82
|
+
const amounts = resolveCaptureAmounts(transaction, amount)
|
|
83
|
+
if (amounts.remainingUnits <= 0n) {
|
|
84
|
+
throw captureConflict(
|
|
85
|
+
'payment_capture_ceiling_exceeded',
|
|
86
|
+
`Transaction is already fully captured (${formatAmountLabel(amounts.capturedUnits)} of ${formatAmountLabel(amounts.authorizedUnits)})`,
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
if (amounts.requestedUnits <= 0n) {
|
|
90
|
+
throw captureConflict('payment_capture_amount_invalid', 'Capture amount must be greater than zero')
|
|
91
|
+
}
|
|
92
|
+
if (amounts.requestedUnits > amounts.remainingUnits) {
|
|
93
|
+
throw captureConflict(
|
|
94
|
+
'payment_capture_ceiling_exceeded',
|
|
95
|
+
`Capture amount ${formatAmountLabel(amounts.requestedUnits)} exceeds the ${formatAmountLabel(amounts.remainingUnits)} still capturable on authorized amount ${formatAmountLabel(amounts.authorizedUnits)} (already captured ${formatAmountLabel(amounts.capturedUnits)})`,
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
return amounts
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Claims the requested slice of the authorized amount before the provider is called, so two
|
|
103
|
+
* concurrent captures can never both charge. The increment is a compare-and-swap on the
|
|
104
|
+
* previous captured-to-date value: whoever loses it never reaches the adapter. The reserved
|
|
105
|
+
* amount is stamped on the operation row in the same transaction, so a retry of the same
|
|
106
|
+
* operation id reuses its reservation instead of reserving twice.
|
|
107
|
+
*/
|
|
108
|
+
export async function reserveCaptureAmount(em: EntityManager, input: {
|
|
109
|
+
transaction: GatewayTransaction
|
|
110
|
+
operation: GatewayPaymentOperation
|
|
111
|
+
amount: number | undefined
|
|
112
|
+
scope: Scope
|
|
113
|
+
}): Promise<bigint> {
|
|
114
|
+
if (input.operation.reservedAmount !== null && input.operation.reservedAmount !== undefined) {
|
|
115
|
+
return parseAmountUnits(input.operation.reservedAmount)
|
|
116
|
+
}
|
|
117
|
+
const amounts = assertCaptureWithinRemaining(input.transaction, input.amount)
|
|
118
|
+
const previousCapturedAmount = input.transaction.capturedAmount
|
|
119
|
+
const reservedAmount = formatAmountUnits(amounts.requestedUnits)
|
|
120
|
+
const nextCapturedAmount = formatAmountUnits(amounts.capturedUnits + amounts.requestedUnits)
|
|
121
|
+
|
|
122
|
+
await em.transactional(async (tx) => {
|
|
123
|
+
const reserved = await tx.nativeUpdate(
|
|
124
|
+
GatewayTransaction,
|
|
125
|
+
{
|
|
126
|
+
id: input.transaction.id,
|
|
127
|
+
organizationId: input.scope.organizationId,
|
|
128
|
+
tenantId: input.scope.tenantId,
|
|
129
|
+
deletedAt: null,
|
|
130
|
+
capturedAmount: previousCapturedAmount,
|
|
131
|
+
},
|
|
132
|
+
{ capturedAmount: nextCapturedAmount, updatedAt: new Date() },
|
|
133
|
+
)
|
|
134
|
+
if (reserved !== 1) {
|
|
135
|
+
throw captureConflict(
|
|
136
|
+
'payment_capture_reservation_conflict',
|
|
137
|
+
'This transaction changed while the capture amount was being reserved; re-read it and retry with the remaining amount',
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
const stamped = await tx.nativeUpdate(
|
|
141
|
+
GatewayPaymentOperation,
|
|
142
|
+
{
|
|
143
|
+
id: input.operation.id,
|
|
144
|
+
organizationId: input.scope.organizationId,
|
|
145
|
+
tenantId: input.scope.tenantId,
|
|
146
|
+
reservedAmount: null,
|
|
147
|
+
},
|
|
148
|
+
{ reservedAmount, updatedAt: new Date() },
|
|
149
|
+
)
|
|
150
|
+
if (stamped !== 1) {
|
|
151
|
+
throw captureConflict(
|
|
152
|
+
'payment_capture_reservation_conflict',
|
|
153
|
+
'Capture reservation could not be recorded for this operation',
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
Object.assign(input.operation, { reservedAmount })
|
|
159
|
+
return amounts.requestedUnits
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Reconciles the reservation with what the provider actually captured. Runs on the transaction
|
|
164
|
+
* instance loaded inside the completion transaction, so the adjustment commits together with
|
|
165
|
+
* the operation result.
|
|
166
|
+
*/
|
|
167
|
+
export function settleCapturedAmount(
|
|
168
|
+
transaction: GatewayTransaction,
|
|
169
|
+
reservedUnits: bigint,
|
|
170
|
+
capturedAmount: number | undefined,
|
|
171
|
+
): void {
|
|
172
|
+
const actualUnits = typeof capturedAmount === 'number' && Number.isFinite(capturedAmount) && capturedAmount >= 0
|
|
173
|
+
? parseAmountUnits(capturedAmount)
|
|
174
|
+
: reservedUnits
|
|
175
|
+
if (actualUnits === reservedUnits) return
|
|
176
|
+
const settledUnits = parseAmountUnits(transaction.capturedAmount) - reservedUnits + actualUnits
|
|
177
|
+
transaction.capturedAmount = formatAmountUnits(settledUnits < 0n ? 0n : settledUnits)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Keeps the ledger honest for captures that happened outside the capture endpoint. A webhook or a
|
|
182
|
+
* status poll reports money that already moved but carries no captured amount, so only `captured`
|
|
183
|
+
* — the provider saying the whole authorization was taken — can be translated into a number: the
|
|
184
|
+
* full amount. The ledger is raised, never lowered, so an already recorded capture survives.
|
|
185
|
+
* `partially_captured` carries no recoverable amount, so it is left alone and the remainder stays
|
|
186
|
+
* capturable.
|
|
187
|
+
*/
|
|
188
|
+
export function alignCapturedAmountWithStatus(
|
|
189
|
+
transaction: GatewayTransaction,
|
|
190
|
+
status: UnifiedPaymentStatus,
|
|
191
|
+
): void {
|
|
192
|
+
if (status !== 'captured') return
|
|
193
|
+
const authorizedUnits = parseAmountUnits(transaction.amount)
|
|
194
|
+
if (parseAmountUnits(transaction.capturedAmount) >= authorizedUnits) return
|
|
195
|
+
transaction.capturedAmount = formatAmountUnits(authorizedUnits)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Gives the reserved slice back after a failed provider call. Both writes share one
|
|
200
|
+
* transaction, so a partial release is impossible: either the amount becomes capturable again
|
|
201
|
+
* and the operation stops holding it, or the reservation stays outstanding. Returning `false`
|
|
202
|
+
* means the reservation is still held — the conservative outcome when it is unknown whether
|
|
203
|
+
* the provider took the money.
|
|
204
|
+
*/
|
|
205
|
+
export async function releaseCaptureAmount(em: EntityManager, input: {
|
|
206
|
+
transactionId: string
|
|
207
|
+
operation: GatewayPaymentOperation
|
|
208
|
+
reservedUnits: bigint
|
|
209
|
+
scope: Scope
|
|
210
|
+
}): Promise<boolean> {
|
|
211
|
+
const reservedAmount = formatAmountUnits(input.reservedUnits)
|
|
212
|
+
try {
|
|
213
|
+
return await em.transactional(async (tx) => {
|
|
214
|
+
const current = await findOneWithDecryption(
|
|
215
|
+
tx,
|
|
216
|
+
GatewayTransaction,
|
|
217
|
+
{
|
|
218
|
+
id: input.transactionId,
|
|
219
|
+
organizationId: input.scope.organizationId,
|
|
220
|
+
tenantId: input.scope.tenantId,
|
|
221
|
+
},
|
|
222
|
+
undefined,
|
|
223
|
+
input.scope,
|
|
224
|
+
)
|
|
225
|
+
if (!current) return false
|
|
226
|
+
const releasedUnits = parseAmountUnits(current.capturedAmount) - input.reservedUnits
|
|
227
|
+
const released = await tx.nativeUpdate(
|
|
228
|
+
GatewayTransaction,
|
|
229
|
+
{
|
|
230
|
+
id: input.transactionId,
|
|
231
|
+
organizationId: input.scope.organizationId,
|
|
232
|
+
tenantId: input.scope.tenantId,
|
|
233
|
+
capturedAmount: current.capturedAmount,
|
|
234
|
+
},
|
|
235
|
+
{ capturedAmount: formatAmountUnits(releasedUnits < 0n ? 0n : releasedUnits), updatedAt: new Date() },
|
|
236
|
+
)
|
|
237
|
+
if (released !== 1) return false
|
|
238
|
+
const cleared = await tx.nativeUpdate(
|
|
239
|
+
GatewayPaymentOperation,
|
|
240
|
+
{
|
|
241
|
+
id: input.operation.id,
|
|
242
|
+
organizationId: input.scope.organizationId,
|
|
243
|
+
tenantId: input.scope.tenantId,
|
|
244
|
+
reservedAmount,
|
|
245
|
+
},
|
|
246
|
+
{ reservedAmount: null, updatedAt: new Date() },
|
|
247
|
+
)
|
|
248
|
+
if (cleared !== 1) {
|
|
249
|
+
throw captureConflict(
|
|
250
|
+
'payment_capture_reservation_conflict',
|
|
251
|
+
'Capture reservation could not be released for this operation',
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
Object.assign(input.operation, { reservedAmount: null })
|
|
255
|
+
return true
|
|
256
|
+
})
|
|
257
|
+
} catch {
|
|
258
|
+
return false
|
|
259
|
+
}
|
|
260
|
+
}
|