Skip to main content
CoreSDK
Guides

Fintech Transaction API

Build LedgerAPI — a double-entry ledger with per-transaction policy enforcement, amount-based approval workflows, SOC 2 audit trail, and idempotency. Java + Spring Boot.

Fintech Transaction API

What you'll build: LedgerAPI — a double-entry ledger service for a fintech platform. Every debit/credit goes through a policy check before execution. Transactions over $10,000 require a compliance_approved claim. Failed auth attempts are rate-limited. The full audit trail is SOC 2 compliant. Java + Spring Boot.

The Story

Clearbridge Financial processes inter-bank transfers for SMEs. Regulatory requirements:

  • Every transaction authenticated via signed JWT (bank's IdP)
  • Transactions > $10K require two-factor + compliance officer approval (claim in JWT)
  • viewer role can read ledger — never write
  • operator role initiates transfers, blocked above threshold without approval
  • compliance role can read everything including suspicious flag metadata
  • All mutations immutable in audit log — no soft deletes, no overwrites

Without CoreSDK: policy checks are if amount > threshold && hasRole(...) scattered across service classes. With CoreSDK: one @PreAuthorize-style check backed by Rego.

Architecture

Bank Operator / Compliance Officer
         │  JWT (role + mfa_verified + compliance_approved)

┌──────────────────────────────────────┐
│        Spring Boot API               │
│                                      │
│  CoreSDKFilter (auth on all routes)  │  ← validates JWT via sidecar
│       │                              │
│  @RequireRole("operator")            │
│       │                              │
│  Amount policy check                 │  ← Rego: amount > 10000 needs approval
│       │                              │
│  Idempotency key dedup               │  ← prevents double-spend
│       │                              │
│  Double-entry ledger write           │
│       │                              │
│  SOC 2 audit append                  │
└──────────────────────────────────────┘
         │  gRPC :50051

┌─────────────────┐
│  CoreSDK Sidecar│
└─────────────────┘

Prerequisites

<!-- pom.xml -->
<dependency>
  <groupId>io.coresdk</groupId>
  <artifactId>coresdk-spring-boot-starter</artifactId>
  <version>0.2.0</version>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web</artifactId>
</dependency>

Quickstart

git clone https://github.com/coresdk-dev/examples
cd examples/java/ledgerapi
CORESDK_FAIL_MODE=closed mvn spring-boot:run

Code Walkthrough

Step 1 — Spring Boot autoconfiguration

application.yml:

coresdk:
  sidecar-addr: localhost:50051
  tenant-id: clearbridge-financial
  fail-mode: closed          # never fail-open for financial transactions
  service-name: ledger-api

spring:
  application:
    name: ledger-api

The CoreSDKFilter is auto-registered — all routes protected by default.

@SpringBootApplication
public class LedgerApiApplication {
    public static void main(String[] args) {
        SpringApplication.run(LedgerApiApplication.class, args);
    }
}

Step 2 — Transaction request with policy enforcement

@RestController
@RequestMapping("/api/v1/transactions")
public class TransactionController {

    @Autowired private CoreSDK sdk;
    @Autowired private LedgerService ledger;
    @Autowired private AuditService audit;

    @PostMapping
    public ResponseEntity<?> createTransaction(
            @RequestBody TransactionRequest req,
            @RequestHeader("Authorization") String authHeader,
            HttpServletRequest httpReq) {

        // Claims already validated by CoreSDKFilter
        Claims claims = (Claims) httpReq.getAttribute("coresdk.claims");

        // Role check: operator minimum
        if (!claims.getRoles().contains("operator")) {
            return problem(403, "Forbidden",
                "Role 'operator' required to initiate transactions.");
        }

        // Policy check: high-value transactions need compliance approval
        boolean allowed = sdk.evaluatePolicy("ledger.allow_transaction", Map.of(
            "amount",              req.getAmountCents(),
            "currency",            req.getCurrency(),
            "roles",               claims.getRoles(),
            "compliance_approved", claims.hasAttribute("compliance_approved"),
            "mfa_verified",        claims.hasAttribute("mfa_verified")
        )).join().isAllowed();

        if (!allowed) {
            audit.record(claims, "transaction.denied", req, "policy_rejected");
            return problem(403, "Compliance Hold",
                req.getAmountCents() > 1_000_000
                    ? "Transactions over $10,000 require compliance officer approval."
                    : "Transaction rejected by policy.");
        }

        // Idempotency: reject duplicate submission
        String idempotencyKey = req.getIdempotencyKey();
        if (ledger.exists(idempotencyKey)) {
            return ResponseEntity.ok(ledger.get(idempotencyKey)); // return existing
        }

        // Execute double-entry
        Transaction tx = ledger.execute(req, claims.getSubject(), claims.getTenantId());
        audit.record(claims, "transaction.created", req, "success");

        return ResponseEntity.status(201).body(tx);
    }
}

Step 3 — Rego policy for transaction approval

policies/ledger.rego:

package ledger

import future.keywords.if

default allow_transaction := false

# Small transactions: operator role sufficient
allow_transaction if {
    input.amount < 1_000_000  # < $10,000 in cents
    "operator" in input.roles
    input.mfa_verified == true
}

# Large transactions: require compliance approval claim in JWT
allow_transaction if {
    input.amount >= 1_000_000
    "operator" in input.roles
    input.compliance_approved == true
    input.mfa_verified == true
}

# Compliance officers can always read (but not write — separate rule)
allow_read if {
    "compliance" in input.roles
}

Step 4 — Double-entry ledger

@Service
public class LedgerService {

    // In production: use a proper database with serializable transactions
    private final Map<String, List<LedgerEntry>> accounts = new ConcurrentHashMap<>();
    private final Map<String, Transaction> idempotencyStore = new ConcurrentHashMap<>();

    public Transaction execute(TransactionRequest req, String actor, String tenantId) {
        // Idempotency guard
        if (idempotencyStore.containsKey(req.getIdempotencyKey())) {
            return idempotencyStore.get(req.getIdempotencyKey());
        }

        String txId = UUID.randomUUID().toString();

        // Debit source account
        debit(req.getFromAccountId(), req.getAmountCents(), txId, tenantId);

        // Credit destination account
        credit(req.getToAccountId(), req.getAmountCents(), txId, tenantId);

        Transaction tx = Transaction.builder()
            .id(txId)
            .fromAccount(req.getFromAccountId())
            .toAccount(req.getToAccountId())
            .amountCents(req.getAmountCents())
            .currency(req.getCurrency())
            .actor(actor)
            .tenantId(tenantId)
            .createdAt(Instant.now())
            .build();

        idempotencyStore.put(req.getIdempotencyKey(), tx);
        return tx;
    }

    private void debit(String accountId, long amountCents, String txId, String tenantId) {
        accounts.computeIfAbsent(accountId, k -> new ArrayList<>())
            .add(new LedgerEntry(txId, tenantId, -amountCents, Instant.now()));
    }

    private void credit(String accountId, long amountCents, String txId, String tenantId) {
        accounts.computeIfAbsent(accountId, k -> new ArrayList<>())
            .add(new LedgerEntry(txId, tenantId, +amountCents, Instant.now()));
    }

    public long getBalance(String accountId, String tenantId) {
        return accounts.getOrDefault(accountId, List.of()).stream()
            .filter(e -> e.getTenantId().equals(tenantId))
            .mapToLong(LedgerEntry::getAmountCents)
            .sum();
    }
}

Step 5 — SOC 2 audit trail

@Service
public class AuditService {

    // Append-only — no update/delete methods on this list
    private final List<AuditRecord> log = Collections.synchronizedList(new ArrayList<>());

    public void record(Claims claims, String event, Object subject, String outcome) {
        log.add(AuditRecord.builder()
            .timestamp(Instant.now())
            .actor(claims.getSubject())
            .tenantId(claims.getTenantId())
            .roles(claims.getRoles())
            .event(event)
            .subjectType(subject.getClass().getSimpleName())
            .outcome(outcome)
            .build());
    }

    // Compliance officers can read audit log
    public List<AuditRecord> getLog(String tenantId) {
        return log.stream()
            .filter(r -> r.getTenantId().equals(tenantId))
            .toList();
    }
}

Test Scenarios

# Small transfer — operator + MFA: approved
curl -X POST http://localhost:8080/api/v1/transactions \
  -H "Authorization: Bearer operator-mfa-token" \
  -H "Content-Type: application/json" \
  -d '{"from":"acct-001","to":"acct-002","amount_cents":50000,"currency":"USD","idempotency_key":"tx-001"}'
# 201 Created

# Large transfer — operator without compliance approval: denied
curl -X POST http://localhost:8080/api/v1/transactions \
  -H "Authorization: Bearer operator-mfa-token" \
  -d '{"from":"acct-001","to":"acct-002","amount_cents":2000000,"currency":"USD","idempotency_key":"tx-002"}'
# 403 {"detail":"Transactions over $10,000 require compliance officer approval."}

# Large transfer — with compliance_approved claim: approved
curl -X POST http://localhost:8080/api/v1/transactions \
  -H "Authorization: Bearer compliance-approved-token" \
  -d '{"from":"acct-001","to":"acct-002","amount_cents":2000000,"currency":"USD","idempotency_key":"tx-003"}'
# 201 Created

# Duplicate idempotency key: returns existing transaction (no double-spend)
curl -X POST http://localhost:8080/api/v1/transactions \
  -H "Authorization: Bearer compliance-approved-token" \
  -d '{"from":"acct-001","to":"acct-002","amount_cents":2000000,"currency":"USD","idempotency_key":"tx-003"}'
# 200 OK — same tx-003 returned, no second debit

SOC 2 Compliance Notes

ControlImplementation
CC6.1 — Logical accessCoreSDKFilter + role claims from IdP JWT
CC6.3 — Least privilegeRego policy: operator can't bypass threshold without approval
CC7.2 — System monitoringAuditService append-only log, compliance role can read
CC8.1 — Change managementIdempotency key prevents duplicate mutations
A1.2 — Availabilityfail-mode=closed prevents auth bypass during outages

Full Source

examples/java/ledgerapi/

cd examples/java/ledgerapi
CORESDK_FAIL_MODE=closed mvn spring-boot:run

On this page