/*
* Expense Policy Rule Engine
*
* Problem asked in a Rippling interview.
*
* You get a list of expenses belonging to trips. Evaluate them against
* company policies and flag violations with reasons.
*
* Rules exist at two levels:
* - Expense-level (per expense)
* - Trip-level (across expenses of a trip)
*
* Design approach (matching the folder structure):
* - model/ → Expense, ExpenseType, Trip, Violation
* - rules/ → ExpenseRule, TripRule interfaces
* - rules/impl/ → DisallowRule, MaxAmountRule, TripTotalMaxRule, TripDurationRule
* - registry/ → RuleRegistry
* - engine/ → RuleEngine
* - service/ → ExpensePolicyService (facade)
* - util/ → TripValidator
*
* New rules can be added by implementing the right interface and registering them.
* No existing code needs to change.
*
* Note: Everything is in one file so it runs on Ideone.
* In a real project this would follow the folder structure shown in the diagram.
*/
import java.util.*;
import java.util.stream.Collectors;
enum ExpenseType {
RESTAURANT, AIRFARE, ENTERTAINMENT, HOTEL, SUPPLIES
}
// ==================== model/ ====================
// Represents a single expense submitted by an employee
class Expense {
private final String expenseId
; private final double amountUSD;
private final ExpenseType type;
public Expense
(String expenseId,
String tripId,
double amountUSD, ExpenseType type
) { this.expenseId = expenseId;
this.tripId = tripId;
this.amountUSD = amountUSD;
this.type = type;
}
public String getExpenseId
() { return expenseId
; } public String getTripId
() { return tripId
; } public double getAmountUSD() { return amountUSD; }
public ExpenseType getType() { return type; }
}
// Represents a trip that groups multiple expenses
class Trip {
private final int durationDays;
this.tripId = tripId;
this.name = name;
this.durationDays = durationDays;
}
public String getTripId
() { return tripId
; } public String getName
() { return name
; } public int getDurationDays() { return durationDays; }
}
// Holds details of a rule violation
class Violation {
private final String expenseId
; // null for trip-level violations
this.message = message;
this.expenseId = expenseId;
}
public static Violation of
(String message
) { return new Violation(message, null);
}
public static Violation of
(String message,
String expenseId
) { return new Violation(message, expenseId);
}
public String getMessage
() { return message
; } public String getExpenseId
() { return expenseId
; }
@Override
return expenseId != null ? "[" + expenseId + "] " + message : message;
}
}
// ==================== rules/ ====================
// Contract for rules that check a single expense
interface ExpenseRule {
Optional<Violation> check(Expense expense);
}
// Contract for rules that check an entire trip
interface TripRule {
Optional<Violation> check(Trip trip, List<Expense> expenses);
}
// ==================== rules/impl/ ====================
// Prevents a specific expense type (e.g. no airfare allowed)
class DisallowRule implements ExpenseRule {
private final ExpenseType disallowedType;
public DisallowRule(ExpenseType disallowedType) {
this.disallowedType = disallowedType;
}
@Override
public Optional<Violation> check(Expense expense) {
if (expense.getType() == disallowedType) {
return Optional.of(Violation.of(disallowedType + " expenses are not allowed", expense.getExpenseId()));
}
return Optional.empty();
}
}
// Checks if a single expense exceeds a maximum amount
class MaxAmountRule implements ExpenseRule {
private final double maxAmount;
public MaxAmountRule(double maxAmount) {
this.maxAmount = maxAmount;
}
@Override
public Optional<Violation> check(Expense expense) {
if (expense.getAmountUSD() > maxAmount) {
return Optional.of(Violation.of(
"Amount $" + expense.getAmountUSD() + " exceeds max $" + maxAmount,
expense.getExpenseId()));
}
return Optional.empty();
}
}
// Checks if the total spend of a trip exceeds a limit
class TripTotalMaxRule implements TripRule {
private final double maxAmount;
public TripTotalMaxRule(double maxAmount) {
this.maxAmount = maxAmount;
}
@Override
public Optional<Violation> check(Trip trip, List<Expense> expenses) {
double total = expenses.stream().mapToDouble(Expense::getAmountUSD).sum();
if (total > maxAmount) {
return Optional.of(Violation.of("Trip total $" + total + " exceeds max $" + maxAmount));
}
return Optional.empty();
}
}
// Checks if the trip duration exceeds a limit
class TripDurationRule implements TripRule {
private final int maxDays;
public TripDurationRule(int maxDays) {
this.maxDays = maxDays;
}
@Override
public Optional<Violation> check(Trip trip, List<Expense> expenses) {
if (trip.getDurationDays() > maxDays) {
return Optional.of(Violation.of("Trip duration " + trip.getDurationDays() + " days exceeds max " + maxDays));
}
return Optional.empty();
}
}
// ==================== util/ ====================
// Optional helper to validate that all expenses belong to the same trip
class TripValidator {
public static boolean allExpensesBelongToSameTrip(List<Expense> expenses) {
if (expenses == null || expenses.isEmpty()) return true;
String tripId
= expenses.
get(0).
getTripId(); return expenses.stream().allMatch(e -> e.getTripId().equals(tripId));
}
}
// ==================== registry/ ====================
// Maintains which rules apply at expense and trip levels
class RuleRegistry {
private final Map<ExpenseType, List<ExpenseRule>> typeRules = new HashMap<>();
private final List<ExpenseRule> globalExpenseRules = new ArrayList<>();
private final List<TripRule> tripRules = new ArrayList<>();
public void addTypeRule(ExpenseType type, ExpenseRule rule) {
typeRules.computeIfAbsent(type, k -> new ArrayList<>()).add(rule);
}
public void addGlobalExpenseRule(ExpenseRule rule) {
globalExpenseRules.add(rule);
}
public void addTripRule(TripRule rule) {
tripRules.add(rule);
}
// Returns type-specific + global expense rules
public List<ExpenseRule> getRulesFor(ExpenseType type) {
List<ExpenseRule> rules = new ArrayList<>(globalExpenseRules);
rules.
addAll(typeRules.
getOrDefault(type,
Collections.
emptyList())); return rules;
}
public List<TripRule> getTripRules() {
return tripRules;
}
}
// ==================== engine/ ====================
interface RuleEngine {
List<Violation> evaluate(Trip trip, List<Expense> expenses, RuleRegistry registry);
}
class SimpleRuleEngine implements RuleEngine {
@Override
public List<Violation> evaluate(Trip trip, List<Expense> expenses, RuleRegistry registry) {
List<Violation> violations = new ArrayList<>();
// Optional validation
if (!TripValidator.allExpensesBelongToSameTrip(expenses)) {
violations.add(Violation.of("Expenses belong to different trips"));
return violations;
}
// Apply all expense-level rules
for (Expense expense : expenses) {
for (ExpenseRule rule : registry.getRulesFor(expense.getType())) {
rule.check(expense).ifPresent(violations::add);
}
}
// Apply all trip-level rules
for (TripRule rule : registry.getTripRules()) {
rule.check(trip, expenses).ifPresent(violations::add);
}
return violations;
}
}
// ==================== service/ ====================
// Facade / entry point that clients use to evaluate expenses
class ExpensePolicyService {
private final RuleRegistry registry;
private final RuleEngine engine;
public ExpensePolicyService(RuleRegistry registry, RuleEngine engine) {
this.registry = registry;
this.engine = engine;
}
public List<Violation> evaluate(Trip trip, List<Expense> expenses) {
return engine.evaluate(trip, expenses, registry);
}
}
// ==================== Main (for Ideone) ====================
public class Main {
public static void main
(String[] args
) { // Sample trip and expenses
Trip trip = new Trip("T1", "Client Visit", 5);
List
<Expense
> expenses
= Arrays.
asList( new Expense("E1", "T1", 80, ExpenseType.RESTAURANT),
new Expense("E2", "T1", 120, ExpenseType.AIRFARE),
new Expense("E3", "T1", 40, ExpenseType.ENTERTAINMENT),
new Expense("E4", "T1", 200, ExpenseType.HOTEL)
);
// Register rules
RuleRegistry registry = new RuleRegistry();
registry.addTypeRule(ExpenseType.RESTAURANT, new MaxAmountRule(75));
registry.addTypeRule(ExpenseType.AIRFARE, new DisallowRule(ExpenseType.AIRFARE));
registry.addTypeRule(ExpenseType.ENTERTAINMENT, new DisallowRule(ExpenseType.ENTERTAINMENT));
registry.addGlobalExpenseRule(new MaxAmountRule(250));
registry.addTripRule(new TripTotalMaxRule(300));
registry.addTripRule(new TripDurationRule(7));
// Evaluate using the service
ExpensePolicyService service = new ExpensePolicyService(registry, new SimpleRuleEngine());
List<Violation> violations = service.evaluate(trip, expenses);
System.
out.
println("=== Violations ==="); if (violations.isEmpty()) {
System.
out.
println("No violations."); } else {
violations.
forEach(v
-> System.
out.
println("- " + v
)); }
}
}