Skip to content

Payment Deduplication

Detect when the same payment appears in multiple processors due to retry logic, reconciliation delays, or multi-gateway routing.

The Problem

Your company processes payments through Stripe, Square, and an internal accounting ledger. When a customer's payment fails on one gateway, it retries on another. When settlement takes time, the same transaction appears in your ledger on a different date. When gateways apply different rounding rules, amounts differ by fractions of a cent.

The result: your finance team reconciles payments manually each month, comparing spreadsheets line by line. A $1,247.50 payment from "Acme Corp" shows up in Stripe on January 15, in Square for $1,247.49 on January 16 (penny rounding plus a one-day settlement lag), and in your internal ledger on January 15. These are the same payment, but exact matching would never find it.

This use case demonstrates how range rules and composite rules handle the real-world data drift that makes payment deduplication difficult.

The Data

Stripe Payments

payment_idmerchantamountdatereferencemethodstatus
pay_3nK8mLqAcme Corp1247.502026-01-15INV-2026-0042cardsettled
pay_7pR2xYwBeta LLC500.002026-01-15INV-2026-0039cardsettled
pay_1mN4vBzGamma Inc89.992026-01-16INV-2026-0045cardsettled
pay_9aT6hDfDelta Co3200.002026-01-17INV-2026-0048achsettled
pay_5kW3jPcAcme Corp625.002026-01-18INV-2026-0051cardsettled
pay_2eL8nRsEpsilon Ltd150.752026-01-19INV-2026-0053cardsettled

Square Payments

transaction_idbusiness_nametotaltransaction_dateinvoice_refpayment_typestate
sq_tx_88214Acme Corp1247.492026-01-16INV-2026-0042creditcomplete
sq_tx_88301Beta LLC500.002026-01-15INV-2026-0039creditcomplete
sq_tx_88455Gamma Incorporated90.002026-01-17INV-2026-0045creditcomplete
sq_tx_88512Delta Company3200.002026-01-17INV-2026-0048achcomplete
sq_tx_88623Zeta Partners780.252026-01-19INV-2026-0055creditcomplete
sq_tx_88701Epsilon Ltd150.752026-01-20INV-2026-0053creditcomplete

Internal Ledger

entry_idvendoramount_usdposted_dateinvoicechannelreconciled
led_20260115_001Acme Corp1247.502026-01-15INV-2026-0042stripefalse
led_20260115_002Beta LLC500.002026-01-15INV-2026-0039stripefalse
led_20260116_001Gamma Inc89.992026-01-16INV-2026-0045stripefalse
led_20260117_001Delta Co3200.002026-01-17INV-2026-0048achfalse
led_20260118_001Acme Corp625.002026-01-18INV-2026-0051stripefalse
led_20260119_001Epsilon Ltd150.752026-01-19INV-2026-0053stripefalse

What Makes This Hard

ChallengeExampleImpact
Penny roundingStripe: $1,247.50 vs Square: $1,247.49Exact amount match fails
Settlement lagStripe: Jan 15 vs Square: Jan 16Exact date match fails
Name variations"Gamma Inc" vs "Gamma Incorporated"Exact merchant match fails
Gateway-only paymentsZeta Partners only in SquareShould not be flagged as duplicate
Different schemasamount vs total vs amount_usdFields have different names across sources

The Spec

yaml
api_version: kanoniv/v1
identity_version: "1.0"

entity:
  name: payment
  description: "Payment deduplication across Stripe, Square, and internal ledger"

sources:
  - name: ledger
    adapter: csv
    location: data/internal_ledger.csv
    primary_key: entry_id
    attributes:
      merchant: vendor
      amount: amount_usd
      date: posted_date
      reference: invoice
      method: channel
      reconciled: reconciled

  - name: stripe
    adapter: csv
    location: data/stripe_payments.csv
    primary_key: payment_id
    attributes:
      merchant: merchant
      amount: amount
      date: date
      reference: reference
      method: method
      status: status

  - name: square
    adapter: csv
    location: data/square_payments.csv
    primary_key: transaction_id
    attributes:
      merchant: business_name
      amount: total
      date: transaction_date
      reference: invoice_ref
      method: payment_type
      status: state

rules:
  # Invoice reference is the strongest signal. If two payments share
  # the same invoice number, they almost certainly represent the same
  # underlying transaction.
  - name: reference_exact
    type: exact
    field: reference
    weight: 1.0

  # Composite rule: amount must be close AND date must be close AND
  # merchant must match. All three conditions are required to prevent
  # false positives (e.g., two different $500 payments on the same day).
  - name: payment_composite
    type: composite
    operator: and
    children:
      # Amount tolerance of 5% handles penny rounding, currency
      # conversion differences, and minor fee adjustments.
      - name: amount_close
        type: range
        field: amount
        tolerance: 0.05
        weight: 0.5

      # Date tolerance of 2 days handles settlement lag, timezone
      # differences, and batch processing delays.
      - name: date_close
        type: range
        field: date
        tolerance: 2
        weight: 0.4

      # Merchant name fuzzy match catches abbreviations and
      # legal name variations ("Inc" vs "Incorporated").
      - name: merchant_fuzzy
        type: similarity
        field: merchant
        algorithm: jaro_winkler
        threshold: 0.85
        weight: 0.4

blocking:
  strategy: exact
  keys: [reference]

decision:
  scoring: weighted_sum
  thresholds:
    match: 0.9
    review: 0.7

survivorship:
  default: source_priority
  overrides:
    - field: amount
      strategy: aggregate
      function: max
    - field: date
      strategy: aggregate
      function: min

metadata:
  owner: [email protected]
  tags: [payment-dedup, finance, reconciliation]
  description: "Monthly payment deduplication across payment processors"

How the Rules Score Each Group

Group 1: Acme Corp $1,247.50 (INV-2026-0042)

Pairreference_exactamount_closedate_closemerchant_fuzzyCompositeTotalDecision
Stripe / Square1.00.5 (0.01% diff)0.4 (1 day)0.4 (exact)0.91.9Match
Stripe / Ledger1.00.5 (0% diff)0.4 (0 days)0.4 (exact)0.91.9Match
Square / Ledger1.00.5 (0.01% diff)0.4 (1 day)0.4 (exact)0.91.9Match

All three are the same payment. Cluster size: 3 records, 1 unique payment.

Group 2: Beta LLC $500.00 (INV-2026-0039)

Pairreference_exactamount_closedate_closemerchant_fuzzyCompositeTotalDecision
Stripe / Square1.00.5 (0% diff)0.4 (0 days)0.4 (exact)0.91.9Match
Stripe / Ledger1.00.5 (0% diff)0.4 (0 days)0.4 (exact)0.91.9Match

Cluster size: 3 records, 1 unique payment.

Group 3: Gamma $89.99 (INV-2026-0045)

Pairreference_exactamount_closedate_closemerchant_fuzzyCompositeTotalDecision
Stripe / Square1.00.5 (0.01% diff)0.4 (1 day)0.4 (0.86 JW)0.91.9Match
Stripe / Ledger1.00.5 (0% diff)0.4 (0 days)0.4 (exact)0.91.9Match

"Gamma Inc" vs "Gamma Incorporated" scores 0.86 in Jaro-Winkler, above the 0.85 threshold. Cluster size: 3.

Group 4: Delta $3,200.00 (INV-2026-0048)

Pairreference_exactamount_closedate_closemerchant_fuzzyCompositeTotalDecision
Stripe / Square1.00.5 (0% diff)0.4 (0 days)0.4 (0.87 JW)0.91.9Match
Stripe / Ledger1.00.5 (0% diff)0.4 (0 days)0.4 (exact)0.91.9Match

"Delta Co" vs "Delta Company" scores 0.87 in Jaro-Winkler. Cluster size: 3.

Singletons (no duplicates detected):

PaymentPresent InWhy No Match
Acme Corp $625.00 (INV-2026-0051)Stripe + Ledger onlyCluster of 2 (Stripe and Ledger are the same transaction)
Zeta Partners $780.25 (INV-2026-0055)Square onlyUnique to Square, no duplicate exists

The Result

Deduplication Summary

Sources:              3
Total records:        18
Clusters:             6
  - 3-record clusters: 4  (genuine triplicates across all 3 processors)
  - 2-record clusters: 1  (Stripe + Ledger, no Square duplicate)
  - Singletons:        1  (Zeta Partners, Square only)
Unique payments:      6
Duplicates removed:   12
Dedup rate:           66.7% (18 records → 6 unique payments)

Duplicate Groups

GroupMerchantAmountInvoiceRecordsSources
1Acme Corp$1,247.50INV-2026-00423Stripe, Square, Ledger
2Beta LLC$500.00INV-2026-00393Stripe, Square, Ledger
3Gamma Inc$89.99INV-2026-00453Stripe, Square, Ledger
4Delta Co$3,200.00INV-2026-00483Stripe, Square, Ledger
5Acme Corp$625.00INV-2026-00512Stripe, Ledger
6Zeta Partners$780.25INV-2026-00551Square

Golden Records

After survivorship, each cluster produces one canonical payment record. The ledger is the system of record (first in the sources list), with amount taking the max across sources and date taking the min (earliest posting date).

json
[
  {
    "canonical_id": "canon_pay_001",
    "canonical_data": {
      "merchant": "Acme Corp",
      "amount": 1247.50,
      "date": "2026-01-15",
      "reference": "INV-2026-0042",
      "method": "card",
      "reconciled": true
    },
    "cluster": { "size": 3, "sources": ["stripe", "square", "ledger"] }
  },
  {
    "canonical_id": "canon_pay_002",
    "canonical_data": {
      "merchant": "Beta LLC",
      "amount": 500.00,
      "date": "2026-01-15",
      "reference": "INV-2026-0039",
      "method": "card",
      "reconciled": true
    },
    "cluster": { "size": 3, "sources": ["stripe", "square", "ledger"] }
  },
  {
    "canonical_id": "canon_pay_003",
    "canonical_data": {
      "merchant": "Gamma Inc",
      "amount": 90.00,
      "date": "2026-01-16",
      "reference": "INV-2026-0045",
      "method": "card",
      "reconciled": true
    },
    "cluster": { "size": 3, "sources": ["stripe", "square", "ledger"] }
  },
  {
    "canonical_id": "canon_pay_004",
    "canonical_data": {
      "merchant": "Delta Co",
      "amount": 3200.00,
      "date": "2026-01-17",
      "reference": "INV-2026-0048",
      "method": "ach",
      "reconciled": true
    },
    "cluster": { "size": 3, "sources": ["stripe", "square", "ledger"] }
  },
  {
    "canonical_id": "canon_pay_005",
    "canonical_data": {
      "merchant": "Acme Corp",
      "amount": 625.00,
      "date": "2026-01-18",
      "reference": "INV-2026-0051",
      "method": "card",
      "reconciled": true
    },
    "cluster": { "size": 2, "sources": ["stripe", "ledger"] }
  },
  {
    "canonical_id": "canon_pay_006",
    "canonical_data": {
      "merchant": "Zeta Partners",
      "amount": 780.25,
      "date": "2026-01-19",
      "reference": "INV-2026-0055",
      "method": "credit",
      "reconciled": false
    },
    "cluster": { "size": 1, "sources": ["square"] }
  }
]

Key Takeaway

Range rules and composite rules handle real-world data drift. Exact matching would miss the Acme Corp payment entirely because the amount differs by $0.01 and the date differs by one day. Range rules with a 5% tolerance and 2-day window catch these near-matches, while the composite AND operator ensures all three signals (amount, date, merchant) must agree before declaring a duplicate.

The invoice reference serves as a "fast path": when it matches exactly, you know immediately. The composite rule is the "safety net" for cases where references are missing or inconsistent.

Variations to Consider

If payments lack invoice references, rely entirely on the composite rule:

yaml
rules:
  - name: payment_composite
    type: composite
    operator: and
    children:
      - name: amount_close
        type: range
        field: amount
        tolerance: 0.02    # tighter tolerance without reference anchor
        weight: 0.5
      - name: date_close
        type: range
        field: date
        tolerance: 1       # tighter window
        weight: 0.4
      - name: merchant_fuzzy
        type: similarity
        field: merchant
        algorithm: jaro_winkler
        threshold: 0.90    # stricter name match
        weight: 0.4

If you need a wider tolerance for international payments (currency conversion drift):

yaml
- name: amount_close
  type: range
  field: amount
  tolerance: 0.10    # 10% tolerance for FX drift
  weight: 0.5

If you want human review for borderline cases, lower the review threshold:

yaml
decision:
  thresholds:
    match: 0.9
    review: 0.5    # catch more borderline duplicates for manual review

The identity and delegation layer for AI agents.