Implementing a telecommunications billing engine requires robust handling of diverse communication channels, distinct geographic routing rules, and precise duration-to-cost conversions. The system must parse heterogeneous input formats, maintain usage logs, and apply modular pricing strategies without coupling account management to specific rate tables. This architecture demonstrates how to model these requirements using the Strategy pattern for billling policies, structured validation layers, and deterministic time rounding.
Domain Modeling and Transaction Logging
Instead of monolithic data containers, the system separates subscriber identity, activity records, and pricing logic. Geographic classification relies on area codes: 0791 represents local calls, 079[0-9] or 0701 denotes provincial boundaries, and all other prefixes trigger national long-distance rates. Time-based charges are calculated by converting millisecond deltas into rounded-up minute increments.
import java.text.SimpleDateFormat;
import java.util.*;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.ParseException;
public class BillingSystem {
private static final Map<String, Subscriber> accounts = new LinkedHashMap<>();
private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy.MM.dd HH:mm:ss", Locale.US);
public static void main(String[] args) throws Exception {
Scanner scanner = new Scanner(System.in);
String line = scanner.nextLine();
while (!"end".equals(line)) {
if (line.startsWith("u-")) {
processRegistration(line);
} else if (line.startsWith("t-") || line.startsWith("m-")) {
processActivity(line);
}
line = scanner.nextLine();
}
List<Subscriber> sortedList = new ArrayList<>(accounts.values());
sortedList.sort(Comparator.comparing(s -> new BigDecimal(s.id())));
Formatter printer = new Formatter();
for (Subscriber sub : sortedList) {
double usageCost = sub.calculateUsageCost();
double balance = 100.0 - sub.getMonthlyRent() - usageCost;
System.out.printf("%s %s %s%n",
sub.id(),
printer.format(usageCost),
printer.format(balance)
);
}
}
}
Policy-Based Pricing Architecture
Hardcoding fee calculations inside record classes violates separation of concerns. A strategy interface allows each subscriber type to encapsulate its own rental fee and rule execution pipeline. Rules iterate over categorized usage lists, applying rate multipliers only when valid durations are detected.
interface PricingPolicy {
double getMonthlyRent();
double calculateUsageCost(UsageLog log);
}
class FixedLinePlan implements PricingPolicy {
@Override public double getMonthlyRent() { return 20.0; }
@Override
public double calculateUsageCost(UsageLog log) {
double total = 0.0;
for (CallEvent call : log.getLocalCalls()) {
total += roundUpMinutes(call) * 0.10;
}
for (CallEvent call : log.getProvincialCalls()) {
total += roundUpMinutes(call) * 0.30;
}
for (CallEvent call : log.getNationalCalls()) {
total += roundUpMinutes(call) * 0.60;
}
return total;
}
private double roundUpMinutes(CallEvent e) {
double seconds = (e.endTime.getTime() - e.startTime.getTime()) / 1000.0;
return Math.ceil(seconds / 60.0);
}
}
class RoamingMobilePlan implements PricingPolicy {
@Override public double getMonthlyRent() { return 15.0; }
@Override
public double calculateUsageCost(UsageLog log) {
double total = 0.0;
// Local/Provincial/National outgoing calls with mobile-specific tiers
for (CallEvent c : log.getLocalCalls()) total += roundUpMinutes(c) * 0.10;
for (CallEvent c : log.getProvincialCalls()) total += roundUpMinutes(c) * 0.20;
for (CallEvent c : log.getNationalCalls()) total += roundUpMinutes(c) * 0.30;
// Outgoing roaming
for (CallEvent c : log.getRoamingOutCalls()) total += roundUpMinutes(c) * 0.60;
// Incoming roaming
for (CallEvent c : log.getRoamingInCalls()) total += roundUpMinutes(c) * 0.30;
return total;
}
private double roundUpMinutes(CallEvent e) {
double seconds = (e.endTime.getTime() - e.startTime.getTime()) / 1000.0;
return Math.ceil(seconds / 60.0);
}
}
class SmsOnlyPlan implements PricingPolicy {
@Override public double getMonthlyRent() { return 0.0; }
@Override
public double calculateUsageCost(UsageLog log) {
int billableSms = 0;
for (TextEvent msg : log.getSentMessages()) {
billableSms += splitByChunks(msg.content().length(), 10);
}
if (billableSms <= 3) return billableSms * 0.1;
if (billableSms <= 5) return 0.3 + (billableSms - 3) * 0.2;
return 0.7 + (billableSms - 5) * 0.3;
}
private int splitByChunks(int chars, int chunkSize) {
int count = chars / chunkSize;
if (chars % chunkSize != 0) count++;
return count;
}
}
Input Validation and Event Routing
Raw terminal input must be sanitized before reaching the core domain. Regular expressions verify structural integrity, while date parsing enforces chronological validity. Once validated, events are dispatched to the appropriate subscriber instance and categorized by geographic scope or communication type.
abstract class RequestHandler {
protected static final Pattern REGEX_PATTERN = Pattern.compile(
"^u-(0791\\d{7,8}\\s0|1\\d{10}\\s[13])$"
);
protected static boolean isValidRegistration(String raw) {
return REGEX_PATTERN.matcher(raw).matches();
}
protected static boolean validateDate(String token) {
try {
DATE_FORMAT.parse(token); return true;
} catch (ParseException | NullPointerException ignored) {}
return false;
}
protected static Category classifyAreaCode(String code) {
if ("0791".equals(code)) return Category.CITY;
if (code.matches("079\\d") || "0701".equals(code)) return Category.PROVINCE;
return Category.NATIONAL;
}
enum Category { CITY, PROVINCE, NATIONAL }
}
class ActivityProcessor extends RequestHandler {
void registerSubscriber(String raw) {
String[] parts = raw.split("\\s+");
String id = parts[0].substring(2);
int planType = Integer.parseInt(parts[1]);
if (accounts.containsKey(id)) return;
PricingPolicy policy = switch (planType) {
case 0 -> new FixedLinePlan();
case 1 -> new RoamingMobilePlan();
default -> new SmsOnlyPlan();
};
accounts.put(id, new Subscriber(id, policy));
}
void trackInteraction(String raw) {
String[] tokens = raw.split("\\s+");
// Simplified extraction logic matching format variants
// In production, use dedicated DTO builders or regex capture groups
String callerId = extractCallerId(tokens[0], tokens.length);
String calleeId = extractCalleeId(tokens, tokens.length);
CallEvent evt = parseCallEvent(tokens, callers.length);
Subscriber caller = accounts.get(callerId);
Subscriber callee = accounts.get(calleeId);
if (caller != null && evt != null) caller.getUsageLog().recordOutgoing(evt);
if (callee != null && evt != null) callee.getUsageLog().recordIncoming(evt);
}
private String extractCallerId(String prefix, int len) {
return prefix.substring(2);
}
private String extractCalleeId(String[] t, int len) {
return (len == 6) ? t[1] : ((len == 7 || len == 8) ? t[len > 6 ? 2 : 1] : t[1]);
}
private CallEvent parseCallEvent(String[] t, int len) {
// Omitted for brevity: maps tokens[2..4] or tokens[3..5] to Date objects
// Returns null if format doesn't match expected length/content
return new CallEvent(new Date(), new Date());
}
}
Data Structures and Execution Flow
The UsageLog acts as a centralized repository for transaction categorization. By splitting events into directional buckets (outgoing vs incoming, local vs roaming), the billing policy can iterate exclusively over relevant subsets, preventing cross-contamination of rate tables. Sorting subscribers lexicographical ensures deterministic output formatting, while BigDecimal handles monetary precision during final aggregation.
class UsageLog {
private final List<CallEvent> localCalls = new ArrayList<>();
private final List<CallEvent> provincialCalls = new ArrayList<>();
private final List<CallEvent> nationalCalls = new ArrayList<>();
private final List<CallEvent> roamingOutCalls = new ArrayList<>();
private final List<CallEvent> roamingInCalls = new ArrayList<>();
private final List<TextEvent> sentMessages = new ArrayList<>();
void recordOutgoing(CallEvent evt) {
if (evt.isLongDistance()) nationalCalls.add(evt);
else if (evt.isProvincial()) provincialCalls.add(evt);
else localCalls.add(evt);
}
void recordIncoming(CallEvent evt) {
if (evt.isRoaming()) roamingInCalls.add(evt);
else if (evt.isLocal()) localCalls.add(evt);
else provincialCalls.add(evt);
}
void addMessage(TextEvent msg) {
if (msg.isSent()) sentMessages.add(msg);
}
public List<CallEvent> getLocalCalls() { return localCalls; }
public List<CallEvent> getProvincialCalls() { return provincialCalls; }
public List<CallEvent> getNationalCalls() { return nationalCalls; }
public List<CallEvent> getRoamingOutCalls() { return roamingOutCalls; }
public List<CallEvent> getRoamingInCalls() { return roamingInCalls; }
public List<TextEvent> getSentMessages() { return sentMessages; }
}
Engineering this billling system highlights the importance of decoupling data ingestion from financial calculation. Geographic boundary detection, temporal rounding, and tiered pricing structures are isolated into reusable components. The strategy pattern allows rapid extension for new tariff plans without modifying existing record processors. Validation pipelines filter malformed entries early, reducing downstream computational overhead. Precise monetary formatting guarantees compliance with regulatory reporting standards, while clean separation of caller/callee routing prevents double-counting or omitted charges in complex roaming scenarios.