Tutorial: Build an Invoice Analyzer
This tutorial walks through a complete Invoice Analyzer from zero to running with a test. You will learn how to add dependencies, define a typed AI service, wire a provider, add observability, and test deterministically.
Goal: Build an invoice analyzer that turns messy invoice text into typed data, in under 20 minutes.
What You Are Building
You will build an InvoiceAnalyzer service that takes raw invoice text and returns a structured InvoiceDecision object. The final application code looks like this:
val result = analyzer.analyze(invoiceText)
println("Status: ${result.status}, Vendor: ${result.vendor}")
InvoiceDecision result = analyzer.analyze(invoiceText);
System.out.println("Status: " + result.status() + ", Vendor: " + result.vendor());
No JSON parsing. No markdown fences to strip. No hand-written retry loops.
Step 1: Add Dependencies
Gradle (Kotlin DSL)
dependencies {
implementation(platform("dev.tramai:tramai-bom:0.4.0"))
implementation("dev.tramai:tramai-standalone")
implementation("dev.tramai:tramai-openai")
testImplementation(platform("dev.tramai:tramai-bom:0.4.0"))
testImplementation("dev.tramai:tramai-testing")
}
Maven
<dependencyManagement>
<dependencies>
<dependency>
<groupId>dev.tramai</groupId>
<artifactId>tramai-bom</artifactId>
<version>0.4.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>dev.tramai</groupId>
<artifactId>tramai-standalone</artifactId>
</dependency>
<dependency>
<groupId>dev.tramai</groupId>
<artifactId>tramai-openai</artifactId>
</dependency>
<dependency>
<groupId>dev.tramai</groupId>
<artifactId>tramai-testing</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
What these do: tramai-standalone gives you the DSL builder without Spring. tramai-openai provides the OpenAI provider adapter. tramai-testing gives you mock providers for deterministic tests.
Minimum setup: These three coordinates, one provider API key, and Java 21+.
Step 2: Define the InvoiceAnalyzer Service
Define a typed contract. This is the heart of TramAI.
Kotlin
import dev.tramai.core.annotations.AiService
import dev.tramai.core.annotations.Operation
import dev.tramai.core.annotations.AiDescription
data class InvoiceDecision(
@property:AiDescription("One of CURRENT, DUE_SOON, OVERDUE, DISPUTED, or UNKNOWN")
val status: String,
@property:AiDescription("Vendor name when present in the input")
val vendor: String?,
@property:AiDescription("Amount exactly as it appears in the invoice text")
val amountText: String?,
@property:AiDescription("Primary next action the accounts-payable team should take")
val nextAction: String,
)
@AiService
interface InvoiceAnalyzer {
@Operation(
prompt = "Analyze the invoice text and return a structured payment decision.",
model = "gpt-4o",
)
suspend fun analyze(invoiceText: String): InvoiceDecision
}
Java
import dev.tramai.core.annotations.AiDescription;
import dev.tramai.core.annotations.AiService;
import dev.tramai.core.annotations.Operation;
public record InvoiceDecision(
@AiDescription("One of CURRENT, DUE_SOON, OVERDUE, DISPUTED, or UNKNOWN")
String status,
@AiDescription("Vendor name when present in the input")
String vendor,
@AiDescription("Amount exactly as it appears in the invoice text")
String amountText,
@AiDescription("Primary next action the accounts-payable team should take")
String nextAction
) {}
@AiService
public interface InvoiceAnalyzer {
@Operation(
prompt = "Analyze the invoice text and return a structured payment decision.",
model = "gpt-4o"
)
InvoiceDecision analyze(String invoiceText);
}
What this does: TramAI generates a JSON schema from InvoiceDecision, injects it into the prompt, sends the request, extracts JSON from the response, deserializes it into your type, and retries if parsing fails.
Key rules:
@AiServiceon an interface — TramAI generates a proxy at runtime@Operationwithmodel = "..."— the model name MUST match a registration in yourTramaibuildersuspendin Kotlin (methods are I/O-bound); blocking in Java (no suspend needed)- Data classes / records — the return type drives schema generation
Step 3: Write the Main Function
Wire a provider, register a model, create the service, and call it.
Kotlin
import dev.tramai.openai.OpenAiProvider
import dev.tramai.standalone.Tramai
suspend fun main() {
val tramai = Tramai {
provider(
OpenAiProvider(apiKey = System.getenv("OPENAI_API_KEY")),
name = "openai",
default = true,
)
model("gpt-4o", "openai")
}
val analyzer = tramai.create<InvoiceAnalyzer>()
val result = analyzer.analyze(
"""
Vendor: Northwind Power
Invoice: INV-1042
Amount due: 4820 USD
Due date: 2026-04-30
Status: 12 days overdue
""".trimIndent(),
)
println("Status: ${result.status}")
println("Vendor: ${result.vendor}")
println("Amount: ${result.amountText}")
println("Next action: ${result.nextAction}")
}
Java
import dev.tramai.openai.OpenAiProvider;
import dev.tramai.standalone.Tramai;
public class Main {
public static void main(String[] args) {
Tramai tramai = Tramai.builder()
.provider(
new OpenAiProvider(System.getenv("OPENAI_API_KEY")),
"openai",
true
)
.model("gpt-4o", "openai")
.build();
InvoiceAnalyzer analyzer = tramai.create(InvoiceAnalyzer.class);
InvoiceDecision result = analyzer.analyze(
"""
Vendor: Northwind Power
Invoice: INV-1042
Amount due: 4820 USD
Due date: 2026-04-30
Status: 12 days overdue
"""
);
System.out.println("Status: " + result.status());
System.out.println("Vendor: " + result.vendor());
System.out.println("Amount: " + result.amountText());
System.out.println("Next action: " + result.nextAction());
}
}
What happens when you call analyze:
- TramAI builds a prompt from your
@Operationtext and generates a JSON schema fromInvoiceDecision - It sends the prompt + schema to
gpt-4ovia OpenAI - It extracts JSON from the response (handles markdown fences automatically)
- It deserializes into
InvoiceDecision - If parsing fails, it retries with structured feedback (default: 2 retries = 3 total attempts)
- It returns a typed
InvoiceDecisionobject
To run: Set OPENAI_API_KEY environment variable and run your main class.
Step 4: Write a Deterministic Test
Use tramai-testing to test your service without network calls.
Kotlin
import dev.tramai.testing.MockAiProvider
import dev.tramai.standalone.Tramai
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
class InvoiceAnalyzerTest {
@Test
fun `returns typed invoice decision`() = runBlocking {
val provider = MockAiProvider {
onMethod("analyze") respondWith """
{
"status": "OVERDUE",
"vendor": "Northwind Power",
"amountText": "4820 USD",
"nextAction": "ESCALATE"
}
""".trimIndent()
}
val tramai = Tramai {
provider(provider, name = "mock", default = true)
model("gpt-4o", "mock")
}
val analyzer = tramai.create<InvoiceAnalyzer>()
val result = analyzer.analyze("Vendor: Northwind Power\nInvoice: INV-1042")
assertEquals(
InvoiceDecision(
status = "OVERDUE",
vendor = "Northwind Power",
amountText = "4820 USD",
nextAction = "ESCALATE",
),
result,
)
}
}
Java
import dev.tramai.testing.MockAiProvider;
import dev.tramai.standalone.Tramai;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class InvoiceAnalyzerTest {
@Test
void returnsTypedInvoiceDecision() {
MockAiProvider provider = new MockAiProvider(builder -> {
builder.onMethod("analyze").respondWith("""
{
"status": "OVERDUE",
"vendor": "Northwind Power",
"amountText": "4820 USD",
"nextAction": "ESCALATE"
}
""");
});
Tramai tramai = Tramai.builder()
.provider(provider, "mock", true)
.model("gpt-4o", "mock")
.build();
InvoiceAnalyzer analyzer = tramai.create(InvoiceAnalyzer.class);
InvoiceDecision result = analyzer.analyze("Vendor: Northwind Power\nInvoice: INV-1042");
assertEquals("OVERDUE", result.status());
assertEquals("Northwind Power", result.vendor());
assertEquals("4820 USD", result.amountText());
assertEquals("ESCALATE", result.nextAction());
}
}
What this proves: Your application code consumes the typed contract correctly. The test runs in milliseconds, costs nothing, and requires no network access.
Testing Retry Behavior
Queue multiple responses for the same method to test recovery from malformed output:
val provider = MockAiProvider {
onMethod("analyze") respondWith "not json"
onMethod("analyze") respondWith """
{
"status": "OVERDUE",
"vendor": "Northwind Power",
"amountText": "4820 USD",
"nextAction": "ESCALATE"
}
""".trimIndent()
}
This verifies that TramAI's structured retry loop handles parse failures and recovers before returning to your code.
Step 5: Add Observability
Add tracing and metrics using the tramai-observability module.
Add the dependency
implementation("dev.tramai:tramai-observability")
Wire the observer
import dev.tramai.observability.OpenTelemetryOperationObserver
import io.opentelemetry.api.OpenTelemetry
val openTelemetry: OpenTelemetry = /* your configured SDK */
val observer = OpenTelemetryOperationObserver(openTelemetry)
val tramai = Tramai {
provider(
OpenAiProvider(apiKey = System.getenv("OPENAI_API_KEY")),
name = "openai",
default = true,
)
model("gpt-4o", "openai")
observer(observer)
}
import dev.tramai.observability.OpenTelemetryOperationObserver;
import io.opentelemetry.api.OpenTelemetry;
OpenTelemetry openTelemetry = /* your configured SDK */;
OpenTelemetryOperationObserver observer = new OpenTelemetryOperationObserver(openTelemetry);
Tramai tramai = Tramai.builder()
.provider(
new OpenAiProvider(System.getenv("OPENAI_API_KEY")),
"openai",
true
)
.model("gpt-4o", "openai")
.observer(observer)
.build();
What is recorded
Each provider attempt produces:
- One OpenTelemetry span with provider, model, tokens, and retry metadata
- Metrics:
tramai.operation.attempts,tramai.operation.duration,tramai.operation.input_tokens,tramai.operation.output_tokens,tramai.operation.parse_failures
You still need to configure your own OpenTelemetry SDK and exporter pipeline (Jaeger, Prometheus, etc.).
Step 6: Run and Verify
- Run your test (
InvoiceAnalyzerTest) first — it should pass without any API key - Set
OPENAI_API_KEYenvironment variable - Run your main function
- Verify the output shows the structured decision
Expected output (Kotlin):
Status: OVERDUE
Vendor: Northwind Power
Amount: 4820 USD
Next action: ESCALATE
Spring Boot Variant
If you use Spring Boot, the service contract stays the same. You change only the runtime wiring.
implementation("dev.tramai:tramai-spring")
tramai:
default-provider: openai
models:
gpt-4o: openai
providers:
openai:
api-key: ${OPENAI_API_KEY}
The @AiService interface is auto-registered as a Spring bean. Inject it like any other dependency:
@Service
class BillingService(
private val invoiceAnalyzer: InvoiceAnalyzer,
) {
suspend fun review(invoiceText: String): InvoiceDecision =
invoiceAnalyzer.analyze(invoiceText)
}
What You Built
- One typed interface (
InvoiceAnalyzer) - One provider mapping (OpenAI)
- One structured output contract (
InvoiceDecision) - One deterministic test (no network)
- One observability setup (OpenTelemetry)
This is a production-shaped first integration. No manual JSON parsing, no regex stripping, no retry loops in application code.
Limitations
- Structured output uses schema-in-prompt + parse-and-retry. Provider-native structured output modes are not yet integrated.
- Streaming structured partials are not supported. Full response required before parsing.
- The test mock requires you to know the exact JSON shape your model would produce.
Next Steps
- Add validation annotations — constrain fields with
@AiRange,@AiMinItems - Explore tool calling — let your AI service invoke backend logic
- Production hardening — circuit breakers, fallback routing, token budgets
- Orchestration — multi-step persisted workflows for complex processes
