The From Beneficial-Ownership Lists to Cypher: A Practical Knowledge-Graph Setup for Due Diligence article used a graph to model ownership, relatively static data, where the diagnostic question is path traversal under a regulatory threshold. Transaction graphs are different in three operational respects. They are high-velocity (hundreds of thousands of edges per quarter in a mid-size book of business). They are cycle-heavy by construction (legitimate trade relationships produce repeated bidirectional flows). And the diagnostic patterns the auditor or DD analyst cares about, round-tripping, layering, structuring, are not natural for SQL but are first-class citizens in Cypher.

This article walks the three transaction-graph anomaly patterns that earn their weight in production financial-audit and FA/FDD work. Round-tripping is cash that returns to its originator through one or more intermediaries, used to fabricate revenue or inflate transaction volume. Layering is the Anti-Money-Laundering pattern that moves funds through a chain of accounts and asset classes specifically to obscure the chain’s beginning from its end. The cycle-structure diagnostic is a population-level measure of how cyclic a transaction graph is relative to a clean baseline, a single number that triages where to look first across a large counterparty universe.

Each pattern is treated below: schema, Cypher idiom, mathematical underpinning, performance caveat, audit-defensibility framing, and where the approach falls down. The companion methodology articles in this sub-series extend the framework into related-party clustering (the forthcoming Community Detection for Related-Party Clusters article), temporal-graph patterns (the forthcoming Temporal-Graph Patterns for Ownership Lifecycle article), and securities-side wash-sale and layering detection (the forthcoming Graph-Based Wash-Sale and Layering Detection article).

Schema design for transaction graphs

A transaction graph differs from an ownership graph in the edge model. Counterparties and accounts are nodes; transfers are edges with three operationally load-bearing properties: amount, timestamp, and currency (plus optional metadata like instrument type, settlement system, or correspondent-bank pathway). The control graph, which accounts are controlled by which counterparties, is a separate layer connected to the transaction graph through IN_ACCOUNT and CONTROLLED_BY relationships.

// Schema setup — constraints and indexes for transaction graphs
CREATE CONSTRAINT counterparty_uid IF NOT EXISTS
  FOR (c:Counterparty) REQUIRE c.uid IS UNIQUE;

CREATE CONSTRAINT account_uid IF NOT EXISTS
  FOR (a:Account) REQUIRE a.uid IS UNIQUE;

CREATE INDEX transferred_at IF NOT EXISTS
  FOR ()-[r:TRANSFERRED]-() ON (r.transferred_at);

CREATE INDEX transferred_amount IF NOT EXISTS
  FOR ()-[r:TRANSFERRED]-() ON (r.amount_usd);

CREATE INDEX counterparty_country IF NOT EXISTS
  FOR (c:Counterparty) ON (c.country_code);

The uid uniqueness constraints play the same load-bearing role as in the From Beneficial-Ownership Lists to Cypher: A Practical Knowledge-Graph Setup for Due Diligence article, they anchor variable-length traversals at deterministic starting nodes. The transferred_at and transferred_amount relationship-property indexes are not optional in this sub-series; cycle-detection queries filter on time windows and materiality thresholds, and the indexes are what make those filters performant at production scale.

APOC dependency. The queries below use apoc.coll.toSet for the path-node-uniqueness idiom. APOC (Awesome Procedures On Cypher) is a standard Neo4j extension shipped pre-installed on Neo4j AuraDB and bundled with Neo4j Enterprise; on Neo4j Community Edition it requires explicit installation via the plugins/ directory. The version compatibility expected for this article’s queries is APOC 5.x against Neo4j 5.x. For deployments without APOC available, the native-Cypher alternative WHERE all(idx IN range(0, size(nodes(cycle))-2) WHERE all(jdx IN range(idx+1, size(nodes(cycle))-1) WHERE nodes(cycle)[idx] <> nodes(cycle)[jdx])) produces the same uniqueness filter at a modest readability cost. The article uses the APOC idiom in the worked example for clarity.

For this article’s worked example, the synthetic-data generator below produces a representative 5,000-counterparty network with 50,000 transfer edges over a 90-day window. The fixed seed ensures every reader can reproduce the same graph and verify the same anomaly-detection results.

# synthetic_transactions_generator.py — produces transactions_synthetic.csv
import csv
import random
from datetime import datetime, timedelta

random.seed(42)

COUNTERPARTY_COUNT = 5000
TRANSACTION_COUNT = 50000
PERIOD_START = datetime(2025, 1, 1)
PERIOD_END = datetime(2025, 3, 31)
PERIOD_DAYS = (PERIOD_END - PERIOD_START).days

ROUND_TRIPPING_SEEDS = [
    # (cycle_origin_uid, cycle_length, total_volume_usd, span_days)
    ('CP-0042', 3, 250000, 14),
    ('CP-0117', 4, 480000, 28),
    ('CP-0093', 2, 150000, 7),
]

rows = []
seen_timestamps = set()

def unique_ts(dt):
    """Ensure each timestamp is distinct (microsecond-resolution offsets on collisions)
    so that loaded :TRANSFERRED edges have a strict total order for chronological queries."""
    while dt in seen_timestamps:
        dt = dt + timedelta(microseconds=1)
    seen_timestamps.add(dt)
    return dt

# Baseline transactions: random pairs over the period
for _ in range(TRANSACTION_COUNT - sum(s[1] for s in ROUND_TRIPPING_SEEDS)):
    src = f'CP-{random.randint(1, COUNTERPARTY_COUNT):04d}'
    dst = f'CP-{random.randint(1, COUNTERPARTY_COUNT):04d}'
    while dst == src:
        dst = f'CP-{random.randint(1, COUNTERPARTY_COUNT):04d}'
    ts = unique_ts(PERIOD_START + timedelta(
        days=random.randint(0, PERIOD_DAYS - 1),
        hours=random.randint(0, 23),
        minutes=random.randint(0, 59),
        seconds=random.randint(0, 59)))
    rows.append({
        'src_uid': src,
        'dst_uid': dst,
        'amount_usd': round(random.lognormvariate(8.5, 1.5), 2),
        'transferred_at': ts.isoformat(),
        'currency': 'USD',
    })

# Inject seeded round-tripping cycles — each cycle's hops are chronologically ordered along the path
for origin, length, total_vol, span in ROUND_TRIPPING_SEEDS:
    intermediates = random.sample([f'CP-{i:04d}' for i in range(1, COUNTERPARTY_COUNT + 1)
                                    if f'CP-{i:04d}' != origin], length - 1)
    path = [origin] + intermediates + [origin]
    start_day = random.randint(0, PERIOD_DAYS - span)
    per_hop_volume = total_vol / length
    for i in range(length):
        ts = unique_ts(PERIOD_START + timedelta(
            days=start_day + (span * i // length),
            hours=random.randint(0, 23),
            minutes=random.randint(0, 59),
            seconds=random.randint(0, 59)))
        rows.append({
            'src_uid': path[i],
            'dst_uid': path[i+1],
            'amount_usd': round(per_hop_volume + random.uniform(-100, 100), 2),
            'transferred_at': ts.isoformat(),
            'currency': 'USD',
        })

random.shuffle(rows)
with open('transactions_synthetic.csv', 'w', newline='') as f:
    writer = csv.DictWriter(f, fieldnames=rows[0].keys())
    writer.writeheader()
    writer.writerows(rows)

print(f'Wrote {len(rows)} transfers; seeded round-trip origins: {[s[0] for s in ROUND_TRIPPING_SEEDS]}')
// Pre-load cleanup (only required on re-runs against the same database)
// Drop all existing :TRANSFERRED edges to avoid duplicate-event accumulation
MATCH ()-[r:TRANSFERRED]->() DELETE r;

// Load transactions into the graph
LOAD CSV WITH HEADERS FROM 'file:///transactions_synthetic.csv' AS row
MERGE (src:Counterparty {uid: row.src_uid})
MERGE (dst:Counterparty {uid: row.dst_uid})
CREATE (src)-[r:TRANSFERRED {
  amount_usd: toFloat(row.amount_usd),
  transferred_at: datetime(row.transferred_at),
  currency: row.currency
}]->(dst);

The use of CREATE rather than MERGE for the relationship is deliberate, distinct transactions between the same counterparties are distinct events, and MERGE would collapse them. The pre-load DELETE step makes the load idempotent against repeated runs; without it, re-running the loader against the same database would accumulate duplicate edges. The Python generator’s unique_ts helper enforces distinct timestamps so that chronological-ordering predicates in the layering query below are unambiguous.

Round-tripping detection

A round-trip is a closed path in the transaction graph: a payment from a counterparty $v_0$ that returns to $v_0$ through some sequence of intermediates $v_1, v_2, \ldots, v_{n-1}$, with all hops occurring inside a bounded time window. Formally, a round-tripping pattern of length $n$ is a sequence of edges $(v_0, v_1), (v_1, v_2), \ldots, (v_{n-1}, v_0)$ where:

$$\max_i t_i – \min_i t_i \le T$$

for some time-window threshold $T$ chosen by the engagement (typically 30 to 90 days for ordinary commercial round-tripping investigations; tighter for AML-investigative work). The cycle volume, the sum of edge amounts, must exceed a materiality threshold $M$ chosen by the audit team.

// Q1: Round-tripping detection within a 90-day window, materiality $100K USD
:param window_start => datetime('2025-01-01');
:param window_end   => datetime('2025-03-31');
:param max_span_days => 90;
:param materiality_usd => 100000;

MATCH cycle = (origin:Counterparty)-[:TRANSFERRED*2..5]->(origin)
WHERE
  ALL(r IN relationships(cycle) WHERE r.transferred_at >= $window_start
      AND r.transferred_at <= $window_end)
  AND size(apoc.coll.toSet(nodes(cycle))) = size(nodes(cycle)) - 1
WITH origin, cycle,
     reduce(total = 0.0, r IN relationships(cycle) | total + r.amount_usd) AS cycle_volume,
     length(cycle) AS hop_count,
     [r IN relationships(cycle) | r.transferred_at] AS timing
WITH origin, cycle_volume, hop_count, timing,
     duration.inDays(reduce(t_min = timing[0], t IN timing | CASE WHEN t < t_min THEN t ELSE t_min END),
                     reduce(t_max = timing[0], t IN timing | CASE WHEN t > t_max THEN t ELSE t_max END)).days AS span_days
WHERE span_days <= $max_span_days
  AND cycle_volume >= $materiality_usd
RETURN origin.uid AS originating_counterparty,
       hop_count,
       round(cycle_volume, 2) AS cycle_volume_usd,
       span_days,
       timing
ORDER BY cycle_volume_usd DESC
LIMIT 100;

Three design choices in this query matter. The *2..5 variable-length-path bound covers virtually all simple round-tripping patterns; cycles longer than 5 typically indicate either complex layering (covered below) or legitimate trade-relationship structure that the diagnostic should not flag. The path-node-uniqueness filter, size(apoc.coll.toSet(nodes(cycle))) = size(nodes(cycle)) - 1 (subtracting one because the cycle’s start and end node are the same by construction), prevents the traversal from revisiting intermediate nodes, which would inflate cycle counts on dense subgraphs. The duration.inDays computation against reduce(...) aggregates over the timing array to extract the actual cycle span; the simpler approach of subtracting the first and last edges’ timestamps is wrong because edges in the cycle path are not guaranteed to be in chronological order.

The query parameterizes the time window, the max span, and the materiality threshold so the same query body adapts to different engagement contexts without code changes.

Layering detection

Layering, under the FATF (2018) Concealment of Beneficial Ownership typology, moves funds through a chain of accounts and intermediaries to obscure the chain’s origin from its destination. Two diagnostic features distinguish layering from ordinary multi-hop transfer activity. First, the chain is longer than commercial reality requires, five or more hops between source and destination where a direct transfer was available. Second, the inter-hop time intervals are short, funds move through the chain in days or hours, rather than the weeks-to-months that legitimate intermediate settlement would imply.

// Q2: Layering candidates — long chains with chronological ordering and tight inter-hop timing
:param window_start => datetime('2025-01-01');
:param window_end   => datetime('2025-03-31');
:param min_chain_length => 5;
:param max_inter_hop_hours => 72;
:param materiality_usd => 100000;

MATCH chain = (source:Counterparty)-[:TRANSFERRED*5..10]->(destination:Counterparty)
WHERE source <> destination
  AND ALL(r IN relationships(chain) WHERE r.transferred_at >= $window_start
          AND r.transferred_at <= $window_end)
  AND size(apoc.coll.toSet(nodes(chain))) = size(nodes(chain))  // no revisits
WITH source, destination, chain,
     [r IN relationships(chain) | r.transferred_at] AS timing,
     [r IN relationships(chain) | r.amount_usd] AS amounts
// Chronological-ordering predicate: every consecutive hop must be later than the previous one.
// Without this, the "max inter-hop interval" calculation is meaningless: the path order returned
// by the variable-length traversal does not encode temporal direction.
WHERE all(i IN range(0, size(timing) - 2) WHERE timing[i] < timing[i+1])
WITH source, destination, chain, timing, amounts,
     reduce(max_gap = 0, i IN range(0, size(timing) - 2) |
            CASE WHEN duration.inHours(timing[i], timing[i+1]).hours > max_gap
                 THEN duration.inHours(timing[i], timing[i+1]).hours
                 ELSE max_gap END) AS max_inter_hop_hours_observed,
     reduce(min_amt = amounts[0], a IN amounts | CASE WHEN a < min_amt THEN a ELSE min_amt END) AS min_amount
WHERE max_inter_hop_hours_observed <= $max_inter_hop_hours
  AND min_amount >= $materiality_usd
RETURN source.uid AS source_counterparty,
       destination.uid AS destination_counterparty,
       length(chain) AS hop_count,
       max_inter_hop_hours_observed,
       round(min_amount, 2) AS min_hop_amount_usd
ORDER BY hop_count DESC, max_inter_hop_hours_observed ASC
LIMIT 100;

The query returns the source-destination pairs whose chain exhibits the length signature and the chronological-monotonicity property and the tight-timing signature. The chronological predicate timing[i] < timing[i+1] is load-bearing, without it, the variable-length traversal returns paths in arbitrary node order, and the "max inter-hop interval" calculation does not measure what its name suggests. The minimum-hop-amount filter excludes chains where the value drops below materiality at any intermediate point (a sign that the chain is not the layering of a single material transfer but the unrelated merging of small ones).

The cycle-structure diagnostic

The two pattern-specific queries above answer the question "show me suspicious individual cycles or chains." The cycle-structure diagnostic answers a different question: "is this transaction graph more cyclic than a clean baseline?" The diagnostic produces a single scalar, the proportion of total transaction volume that participates in at least one cycle of bounded length and bounded time-span, that triages where to investigate first across a large counterparty universe.

The participation semantics matter. Let $E$ be the set of all TRANSFERRED edges in the window. Let $E_{\text{cycle}} \subseteq E$ be the set of edges that appear in at least one cycle of length $\le 5$ whose hop timestamps fit within a $\le 90$-day span. Each edge is counted at most once in $E_{\text{cycle}}$ regardless of how many distinct cycles it participates in. The diagnostic is then:

$$\text{cycle\_share} = \frac{\sum_{e \in E_{\text{cycle}}} a(e)}{\sum_{e \in E} a(e)}$$

where $a(e)$ is the USD amount of edge $e$. Reported as a percentage, this becomes \$100 \cdot \text{cycle\_share}$. The set-semantics matter: summing cycle volumes across multiple matches would double-count edges that appear in multiple cycles and would not satisfy the "at least one" framing the diagnostic claims.

// Q3: Cycle-structure diagnostic — distinct edges participating in any cycle ≤ 5 hops, ≤ 90 days
:param window_start => datetime('2025-01-01');
:param window_end   => datetime('2025-03-31');

// Step 1: identify the set of edges that participate in at least one bounded cycle
MATCH cycle = (origin:Counterparty)-[:TRANSFERRED*2..5]->(origin)
WHERE
  ALL(r IN relationships(cycle) WHERE r.transferred_at >= $window_start
      AND r.transferred_at <= $window_end)
  AND size(apoc.coll.toSet(nodes(cycle))) = size(nodes(cycle)) - 1
UNWIND relationships(cycle) AS cycle_edge
WITH collect(DISTINCT id(cycle_edge)) AS cycle_edge_ids

// Step 2: sum volume across the deduplicated participating-edge set
MATCH ()-[r:TRANSFERRED]->()
WHERE r.transferred_at >= $window_start AND r.transferred_at <= $window_end
WITH cycle_edge_ids,
     sum(CASE WHEN id(r) IN cycle_edge_ids THEN r.amount_usd ELSE 0.0 END) AS cycle_participating_volume,
     sum(r.amount_usd) AS total_volume

RETURN round(cycle_participating_volume, 2) AS cycle_volume_usd,
       round(total_volume, 2) AS total_volume_usd,
       round(cycle_participating_volume / total_volume * 100, 4) AS cycle_share_pct;

The two-step structure is what makes the participation semantics correct. Step 1 collects the distinct edge IDs of all edges that appear in any bounded cycle (the DISTINCT ensures an edge appearing in multiple cycles contributes its ID once). Step 2 then sums the amounts across that deduplicated set against the total window volume. Without the deduplication, an edge that participates in three overlapping cycles would have its amount summed three times, inflating the diagnostic.

For a clean transaction graph at typical mid-size-bank scale, the cycle-share-pct figure tends to be a fraction of one percent. Round-tripping perturbs this materially, a clean diagnostic baseline of 0.3% can move to 1.5%, 4% when even a handful of round-tripping schemes are seeded. The diagnostic value is in the comparison to a baseline, not the absolute number; the baseline is either the prior period's diagnostic, a peer-entity diagnostic, or, for the most conservative DD framing, a Monte Carlo random-graph null computed from the same node-and-edge degree distributions.

Formally, the Monte Carlo null is constructed by drawing $N$ random graphs from the configuration model conditioned on the observed degree sequence (Newman, 2003), computing the cycle-share statistic on each, and forming the null distribution from those $N$ values. The observed cycle-share is then compared to the quantiles of the null, a value above the 99th percentile of the null is a strong signal of structural anomaly relative to the underlying activity pattern, independent of any specific cycle the auditor's investigators might or might not surface manually. The Monte Carlo framing requires substantially more compute than the prior-period or peer-entity baseline; it is the most defensible choice for high-stakes engagements but is not a routine production step.

Note that this query, like the queries in the From Beneficial-Ownership Lists to Cypher: A Practical Knowledge-Graph Setup for Due Diligence article, returns volume figures in dollars and the share value as a percentage (via the * 100 factor) for workpaper-readability; the underlying math operates in fractions of total volume.

Performance and audit-defensibility

Variable-length-path queries on transaction graphs are the canonical Cypher performance hotspot, the same caveat from the From Beneficial-Ownership Lists to Cypher: A Practical Knowledge-Graph Setup for Due Diligence article's beneficial-ownership setup applies, with additional considerations specific to this sub-series.

Index strategy. The transferred_at and transferred_amount relationship-property indexes are not optional. Without them, the time-window and materiality predicates require full edge scans. With them, the planner narrows the search before expanding the variable-length path. The forthcoming Production-Scale DD Graph Operations article covers production tuning in detail; the operational rule for this sub-series is: declare both indexes during schema setup and confirm they appear in the query plan via PROFILE before deploying any of the queries above against a production graph.

Path-node-uniqueness enforcement. Real-world transaction graphs contain cycles by design (commercial counterparties trade back and forth, treasury operations net positions through intermediaries, settlement systems route through correspondent-bank chains). Without an explicit uniqueness filter, variable-length-path queries on transaction graphs hit catastrophic expansion. The size(apoc.coll.toSet(nodes(cycle))) = size(nodes(cycle)) [- 1] idiom used above is one solution; the alternative is to use the modern Cypher syntax that requires distinct nodes by construction, where available in the Neo4j version deployed.

Do not substitute shortest-path procedures for cycle detection. Shortest-path procedures optimize for path length and answer connectivity questions (is there a path from $A$ to $B$, how long is the shortest one). The diagnostic questions in this article are different, round-tripping detection asks whether a path that starts and ends at the same node exists and exceeds a volume threshold; layering detection asks whether long paths with tight inter-hop timing exist between source-destination pairs. Shortest-path procedures address neither problem regardless of how the graph is structured.

Audit-evidence framing. The output of every query above is a candidate detection, not a determination. Under PCAOB AS 2401 (Consideration of Fraud) and AS 2410 (Related Parties), the auditor's responsibility is to investigate flagged transactions and reach an evidence-supported conclusion about whether the flag reflects an actual anomaly, a benign business pattern, or a data-quality artifact. Round-tripping flagged by Q1 may be legitimate netting between affiliates; layering flagged by Q2 may be ordinary correspondent-banking routing; an elevated cycle-share-pct from Q3 may reflect a legitimate seasonal trade pattern. The Cypher idioms above produce ranked queues; investigator judgment, supported by source documents, counterparty interviews, and engagement-specific business context, produces conclusions.

The audit-evidence and controls discipline from the From Beneficial-Ownership Lists to Cypher: A Practical Knowledge-Graph Setup for Due Diligence article, source-data lineage on every node and edge, ingestion-batch identifiers, query-result archival, applies identically to transaction graphs and is, if anything, more important when the queries flag material financial activity. The transaction-graph workpaper template extends the ownership-graph template with three additional fields: query parameters used (time window, materiality, max-span), the baseline used for the cycle-structure diagnostic comparison, and the investigator's resolution notes for each candidate detection. The article series companion repository will provide the extended template when it is published.

Where the approach falls short

Three failure modes are worth flagging explicitly.

Materiality-fragmentation evasion. A round-tripping scheme that fragments its cycle hops below the per-engagement materiality threshold will evade Q1. The conservative DD framing is to run Q1 at multiple thresholds (e.g., materiality at 50%, 75%, and 100% of the planning-materiality figure) and to compare the resulting cycle inventories, schemes structured around a known threshold typically produce step-function changes in the inventory across thresholds.

Time-spreading evasion. A round-trip that spans more than the time-window threshold (90 days in the worked example) will not show up in Q1. The forthcoming Temporal-Graph Patterns for Ownership Lifecycle article (temporal-graph patterns) extends the framework to multi-period cycle detection that handles longer time horizons; the forthcoming Graph-Based Wash-Sale and Layering Detection article (graph-based wash-sale and layering detection in securities) handles the related securities-trading patterns where the time window is structured by regulatory definitions rather than engagement choice.

Population-level baseline drift. The cycle-structure diagnostic depends on a meaningful baseline. A baseline period that itself contains undetected round-tripping under-states the diagnostic; a baseline drawn from a different business cycle (e.g., comparing a recession quarter to a growth quarter) may over-state it. The conservative approach is to use the Monte Carlo random-graph null as the cleanest baseline available, with the prior-period and peer-entity baselines used as triangulation rather than the single source of comparison.

Next in the sub-series

The Schema Design for Sanctions Screening: Modeling the OFAC SDN List as a Knowledge Graph for Real-Time DD Lookups article takes the same graph apparatus to sanctions screening, the OFAC SDN-list schema design that supports real-time counterparty lookups against tens of thousands of sanctioned entries with transliteration awareness and composite-scored matching. The forthcoming Random Walks, PageRank, and Personalized PageRank for Cascade-Exposure Ranking article extends today's cycle-volume diagnostic with Personalized PageRank for indirect-exposure ranking through cascade structures. The forthcoming Graph-Based Wash-Sale and Layering Detection article carries the time-windowed pattern into securities-transaction graphs where the wash-sale and layering signatures are defined by IRC §1091, FATF typologies, and FINRA market-surveillance frameworks rather than the open-ended commercial-round-tripping framing of this article.


Authority:

Graph database foundations and Cypher idioms:

  • Robinson, I., Webber, J., & Eifrem, E. (2015). Graph Databases (2nd ed.). O'Reilly.
  • Neo4j Cypher Manual (current release), variable-length paths, apoc.coll.toSet, duration.inHours/duration.inDays, PROFILE query-planning.
  • Neo4j APOC documentation (current release), apoc.coll.toSet and related collection procedures.
  • Holme, P., & Saramäki, J. (2012). "Temporal Networks." Physics Reports, 519(3), 97-125.
  • Newman, M.E.J. (2003). "The Structure and Function of Complex Networks." SIAM Review, 45(2), 167-256. (Configuration model and degree-preserving random graphs for the Monte Carlo null.)

Anti-Money-Laundering typology and AML/CFT framework:

  • FATF. (2018). Concealment of Beneficial Ownership. (Authoritative typology framework for layering patterns.)
  • FATF. (2009). Money Laundering through the Football Sector. (Case-study source for layering-typology language; the article uses synthetic data, not real cases.)
  • FinCEN. (2014). Guidance on Risk-Based Approach to Combating Money Laundering and Terrorist Financing.
  • FFIEC. BSA/AML Examination Manual, Customer Due Diligence and Transaction Monitoring sections.

Audit standards (U.S. PCAOB framework):

  • PCAOB AS 2401, Consideration of Fraud in a Financial Statement Audit (journal-entry testing, including §60 to 67 on populating-population analytical procedures).
  • PCAOB AS 2410, Related Parties.
  • PCAOB AS 2305, Substantive Analytical Procedures.

Reproducible code: Companion notebook and synthetic dataset will be published with the article series repository. Until the repository is live, the synthetic-data generator and Cypher queries in this article are self-contained and reproducible from the source text alone.