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"),