From 790b03259c4f0f2b8c5f84c0090e07bce19e05e3 Mon Sep 17 00:00:00 2001 From: Nikol Georgieva Date: Sat, 3 Oct 2026 11:05:56 +0300 Subject: [PATCH 1/2] intent: a settlement sizes an allocation from the invoice's rows, not its lagging paid roll-up (#7559) Cause: the generated settlement handlers computed an invoice's open amount as total - Paid, where Paid is a roll-up maintained asynchronously through the outbox. A payment event arriving while the allocation rows had moved but Paid had not caught up sized an allocation the junction's capacity guard (which re-sums the rows synchronously) refused: the listener failed with an ERROR, and the rest of the payment the same pass would have spent on later invoices stayed unallocated. Change: - open() in SettlementOnPayment (create / correction / re-key) and SettlementOnInvoice re-sums the invoice's junction rows - the guard's own computation, the mirror of allocated(paymentId) - and no longer reads Paid. - The rows summed are the rows the paid roll-up counts: the settlement descriptor carries that roll-up's where: clauses (invoiceRowsFilter), rendered by GlueGenerator with the same criteriaChain the recompute and the guard use, so a cancelled allocation does not hold the invoice. A .glue without the key sums every row. - invoicePaid leaves the descriptor; `paid:` still names the roll-up. - Assistant guide, README and SettlementIntent docs updated. Verified: GlueSettlementOpenAmountTest + GlueGeneratorTest case (new); engine-intent (1466) and ide-template (215) unit suites; IntentSettlementOpenAmountIT (new, runtime: the paid column made permanently stale, a correction must take only what is left on the older invoice and carry the rest), IntentSettlementAllocationDeleteIT and IntentEngineIT (81) green on H2; formatter:validate with the cache wiped. Co-Authored-By: Claude Opus 5.5 (1M context) --- components/engine/engine-intent/README.md | 3 +- .../intent/generator/GlueIntentGenerator.java | 35 ++- .../intent/model/SettlementIntent.java | 14 +- .../main/resources/intent-assistant-guide.md | 9 +- .../GlueSettlementOpenAmountTest.java | 125 ++++++++++ .../template/service/model/GlueGenerator.java | 12 +- .../service/model/GlueGeneratorTest.java | 19 ++ .../events/SettlementOnInvoice.java.template | 16 +- .../events/SettlementOnPayment.java.template | 16 +- .../integration/tests/api/IntentEngineIT.java | 9 + .../api/IntentSettlementOpenAmountIT.java | 224 ++++++++++++++++++ 11 files changed, 464 insertions(+), 18 deletions(-) create mode 100644 components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueSettlementOpenAmountTest.java create mode 100644 tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentSettlementOpenAmountIT.java diff --git a/components/engine/engine-intent/README.md b/components/engine/engine-intent/README.md index 03aa851b3b1..ced96641d34 100644 --- a/components/engine/engine-intent/README.md +++ b/components/engine/engine-intent/README.md @@ -805,7 +805,8 @@ entry that maintains `paid`/`balance`/status. The spread handler is bound to the 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). +(newest first). What an invoice can still take is re-summed from its allocation rows (narrowed by the +paid roll-up's `where:`), not read off `paid`, which the roll-up updates asynchronously. ## 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 838cdeab1be..59ce01cc72d 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 @@ -850,7 +850,6 @@ private static List> buildSettlements(IntentModel model, Map e.put("invoicePerspective", IntentEntities.resolvePerspective(s.getInvoice(), compositionParents, model)); e.put("invoicePk", IntentEntities.keyFieldName(invoice)); e.put("invoiceTotal", IntentNaming.pascalCase(s.getTotal())); - e.put("invoicePaid", IntentNaming.pascalCase(s.getPaid())); e.put("order", IntentNaming.pascalCase(s.getOrder())); e.put("invoiceStatus", s.getStatus() == null ? "" : IntentNaming.pascalCase(s.getStatus())); e.put("payableCondition", payableCondition(s.getPayableStatuses())); @@ -861,6 +860,10 @@ private static List> buildSettlements(IntentModel model, Map e.put("junctionFkInvoice", IntentNaming.pascalCase(fkInvoice.getName())); e.put("junctionFkPayment", IntentNaming.pascalCase(fkPayment.getName())); e.put("junctionAmount", IntentNaming.pascalCase(s.getAmount())); + List> invoiceRowsFilter = paidRollupFilter(model, s, fkInvoice); + if (!invoiceRowsFilter.isEmpty()) { + e.put("invoiceRowsFilter", invoiceRowsFilter); + } // payment (possibly cross-model) e.put("crossModel", crossModel); e.put("paymentEntity", s.getPayment()); @@ -875,6 +878,36 @@ private static List> buildSettlements(IntentModel model, Map return out; } + /** + * Which of the junction's rows consume an invoice, as the settlement re-sums them (#7559): the + * {@code where:} clauses of the roll-up that keeps the settlement's {@code paid} column, i.e. the + * one over the junction, via its invoice relation, summing its amount. The settlement sizes an + * allocation from the rows rather than from that column, which the roll-up maintains asynchronously + * and which lags them; summing the very rows the roll-up and its capacity guard count keeps the + * three on one authored definition, so a cancelled allocation the roll-up has retired does not + * still hold the invoice's capacity here. + * + * @param model the intent model + * @param settlement the settlement + * @param fkInvoice the junction's relation to the invoice + * @return the roll-up's filter clauses, empty when it declares none or no such roll-up exists + */ + private static List> paidRollupFilter(IntentModel model, SettlementIntent settlement, RelationIntent fkInvoice) { + for (RollupIntent rollup : model.getRollups()) { + if (settlement.getJunction() + .equals(rollup.getEntity()) + && fkInvoice.getName() + .equalsIgnoreCase(rollup.getVia()) + && settlement.getPaid() + .equalsIgnoreCase(rollup.getField()) + && settlement.getAmount() + .equalsIgnoreCase(rollup.getOf())) { + return ScheduleSupport.conditions(rollup.getWhere()); + } + } + return List.of(); + } + /** * 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 diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/SettlementIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/SettlementIntent.java index 503f98c53d7..e32ed63748c 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/SettlementIntent.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/SettlementIntent.java @@ -22,9 +22,9 @@ * payment's create event, spreads the payment across open invoices) and an {@code onInvoice} * {@code JavaDelegate} (wired as a {@code delegate:} service task after the invoice becomes * payable, pulls the customer's unallocated payment balance onto it). Allocation for both - * directions is bounded by the invoice open amount ({@link #total} - {@link #paid}) and the payment - * pot ({@link #pot} minus what it has already allocated), and restricted to rows agreeing on every - * {@link #match} relation. + * directions is bounded by the invoice open amount ({@link #total} minus the invoice's junction + * rows, re-summed from the store) and the payment pot ({@link #pot} minus what it has already + * allocated), and restricted to rows agreeing on every {@link #match} relation. */ public class SettlementIntent { @@ -39,9 +39,13 @@ public class SettlementIntent { private String payment; /** The junction's amount payload field (the allocated slice). */ private String amount; - /** The invoice capacity field (e.g. total); open = total - paid. */ + /** The invoice capacity field (e.g. total); open = total - the invoice's allocation rows. */ private String total; - /** The invoice consumed field (e.g. paid); open = total - paid. */ + /** + * The invoice consumed field (e.g. paid) the roll-up over the junction keeps. The settlement does + * not read it - it lags the rows (#7559) - but that roll-up's {@code where:} decides which rows + * count as consumed. + */ private String paid; /** The payment pot field (e.g. amount); unallocated = pot - already-allocated. */ private String pot; 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 3c7c3efcc43..e67be5c2cd3 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 @@ -4071,8 +4071,8 @@ settlements: invoice: SalesInvoice # the open-receivable side payment: CustomerPayment # the pot side (often cross-model) amount: amount # the junction's allocated-slice field - total: total # invoice capacity; open = total - paid - paid: paid # invoice consumed (kept by the paid roll-up) + total: total # invoice capacity; open = total - its allocation rows + paid: paid # invoice consumed (kept by the paid roll-up; its where: decides which rows count) pot: amount # payment pot field (payment.amount) order: date # allocate oldest first (FIFO) match: [Customer, Currency] # only allocate within the same customer + currency @@ -4097,7 +4097,10 @@ Generates two client-Java glue classes (bind them with a `rollups` sum entry tha relation to both the invoice and the payment; `amount` is a junction field; `total` / `paid` / `order` are invoice fields; `status` a to-one relation of the invoice; `match` are to-one relations of the invoice (and same-named on the payment). Allocation is bounded by the invoice open amount and the -payment's unallocated balance; entity writes go only through the generated repositories. +payment's unallocated balance; entity writes go only through the generated repositories. The open +amount is re-summed from the invoice's junction rows - the rows its capacity guard counts, narrowed by +the paid roll-up's `where:` - and never read off `paid`, which the roll-up maintains asynchronously: +sized from it, a payment event racing the roll-up asked for more than the guard allows and failed. ### resolves - fill a relation from a register valid on a date diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueSettlementOpenAmountTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueSettlementOpenAmountTest.java new file mode 100644 index 00000000000..5d9b056ffb9 --- /dev/null +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueSettlementOpenAmountTest.java @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.intent.generator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import java.util.List; +import java.util.Map; + +import org.eclipse.dirigible.components.intent.parser.IntentParser; +import org.junit.jupiter.api.Test; + +/** + * A settlement sizes an allocation from the invoice's allocation ROWS, never from its {@code paid} + * roll-up column (#7559). The column is maintained asynchronously and lags the rows, so a payment + * event racing it sized an allocation the junction's capacity guard - which re-sums the rows - then + * refused, failing the listener mid-loop. The rows the settlement sums are the rows the paid + * roll-up counts: its {@code where:} clauses travel on the settlement descriptor, so the + * settlement, the roll-up and the guard read one authored definition. + */ +class GlueSettlementOpenAmountTest { + + private static final String YAML = """ + name: settle + entities: + - name: Invoice + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: date, type: date } + - { name: total, type: decimal, precision: 18, scale: 2 } + - { name: paid, type: decimal, precision: 18, scale: 2 } + relations: + - { name: Customer, kind: manyToOne, to: Customer } + - name: Payment + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: date, type: date } + - { name: amount, type: decimal, precision: 18, scale: 2, required: true } + relations: + - { name: Customer, kind: manyToOne, to: Customer } + - name: Customer + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string, required: true, length: 100 } + - name: AllocationStatus + kind: setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: InvoicePayment + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: amount, type: decimal, precision: 18, scale: 2, required: true } + relations: + - { name: Invoice, kind: manyToOne, to: Invoice, composition: true, required: true } + - { name: Payment, kind: manyToOne, to: Payment, required: true } + - { name: Status, kind: manyToOne, to: AllocationStatus, function: EntityStatus, init: ACTIVE } + seeds: + - name: allocationStatuses + entity: AllocationStatus + rows: + - { id: 1, name: ACTIVE } + - { id: 2, name: CANCELLED, stage: cancelled } + rollups: + - name: invoicePaid + entity: InvoicePayment + via: Invoice + field: paid + op: sum + of: amount + capacity: total + where: + - { field: Status, op: ne, value: CANCELLED } + settlements: + - { name: autoSettle, junction: InvoicePayment, invoice: Invoice, payment: Payment, + amount: amount, total: total, paid: paid, pot: amount, order: date, + match: [Customer] } + """; + + private static final String WHERE = " where:\n - { field: Status, op: ne, value: CANCELLED }\n"; + + @SuppressWarnings("unchecked") + @Test + void theInvoicesRowsAreNarrowedByThePaidRollupsFilter() { + List> filter = (List>) settlement(YAML).get("invoiceRowsFilter"); + + assertEquals(1, filter.size(), "a cancelled allocation consumes nothing the paid roll-up does not count: " + filter); + assertEquals("ne", filter.get(0) + .get("op")); + assertEquals("Status", filter.get(0) + .get("property")); + assertEquals("2", ((Map) filter.get(0) + .get("value")).get("text"), + "the CANCELLED name resolves to its seed id, exactly as on the roll-up"); + } + + @Test + void anUnfilteredPaidRollupNarrowsNothing() { + assertFalse(settlement(YAML.replace(WHERE, "")).containsKey("invoiceRowsFilter"), + "with no filter every allocation row consumes the invoice, as the guard counts it"); + } + + @Test + void aFilteredRollupOfAnotherColumnIsNotTheSettlementsDefinition() { + String yaml = YAML.replace(" field: paid\n", " field: total\n") + .replace(" capacity: total\n", ""); + + assertFalse(settlement(yaml).containsKey("invoiceRowsFilter"), + "only the roll-up keeping the settlement's paid column decides which rows count"); + } + + private static Map settlement(String yaml) { + List> cleanups = GlueIntentGenerator.buildSettlementCleanupsForTest(IntentParser.parse(yaml)); + assertEquals(1, cleanups.size()); + return cleanups.get(0); + } +} 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 8fce1a7546a..4e596c28474 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 @@ -681,10 +681,14 @@ 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", "junctionPk", "junctionFkInvoice", "junctionFkPayment", "junctionAmount", - "paymentEntity", "paymentPk", "paymentPot", "paymentTopic"); + static void bindSettlement(Map item, Map context, Map parameters) { + copy(context, item, "name", "match", "order", "invoiceEntity", "invoicePk", "invoiceTotal", "invoiceStatus", "payableCondition", + "junctionEntity", "junctionPk", "junctionFkInvoice", "junctionFkPayment", "junctionAmount", "paymentEntity", "paymentPk", + "paymentPot", "paymentTopic"); + // The rows that consume an invoice, as the paid roll-up and its capacity guard count them (#7559): + // the settlement sizes an allocation by re-summing them, never from the lagging paid column. A + // descriptor carrying no clauses - or one written before the key existed - sums every row. + context.put("invoiceRowsFilter", JavaLiterals.criteriaChain(asMaps(item.get("invoiceRowsFilter")))); context.put("invoiceJavaPerspective", sanitize(item, "invoicePerspective")); context.put("junctionJavaPerspective", sanitize(item, "junctionPerspective")); context.put("paymentGenFolder", truthy(item, "crossModel") ? sanitize(item, "paymentModel") : str(parameters, "javaGenFolderName")); diff --git a/components/ide/ide-template/src/test/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGeneratorTest.java b/components/ide/ide-template/src/test/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGeneratorTest.java index ecbba932165..d9f45ced59b 100644 --- a/components/ide/ide-template/src/test/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGeneratorTest.java +++ b/components/ide/ide-template/src/test/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGeneratorTest.java @@ -195,6 +195,25 @@ void aForeignKeyCriteriaIsBuiltFromTheKeyTheDescriptorCarries() { assertThat(context).containsEntry("criteriaExpression", "Criteria.create().eq(\"Member\", entity.Member)"); } + /** + * A settlement re-sums an invoice's allocation rows to size what it may still take (#7559), and the + * rows it sums are the ones the paid roll-up counts - its clauses rendered as the same chain the + * roll-up recompute and the capacity guard append. A descriptor without them, including a .glue + * generated before the key existed, sums every row. + */ + @Test + void aSettlementSumsTheInvoicesRowsThePaidRollupCounts() { + Map settlement = new LinkedHashMap<>(); + settlement.put("invoiceRowsFilter", List.of(clause("ne", "Status", Map.of("kind", "number", "text", "2")))); + Map context = new LinkedHashMap<>(); + + GlueGenerator.bindSettlement(settlement, context, Map.of("javaGenFolderName", "shop")); + assertThat(context).containsEntry("invoiceRowsFilter", ".ne(\"Status\", 2)"); + + GlueGenerator.bindSettlement(new LinkedHashMap<>(), context, Map.of("javaGenFolderName", "shop")); + assertThat(context).containsEntry("invoiceRowsFilter", ""); + } + /** * A scheduled generation's {@code run:} key term carries the PERIOD, and the calendar arithmetic * the guard queries by is derived from it here - which is also the only place the two bounds stay diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/SettlementOnInvoice.java.template b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/SettlementOnInvoice.java.template index c37ae2ea420..9ca8d9e2bf0 100644 --- a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/SettlementOnInvoice.java.template +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/SettlementOnInvoice.java.template @@ -60,10 +60,22 @@ public class ${name}OnInvoice implements JavaDelegate { } } + /** + * What the ${invoiceEntity} can still take, re-summed from its ${junctionEntity} rows - the rows its + * capacity guard counts - and never read off its paid roll-up column. That column is maintained + * asynchronously and lags the rows, so an allocation sized from it could be one the guard has + * to refuse, failing this handler before it reached the remaining ${invoiceEntity}s. + */ private static BigDecimal open(${invoiceEntity}Entity invoice) { BigDecimal total = invoice.${invoiceTotal} == null ? BigDecimal.ZERO : invoice.${invoiceTotal}; - BigDecimal paid = invoice.${invoicePaid} == null ? BigDecimal.ZERO : invoice.${invoicePaid}; - return total.subtract(paid) + BigDecimal settled = BigDecimal.ZERO; + for (${junctionEntity}Entity row : new ${junctionEntity}Repository().findAll(Criteria.create() + .eq("${junctionFkInvoice}", invoice.${invoicePk})${invoiceRowsFilter})) { + if (row.${junctionAmount} != null) { + settled = settled.add(row.${junctionAmount}); + } + } + return total.subtract(settled) .max(BigDecimal.ZERO); } 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 b8a127cb113..a5f33c83dca 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 @@ -126,10 +126,22 @@ public class ${className} implements MessageHandler { LOG.error("Settlement ${name}: the listener on [{}] failed - [{}]", destination(), error); } + /** + * What the ${invoiceEntity} can still take, re-summed from its ${junctionEntity} rows - the rows its + * capacity guard counts - and never read off its paid roll-up column. That column is maintained + * asynchronously and lags the rows, so an allocation sized from it could be one the guard has + * to refuse, failing this handler before it reached the remaining ${invoiceEntity}s. + */ private static BigDecimal open(${invoiceEntity}Entity invoice) { BigDecimal total = invoice.${invoiceTotal} == null ? BigDecimal.ZERO : invoice.${invoiceTotal}; - BigDecimal paid = invoice.${invoicePaid} == null ? BigDecimal.ZERO : invoice.${invoicePaid}; - return total.subtract(paid) + BigDecimal settled = BigDecimal.ZERO; + for (${junctionEntity}Entity row : new ${junctionEntity}Repository().findAll(Criteria.create() + .eq("${junctionFkInvoice}", invoice.${invoicePk})${invoiceRowsFilter})) { + if (row.${junctionAmount} != null) { + settled = settled.add(row.${junctionAmount}); + } + } + return total.subtract(settled) .max(BigDecimal.ZERO); } 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 5fcf324452c..f1a926f7b2b 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 @@ -3016,6 +3016,15 @@ void settlement_generates_on_payment_listener_and_on_invoice_delegate() { "the onInvoice settlement delegate should be generated"); assertTrue(onInvoice.contains("new PaymentRepository().findAll") && onInvoice.contains(".eq(\"Customer\", invoice.Customer)"), "it should pull the customer's payments matching on the shared Customer"); + + // What an invoice can still take is re-summed from its allocation ROWS - the rows the capacity + // guard counts - never read off the paid roll-up column, which lags them (#7559). Sized from the + // column, a payment event racing the roll-up asked for more than the guard allows and failed. + for (String handler : List.of(onPayment, onPaymentUpdated, onInvoice)) { + String code = stripComments(handler); + assertTrue(code.contains(".eq(\"Invoice\", invoice.Id)"), "the open amount should be summed from the invoice's rows"); + assertFalse(code.contains("invoice.Paid"), "the open amount must not be read off the lagging paid column"); + } } @Test diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentSettlementOpenAmountIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentSettlementOpenAmountIT.java new file mode 100644 index 00000000000..4f2b20af7af --- /dev/null +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentSettlementOpenAmountIT.java @@ -0,0 +1,224 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.integration.tests.api; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import org.eclipse.dirigible.components.initializers.synchronizer.SynchronizationProcessor; +import org.eclipse.dirigible.repository.api.IRepository; +import org.eclipse.dirigible.repository.api.IRepositoryStructure; +import org.eclipse.dirigible.repository.api.IResource; +import org.eclipse.dirigible.tests.base.IntegrationTest; +import org.eclipse.dirigible.tests.framework.restassured.RestAssuredExecutor; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.annotation.DirtiesContext; + +/** + * A settlement sizes an allocation from the invoice's allocation ROWS, not from its paid column + * (#7559), through the published application. + * + *

+ * The paid column is a roll-up, maintained asynchronously, and it lags the rows. A payment event + * arriving inside that lag sized an allocation from the stale figure; the junction's capacity guard + * - which re-sums the rows - refused it, the listener failed with an ERROR, and the part of the + * payment the same pass would have spent on the LATER invoices stayed unallocated. The race is a + * matter of timing, so the fixture makes the lag permanent instead: the capacity-bearing roll-up + * keeps another column, and the settlement's {@code paid} column is never written. Sized from the + * column, the correction below asks the first invoice for its whole total again and stops at the + * guard; sized from the rows, it takes only what is left on it and carries the rest to the second. + */ +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class IntentSettlementOpenAmountIT extends IntegrationTest { + + private static final String WORKSPACE = "workspace"; + private static final String PROJECT = "settleopen"; + private static final String PROJECT_PATH = IRepositoryStructure.PATH_USERS + "/admin/" + WORKSPACE + "/" + PROJECT; + private static final String API = "/services/java/" + PROJECT + "/gen/" + PROJECT + "/api"; + private static final String CUSTOMERS = API + "/customer/CustomerController"; + private static final String INVOICES = API + "/invoice/InvoiceController"; + private static final String ALLOCATIONS = API + "/invoice/InvoicePaymentController"; + private static final String PAYMENTS = API + "/payment/PaymentController"; + private static final long TIMEOUT_SECONDS = 90; + /** Settlement runs off the payment's events, dispatched after the commit. */ + private static final long EVENT_TIMEOUT_SECONDS = 30; + + private static final String INTENT_YAML = """ + name: settleopen + description: settlement fixture - an allocation is sized from the invoice's rows, not its lagging paid column + + entities: + - name: Customer + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string, required: true, length: 100 } + + - name: Invoice + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: date, type: date } + - { name: total, type: decimal, precision: 18, scale: 2 } + # The settlement's paid column, deliberately kept by no roll-up: permanently stale. + - { name: paid, type: decimal, precision: 18, scale: 2 } + - { name: settled, type: decimal, precision: 18, scale: 2 } + relations: + - { name: Customer, kind: manyToOne, to: Customer } + + - name: Payment + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: date, type: date } + - { name: amount, type: decimal, precision: 18, scale: 2, required: true } + relations: + - { name: Customer, kind: manyToOne, to: Customer } + + - name: InvoicePayment + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: amount, type: decimal, precision: 18, scale: 2, required: true } + relations: + - { name: Invoice, kind: manyToOne, to: Invoice, composition: true, required: true } + - { name: Payment, kind: manyToOne, to: Payment, required: true } + + rollups: + # Capacity-bearing, so the junction carries the guard that re-sums the rows. + - { name: invoiceSettled, entity: InvoicePayment, via: Invoice, field: settled, op: sum, of: amount, capacity: total } + + settlements: + - { name: autoSettle, junction: InvoicePayment, invoice: Invoice, payment: Payment, + amount: amount, total: total, paid: paid, pot: amount, order: date, + match: [Customer] } + """; + + @Autowired + private IRepository repository; + + @Autowired + private RestAssuredExecutor restAssuredExecutor; + + @Autowired + private SynchronizationProcessor synchronizationProcessor; + + @Test + void a_correction_takes_only_what_is_left_on_the_older_invoice_and_carries_the_rest() { + generateProject(); + publishProject(); + synchronizationProcessor.forceProcessSynchronizers(); + + int customer = create(CUSTOMERS, "{\"Name\":\"acme\"}"); + int older = create(INVOICES, "{\"Date\":\"2026-09-01\",\"Total\":120,\"Customer\":" + customer + "}"); + int newer = create(INVOICES, "{\"Date\":\"2026-09-15\",\"Total\":200,\"Customer\":" + customer + "}"); + int payment = create(PAYMENTS, "{\"Date\":\"2026-09-20\",\"Amount\":100,\"Customer\":" + customer + "}"); + + awaitAllocated(older, "100"); + awaitAllocated(newer, "0"); + + // The payment is corrected upwards: 200 more to spread. The older invoice can take 20 of it. + restAssuredExecutor.execute(() -> given().contentType("application/json") + .body("{\"Id\":" + payment + ",\"Date\":\"2026-09-20\",\"Amount\":300,\"Customer\":" + + customer + "}") + .when() + .put(PAYMENTS + "/" + payment) + .then() + .statusCode(200)); + + awaitAllocated(older, "120"); + awaitAllocated(newer, "180"); + } + + /** The sum of the invoice's allocation rows - what the capacity guard counts. */ + private void awaitAllocated(int invoice, String expected) { + restAssuredExecutor.execute(() -> { + List> rows = given().when() + .get(ALLOCATIONS + "?Invoice=" + invoice) + .then() + .statusCode(200) + .extract() + .jsonPath() + .getList(""); + BigDecimal allocated = BigDecimal.ZERO; + for (Map row : rows) { + allocated = allocated.add(new BigDecimal(String.valueOf(row.get("Amount")))); + } + assertEquals(0, allocated.compareTo(new BigDecimal(expected)), + "invoice " + invoice + " should carry " + expected + " in allocations: " + rows); + }, EVENT_TIMEOUT_SECONDS); + } + + private int create(String controller, String body) { + AtomicInteger id = new AtomicInteger(); + restAssuredExecutor.execute(() -> id.set(given().contentType("application/json") + .body(body) + .when() + .post(controller) + .then() + .statusCode(200) + .extract() + .path("Id")), + TIMEOUT_SECONDS); + return id.get(); + } + + private void generateProject() { + writeIntent(); + AtomicReference>> plan = new AtomicReference<>(); + restAssuredExecutor.execute(() -> plan.set(given().when() + .post("/services/ide/intent/generate?workspace=" + WORKSPACE + "&project=" + + PROJECT + "&path=app.intent") + .then() + .statusCode(200) + .extract() + .jsonPath() + .getList("codeGenerations"))); + for (Map codeGeneration : plan.get()) { + assertEquals(Boolean.TRUE, codeGeneration.get("generated"), + "generating code from " + codeGeneration.get("path") + " failed: " + codeGeneration.get("error")); + } + } + + private void publishProject() { + restAssuredExecutor.execute(() -> given().when() + .post("/services/ide/publisher/" + WORKSPACE + "/" + PROJECT + "/") + .then() + .statusCode(200)); + } + + private void writeIntent() { + String path = PROJECT_PATH + "/app.intent"; + IResource existing = repository.getResource(path); + if (existing.exists()) { + existing.setContent(INTENT_YAML.getBytes(StandardCharsets.UTF_8)); + } else { + repository.createResource(path, INTENT_YAML.getBytes(StandardCharsets.UTF_8)); + } + } + + @AfterEach + void cleanup() { + restAssuredExecutor.execute(() -> given().when() + .delete("/services/ide/publisher/" + WORKSPACE + "/" + PROJECT) + .then() + .statusCode(greaterThanOrEqualTo(200))); + if (repository.hasCollection(PROJECT_PATH)) { + repository.removeCollection(PROJECT_PATH); + } + synchronizationProcessor.forceProcessSynchronizers(); + } +} From 957a531d568659dac93d1e5087d419c8da1ff1fe Mon Sep 17 00:00:00 2001 From: delchev Date: Sat, 3 Oct 2026 11:47:08 +0300 Subject: [PATCH 2/2] intent: format the settlement binder after merging master (#7559) --- .../ide/template/service/model/GlueGenerator.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 85b375f7fed..5d5d2336aa7 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 @@ -682,9 +682,9 @@ private static void bindExpansionCleanup(Map item, Map item, Map context, Map parameters) { - copy(context, item, "name", "match", "invoiceOrder", "paymentOrder", "invoiceEntity", "invoicePk", "invoiceTotal", - "invoiceStatus", "payableCondition", "junctionEntity", "junctionPk", "junctionFkInvoice", "junctionFkPayment", - "junctionAmount", "paymentEntity", "paymentPk", "paymentPot", "paymentTopic"); + copy(context, item, "name", "match", "invoiceOrder", "paymentOrder", "invoiceEntity", "invoicePk", "invoiceTotal", "invoiceStatus", + "payableCondition", "junctionEntity", "junctionPk", "junctionFkInvoice", "junctionFkPayment", "junctionAmount", + "paymentEntity", "paymentPk", "paymentPot", "paymentTopic"); // The rows that consume an invoice, as the paid roll-up and its capacity guard count them (#7559): // the settlement sizes an allocation by re-summing them, never from the lagging paid column. A // descriptor carrying no clauses - or one written before the key existed - sums every row.