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
1 change: 1 addition & 0 deletions components/engine/engine-intent/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Name>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.
6 changes: 5 additions & 1 deletion components/engine/engine-intent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ public void generate(IntentGenerationContext context) {
List<Map<String, Object>> expansions = expansionHandlers.regenerations();
List<Map<String, Object>> expansionCleanups = expansionHandlers.cleanups();
List<Map<String, Object>> settlements = buildSettlements(model, byName, compositionParents, settings, context);
List<Map<String, Object>> settlementListeners = buildSettlementListeners(settlements);
List<Map<String, Object>> generates = buildGenerates(model, byName, compositionParents, settings, context);
List<Map<String, Object>> transitions = buildTransitions(model, byName, compositionParents, settings, context);
List<Map<String, Object>> sends = buildSends(model, byName, compositionParents, settings, context);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -648,6 +650,7 @@ private static List<Map<String, Object>> 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()));
Expand All @@ -665,6 +668,28 @@ private static List<Map<String, Object>> 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<Map<String, Object>> buildSettlementListeners(List<Map<String, Object>> settlements) {
List<Map<String, Object>> listeners = new ArrayList<>();
for (Map<String, Object> 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
Expand Down Expand Up @@ -2584,6 +2609,16 @@ private static String payableCondition(List<Integer> 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<String, Object> rollupEntry(Map<String, Object> base, String className, String topicSuffix) {
Map<String, Object> entry = new LinkedHashMap<>(base);
entry.put("className", className);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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):
- **`<Name>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.
- **`<Name>OnPayment`** / **`<Name>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).
- **`<Name>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: … }`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,11 @@
class GlueGenerator {

/** The names of the collections this generator handles. */
private static final List<String> 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<String> 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;
Expand Down Expand Up @@ -104,6 +105,7 @@ List<GeneratedFile> 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.
Expand Down Expand Up @@ -580,14 +582,27 @@ private static void bindExpansionCleanup(Map<String, Object> item, Map<String, O
*/
private static void bindSettlement(Map<String, Object> item, Map<String, Object> context, Map<String, Object> 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<String, Object> item, Map<String, Object> context, Map<String, Object> 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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading