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
15 changes: 10 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,13 +356,18 @@ Interactive docs: `http://localhost:8080/swagger-ui.html`
| `GET` | `/api/v1/claims/{claimId}` | Get claim by ID | any authenticated |
| `GET` | `/api/v1/claims?policyHolderId=` | List all claims for a policy holder | any authenticated |
| `GET` | `/api/v1/claims/status/{status}` | List claims by status | `SCOPE_backoffice` |
ra o login o que achas mais natural para uma empresa de seguros ?| `PUT` | `/api/v1/claims/{claimId}/review` | Submit manual review decision (`{"decision":"APPROVE"\|"REJECT","notes":"..."}`) | `SCOPE_backoffice` |

### Claim lifecycle states

```
PENDING_OCR → OCR_PROCESSING → PENDING_VALIDATION → APPROVED
↘ REJECTED
PENDING_OCR → OCR_PROCESSING → PENDING_VALIDATION → APPROVED (auto)
↘ REJECTED (auto)
↘ MANUAL_REVIEW_REQUIRED
reviewer action
↙ ↘
APPROVED REJECTED
```

---
Expand Down Expand Up @@ -448,8 +453,8 @@ and generates PlantUML architecture diagrams to `target/modulith-docs/`.
- [x] nginx frontend container in Docker Compose
- [x] Angular Testing Library unit tests (87%+ coverage)
- [x] Playwright E2E test suite
- [x] Backoffice review queue — manual approve/reject with reviewer notes
- [ ] `PolicyManagementServiceAdapter` — live policy lookup (currently stub)
- [ ] Backoffice review-queue UI (Angular feature)
- [ ] SonarQube quality gate in CI
- [ ] Snyk dependency vulnerability scanning in CI
- [ ] Helm chart for Kubernetes deployment (AKS / `switzerlandnorth`)
Expand All @@ -473,7 +478,7 @@ For permissions and licensing inquiries: **create@raigonlab.com**

**Fernando Goncalves**
Full-stack Software Engineer — Java / Spring Boot / Angular
Portfolio project targeting Swiss Krankenkassen engineering roles
Portfolio project targeting Swiss Krankenkassen

[![GitHub](https://img.shields.io/badge/GitHub-Goncalves95-181717?logo=github)](https://github.com/Goncalves95)
[![LinkedIn](https://img.shields.io/badge/LinkedIn-Fernando_Goncalves-0A66C2?logo=linkedin)](https://www.linkedin.com/in/o-teu-link-aqui/)
[![LinkedIn](https://img.shields.io/badge/LinkedIn-Fernando_Goncalves-0A66C2?logo=linkedin)](https://www.linkedin.com/in/fernandojcgoncalves/)
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package ch.insurtech.platform.claim.api;

import ch.insurtech.platform.claim.api.dto.ClaimResponse;
import ch.insurtech.platform.claim.api.dto.ReviewClaimRequest;
import ch.insurtech.platform.claim.api.mapper.ClaimMapper;
import ch.insurtech.platform.claim.application.ClaimApplicationService;
import ch.insurtech.platform.claim.domain.model.Claim;
import ch.insurtech.platform.claim.domain.model.ClaimStatus;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
Expand All @@ -16,6 +18,8 @@
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
Expand Down Expand Up @@ -67,4 +71,17 @@ public ResponseEntity<List<ClaimResponse>> listByPolicyHolder(
public ResponseEntity<List<ClaimResponse>> listByStatus(@PathVariable ClaimStatus status) {
return ResponseEntity.ok(claimMapper.toResponseList(claimService.findByStatus(status)));
}

@PutMapping("/{claimId}/review")
@PreAuthorize("hasAuthority('SCOPE_backoffice')")
@Operation(summary = "Submit a manual review decision — backoffice only")
public ResponseEntity<ClaimResponse> reviewClaim(
@PathVariable UUID claimId,
@RequestBody @Valid ReviewClaimRequest request) {
return ResponseEntity.ok(
claimMapper.toResponse(
claimService.reviewClaim(claimId, request.decision(), request.notes())
)
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import ch.insurtech.platform.claim.domain.model.ClaimStatus;

import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;

Expand All @@ -10,5 +11,8 @@ public record ClaimResponse(
String policyHolderId,
ClaimStatus status,
String reviewerNote,
BigDecimal totalAmount,
BigDecimal deductible,
BigDecimal reimbursableAmount,
Instant submittedAt
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package ch.insurtech.platform.claim.api.dto;

import jakarta.validation.constraints.NotNull;

public record ReviewClaimRequest(
@NotNull ReviewDecision decision,
String notes
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package ch.insurtech.platform.claim.api.dto;

public enum ReviewDecision {
APPROVE,
REJECT
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ public ClaimResponse toResponse(Claim claim) {
claim.getPolicyHolderId(),
claim.getStatus(),
claim.getReviewerNote(),
claim.getTotalAmount(),
claim.getDeductible(),
claim.getReimbursableAmount(),
claim.getSubmittedAt()
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package ch.insurtech.platform.claim.application;

import ch.insurtech.platform.claim.api.dto.ReviewDecision;
import ch.insurtech.platform.claim.domain.event.DocumentUploadedEvent;
import ch.insurtech.platform.claim.domain.exception.ClaimNotFoundException;
import ch.insurtech.platform.claim.domain.model.Claim;
Expand Down Expand Up @@ -66,4 +67,16 @@ public List<Claim> findByPolicyHolder(String policyHolderId) {
public List<Claim> findByStatus(ClaimStatus status) {
return claimRepository.findByStatus(status);
}

public Claim reviewClaim(UUID claimId, ReviewDecision decision, String notes) {
Claim claim = claimRepository.findById(claimId)
.orElseThrow(() -> new ClaimNotFoundException(claimId));
switch (decision) {
case APPROVE -> claim.manuallyApprove(notes);
case REJECT -> claim.reject(notes);
}
Claim saved = claimRepository.save(claim);
log.info("Claim {} {} by reviewer", claimId, decision);
return saved;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import ch.insurtech.platform.shared.domain.exception.ValidationException;

import java.math.BigDecimal;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
Expand All @@ -19,6 +20,9 @@ public class Claim {
private final String documentStorageKey;
private ClaimStatus status;
private String reviewerNote;
private BigDecimal totalAmount;
private BigDecimal deductible;
private BigDecimal reimbursableAmount;
private final List<TarmedPosition> tarmedPositions;
private final Instant submittedAt;

Expand Down Expand Up @@ -58,6 +62,12 @@ public void approve() {
this.status = ClaimStatus.APPROVED;
}

public void manuallyApprove(String notes) {
assertStatus(ClaimStatus.MANUAL_REVIEW_REQUIRED, "manually approve after review");
this.reviewerNote = notes;
this.status = ClaimStatus.APPROVED;
}

public void flagForManualReview(String reason) {
assertStatus(ClaimStatus.PENDING_VALIDATION, "flag for manual review");
this.reviewerNote = reason;
Expand All @@ -80,11 +90,20 @@ private void assertStatus(ClaimStatus expected, String operation) {
}
}

public void setFinancials(BigDecimal totalAmount, BigDecimal deductible, BigDecimal reimbursableAmount) {
this.totalAmount = totalAmount;
this.deductible = deductible;
this.reimbursableAmount = reimbursableAmount;
}

public UUID getId() { return id; }
public String getPolicyHolderId() { return policyHolderId; }
public String getDocumentStorageKey() { return documentStorageKey; }
public ClaimStatus getStatus() { return status; }
public String getReviewerNote() { return reviewerNote; }
public BigDecimal getTotalAmount() { return totalAmount; }
public BigDecimal getDeductible() { return deductible; }
public BigDecimal getReimbursableAmount() { return reimbursableAmount; }
public List<TarmedPosition> getTarmedPositions() { return Collections.unmodifiableList(tarmedPositions); }
public Instant getSubmittedAt() { return submittedAt; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import jakarta.persistence.Id;
import jakarta.persistence.Table;

import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;

Expand All @@ -33,18 +34,32 @@ public class ClaimJpaEntity extends AuditableEntity {
@Column(name = "reviewer_note", columnDefinition = "TEXT")
private String reviewerNote;

@Column(name = "total_amount", precision = 10, scale = 2)
private BigDecimal totalAmount;

@Column(name = "deductible", precision = 10, scale = 2)
private BigDecimal deductible;

@Column(name = "reimbursable_amount", precision = 10, scale = 2)
private BigDecimal reimbursableAmount;

@Column(name = "submitted_at", nullable = false, updatable = false)
private Instant submittedAt;

protected ClaimJpaEntity() {}

public ClaimJpaEntity(UUID id, String policyHolderId, String documentStorageKey,
ClaimStatus status, String reviewerNote, Instant submittedAt) {
ClaimStatus status, String reviewerNote,
BigDecimal totalAmount, BigDecimal deductible, BigDecimal reimbursableAmount,
Instant submittedAt) {
this.id = id;
this.policyHolderId = policyHolderId;
this.documentStorageKey = documentStorageKey;
this.status = status;
this.reviewerNote = reviewerNote;
this.totalAmount = totalAmount;
this.deductible = deductible;
this.reimbursableAmount = reimbursableAmount;
this.submittedAt = submittedAt;
}

Expand All @@ -53,5 +68,8 @@ public ClaimJpaEntity(UUID id, String policyHolderId, String documentStorageKey,
public String getDocumentStorageKey() { return documentStorageKey; }
public ClaimStatus getStatus() { return status; }
public String getReviewerNote() { return reviewerNote; }
public BigDecimal getTotalAmount() { return totalAmount; }
public BigDecimal getDeductible() { return deductible; }
public BigDecimal getReimbursableAmount() { return reimbursableAmount; }
public Instant getSubmittedAt() { return submittedAt; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ ClaimJpaEntity toEntity(Claim claim) {
claim.getDocumentStorageKey(),
claim.getStatus(),
claim.getReviewerNote(),
claim.getTotalAmount(),
claim.getDeductible(),
claim.getReimbursableAmount(),
claim.getSubmittedAt()
);
}
Expand All @@ -24,6 +27,9 @@ Claim toDomain(ClaimJpaEntity entity) {
entity.getDocumentStorageKey(),
entity.getStatus(),
entity.getReviewerNote(),
entity.getTotalAmount(),
entity.getDeductible(),
entity.getReimbursableAmount(),
entity.getSubmittedAt()
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import ch.insurtech.platform.claim.domain.model.ClaimStatus;

import java.lang.reflect.Constructor;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;

Expand All @@ -17,7 +18,9 @@ class ClaimReconstituter {
private ClaimReconstituter() {}

static Claim reconstitute(UUID id, String policyHolderId, String documentStorageKey,
ClaimStatus status, String reviewerNote, Instant submittedAt) {
ClaimStatus status, String reviewerNote,
BigDecimal totalAmount, BigDecimal deductible, BigDecimal reimbursableAmount,
Instant submittedAt) {
try {
Constructor<Claim> constructor = Claim.class.getDeclaredConstructor(
UUID.class, String.class, String.class, Instant.class);
Expand All @@ -28,6 +31,9 @@ static Claim reconstitute(UUID id, String policyHolderId, String documentStorage
if (reviewerNote != null) {
setField(claim, "reviewerNote", reviewerNote);
}
if (totalAmount != null) {
claim.setFinancials(totalAmount, deductible, reimbursableAmount);
}
return claim;
} catch (Exception e) {
throw new IllegalStateException("Failed to reconstitute Claim from persistence", e);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,22 @@ public ExtractedInvoiceData extract(UUID claimId, String documentStorageKey) {
List<TarmedPosition> positions = List.of(
new TarmedPosition("00.0010", "Konsultation, erste 5 Min. (Grundkonsultation)", 1,
new BigDecimal("17.28"), new BigDecimal("17.28")),
new TarmedPosition("00.0030", "Konsultation, jede weiteren 5 Min.", 3,
new BigDecimal("10.30"), new BigDecimal("30.90")),
new TarmedPosition("00.0030", "Konsultation, jede weiteren 5 Min.", 6,
new BigDecimal("10.30"), new BigDecimal("61.80")),
new TarmedPosition("35.0020", "Blutentnahme durch Punktion der Vene", 1,
new BigDecimal("5.60"), new BigDecimal("5.60"))
new BigDecimal("5.60"), new BigDecimal("5.60")),
new TarmedPosition("39.0020", "Laboranalyse im Praxislabor (Basispanel)", 1,
new BigDecimal("42.40"), new BigDecimal("42.40")),
new TarmedPosition("00.0060", "Beratung, schriftlich, pro angefangene 5 Min.", 2,
new BigDecimal("10.76"), new BigDecimal("21.52"))
);

return new ExtractedInvoiceData(
claimId,
"7601000000000",
"Dr. med. Hans Muster",
LocalDate.now().minusDays(7),
new BigDecimal("53.78"),
new BigDecimal("148.60"),
positions
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;
import java.util.List;

@Service
Expand Down Expand Up @@ -52,6 +53,11 @@ public void onDataExtracted(DataExtractedEvent event) {
.map(strategy -> strategy.validate(event.extractedData(), policyContext))
.reduce(ValidationResult.pass(), ValidationResult::merge);

BigDecimal totalAmount = event.extractedData().totalAmount();
BigDecimal deductible = totalAmount.min(policyContext.remainingDeductible());
BigDecimal reimbursable = totalAmount.subtract(deductible).max(BigDecimal.ZERO);
claim.setFinancials(totalAmount, deductible, reimbursable);

if (combined.isValid()) {
claim.approve();
claimRepository.save(claim);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public PolicyContext loadForPolicyHolder(String policyHolderId) {
return new PolicyContext(
policyHolderId,
new BigDecimal("300.00"),
new BigDecimal("200.00"),
new BigDecimal("300.00"),
false,
new BigDecimal("5000.00")
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">

<changeSet id="V004-1" author="insurtech-platform">
<addColumn tableName="claims">
<column name="total_amount" type="DECIMAL(10,2)"/>
<column name="deductible" type="DECIMAL(10,2)"/>
<column name="reimbursable_amount" type="DECIMAL(10,2)"/>
</addColumn>
</changeSet>

</databaseChangeLog>
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@
<include file="db/changelog/changes/V001__create_claims_table.xml"/>
<include file="db/changelog/changes/V002__create_tarmed_positions_table.xml"/>
<include file="db/changelog/changes/V003__create_event_publication_table.xml"/>
<include file="db/changelog/changes/V004__add_financials_to_claims.xml"/>

</databaseChangeLog>
2 changes: 1 addition & 1 deletion build/generated-spring-modulith/javadoc.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
[{"name":"ch.insurtech.platform.DockerAvailableCondition","comment":"JUnit 5 ExecutionCondition that disables a test class when Docker is not available. Evaluated before SpringExtension loads the application context, so disabled tests incur zero startup cost and produce a clean \"skipped\" result in Failsafe reports."}]
[]
Loading
Loading