"""Deterministic generation of legitimate payment records and events."""
import math
from collections.abc import Callable, Iterable, Iterator
from dataclasses import dataclass
from datetime import UTC, date, datetime, time, timedelta
from random import Random
from typing import Literal, TypeVar, cast
from fraudtwin.calibration import (
CALIBRATED_AMOUNT_STREAM_ID,
CALIBRATED_TIMING_STREAM_ID,
ResolvedCalibration,
)
from fraudtwin.config import (
ACCOUNT_TRANSFER_SOURCE_DELAY_SECONDS,
CARD_EVENT_ENVELOPE_DELAY_SECONDS,
CARD_SOURCE_DELAY_SECONDS,
PIX_EVENT_ENVELOPE_DELAY_SECONDS,
PIX_SOURCE_DELAY_SECONDS,
SimulationRunConfig,
config_hash,
)
from fraudtwin.domain import (
CARD_LIFECYCLE_EVENT_TYPES,
PAYMENT_EVENT_CONTRACT_VERSION,
PIX_LIFECYCLE_EVENT_TYPES,
Account,
BehaviorProfile,
Card,
CardLifecycleEventType,
Device,
LedgerEntry,
Merchant,
Payment,
PaymentEvent,
PaymentEventType,
PaymentRail,
PaymentType,
PixKey,
PixLifecycleEventType,
validate_payment_lifecycle,
)
from fraudtwin.seed import create_stream_rng
_ID_WIDTH = 8
T = TypeVar("T")
Record = TypeVar("Record")
type ProfileTimeCache = tuple[
tuple[int, ...],
tuple[date, ...],
tuple[float, ...],
dict[date, tuple[int, ...]],
dict[date, tuple[float, ...]],
]
def _weighted_choice(rng: Random, values: tuple[T, ...], weights: tuple[float, ...]) -> T:
return rng.choices(values, weights=weights, k=1)[0]
def _group_by(
records: tuple[Record, ...], key: Callable[[Record], str]
) -> dict[str, tuple[Record, ...]]:
"""Group records by a stable relationship key."""
grouped: dict[str, list[Record]] = {}
for record in records:
grouped.setdefault(key(record), []).append(record)
return {group_key: tuple(values) for group_key, values in grouped.items()}
def count_lifecycle_events(
events: Iterable[PaymentEvent], event_types: tuple[str, ...]
) -> dict[str, int]:
"""Count a lifecycle vocabulary in one pass."""
counts = dict.fromkeys(event_types, 0)
for event in events:
if event.event_type in counts:
counts[event.event_type] += 1
return counts
def count_card_lifecycle_events(events: Iterable[PaymentEvent]) -> dict[str, int]:
"""Count the explicit card lifecycle event types."""
return count_lifecycle_events(events, CARD_LIFECYCLE_EVENT_TYPES)
[docs]
def count_pix_lifecycle_events(events: Iterable[PaymentEvent]) -> dict[str, int]:
"""Count the explicit PIX lifecycle event types."""
return count_lifecycle_events(events, PIX_LIFECYCLE_EVENT_TYPES)
def _event_times(
event_time: datetime, source_delay_seconds: int
) -> tuple[datetime, datetime, datetime]:
"""Return source, ingestion, and processing times for one event."""
source_available_at = event_time + timedelta(seconds=source_delay_seconds)
ingested_at = source_available_at + timedelta(seconds=1)
processed_at = ingested_at + timedelta(seconds=1)
return source_available_at, ingested_at, processed_at
def payment_event_times(
event_time: datetime, rail: PaymentRail
) -> tuple[datetime, datetime, datetime]:
"""Return source, ingestion, and processing times for a payment rail."""
source_delay_seconds = {
"CARD": CARD_SOURCE_DELAY_SECONDS,
"PIX": PIX_SOURCE_DELAY_SECONDS,
"ACCOUNT_TRANSFER": ACCOUNT_TRANSFER_SOURCE_DELAY_SECONDS,
}[rail]
return _event_times(event_time, source_delay_seconds)
[docs]
@dataclass(frozen=True)
class PaymentDataset:
"""Stable, ordered payment business objects and their event envelopes."""
payments: tuple[Payment, ...]
payment_events: tuple[PaymentEvent, ...]
ledger_entries: tuple[LedgerEntry, ...] = ()
@property
def counts(self) -> dict[str, int]:
return {
"payments": len(self.payments),
"payment_events": len(self.payment_events),
"ledger_entries": len(self.ledger_entries),
}
@property
def card_lifecycle_event_counts(self) -> dict[str, int]:
return count_card_lifecycle_events(self.payment_events)
@property
def pix_lifecycle_event_counts(self) -> dict[str, int]:
return count_pix_lifecycle_events(self.payment_events)
@dataclass(frozen=True)
class _PaymentDetails:
"""Rail-specific fields assembled before creating shared payment records."""
amount: float
merchant_id: str | None
card_id: str | None
payer_account_id: str
payee_account_id: str | None
payer_institution_id: str | None
payee_institution_id: str | None
payer_pix_key_id: str | None
payee_pix_key_id: str | None
payment_type: PaymentType
event_type: PaymentEventType
online: bool
[docs]
class PaymentGenerator:
"""Generate positive, relationship-valid, legitimate payment events."""
def __init__(
self,
config: SimulationRunConfig,
accounts: tuple[Account, ...],
cards: tuple[Card, ...],
merchants: tuple[Merchant, ...],
devices: tuple[Device, ...],
pix_keys: tuple[PixKey, ...] = (),
simulation_run_id: str | None = None,
calibration: ResolvedCalibration | None = None,
include_lifecycle: bool = True,
include_ledger: bool = True,
) -> None:
self.config = config
self.start = config.simulation.start.astimezone(UTC)
self.end = self.start + timedelta(days=config.simulation.duration_days)
self.accounts = accounts
self.merchants = merchants
self.simulation_run_id = simulation_run_id or self._stable_run_id()
self.calibration = calibration
self.include_lifecycle = include_lifecycle
self.include_ledger = include_ledger
self.accounts_by_customer = _group_by(accounts, lambda account: account.customer_id)
self.accounts_by_institution = _group_by(accounts, lambda account: account.institution_id)
self.cards_by_customer = _group_by(cards, lambda card: card.customer_id)
self.accounts_by_id = {account.account_id: account for account in accounts}
self.devices_by_id = {device.device_id: device for device in devices}
self.pix_keys_by_account = _group_by(pix_keys, lambda key: key.account_id)
self._configured_rails = tuple(
rail for rail, weight in config.payments.rails.items() if weight > 0
)
self._active_accounts = tuple(account for account in accounts if account.status == "ACTIVE")
self._active_accounts_by_customer = {
customer_id: tuple(
account for account in customer_accounts if account.status == "ACTIVE"
)
for customer_id, customer_accounts in self.accounts_by_customer.items()
}
self._active_pix_accounts = tuple(
account
for account in self._active_accounts
if account.account_id in self.pix_keys_by_account
)
self._active_pix_key_accounts = frozenset(
account.account_id
for account in self._active_accounts
if any(
key.status == "ACTIVE"
for key in self.pix_keys_by_account.get(account.account_id, ())
)
)
self._active_payees_by_account = {
account.account_id: tuple(
candidate
for candidate in self._active_accounts
if candidate.account_id != account.account_id
)
for account in self._active_accounts
}
self._active_pix_key_payees_by_account = {
account.account_id: tuple(
candidate
for candidate in self._active_payees_by_account[account.account_id]
if candidate.account_id in self._active_pix_key_accounts
)
for account in self._active_accounts
}
self._active_cards_by_customer = {
customer_id: tuple(
card
for card in customer_cards
if card.status == "ACTIVE" and card.expires_at > self.start
)
for customer_id, customer_cards in self.cards_by_customer.items()
}
self._eligible_rails_by_customer: dict[str, tuple[PaymentRail, ...]] = {}
self._profile_time_cache: dict[str, ProfileTimeCache] = {}
self._profile_merchant_cache: dict[
str, tuple[tuple[Merchant, ...], tuple[float, ...]] | None
] = {}
self._profile_device_cache: dict[str, tuple[str, ...]] = {}
self._profile_median_cache: dict[str, float] = {}
self._allowed_months = frozenset(config.behavior.travel_period_months)
self._holiday_dates = frozenset(config.behavior.holiday_dates)
self._merchant_active_hours = frozenset(config.behavior.merchant_active_hours)
self._days = self._simulation_days()
self._valid_hours_by_day = self._build_valid_hours_by_day()
self._card_max_delay_seconds = config.card_lifecycle.maximum_delay_seconds
self._pix_max_delay_seconds = config.pix_lifecycle.maximum_delay_seconds
self._card_daily_spend: dict[tuple[str, date], float] = {}
self._account_spend: dict[str, float] = {}
self._calibrated_amount_rng = (
create_stream_rng(self.config.simulation.seed, CALIBRATED_AMOUNT_STREAM_ID)
if calibration is not None and calibration.enabled
else None
)
self._calibrated_time_rng = (
create_stream_rng(self.config.simulation.seed, CALIBRATED_TIMING_STREAM_ID)
if calibration is not None and calibration.enabled
else None
)
self._calibrated_amount_quantiles = self._resolve_calibrated_amount_quantiles()
self._calibrated_seasonality_weights = self._resolve_calibrated_seasonality_weights()
def _resolve_calibrated_amount_quantiles(self) -> tuple[float, ...]:
if (
self.calibration is None
or not self.calibration.enabled
or self.calibration.profile is None
or "amount_distribution" not in self.config.calibration.summary_names
):
return ()
distribution = next(
(item for item in self.calibration.profile.distributions if item.name == "amount"),
None,
)
return tuple(distribution.quantiles) if distribution and distribution.quantiles else ()
def _resolve_calibrated_seasonality_weights(self) -> tuple[float, ...]:
if (
self.calibration is None
or not self.calibration.enabled
or self.calibration.profile is None
or "seasonality" not in self.config.calibration.summary_names
):
return ()
seasonality = next(
(item for item in self.calibration.profile.summaries if item.name == "seasonality"),
None,
)
values = seasonality.parameters.get("hour_weights") if seasonality else None
return tuple(cast(tuple[float, ...], values)) if values else ()
def _stable_run_id(self) -> str:
# Lifecycle settings must not change the base payment stream ID.
stable_hash = config_hash(
self.config,
include_card_lifecycle=False,
include_pix_lifecycle=False,
include_scale_execution=False,
)
return f"SIM-{stable_hash[:16]}"
def _simulation_days(self) -> tuple[date, ...]:
first = self.start.date()
last = self.end.date()
return tuple(first + timedelta(days=offset) for offset in range((last - first).days + 1))
def _build_valid_hours_by_day(self) -> dict[date, tuple[int, ...]]:
active_hours = self.config.behavior.active_hours
return {
current_day: tuple(
hour
for hour in active_hours
if self.start <= datetime.combine(current_day, time(hour), tzinfo=UTC) < self.end
)
for current_day in self._days
}
def _eligible_profiles(
self, profiles: tuple[BehaviorProfile, ...]
) -> tuple[BehaviorProfile, ...]:
eligible_profiles: list[BehaviorProfile] = []
for profile in profiles:
available_rails = self._available_rails(profile)
if profile.customer_id not in self.accounts_by_customer or not available_rails:
continue
self._eligible_rails_by_customer[profile.customer_id] = available_rails
self._build_profile_cache(profile)
eligible_profiles.append(profile)
eligible = tuple(eligible_profiles)
if self.config.payments.daily_target and not eligible:
raise ValueError("payment generation requires at least one customer with an account")
return eligible
def _available_rails(self, profile: BehaviorProfile) -> tuple[PaymentRail, ...]:
available: list[PaymentRail] = []
customer_accounts = self._active_accounts_by_customer.get(profile.customer_id, ())
customer_cards = self._active_cards_by_customer.get(profile.customer_id, ())
has_pix_key = any(
account.account_id in self.pix_keys_by_account for account in customer_accounts
)
for rail in self._configured_rails:
if rail == "CARD" and customer_cards and self.merchants:
available.append(rail)
elif (
rail in ("PIX", "ACCOUNT_TRANSFER")
and customer_accounts
and (rail == "ACCOUNT_TRANSFER" or has_pix_key)
):
available.append(rail)
return tuple(available)
def _choose_rail(self, profile: BehaviorProfile, rng: Random) -> PaymentRail:
available = self._eligible_rails_by_customer.get(profile.customer_id)
if available is None:
available = self._available_rails(profile)
if not available:
raise ValueError("payment generation has no configured rail with required entities")
weights = tuple(
self.config.payments.rails[rail]
* (
0.5 + profile.card_vs_transfer_preference
if rail == "CARD"
else 1.5 - profile.card_vs_transfer_preference
)
for rail in available
)
return _weighted_choice(rng, available, weights)
def _build_profile_cache(self, profile: BehaviorProfile) -> None:
"""Build immutable lookup data used by every payment for a profile."""
profile_id = profile.behavior_profile_id
if profile_id in self._profile_time_cache:
return
active_hours = tuple(hour for hour, weight in enumerate(profile.hour_weights) if weight > 0)
day_candidates: list[date] = []
day_weights: list[float] = []
valid_hours_by_day: dict[date, tuple[int, ...]] = {}
hour_weights_by_day: dict[date, tuple[float, ...]] = {}
for current_day in self._days:
valid_hours = self._valid_hours_by_day[current_day]
if not valid_hours or current_day.month not in self._allowed_months:
continue
weight = profile.weekday_weights[current_day.weekday()]
if current_day.day <= 3:
weight *= self.config.behavior.beginning_of_month_weight
if current_day.day >= 28:
weight *= self.config.behavior.end_of_month_weight
if current_day.day in self.config.behavior.payday_days:
weight *= self.config.behavior.payday_weight
if current_day.isoformat() in self._holiday_dates:
weight *= self.config.behavior.holiday_weight
weight *= sum(
profile.hour_weights[hour]
for hour in valid_hours
if hour in self._merchant_active_hours
)
day_candidates.append(current_day)
day_weights.append(weight)
selected_hours = tuple(
hour
for hour in valid_hours
if hour in active_hours and hour in self._merchant_active_hours
)
selected_hours = selected_hours or valid_hours
selected_weights = tuple(profile.hour_weights[hour] for hour in selected_hours)
if (
self._calibrated_seasonality_weights
and sum(self._calibrated_seasonality_weights[hour] for hour in selected_hours) > 0
):
selected_weights = tuple(
self._calibrated_seasonality_weights[hour]
* max(profile.hour_weights[hour], 0.01)
for hour in selected_hours
)
valid_hours_by_day[current_day] = selected_hours
hour_weights_by_day[current_day] = selected_weights
matching_merchants = tuple(
merchant
for merchant in self.merchants
if merchant.merchant_category_code in profile.merchant_category_preferences
)
merchant_weights_by_code = dict(
zip(
profile.merchant_category_preferences,
profile.merchant_category_weights,
strict=True,
)
)
matching_weights = tuple(
merchant_weights_by_code[merchant.merchant_category_code]
for merchant in matching_merchants
)
self._profile_merchant_cache[profile_id] = (
(matching_merchants, matching_weights)
if matching_merchants and sum(matching_weights) > 0
else None
)
self._profile_device_cache[profile_id] = tuple(
device_id
for device_id in profile.preferred_device_ids
if device_id in self.devices_by_id
)
median_by_level = {
"LOW": 0.35,
"MEDIUM": 0.75,
"HIGH": 1.2,
}
self._profile_median_cache[profile_id] = max(
self.config.behavior.amount_min,
profile.monthly_spending_budget / 30.0 * median_by_level[profile.spending_level],
)
self._profile_time_cache[profile_id] = (
active_hours,
tuple(day_candidates),
tuple(day_weights),
valid_hours_by_day,
hour_weights_by_day,
)
def _sample_time(self, profile: BehaviorProfile, rng: Random) -> datetime:
if profile.behavior_profile_id not in self._profile_time_cache:
self._build_profile_cache(profile)
(
_,
day_candidates,
day_weights,
valid_hours_by_day,
hour_weights_by_day,
) = self._profile_time_cache[profile.behavior_profile_id]
if not day_candidates:
seconds = rng.randrange(max(1, int((self.end - self.start).total_seconds())))
return self.start + timedelta(seconds=seconds)
if sum(day_weights) <= 0:
day_weights = (1.0,) * len(day_candidates)
current_day: date = _weighted_choice(rng, day_candidates, tuple(day_weights))
valid_hours = valid_hours_by_day[current_day]
hour_weights = hour_weights_by_day[current_day]
timing_rng = self._calibrated_time_rng or rng
hour = _weighted_choice(timing_rng, valid_hours, hour_weights)
event_time = datetime.combine(current_day, time(hour), tzinfo=UTC) + timedelta(
minutes=rng.randrange(60)
)
if event_time < self.start:
return self.start
if event_time >= self.end:
return self.end - timedelta(microseconds=1)
return event_time
def _sample_amount(self, profile: BehaviorProfile, rng: Random) -> float:
if self._calibrated_amount_quantiles:
assert self._calibrated_amount_rng is not None
sampled = self._calibrated_amount_rng.choice(self._calibrated_amount_quantiles)
return round(
min(
self.config.behavior.amount_max,
max(self.config.behavior.amount_min, sampled),
),
2,
)
median = self._profile_median_cache.get(profile.behavior_profile_id)
if median is None:
self._build_profile_cache(profile)
median = self._profile_median_cache[profile.behavior_profile_id]
sampled_amount = math.exp(rng.gauss(math.log(median), 0.65))
bounded_amount = min(
self.config.behavior.amount_max,
max(self.config.behavior.amount_min, sampled_amount),
)
rounded = round(bounded_amount, 2)
return rounded if rounded > 0 else self.config.behavior.amount_min
def _sample_lifecycle_time(
self,
profile: BehaviorProfile,
rng: Random,
maximum_delay_seconds: int,
envelope_delay_seconds: int,
rail_name: str,
) -> datetime:
"""Sample a payment time while leaving room for its lifecycle."""
latest = self.end - timedelta(
seconds=maximum_delay_seconds + envelope_delay_seconds,
microseconds=1,
)
if latest <= self.start:
raise ValueError(f"{rail_name} lifecycle timing settings exceed the simulation window")
return min(self._sample_time(profile, rng), latest)
def _merchant(self, profile: BehaviorProfile, rng: Random) -> Merchant:
cached = self._profile_merchant_cache.get(profile.behavior_profile_id)
if cached is not None:
matching, weights = cached
return _weighted_choice(rng, matching, weights)
return rng.choice(self.merchants)
def _device_id(self, profile: BehaviorProfile, rng: Random) -> str | None:
available = self._profile_device_cache.get(profile.behavior_profile_id, ())
return rng.choice(available) if available else None
def _payee_account(
self, payer_account_id: str, rng: Random, *, require_pix_key: bool = False
) -> Account:
alternatives = self._active_payees_by_account.get(payer_account_id, ())
if require_pix_key:
alternatives = self._active_pix_key_payees_by_account.get(payer_account_id, ())
alternatives = alternatives or self._active_pix_accounts
return rng.choice(alternatives or self.accounts)
def _initiated_at(self, profile: BehaviorProfile, rail: PaymentRail, rng: Random) -> datetime:
if rail == "CARD":
return self._sample_lifecycle_time(
profile,
rng,
self._card_max_delay_seconds,
CARD_EVENT_ENVELOPE_DELAY_SECONDS,
"card",
)
if rail == "PIX":
return self._sample_lifecycle_time(
profile,
rng,
self._pix_max_delay_seconds,
PIX_EVENT_ENVELOPE_DELAY_SECONDS,
"PIX",
)
return self._sample_time(profile, rng)
def _card_details(
self,
profile: BehaviorProfile,
initiated_at: datetime,
amount: float,
rng: Random,
) -> _PaymentDetails:
cards = tuple(
card
for card in self._active_cards_by_customer[profile.customer_id]
if card.expires_at > initiated_at
)
if not cards:
raise ValueError("card payment requires an active, unexpired card")
card = rng.choice(cards)
payer_account_id = card.account_id
payer_institution_id = self.accounts_by_id[payer_account_id].institution_id
merchant = self._merchant(profile, rng)
settlement_accounts = tuple(
account
for account in self.accounts_by_institution.get(merchant.acquirer_id, ())
if account.status == "ACTIVE" and account.account_id != payer_account_id
)
payee_account_id = (
settlement_accounts[0].account_id if settlement_accounts else payer_account_id
)
amount = min(amount, card.transaction_limit)
spent = self._card_daily_spend.get((card.card_id, initiated_at.date()), 0.0)
remaining = max(0.0, card.daily_limit - spent)
if remaining < self.config.behavior.amount_min:
raise ValueError("card daily limit exhausted")
amount = min(amount, remaining)
account = self.accounts_by_id[payer_account_id]
account_spend = self._account_spend.get(account.account_id, 0.0)
available = max(
account.available_balance - account_spend,
account.ledger_balance + account.credit_limit + account.overdraft_limit - account_spend,
)
if available < self.config.behavior.amount_min:
raise ValueError("account spendable balance exhausted")
amount = min(amount, available)
self._card_daily_spend[(card.card_id, initiated_at.date())] = round(spent + amount, 2)
self._account_spend[account.account_id] = round(account_spend + amount, 2)
return _PaymentDetails(
amount=amount,
merchant_id=merchant.merchant_id,
card_id=card.card_id,
payer_account_id=payer_account_id,
payee_account_id=payee_account_id,
payer_institution_id=payer_institution_id,
payee_institution_id=merchant.acquirer_id,
payer_pix_key_id=None,
payee_pix_key_id=None,
payment_type="PURCHASE",
event_type="CARD_PAYMENT_INITIATED",
online=merchant.online_only or rng.random() < profile.online_purchase_rate,
)
def _transfer_details(
self,
profile: BehaviorProfile,
rail: PaymentRail,
amount: float,
rng: Random,
) -> _PaymentDetails:
payer_candidates = self._active_accounts_by_customer[profile.customer_id]
if rail == "PIX":
keyed_payers = tuple(
account
for account in payer_candidates
if account.account_id in self._active_pix_key_accounts
)
payer_candidates = keyed_payers or payer_candidates
payer_account = rng.choice(payer_candidates)
payee_account = self._payee_account(
payer_account.account_id, rng, require_pix_key=rail == "PIX"
)
payer_keys = self.pix_keys_by_account.get(payer_account.account_id, ())
payee_keys = self.pix_keys_by_account.get(payee_account.account_id, ())
return _PaymentDetails(
amount=amount,
merchant_id=None,
card_id=None,
payer_account_id=payer_account.account_id,
payee_account_id=payee_account.account_id,
payer_institution_id=payer_account.institution_id,
payee_institution_id=payee_account.institution_id,
payer_pix_key_id=(payer_keys[0].pix_key_id if rail == "PIX" and payer_keys else None),
payee_pix_key_id=(payee_keys[0].pix_key_id if rail == "PIX" and payee_keys else None),
payment_type="TRANSFER",
event_type="PIX_INITIATED" if rail == "PIX" else "TRANSFER_COMPLETED",
online=False,
)
def _generate_one(
self, number: int, profile: BehaviorProfile, rng: Random
) -> tuple[Payment, PaymentEvent]:
rail = self._choose_rail(profile, rng)
initiated_at = self._initiated_at(profile, rail, rng)
amount = self._sample_amount(profile, rng)
device_id = self._device_id(profile, rng)
details = (
self._card_details(profile, initiated_at, amount, rng)
if rail == "CARD"
else self._transfer_details(profile, rail, amount, rng)
)
payment_id = f"PAY-{number:0{_ID_WIDTH}d}"
event_id = f"EVT-{number:0{_ID_WIDTH}d}"
payment = Payment(
payment_id=payment_id,
payment_rail=rail,
payment_type=details.payment_type,
payer_account_id=details.payer_account_id,
payee_account_id=details.payee_account_id,
merchant_id=details.merchant_id,
card_id=details.card_id,
amount=details.amount,
currency=self.accounts_by_id[details.payer_account_id].currency,
initiated_at=initiated_at,
current_status="SETTLED" if rail == "PIX" else "COMPLETED",
payer_institution_id=details.payer_institution_id,
payee_institution_id=details.payee_institution_id,
payer_pix_key_id=details.payer_pix_key_id,
payee_pix_key_id=details.payee_pix_key_id,
)
source_available_at, ingested_at, processed_at = payment_event_times(initiated_at, rail)
event = PaymentEvent(
event_id=event_id,
event_type=details.event_type,
event_version=1,
payment_id=payment_id,
customer_id=profile.customer_id,
account_id=details.payer_account_id,
event_time=initiated_at,
source_created_at=initiated_at,
source_available_at=source_available_at,
ingested_at=ingested_at,
processed_at=processed_at,
producer="fraudtwin.behavior",
source_system="synthetic_payment_source",
schema_version=PAYMENT_EVENT_CONTRACT_VERSION,
correlation_id=payment_id,
causation_id=None,
simulation_run_id=self.simulation_run_id,
scenario_id=None,
payment_rail=rail,
payment_type=details.payment_type,
payee_account_id=details.payee_account_id,
merchant_id=details.merchant_id,
card_id=details.card_id,
device_id=device_id,
online=details.online,
amount=details.amount,
currency=payment.currency,
)
return payment, event
@staticmethod
def _advance(previous: datetime, delay_seconds: int) -> datetime:
"""Advance an event clock while keeping zero-delay events ordered."""
candidate = previous + timedelta(seconds=delay_seconds)
return candidate if candidate > previous else previous + timedelta(microseconds=1)
def _lifecycle_event(
self,
initial: PaymentEvent,
event_type: CardLifecycleEventType | PixLifecycleEventType,
event_time: datetime,
sequence: int,
causation_id: str,
source_delay_seconds: int,
) -> PaymentEvent:
"""Create a lifecycle event while preserving the common envelope."""
source_available_at, ingested_at, processed_at = _event_times(
event_time, source_delay_seconds
)
return initial.model_copy(
update={
"event_id": f"{initial.event_id}-{sequence:02d}",
"event_type": event_type,
"event_time": event_time,
"source_created_at": event_time,
"source_available_at": source_available_at,
"ingested_at": ingested_at,
"processed_at": processed_at,
"causation_id": causation_id,
}
)
def _append_lifecycle_event(
self,
events: list[PaymentEvent],
initial: PaymentEvent,
previous_time: datetime,
previous_id: str,
event_type: CardLifecycleEventType | PixLifecycleEventType,
delay_seconds: int,
source_delay_seconds: int,
) -> tuple[datetime, str]:
"""Append one ordered lifecycle event and return its new cursor."""
event_time = self._advance(previous_time, delay_seconds)
if event_time >= self.end:
raise ValueError("lifecycle events exceed the simulation window")
event = self._lifecycle_event(
initial,
event_type,
event_time,
len(events) + 1,
previous_id,
source_delay_seconds,
)
events.append(event)
return event_time, event.event_id
def _card_lifecycle(
self,
payment: Payment,
initial: PaymentEvent,
rng: Random,
) -> tuple[Payment, tuple[PaymentEvent, ...]]:
"""Generate and validate one deterministic card lifecycle."""
settings = self.config.card_lifecycle
events = [initial]
previous_time = initial.event_time
previous_id = initial.event_id
def append(event_type: CardLifecycleEventType, delay_seconds: int) -> None:
nonlocal previous_time, previous_id
previous_time, previous_id = self._append_lifecycle_event(
events,
initial,
previous_time,
previous_id,
event_type,
delay_seconds,
CARD_SOURCE_DELAY_SECONDS,
)
append("CARD_AUTHORIZATION_REQUESTED", settings.authorization_delay_seconds)
if rng.random() >= settings.authorization_approval_probability:
append("CARD_DECLINED", settings.authorization_delay_seconds)
final_status = "DECLINED"
else:
append("CARD_AUTHORIZED", settings.authorization_delay_seconds)
if rng.random() < settings.reversal_probability:
if rng.random() < 0.5:
append("CARD_REVERSED", settings.reversal_delay_seconds)
else:
append("CARD_CAPTURED", settings.capture_delay_seconds)
append("CARD_REVERSED", settings.reversal_delay_seconds)
final_status = "REVERSED"
else:
append("CARD_CAPTURED", settings.capture_delay_seconds)
append("CARD_CLEARED", settings.clearing_delay_seconds)
append("CARD_SETTLED", settings.settlement_delay_seconds)
if rng.random() < settings.refund_probability:
append("CARD_REFUNDED", settings.refund_delay_seconds)
final_status = "REFUNDED"
else:
final_status = "SETTLED"
if rng.random() < settings.chargeback_probability:
append("CARD_CHARGEBACK_CREATED", settings.chargeback_delay_seconds)
append(
"CARD_CHARGEBACK_RESOLVED",
settings.chargeback_resolution_delay_seconds,
)
final_status = "CHARGEBACK_RESOLVED"
result = payment.model_copy(update={"current_status": final_status})
event_tuple = tuple(events)
validate_payment_lifecycle(result, event_tuple)
return result, event_tuple
def _pix_lifecycle(
self,
payment: Payment,
initial: PaymentEvent,
rng: Random,
*,
always_approve: bool = False,
) -> tuple[Payment, tuple[PaymentEvent, ...]]:
"""Generate and validate one deterministic PIX lifecycle."""
settings = self.config.pix_lifecycle
events = [initial]
previous_time = initial.event_time
previous_id = initial.event_id
def append(event_type: PixLifecycleEventType, delay_seconds: int) -> None:
nonlocal previous_time, previous_id
previous_time, previous_id = self._append_lifecycle_event(
events,
initial,
previous_time,
previous_id,
event_type,
delay_seconds,
PIX_SOURCE_DELAY_SECONDS,
)
append("PIX_VALIDATED", settings.validation_delay_seconds)
rejected = not always_approve and (
rng.random() >= settings.authorization_approval_probability
or rng.random() < settings.rejection_probability
)
if rejected:
append("PIX_REJECTED", settings.authorization_delay_seconds)
final_status = "REJECTED"
else:
append("PIX_AUTHORIZED", settings.authorization_delay_seconds)
append("PIX_SUBMITTED", settings.submission_delay_seconds)
if rng.random() < settings.timeout_probability:
append("PIX_TIMEOUT", settings.timeout_delay_seconds)
final_status = "TIMED_OUT"
else:
append("PIX_SETTLED", settings.settlement_delay_seconds)
append("PIX_RECEIVED", settings.receipt_delay_seconds)
if rng.random() < settings.return_probability:
append("PIX_RETURN_REQUESTED", settings.return_request_delay_seconds)
append("PIX_RETURNED", settings.return_delay_seconds)
final_status = "RETURNED"
else:
final_status = "RECEIVED"
result = payment.model_copy(update={"current_status": final_status})
event_tuple = tuple(events)
validate_payment_lifecycle(result, event_tuple)
return result, event_tuple
@staticmethod
def _ledger_specs(
payment: Payment, events: tuple[PaymentEvent, ...]
) -> list[tuple[PaymentEvent, str, str]]:
"""Return event, account, and direction tuples for posted transfers."""
payer = payment.payer_account_id
payee = payment.payee_account_id
if payee is None:
return []
specs: list[tuple[PaymentEvent, str, str]] = []
for event in events:
if event.event_type in {"PIX_SETTLED", "TRANSFER_COMPLETED", "CARD_SETTLED"}:
specs.extend(((event, payer, "DEBIT"), (event, payee, "CREDIT")))
elif event.event_type in {
"PIX_RETURNED",
"CARD_REFUNDED",
"CARD_CHARGEBACK_RESOLVED",
}:
specs.extend(((event, payer, "CREDIT"), (event, payee, "DEBIT")))
return specs
def _materialize_ledger(
self, specs: list[tuple[PaymentEvent, str, str]]
) -> tuple[LedgerEntry, ...]:
"""Create stable ledger rows and running balances from account openings."""
balances = {account.account_id: account.ledger_balance for account in self.accounts}
ordered = sorted(
specs,
key=lambda item: (
item[0].processed_at,
item[0].event_id,
item[1],
item[2],
),
)
entries: list[LedgerEntry] = []
for event, account_id, raw_entry_type in ordered:
entry_type = cast(Literal["DEBIT", "CREDIT"], raw_entry_type)
delta = event.amount if entry_type == "CREDIT" else -event.amount
balance = round(balances[account_id] + delta, 2)
if balance < -self.accounts_by_id[account_id].overdraft_limit:
raise ValueError(f"ledger debit exceeds overdraft limit for {account_id}")
balances[account_id] = balance
entries.append(
LedgerEntry(
ledger_entry_id=f"LED-{event.event_id}-{len(entries) + 1:02d}",
payment_id=event.payment_id,
account_id=account_id,
event_id=event.event_id,
entry_type=entry_type,
amount=event.amount,
currency=event.currency,
occurred_at=event.event_time,
effective_at=event.event_time,
posted_at=event.processed_at,
balance_after=balance,
)
)
return tuple(entries)
[docs]
def materialize_ledger(
self, payments: tuple[Payment, ...], events: tuple[PaymentEvent, ...]
) -> tuple[LedgerEntry, ...]:
"""Reconcile a complete payment stream, including scenario payments."""
payments_by_id = {payment.payment_id: payment for payment in payments}
specs: list[tuple[PaymentEvent, str, str]] = []
for event in events:
payment = payments_by_id.get(event.payment_id)
if payment is not None:
specs.extend(self._ledger_specs(payment, (event,)))
return self._materialize_ledger(specs)
[docs]
def iter_generate(
self, profiles: tuple[BehaviorProfile, ...]
) -> Iterator[tuple[Payment, PaymentEvent]]:
"""Stream exactly daily_target * duration_days legitimate payment pairs."""
count = self.config.payments.daily_target * self.config.simulation.duration_days
if count == 0:
return
eligible_profiles = self._eligible_profiles(profiles)
if not eligible_profiles:
raise ValueError("payment generation requires at least one eligible behavior profile")
rng = create_stream_rng(self.config.simulation.seed, "milestone-3:payments")
for number in range(1, count + 1):
profile = rng.choice(eligible_profiles)
yield self._generate_one(number, profile, rng)
[docs]
def generate(self, profiles: tuple[BehaviorProfile, ...]) -> PaymentDataset:
"""Materialize the deterministic payment stream as an in-memory dataset."""
payments: list[Payment] = []
events: list[PaymentEvent] = []
ledger_specs: list[tuple[PaymentEvent, str, str]] = []
lifecycle_rng = (
create_stream_rng(self.config.simulation.seed, "milestone-4:card-lifecycle")
if self.include_lifecycle
else None
)
pix_lifecycle_rng = (
create_stream_rng(self.config.simulation.seed, "milestone-5:pix-lifecycle")
if self.include_lifecycle
else None
)
for payment, event in self.iter_generate(profiles):
if self.include_lifecycle and payment.payment_rail == "CARD":
assert lifecycle_rng is not None
payment, payment_events = self._card_lifecycle(payment, event, lifecycle_rng)
elif self.include_lifecycle and payment.payment_rail == "PIX":
assert pix_lifecycle_rng is not None
payment, payment_events = self._pix_lifecycle(payment, event, pix_lifecycle_rng)
else:
payment_events = (event,)
events.extend(payment_events)
if self.include_ledger:
ledger_specs.extend(self._ledger_specs(payment, payment_events))
payments.append(payment)
return PaymentDataset(
tuple(payments),
tuple(events),
self._materialize_ledger(ledger_specs) if self.include_ledger else (),
)