Skip to content

VitalCard Rewards Engine

A rewards calculation engine for credit card transactions — built serverless-first on AWS Lambda, SQS, and DynamoDB, and designed around the assumption that things will fail mid-batch.

Browse the architecture View on GitHub


The problem

Rewards look like a simple multiplication problem — amount × rate = points — right up until you put them behind a real payment network.

Settlement files land in S3 in bursts of hundreds of thousands. Webhooks arrive out of order. Lambdas time out mid-batch and get retried. And unlike a cache miss or a dropped analytics event, a mistake here is financial: credit the same transaction twice and you have handed out points that a customer will spend and an accountant will eventually come asking about.

The rule engine in this project is deliberately boring — declarative JSON rules, four calculation types, dot-notation field paths. The interesting engineering is everywhere else: making sure each transaction is counted exactly once, at volume, while failures are happening.


What's actually interesting here

  • Exactly-once processing

    Not "at-least-once and hope." Every event gets a deterministic idempotency key derived from its own contents, checked before any work is done. Batch progress is checkpointed every 100 events, so a Lambda that dies 800 events into a 1,000-event batch resumes rather than restarts — and resuming can't double-credit the first 800.

    Exactly-once processing

  • Explicit SQS acknowledgment

    The obvious Lambda + SQS integration acknowledges messages implicitly when the handler returns — quietly deleting messages the handler never finished. This engine tracks receipt handles per message and acknowledges only the ones that provably succeeded, so partial batch failures retry the failures instead of losing them.

    Acknowledgment strategy

  • Batch-first data access

    Naive per-transaction processing costs one Lambda invocation and one DynamoDB round trip each. Batching metadata and rule loads collapses 1,000 transactions into a single invocation and roughly 10 database operations — about a 100× reduction in DynamoDB calls.

    Performance analysis

  • Failure modes, enumerated

    Lambda timeouts, memory exhaustion, cold starts, DynamoDB throttling, partial batch writes, network partitions — each documented with its blast radius, how you detect it in CloudWatch, and what the system does about it.

    Failure modes analysis


How it fits together

flowchart TB
    subgraph sources [Event sources]
        S3[S3 settlement files]
        API[Real-time webhooks]
        ML[ML enrichment pipeline]
    end

    subgraph ingest [Ingestion]
        LAM1[Lambda ingesters]
    end

    subgraph queue [Durable buffer]
        SQS[SQS + DLQ]
    end

    subgraph core [Core processing]
        RBP[Resilient batch processor<br/>idempotency + checkpointing]
        RULE[Rule engine]
    end

    subgraph store [DynamoDB]
        EV[(Events)]
        MD[(Metadata)]
        RL[(Rewards ledger)]
    end

    S3 --> LAM1
    API --> LAM1
    ML --> LAM1
    LAM1 --> SQS
    SQS --> RBP
    RBP --> RULE
    RULE --> RL
    RBP --> EV
    MD --> RULE

Every stage between the queue and the ledger is retry-safe. The idempotency check sits in front of the rule engine, so a redelivered SQS message costs a single DynamoDB read and then stops.


The rule engine

Rules are declarative JSON, versioned in DynamoDB, and evaluated against a merged view of the event, its enriched metadata, and computed fields.

{
  "type": "multiplier",
  "base_rate": 1.0,
  "multiplier": 5.0,
  "max_monthly_earn": 50000,
  "max_per_transaction": 5000
}
{
  "type": "tiered",
  "tiers": [
    {"min_amount": 0,   "max_amount": 100, "rate": 2.0},
    {"min_amount": 100, "max_amount": 500, "rate": 3.0},
    {"min_amount": 500, "rate": 5.0}
  ]
}
{
  "type": "bonus_points",
  "bonus_amount": 500,
  "once_per_month": true
}
{
  "type": "percentage",
  "percentage": 2.5,
  "max_monthly_earn": 10000
}

Conditions support comparison, set, string, regex, date, and logical operators, with dot-notation field paths that reach across the event and its metadata:

event.amount
event.metadata.location
metadata.user_metadata.BASE.tier
computed.is_weekend

Getting started

git clone https://github.com/louisalexander/rewards-engine.git
cd rewards-engine

pip install -r requirements.txt
cp .env.example .env

pytest tests/unit/ -v

For local development, run DynamoDB Local and seed sample data:

docker run -p 8000:8000 amazon/dynamodb-local
python scripts/seed_data.py

Status

Alpha — a reference implementation, not a deployed production system

The core rule engine, batch processors, and resilience machinery are implemented and unit-tested. Test coverage is still partial and the infrastructure-as-code layer is in progress.

Read this as a worked example of how to build correctness-critical event processing on serverless primitives — not as something to point at your card network on Monday.

Licensed under the MIT License.