Design a Tunable Recurring-Transaction Detector
RampSoftware Engineer · OnsiteLast updated
Design and implement a configurable, explainable detector that identifies recurring subscription transactions from noisy transaction logs, handling price variations, irregular cadences, and missing cycles.
Problem Statement
Corporate cardholders frequently incur recurring SaaS and vendor subscriptions. However, real-world transaction logs are messy: descriptions vary (e.g., GITHUB* 123 vs GITHUB INC), billing intervals drift due to weekends and leap days (e.g., 28 to 33 days for monthly), and prices fluctuate due to seat changes, tier upgrades, or tax adjustments.
Design a class RecurringDetector that ingests a user's transaction history and outputs detected recurring subscription streams with explainable metadata.
Requirements
- Merchant Normalization: Normalize raw transaction descriptions by stripping non-alphanumeric trailing tokens (such as IDs, reference codes, state codes, and asterisks) to group related transactions.
- Cadence Classification: For a candidate group of transactions from the same merchant (minimum 3 transactions), determine if they follow a regular cadence:
WEEKLY: Average interval between consecutive occurrences is approximately 7 days (within interval tolerancet_days).MONTHLY: Average interval is approximately 30 days (28–33 days acceptable within tolerance).ANNUAL: Average interval is approximately 365 days (360–370 days acceptable within tolerance).
- Amount Variance: Transactions within a detected stream should be within an amount tolerance
t_amount_pctrelative to the stream's median amount, but should tolerate at most one legitimate step-change (e.g., a plan upgrade or price increase). - Duplicate Deduplication: Ignore duplicate transactions from the same merchant with identical amounts occurring within 24 hours of each other (keep only the first).
- Explainability: Each detected stream must return the merchant key, detected cadence, average amount, estimated next charge date, and a confidence score between
0.0and1.0based on regularity.
Interface
from dataclasses import dataclass
from typing import List, Optional
from datetime import date
@dataclass
class Transaction:
id: str
description: str
amount_cents: int
date: str # "YYYY-MM-DD"
@dataclass
class RecurringStream:
merchant: str
cadence: str # "WEEKLY", "MONTHLY", "ANNUAL"
typical_amount_cents: int
next_expected_date: str
confidence: float # 0.0 to 1.0
transaction_ids: List[str]
class RecurringDetector:
def __init__(self, t_days: int = 3, t_amount_pct: float = 0.15):
"""
t_days: allowed drift in days from expected interval.
t_amount_pct: allowed fractional deviation from median (e.g., 0.15 for 15%).
"""
pass
def detect(self, transactions: List[Transaction]) -> List[RecurringStream]:
pass
Constraints
- Number of transactions
N <= 100,000 - Transactions can be provided in arbitrary order.
- Amounts are positive integers representing cents (
1 <= amount_cents <= 100,000,000). - Time complexity should be
O(N log N)or better.
Example
txs = [
Transaction("1", "SLACK TECHNOLOGIES *441", 1200, "2023-01-15"),
Transaction("2", "SLACK TECHNOLOGIES *998", 1200, "2023-02-15"),
Transaction("3", "SLACK INC", 1200, "2023-03-16"),
Transaction("4", "SLACK INC", 1200, "2023-04-15"),
]
detector = RecurringDetector(t_days=3, t_amount_pct=0.10)
streams = detector.detect(txs)
# Expected:
# [RecurringStream(merchant="SLACK", cadence="MONTHLY", typical_amount_cents=1200,
# next_expected_date="2023-05-15", confidence=0.95, transaction_ids=["1","2","3","4"])]
Hints
Hint 1
Normalize merchant strings first using a pipeline of regex cleaning rules and common suffix removals before grouping transactions.
Hint 2
Sort each merchant bucket chronologically and filter out exact duplicates within a 1-day window before computing inter-arrival delta days.
Hint 3
Compute median intervals and median amounts to make cadence and price baseline robust against single-cycle outliers or price hikes.
Follow-up questions
- How would you adapt this logic to a real-time streaming pipeline (e.g., Kafka + Flink) without re-evaluating the full historical window on every incoming swipe?
- How can you handle missed cycles (e.g., a customer skipped a month or paused a subscription) without breaking the cadence detector?
- How would you evaluate and tune `t_days` and `t_amount_pct` systematically using labeled historical data to balance false discovery rate and recall?
Solution walkthrough is for members
Get the full walkthrough with reference code and complexity analysis, plus Kitty Coach at 300 messages a day. Hints, the code runner and community answers stay free.