diff --git a/components/engine/engine-intent/README.md b/components/engine/engine-intent/README.md index 6b90230797e..dd6570a3990 100644 --- a/components/engine/engine-intent/README.md +++ b/components/engine/engine-intent/README.md @@ -806,7 +806,9 @@ AND its update event, and is a recompute of the payment's unallocated balance ra 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). Oldest first is a total order: after the `order` fields, each side is sorted by its -own primary key, so same-day documents settle in creation order. +own primary key, so same-day documents settle in creation order. 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 844f77850a4..d00e65d0e3d 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("invoiceStatus", s.getStatus() == null ? "" : IntentNaming.pascalCase(s.getStatus())); e.put("payableCondition", payableCondition(s.getPayableStatuses())); // junction (this project) @@ -860,6 +859,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()); @@ -877,6 +880,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(); + } + /** * The settlement's authored order made TOTAL by the side's primary key (#7556): ordered by * {@code order:} alone, documents sharing that value - every invoice issued on the same day - came 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 c8dde2da560..3f255f31186 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 a5eef6d4ac8..c9c2e46ac3e 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 @@ -4098,8 +4098,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); or a list: [date, number] match: [Customer, Currency] # only allocate within the same customer + currency @@ -4124,10 +4124,14 @@ 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` and every `order` field 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. The order is -always total: both sides sort by the `order` fields and then by their own primary key, so documents -sharing a date (every invoice issued that day) settle in creation order, never in database order. Give -`order` a second field (`[date, number]`) when creation order is not the order you mean. +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. +The order is always total: both sides sort by the `order` fields and then by their own primary key, +so documents sharing a date (every invoice issued that day) settle in creation order, never in +database order. Give `order` a second field (`[date, number]`) when creation order is not the order +you mean. ### 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 eb445f5703b..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 @@ -681,10 +681,14 @@ private static void bindExpansionCleanup(Map item, Map item, Map context, Map parameters) { - copy(context, item, "name", "match", "invoiceOrder", "paymentOrder", "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", "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. + 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 5435a28a772..a6cb4a1f2b9 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 @@ -62,10 +62,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 2935bda8544..0f9b25cd7c4 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 @@ -128,10 +128,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 807eeb6476a..3ddc3294f3d 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 @@ -3025,6 +3025,15 @@ void settlement_generates_on_payment_listener_and_on_invoice_delegate() { assertTrue(onInvoice.replaceAll("\\s+", "") .contains(".orderByAsc(\"Date\").orderByAsc(\"Id\");"), "the customer's payments should be drawn by date, then by key"); + + // 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(); + } +}