Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion components/engine/engine-intent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -850,7 +850,6 @@ private static List<Map<String, Object>> 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)
Expand All @@ -860,6 +859,10 @@ private static List<Map<String, Object>> 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<Map<String, Object>> 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());
Expand All @@ -877,6 +880,36 @@ private static List<Map<String, Object>> 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<Map<String, Object>> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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<Map<String, Object>> filter = (List<Map<String, Object>>) 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<String, Object> settlement(String yaml) {
List<Map<String, Object>> cleanups = GlueIntentGenerator.buildSettlementCleanupsForTest(IntentParser.parse(yaml));
assertEquals(1, cleanups.size());
return cleanups.get(0);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -681,10 +681,14 @@ private static void bindExpansionCleanup(Map<String, Object> item, Map<String, O
* @param context the template context
* @param parameters the generation parameters
*/
private static void bindSettlement(Map<String, Object> item, Map<String, Object> context, Map<String, Object> 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<String, Object> item, Map<String, Object> context, Map<String, Object> 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"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object> settlement = new LinkedHashMap<>();
settlement.put("invoiceRowsFilter", List.of(clause("ne", "Status", Map.of("kind", "number", "text", "2"))));
Map<String, Object> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
Loading
Loading