Machine Learning · Architecture
A Second Fraud Signal:
Scoring History, Not Reading It
RefundReasonClassifier reads what a customer says. RefundFraudRiskScorer looks at what they've done — refund count, rejection rate, amount ratio, time since payment — and, like the classifier, still doesn't get the final vote.
RefundFraudRiskScorer described below has since been removed too — not because it shared the flaw covered in The Fraud Signal That Trusted the Fraudster (the requester's own history isn't something they can rewrite on request), but as a separate simplification decision made in the same round. RefundEligibilityService now carries no fraud-risk judgment of any kind. The source link at the bottom of this post now points to the last commit where the file still existed.
Two Technical Services now feed RefundEligibilityService, and they're deliberately different shapes of "machine learning." RefundReasonClassifier is an LLM reading free text. RefundFraudRiskScorer is a hand-rolled logistic regression reading structured numbers — no LLM, no external API by default, just four features and a sigmoid.
The Interface, and Two Implementations Behind It
interface RefundFraudRiskScorer {
fun score(features: RefundRiskFeatures): Double
}Two classes implement it, selected by config rather than by the caller — RequestRefundService depends only on the interface and never knows which one is live.
The Feature Vector
Everything the model sees comes from the requester's own history, assembled by the Application layer from the Payment and Refund Aggregates plus a repository summary query:
val mlFraudRiskScore =
refundFraudRiskScorer.score(
RefundRiskFeatures(
refundCountLast30Days = refundSummary.count.toInt(),
rejectedRefundCountLast30Days = rejectedRefundSummary.count.toInt(),
refundToPaymentAmountRatio = refund.amount.toDouble() / payment.amount.toDouble(),
minutesSincePayment =
Duration.between(payment.createdAt, LocalDateTime.now())
.toMinutes()
.coerceAtLeast(0)
.toDouble(),
),
)Trained on a Placeholder, By Design
There's no real user base behind this example repo, so there's no real historical fraud-review outcome to train against. The native implementation trains itself once, at construction, against a synthetic seeded dataset and a deliberately simple ground-truth rule:
private fun generateTrainingData(): List<TrainingExample> {
val random = Random(TRAINING_SEED)
return (0 until TRAINING_EXAMPLE_COUNT).map {
val refundCountLast30Days = random.nextInt(8)
val rejectedRefundCountLast30Days = random.nextInt(4)
val refundToPaymentAmountRatio = random.nextDouble()
val minutesSincePayment = random.nextDouble() * 43200
val riskScore =
refundCountLast30Days * 0.15 +
rejectedRefundCountLast30Days * 0.3 +
refundToPaymentAmountRatio * 0.4 +
maxOf(0.0, 1 - minutesSincePayment / 1440) * 0.3
val label = if (riskScore > 1.1) 1.0 else 0.0
TrainingExample(/* ... */ label = label)
}
}Plain batch gradient descent, four weights plus a bias, no ML library:
private fun trainLogisticRegression(examples: List<TrainingExample>): LogisticModel {
val weights = DoubleArray(FEATURE_COUNT)
var bias = 0.0
repeat(EPOCHS) {
val weightGradients = DoubleArray(FEATURE_COUNT)
var biasGradient = 0.0
for (example in examples) {
val vector = toVector(example.features)
var z = bias
for (i in vector.indices) z += vector[i] * weights[i]
val error = sigmoid(z) - example.label
for (i in vector.indices) weightGradients[i] += error * vector[i]
biasGradient += error
}
for (i in weights.indices) weights[i] -= (LEARNING_RATE * weightGradients[i]) / examples.size
bias -= (LEARNING_RATE * biasGradient) / examples.size
}
return LogisticModel(weights, bias)
}The fixed random seed matters here: the generated dataset — and therefore the trained weights — is identical on every run. It's explicitly a stand-in; the interface is what matters, not the model's actual predictive power.
Swappable by Config, Not by Rewrite
The same native/HTTP toggle already used for the LLM classifier shows up here too — a config property picks between an in-process computation and a call to the shared services/fraud-risk-scorer microservice:
@ConfigurationProperties(prefix = "fraud-scorer")
data class FraudScorerProperties(
val mode: String = "native",
val baseUrl: String = "http://localhost:8000",
) {
val isHttpMode: Boolean get() = mode == "http"
}The HTTP implementation fails open — any network error, non-2xx, or malformed response returns a score of 0.0 rather than blocking the refund:
override fun score(features: RefundRiskFeatures): Double =
try {
val response = httpClient.send(buildRequest(features), HttpResponse.BodyHandlers.ofString())
if (response.statusCode() !in 200..299) FALLBACK_SCORE else parseScore(response.body()) ?: FALLBACK_SCORE
} catch (e: Exception) {
// A scoring failure is a technical-infrastructure concern, not a domain error — it must
// never block a refund request. Swallow it here at the boundary and fall back.
FALLBACK_SCORE
}Two Thresholds, One Decision
RefundEligibilityService takes both signals as independent values, each with its own threshold, and neither Technical Service knows the other exists:
companion object {
private const val FRAUD_RISK_REJECTION_THRESHOLD = 0.7 // from RefundReasonClassifier (LLM)
private const val ML_FRAUD_RISK_REJECTION_THRESHOLD = 0.8 // from RefundFraudRiskScorer (history model)
}
fun evaluate(payment: Payment, refund: Refund, classification: RefundReasonClassification, mlFraudRiskScore: Double): RefundDecision {
// ...
if (mlFraudRiskScore >= ML_FRAUD_RISK_REJECTION_THRESHOLD) {
return RefundDecision(approved = false, reason = "This refund pattern was flagged as high risk by the fraud-risk model and requires manual review.")
}
return RefundDecision(approved = true)
}The Domain Service is the only place both numbers meet, and it's still the only place that decides what they mean.
The Bug a Shared Test Owner Caused
Adding a history-aware scorer to an E2E suite with shared test fixtures created a real, deterministic failure elsewhere in this repo — not a flaky one. Multiple test methods reusing the same owner ID against a Testcontainers Postgres instance (no per-test reset) meant later tests inherited rejected-refund history from earlier ones, pushing the native score past the 0.8 threshold and misclassifying a legitimately valid refund as high-risk.
The two ports that hit this fixed it two different ways, worth naming precisely rather than claiming one shared technique. The java-springboot port forces its entire E2E suite into HTTP mode against an unreachable address, so scoring deterministically falls back to 0 for every test. The nestjs port instead left native scoring live for the rest of the suite and gave only the one affected test its own dedicated owner ID — a narrower fix, same underlying cause.
It's worth naming the difference: a flaky test fails unpredictably for reasons unrelated to the code under test. This failure happened every time, in the same order, for the same reason — accumulated state from earlier tests changing the input to a later one. That's a test-isolation bug wearing a "flaky test" costume, and it's worth looking twice before reaching for a retry-on-failure fix instead of an isolation fix.
docs/architecture/domain-service.md — the Technical Service pattern (this example has since been replaced, see the update note above) · RefundFraudRiskScorerNativeImpl.kt — the training/scoring code as it existed, pinned to the last commit before removal