From b3d3c156f4ab713cbf3d77a8fbd579e0cc3303ff Mon Sep 17 00:00:00 2001 From: delchev Date: Wed, 19 Aug 2026 13:52:42 +0300 Subject: [PATCH] fix(intent): settlement allocation follows a corrected payment (#6818) `settlements:` bound only the payment's bare create topic, so a payment booked for the wrong amount and corrected the next day - or created incomplete and completed later - was never re-allocated: the invoice kept the settled figure of an amount the payment no longer carried, or the payment was never allocated at all. The spread handler is now emitted once per bound payment event, from a new `settlementListeners` glue collection. `settlements` still drives the one-per-settlement `OnInvoice` delegate; a second collection is what lets the two templates fan out differently off one shared descriptor (`rollupEntry` copies it per class name + topic suffix, exactly as roll-ups and expansions already do). The create handler keeps its established class name, so an existing project regenerates without churn; the correction one is `OnPaymentUpdated`. The allocation was already written as a recompute of the payment's UNALLOCATED balance, which is what makes an extra delivery a no-op. It now converges in the other direction too: a payment corrected below what it already covers releases the excess through the junction repository, newest allocation first, so the allocations sum to the payment again and the paid roll-up follows them down. `-transitioned` is deliberately not bound here - that vocabulary gap is Co-Authored-By: Claude Opus 5 --- components/engine/engine-intent/CLAUDE.md | 1 + components/engine/engine-intent/README.md | 6 ++- .../intent/generator/GlueIntentGenerator.java | 35 +++++++++++++ .../main/resources/intent-assistant-guide.md | 8 ++- .../template/service/model/GlueGenerator.java | 27 +++++++--- .../events/SettlementOnPayment.java.template | 50 ++++++++++++++++--- .../template/template.js | 4 +- .../integration/tests/api/IntentEngineIT.java | 19 ++++++- 8 files changed, 129 insertions(+), 21 deletions(-) diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index 4ac958dfc5e..75213598c50 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -686,6 +686,7 @@ Implemented and generating annotated client-Java off the shared `EventBinding` / - **Editor renders the glue + outputs.** New `renderGlue()` "Glue & Outputs" mxGraph section with SAP-icon-badged cards edged to their entities (see the diagram section). `IntentEditorLoadsIT` asserts the `loanUpdated` card renders. - **Parser hardening.** A wrong-typed scalar (e.g. an unquoted brace recipient `to: {member.email}`, which YAML parses as an object) now surfaces as a clean `IntentValidationException` issue with a helpful message instead of a raw 500 Gson error that wedged the editor. `IntentParserTest` covers it. - **Externalized AI system prompt.** Moved from an inline string to `intent-assistant-guide.md` (classpath resource, fail-fast load), corrected to the full current schema incl. the glue catalog + `businessKey`/`businessKeyStrategy`, and restored the propose-the-whole-file tool contract the draft had dropped. +- **Settlements re-allocate a corrected payment (#6818).** The payment spread handler used to bind the payment's bare create topic only, so a payment booked for the wrong amount and corrected afterwards - or created incomplete and completed later - was never re-allocated and the invoice kept the original settled figure. It is now emitted once per bound payment event (create + `-updated`) from its own glue collection, **`settlementListeners`** - the `settlements` collection still drives the one-per-settlement `OnInvoice` delegate, and a second collection is what lets the two templates fan out differently while sharing one descriptor (`rollupEntry` copies it per class name + topic suffix, exactly as roll-ups and expansions do). Note the two traps a new glue collection carries: it needs a `case` in **both** `GlueGenerator` (Java) and `generateUtils.js` (JS) or it renders whole-model with raw `${...}` placeholders, and `GlueGenerator.copy` is a per-key allow-list, so a new descriptor key that is not listed there never reaches the template. The handler itself was already a recompute of the payment's *unallocated* balance (re-delivery is a no-op by construction); it now also **releases** the excess - newest allocation first, through the junction repository - when the payment is corrected below what it already covers, so the recompute converges in both directions. - **CI runs on Corretto 24** (compile target stays 21); the integration-test fork gets `-Xmx6g`. (Root-level change; recorded here because it landed alongside the intent work.) **Cross-artefact field naming:** the `.form` control `model` (and control `id`) bind to the entity property, so they use `IntentNaming.pascalCase` to match the EDM property names (`loanedOn` -> `LoanedOn`). The `.report` references physical UPPER_SNAKE columns and humanized display aliases (no camelCase property identifiers), so it needs no PascalCasing. diff --git a/components/engine/engine-intent/README.md b/components/engine/engine-intent/README.md index 19f45c4052f..ff14c5b7dd3 100644 --- a/components/engine/engine-intent/README.md +++ b/components/engine/engine-intent/README.md @@ -516,7 +516,11 @@ settlements: ``` Generates the on-payment spread handler and an on-invoice pull delegate; pair with a `rollups` sum -entry that maintains `paid`/`balance`/status. +entry that maintains `paid`/`balance`/status. The spread handler is bound to the payment's create +AND its update event, and is a recompute of the payment's unallocated balance rather than an append: +a payment corrected after it was booked - or created incomplete and completed later - is re-allocated +for the amount it actually carries, and an amount corrected downwards releases the excess allocation +(newest first). ## reports - read-only aggregations diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java index 1ed3d837344..52f7436f489 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java @@ -112,6 +112,7 @@ public void generate(IntentGenerationContext context) { List> expansions = expansionHandlers.regenerations(); List> expansionCleanups = expansionHandlers.cleanups(); List> settlements = buildSettlements(model, byName, compositionParents, settings, context); + List> settlementListeners = buildSettlementListeners(settlements); List> generates = buildGenerates(model, byName, compositionParents, settings, context); List> transitions = buildTransitions(model, byName, compositionParents, settings, context); List> sends = buildSends(model, byName, compositionParents, settings, context); @@ -159,6 +160,7 @@ public void generate(IntentGenerationContext context) { glue.put("expansions", expansions); glue.put("expansionCleanups", expansionCleanups); glue.put("settlements", settlements); + glue.put("settlementListeners", settlementListeners); glue.put("generates", generates); // The event-driven subset (issue #6711) - the SAME descriptors, filtered, so the listener and // the create-from it calls can never be built from divergent data. A create-from with no event @@ -648,6 +650,7 @@ private static List> buildSettlements(IntentModel model, Map // junction (this project) e.put("junctionEntity", s.getJunction()); e.put("junctionPerspective", IntentEntities.resolvePerspective(s.getJunction(), compositionParents, model)); + e.put("junctionPk", IntentEntities.keyFieldName(junction)); e.put("junctionFkInvoice", IntentNaming.pascalCase(fkInvoice.getName())); e.put("junctionFkPayment", IntentNaming.pascalCase(fkPayment.getName())); e.put("junctionAmount", IntentNaming.pascalCase(s.getAmount())); @@ -665,6 +668,28 @@ private static List> buildSettlements(IntentModel model, Map return out; } + /** + * One payment-listener entry per settlement per event moment of the payment: its create AND its + * update. The allocation is written as a recompute of the payment's unallocated balance, so binding + * the correction event too is safe by construction - a re-delivery with nothing left to allocate + * does nothing. Bound to create alone (#6818), a payment booked for the wrong amount and corrected + * afterwards, or created in a draft state and completed later, was never (re-)allocated and the + * invoice silently kept the original settled figure. + * + * @param settlements the settlement descriptors + * @return one entry per settlement per bound event + */ + private static List> buildSettlementListeners(List> settlements) { + List> listeners = new ArrayList<>(); + for (Map settlement : settlements) { + String name = String.valueOf(settlement.get("name")); + // The create handler keeps its established class name; the correction one is suffixed. + listeners.add(rollupEntry(settlement, name + "OnPayment", "")); + listeners.add(rollupEntry(settlement, name + "OnPaymentUpdated", "-updated")); + } + return listeners; + } + /** * One glue entry per {@link GeneratesIntent}: resolves the source entity's perspective/genFolder * (in this project) and the target's - possibly cross-model, via {@link CrossModelSupport} - plus @@ -2584,6 +2609,16 @@ private static String payableCondition(List statuses) { return sb.toString(); } + /** + * Copies a descriptor into one per-event handler entry - the shape shared by every recompute-style + * listener (roll-ups, expansions, settlements): the same descriptor rendered once per bound event, + * distinguished only by its class name and the topic suffix it binds. + * + * @param base the descriptor + * @param className the generated handler class name + * @param topicSuffix the bound event's topic suffix + * @return the entry + */ private static Map rollupEntry(Map base, String className, String topicSuffix) { Map entry = new LinkedHashMap<>(base); entry.put("className", className); diff --git a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md index ef0aa832384..cc2b3d74759 100644 --- a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md +++ b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md @@ -2489,8 +2489,12 @@ settlements: Generates two client-Java glue classes (bind them with a `rollups` sum entry that keeps `paid` + `balance` + status — see rollups above): -- **`OnPayment`** - a `MessageHandler` on the payment's create event: spreads the new payment - across the payer's open invoices (oldest first), creating junction rows until the pot is used up. +- **`OnPayment`** / **`OnPaymentUpdated`** - a `MessageHandler` on the payment's create + event and one on its update event: spreads the payment across the payer's open invoices (oldest + first), creating junction rows until the pot is used up. It allocates the payment's *unallocated* + balance, so it is a recompute, not an append: a payment corrected after it was booked, or created + incomplete and completed later, is re-allocated for the amount it actually carries, and an amount + corrected below what it already covers releases the excess allocation (newest first). - **`OnInvoice`** - a `JavaDelegate` that pulls the customer's unallocated payment balance onto an invoice; wire it as a **`delegate:` service task** on the process step where the invoice becomes payable (e.g. right after Issue), e.g. `args: { delegate: gen.events.AutoAllocateOnInvoice, next: … }` diff --git a/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGenerator.java b/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGenerator.java index 629453ada24..82834505c10 100644 --- a/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGenerator.java +++ b/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGenerator.java @@ -43,10 +43,11 @@ class GlueGenerator { /** The names of the collections this generator handles. */ - private static final List COLLECTIONS = List.of("triggers", "resolvers", "fieldLoaders", "assignees", "timerLoaders", "waits", - "aborts", "setters", "writers", "notifications", "schedules", "integrations", "inbound", "inboundMessages", "inboundFiles", - "outbound", "stepEvents", "rollups", "expansions", "expansionCleanups", "settlements", "generates", "generateEvents", - "transitions", "sends", "posts", "aggregates", "postings", "printFeeders", "snapshots", "numbering", "resolves"); + private static final List COLLECTIONS = + List.of("triggers", "resolvers", "fieldLoaders", "assignees", "timerLoaders", "waits", "aborts", "setters", "writers", + "notifications", "schedules", "integrations", "inbound", "inboundMessages", "inboundFiles", "outbound", "stepEvents", + "rollups", "expansions", "expansionCleanups", "settlements", "settlementListeners", "generates", "generateEvents", + "transitions", "sends", "posts", "aggregates", "postings", "printFeeders", "snapshots", "numbering", "resolves"); /** The renderer. */ private final ModelTemplateRenderer renderer; @@ -104,6 +105,7 @@ List generate(String collection, GenerationTemplateMetadataSource case "expansions" -> each(collection, source, content, model, parameters, GlueGenerator::bindExpansion); case "expansionCleanups" -> each(collection, source, content, model, parameters, GlueGenerator::bindExpansionCleanup); case "settlements" -> each(collection, source, content, model, parameters, GlueGenerator::bindSettlement); + case "settlementListeners" -> each(collection, source, content, model, parameters, GlueGenerator::bindSettlementListener); // Both collections carry the SAME create-from descriptors (generateEvents is the // event-driven subset), so they share one binding - the listener and the create-from it // calls cannot be rendered from divergent data. @@ -580,14 +582,27 @@ private static void bindExpansionCleanup(Map item, Map item, Map context, Map parameters) { copy(context, item, "name", "match", "order", "invoiceEntity", "invoicePk", "invoiceTotal", "invoicePaid", "invoiceStatus", - "payableCondition", "junctionEntity", "junctionFkInvoice", "junctionFkPayment", "junctionAmount", "paymentEntity", - "paymentPk", "paymentPot", "paymentTopic"); + "payableCondition", "junctionEntity", "junctionPk", "junctionFkInvoice", "junctionFkPayment", "junctionAmount", + "paymentEntity", "paymentPk", "paymentPot", "paymentTopic"); context.put("invoiceJavaPerspective", sanitize(item, "invoicePerspective")); context.put("junctionJavaPerspective", sanitize(item, "junctionPerspective")); context.put("paymentGenFolder", truthy(item, "crossModel") ? sanitize(item, "paymentModel") : str(parameters, "javaGenFolderName")); context.put("paymentJavaPerspective", sanitize(item, "paymentPerspective")); } + /** + * Binds one payment listener of an auto-settlement - the same descriptor as the settlement itself, + * rendered once per bound payment event (create, correction). + * + * @param item the descriptor + * @param context the template context + * @param parameters the generation parameters + */ + private static void bindSettlementListener(Map item, Map context, Map parameters) { + bindSettlement(item, context, parameters); + copy(context, item, "className", "topicSuffix"); + } + /** * Binds a create-from action - the controller that clones a source record into a fresh target. * diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/SettlementOnPayment.java.template b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/SettlementOnPayment.java.template index ca7a3849716..629b757bac5 100644 --- a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/SettlementOnPayment.java.template +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/SettlementOnPayment.java.template @@ -17,21 +17,27 @@ import gen.${javaGenFolderName}.data.${junctionJavaPerspective}.${junctionEntity import gen.${paymentGenFolder}.data.${paymentJavaPerspective}.${paymentEntity}Entity; /** - * Auto-settlement (on ${paymentEntity} create): allocates the new payment across the payer's open + * Auto-settlement (on ${paymentEntity} #if($topicSuffix == "")create#{else}correction#end): allocates the payment across the payer's open * ${invoiceEntity}s - oldest first, matching ${match} - creating ${junctionEntity} rows until the * payment is used up. The paid roll-up then updates each invoice's paid / balance / status. * + * This is a RECOMPUTE of the payment's unallocated balance, not an append: it allocates only what is + * still unallocated, and releases the excess - newest allocation first - when the payment is corrected + * to less than what it already covers. That is what makes it safe to bind to the payment's correction + * event as well as to its create one, so a payment booked for the wrong amount, or completed after it + * was created, is settled for the amount it actually carries. + * * Generated from the intent settlements block - do not edit; it is re-generated with the application. * Entity access goes ONLY through the generated repositories (validations / events / i18n). */ -@Component("${javaGenFolderName}_${name}OnPayment") -public class ${name}OnPayment implements MessageHandler { +@Component("${javaGenFolderName}_${className}") +public class ${className} implements MessageHandler { - private static final Logger LOG = Logging.getLogger("gen.events.${javaGenFolderName}.${name}OnPayment"); + private static final Logger LOG = Logging.getLogger("gen.events.${javaGenFolderName}.${className}"); @Override public String destination() { - return "${paymentTopic}"; + return "${paymentTopic}${topicSuffix}"; } @Override @@ -45,9 +51,12 @@ public class ${name}OnPayment implements MessageHandler { if (payment == null || payment.${paymentPk} == null || payment.${paymentPot} == null) { return; } - BigDecimal pot = payment.${paymentPot}.subtract(allocated(payment.${paymentPk})) - .max(BigDecimal.ZERO); - if (pot.signum() <= 0) { + BigDecimal pot = payment.${paymentPot}.subtract(allocated(payment.${paymentPk})); + if (pot.signum() < 0) { + release(payment.${paymentPk}, pot.negate()); + return; + } + if (pot.signum() == 0) { return; } Criteria criteria = Criteria.create()#foreach($m in $match) @@ -96,6 +105,31 @@ public class ${name}OnPayment implements MessageHandler { return sum; } + /** + * Gives back the amount by which the ${junctionEntity} rows of this payment now exceed it - newest + * allocation first, so the oldest ${invoiceEntity}s stay settled. Reached only when the payment was + * corrected downwards after it had already been allocated. + */ + private static void release(Integer paymentId, BigDecimal excess) { + ${junctionEntity}Repository rows = new ${junctionEntity}Repository(); + for (${junctionEntity}Entity row : rows.findAll(Criteria.create() + .eq("${junctionFkPayment}", paymentId) + .orderByDesc("${junctionPk}"))) { + if (excess.signum() <= 0) { + break; + } + BigDecimal amount = row.${junctionAmount} == null ? BigDecimal.ZERO : row.${junctionAmount}; + if (amount.compareTo(excess) <= 0) { + rows.delete(row); + excess = excess.subtract(amount); + } else { + row.${junctionAmount} = amount.subtract(excess); + rows.update(row); + excess = BigDecimal.ZERO; + } + } + } + private static void create(Integer invoiceId, Integer paymentId, BigDecimal amount) { ${junctionEntity}Entity row = new ${junctionEntity}Entity(); row.${junctionFkInvoice} = invoiceId; diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/template/template.js b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/template/template.js index 11f805c8cd1..e536a591991 100644 --- a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/template/template.js +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/template/template.js @@ -174,9 +174,9 @@ export function getTemplate(parameters) { { location: "/template-application-events-java/events/SettlementOnPayment.java.template", action: "generate", - rename: "gen/events/{{javaGenFolderName}}/{{name}}OnPayment.java", + rename: "gen/events/{{javaGenFolderName}}/{{className}}.java", engine: "velocity", - collection: "settlements" + collection: "settlementListeners" }, { location: "/template-application-events-java/events/SettlementOnInvoice.java.template", diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java index 7fab13ef1fd..28e35614696 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java @@ -1814,8 +1814,9 @@ void sum_rollup_with_capacity_maintains_balance_and_sets_status() { @Test void settlement_generates_on_payment_listener_and_on_invoice_delegate() { // A settlement auto-allocates a Payment across a Customer's open Invoices (oldest first) via the - // InvoicePayment junction: an onPayment MessageHandler (payment create) + an onInvoice - // JavaDelegate (wired as a delegate: service task once the invoice is payable). + // InvoicePayment junction: an onPayment MessageHandler per bound payment event (create and + // correction) + an onInvoice JavaDelegate (wired as a delegate: service task once the invoice is + // payable). String yaml = """ name: settle entities: @@ -1872,6 +1873,20 @@ void settlement_generates_on_payment_listener_and_on_invoice_delegate() { assertTrue(onPayment.contains("s == 3 || s == 4 || s == 6"), "it should only allocate to invoices in a payable status"); assertTrue(onPayment.contains("new InvoicePaymentRepository().save(row)"), "it should create allocation rows through the junction repository (never the generic Store)"); + assertTrue(onPayment.contains("return \"" + PROJECT + "-Payment-Payment\";"), + "the create listener should bind the bare payment topic"); + + // A payment corrected after it was booked - or created incomplete and completed later - must be + // re-allocated, so the same recompute is bound to the payment's update event too (#6818). + String onPaymentUpdated = contentOf("gen/events/settle/AutoSettleOnPaymentUpdated.java"); + assertTrue(onPaymentUpdated.contains("class AutoSettleOnPaymentUpdated implements MessageHandler"), + "a second settlement listener should be generated for the payment's correction event"); + assertTrue(onPaymentUpdated.contains("return \"" + PROJECT + "-Payment-Payment-updated\";"), + "it should bind the payment's update topic"); + assertTrue(onPaymentUpdated.contains("release(payment.Id, pot.negate())"), + "a payment corrected below what it already covers should release the excess allocation"); + assertTrue(onPaymentUpdated.contains(".orderByDesc(\"Id\")") && onPaymentUpdated.contains("rows.delete(row)"), + "the release should give back the newest allocations first, through the junction repository"); String onInvoice = contentOf("gen/events/settle/AutoSettleOnInvoice.java"); assertTrue(onInvoice.contains("class AutoSettleOnInvoice implements JavaDelegate"),