Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
32 changes: 32 additions & 0 deletions .operaton-starter.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1713,3 +1713,35 @@ examples:
screenshots:
- examples/use-cases/bank-account-opening/src/main/resources/bank-account-opening.png
lastUpdated: "2026-06-30"

- id: expense-reimbursement
title: "Use Case — Expense Reimbursement"
icon: "🧾"
path: examples/use-cases/expense-reimbursement
shortDescription: >
Vision LLM verifies a receipt image, DMN decides whether finance approval is required,
simulated payment, and an outcome email notification.
longDescription: |
Demonstrates multimodal receipt verification via Java delegates (Base64 FileValue),
a FIRST-hit DMN approval decision, an optional finance user task, simulated payment,
and LLM-drafted email notifications.
buildSystem: maven
runtime: spring-boot
operatonVersion: "2.1.1"
javaVersion: "21"
complexity: intermediate
tags:
- { label: "LLM", category: integration }
- { label: "DMN", category: concept }
- { label: "User Task", category: concept }
- { label: "Email", category: integration }
integrations: [postgres, ollama, wiremock, mailpit]
bpmnConcepts: [service-task, business-rule-task, exclusive-gateway, user-task, send-task]
requires: "Java 21+, Docker"
authors:
- { name: "Karsten Thoms", url: "https://github.com/kthoms" }
license: "Apache-2.0"
documentationUrl: "https://github.com/kthoms/operaton-examples/blob/main/examples/use-cases/expense-reimbursement/README.md"
screenshots:
- examples/use-cases/expense-reimbursement/src/main/resources/expense-reimbursement.png
lastUpdated: "2026-07-01"
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ docker compose up -d --wait # start PostgreSQL (and example-specific services)
| [employee-onboarding](examples/use-cases/employee-onboarding) | Employee HR onboarding | Call activity orchestration: parallel MI for equipment provisioning, single call activity for system access, in/out variable mapping |
| [procurement-collaboration](examples/use-cases/procurement-collaboration) | Buyer ↔ Supplier procurement | Two-pool collaboration, three correlated messages, async continuation decoupling |
| [supply-chain-tracking](examples/use-cases/supply-chain-tracking) | Shipment tracking | Event-based gateway, async Kafka message correlation by business key, P7D timer re-arm |
| [expense-reimbursement](examples/use-cases/expense-reimbursement) | Vision LLM receipt verification | Multimodal vision LLM via delegate, three-way match verification, FIRST-hit DMN, LLM-drafted email |

## Anatomy of every example

Expand Down Expand Up @@ -168,6 +169,7 @@ Quick lookup: which example demonstrates each BPMN construct.
| Operaton Connectors | integration-connectors |
| Micrometer / Prometheus | approval-sla-metrics |
| Keycloak identity provider | integration-keycloak |
| Vision LLM (Ollama) | [expense-reimbursement](examples/use-cases/expense-reimbursement) |

### Platforms / Runtimes

Expand Down
7 changes: 6 additions & 1 deletion docs/EXAMPLE_STANDARDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,6 @@ Every example README contains, in this order:
![Process diagram](src/main/resources/<process-name>.png)
```
Commit the `.png` and the updated README together.
Register the PNG path in `.operaton-starter.yml` under `screenshots`.
Prerequisites: `npm install -g bpmn-to-image`.
4. **Prerequisites** — JDK 21, Docker; exact versions.
5. **Run it** — `docker compose up -d`, then both
Expand All @@ -174,6 +173,10 @@ Every example README contains, in this order:

- Code comments only where the code cannot speak (e.g. why an async
continuation is placed where it is).
- Update `.operaton-starter.yml`:
- Register the example
- Register the BPMN PNG path under `screenshots`.
- Update main README: Add the example to Catalog and BPMN Concept Reference

## 9. Quality gate (CI)

Expand All @@ -194,6 +197,8 @@ Every example README contains, in this order:
- [ ] Versions match pom.xml == build.gradle.kts == root README table
- [ ] §7 app conventions: demo/demo admin user, named seed users, application.yaml
- [ ] No dead code, no unused dependencies, no TODO/stub delegates
- [ ] The example is registered in `.operaton-starter.yml`
- [ ] The example is registered in the `README.md` in the repository root
```

## 11. Platform Integration Examples
Expand Down
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.12/apache-maven-3.9.12-bin.zip
wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.1/maven-wrapper-3.1.1.jar
161 changes: 161 additions & 0 deletions examples/use-cases/expense-reimbursement/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# Expense Reimbursement

An employee submits a receipt image; a **vision LLM** (via Spring delegate) verifies the receipt
matches the stated expense, a **FIRST-hit DMN table** decides whether manager approval is required,
an optional **user task** allows the finance team to approve or reject, a stub payment service
records the transaction, and the **LLM drafts a personalised email** sent via Spring Mail.

## What you will learn

- How to call a **multimodal vision LLM** from a Java delegate — encoding a `FileValue` as Base64
and building a structured prompt with `PromptBuilder`
- How **process variables of type file** work: uploading via the embedded start form, reading bytes
in `ReceiptAnalyzer`, and the fail-safe fallback to `UNRELATED` on any exception
- How a **three-way match result** (`MATCH` / `MISMATCH` / `UNRELATED`) feeds into a DMN decision
to produce `approvalRequired`
- How a **DMN table with FIRST hit policy** works: the `UNRELATED` override row fires before the
per-kind threshold rows, ensuring unverifiable receipts always route to a human
- How an **LLM drafts outcome emails** — two separate prompt strategies (approval vs. rejection
tone) produce personalised email bodies sent via Spring Mail to Mailpit

## Process model

![Process diagram](src/main/resources/expense-reimbursement.png)

> To render the PNG yourself: `./scripts/render-bpmn.sh examples/use-cases/expense-reimbursement`
> (requires `npm install -g bpmn-to-image`).

## Prerequisites

- JDK 21
- Docker (recent version)

## Run it

```bash
docker compose up -d
./mvnw spring-boot:run
# or:
./gradlew bootRun
```

- Cockpit / Tasklist: http://localhost:8080 — **demo/demo**
- Mailpit (captured emails): http://localhost:8025
- Ollama API: http://localhost:11434

> **Hosted LLM override** — to point at any OpenAI-compatible endpoint instead of local Ollama:
> ```bash
> export LLM_BASE_URL=https://api.openai.com
> export LLM_API_KEY=sk-...
> export LLM_MODEL=gpt-4o-mini
> ```
> For a lighter local model use `LLM_MODEL=moondream` (~1.7 GB, vision-capable).
>
> The `docker-compose.yml` pulls `llama3.2-vision` by default via the `ollama-pull` init service.

## Walk through it

The start form is at http://localhost:8080 — open Tasklist, click **Start process**, then choose
**Expense Reimbursement**. If you use the REST API, include a `receipt` file variable (Base64);
otherwise `ReceiptAnalyzer` falls back to `matchResult=UNRELATED` and the DMN will require approval.

### Happy path — receipt matches, within tier (auto-approved)

# Submit a MEALS expense for €35 — below the €50 auto-approval threshold
# Replace <BASE64_JPEG> with: base64 -w0 receipt.jpg
curl -s -X POST http://localhost:8080/engine-rest/process-definition/key/expense-reimbursement/start \
-u demo:demo \
-H "Content-Type: application/json" \
-d '{
"variables": {
"requesterName": { "value": "Alice Berger", "type": "String" },
"requesterEmail": { "value": "alice@example.com", "type": "String" },
"kind": { "value": "MEALS", "type": "String" },
"statedCost": { "value": 35.0, "type": "Double" },
"reason": { "value": "Team lunch", "type": "String" },
"receipt": {
"value": "<BASE64_JPEG>",
"type": "File",
"valueInfo": { "filename": "receipt.jpg", "mimeType": "image/jpeg", "encoding": "Base64" }
}
}
}' | jq .id
```

The process completes automatically: `matchResult=MATCH`, `approvalRequired=false`, payment
reference set, approval email visible in Mailpit.

### Alternative path — UNRELATED receipt, finance approves

```bash
# Submit a TRAVEL expense — WireMock returns UNRELATED for "Bob Richter"
curl -s -X POST http://localhost:8080/engine-rest/process-definition/key/expense-reimbursement/start \
-u demo:demo \
-H "Content-Type: application/json" \
-d '{
"variables": {
"requesterName": { "value": "Bob Richter", "type": "String" },
"requesterEmail": { "value": "bob@example.com", "type": "String" },
"kind": { "value": "TRAVEL", "type": "String" },
"statedCost": { "value": 150.0, "type": "Double" },
"reason": { "value": "Conference travel", "type": "String" }
}
}' | jq .id
```

Open Tasklist as `alice` (password: `alice`) — claim and approve the **Approve Reimbursement** task.
The process completes at *Expense Reimbursed* and Mailpit shows the approval email.

### Rejection path — over tier, finance rejects

```bash
# EQUIPMENT €1200 > €1000 threshold → approvalRequired=true
curl -s -X POST http://localhost:8080/engine-rest/process-definition/key/expense-reimbursement/start \
-u demo:demo \
-H "Content-Type: application/json" \
-d '{
"variables": {
"requesterName": { "value": "Charlie Weiss", "type": "String" },
"requesterEmail": { "value": "charlie@example.com", "type": "String" },
"kind": { "value": "EQUIPMENT", "type": "String" },
"statedCost": { "value": 1200.0, "type": "Double" },
"reason": { "value": "Laptop purchase", "type": "String" }
}
}' | jq .id
```

Open Tasklist as `bob` — claim and **reject** the task. The process completes at *Expense Rejected*
and Mailpit shows the rejection email.

## How it works

| Model element | Code |
|---|---|
| `Analyze Receipt` (service task) | [`ReceiptAnalyzer`](src/main/java/org/operaton/examples/expensereimbursement/delegate/ReceiptAnalyzer.java) — reads the `receipt` `FileValue`, Base64-encodes it, calls `PromptBuilder.receiptAnalysisRequest`, parses `matchResult` / `extractedName` / `extractedCost` / `analysisNotes`; catches all exceptions and defaults to `UNRELATED` |
| `Decide on Approval` (business rule task) | [`reimbursement-approval.dmn`](src/main/resources/reimbursement-approval.dmn) — FIRST hit policy; first rule matches `UNRELATED` unconditionally; remaining rules compare `kind` and `statedCost` against per-category thresholds (MEALS ≤ €50, TRAVEL ≤ €250, ACCOMMODATION ≤ €400, EQUIPMENT ≤ €1,000); catch-all last row requires approval |
| `Approve Reimbursement` (user task) | `candidateGroups="finance"` — seed users `alice` and `bob` can claim it; the `approved` boolean variable drives the downstream gateway |
| `Perform Payment` (service task) | [`PaymentService`](src/main/java/org/operaton/examples/expensereimbursement/delegate/PaymentService.java) — simulates payment by generating a `PAY-` reference and recording `paymentDate` |
| `Draft Approval Email` (service task) | [`ApprovalEmailDrafter`](src/main/java/org/operaton/examples/expensereimbursement/delegate/ApprovalEmailDrafter.java) — calls LLM to draft a personalised approval email; falls back to a fixed template on error |
| `Draft Rejection Email` (service task) | [`RejectionEmailDrafter`](src/main/java/org/operaton/examples/expensereimbursement/delegate/RejectionEmailDrafter.java) — calls LLM to draft a personalised rejection email; same fail-safe pattern |
| `Notify Requester` (send task, both paths) | [`EmailDispatcher`](src/main/java/org/operaton/examples/expensereimbursement/EmailDispatcher.java) — reads `emailSubject` / `emailBody` / `requesterEmail` variables and sends via Spring `JavaMailSender` |

`PromptBuilder` constructs both the vision request (with the Base64 image embedded in the
`image_url` field) and the two email drafting prompts.
`ResponseParser` extracts JSON fields from the LLM response and provides safe defaults for
unparseable output.
`LlmClient` wraps a `RestTemplate` call to the configured `LLM_BASE_URL`; connection properties
are bound to `LlmProperties` (`llm.base-url`, `llm.model`, `llm.api-key`).

## Run the tests

```bash
./mvnw verify
# or:
./gradlew build
```

Three integration tests (`ExpenseReimbursementIT`) run against real PostgreSQL (Testcontainers),
the LLM stubbed by WireMock, and Mailpit as the SMTP sink: happy path (MEALS €35, auto-approved,
email sent), UNRELATED receipt then finance approval, and EQUIPMENT €1,200 over tier then
rejection — each asserts process end state, key variable values, and email delivery.
`ExpenseReimbursementDeploymentIT` verifies the BPMN and DMN deploy without errors.
43 changes: 43 additions & 0 deletions examples/use-cases/expense-reimbursement/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import org.springframework.boot.gradle.plugin.SpringBootPlugin

plugins {
java
id("org.springframework.boot") version "4.1.0"
}

group = "org.operaton.examples"
version = "0.1.0-SNAPSHOT"

java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}

repositories {
mavenCentral()
}

val operatonVersion = "2.1.1"

dependencies {
implementation(platform(SpringBootPlugin.BOM_COORDINATES))
implementation(platform("org.operaton.bpm:operaton-bom:$operatonVersion"))

implementation("org.operaton.bpm.springboot:operaton-bpm-spring-boot-starter-webapp")
implementation("org.operaton.bpm.springboot:operaton-bpm-spring-boot-starter-rest")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-mail")
runtimeOnly("org.postgresql:postgresql")

testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.boot:spring-boot-testcontainers")
testImplementation("org.testcontainers:testcontainers-junit-jupiter")
testImplementation("org.testcontainers:testcontainers-postgresql")
testImplementation("org.wiremock.integrations.testcontainers:wiremock-testcontainers-module:1.0-alpha-15")
testImplementation("org.awaitility:awaitility")
}

tasks.test {
useJUnitPlatform()
}
54 changes: 54 additions & 0 deletions examples/use-cases/expense-reimbursement/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
services:
postgres:
image: postgres:16-alpine
container_name: expense-reimbursement-postgres
environment:
POSTGRES_DB: operaton
POSTGRES_USER: operaton
POSTGRES_PASSWORD: operaton
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U operaton -d operaton"]
interval: 5s
timeout: 3s
retries: 10

ollama:
image: ollama/ollama:latest
container_name: expense-reimbursement-ollama
ports:
- "11434:11434"
volumes:
- ollama:/root/.ollama
healthcheck:
test: ["CMD-SHELL", "ollama list >/dev/null 2>&1 || exit 1"]
interval: 10s
timeout: 5s
retries: 30

ollama-pull:
image: ollama/ollama:latest
container_name: expense-reimbursement-ollama-pull
depends_on:
ollama:
condition: service_healthy
environment:
OLLAMA_HOST: ollama:11434
entrypoint: ["/bin/sh", "-c", "ollama pull llama3.2-vision"]
restart: "no"

mailpit:
image: axllent/mailpit:latest
container_name: expense-reimbursement-mailpit
ports:
- "1025:1025"
- "8025:8025"
healthcheck:
test: ["CMD-SHELL", "wget --quiet --tries=1 --spider http://localhost:8025/livez || exit 1"]
interval: 5s
timeout: 3s
retries: 10

volumes:
ollama:
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.0-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Loading