Engineering reference: the card fee auditor architecture
The same system with the service names filled in: what reads the statement, what joins it to the transactions, and where the two tables sit.
Key takeaways
- Single region, single account. Every resource is regional; nothing is global except the IAM roles.
- 4 Lambda functions, each with its own execution role. No shared role, no wildcards on resources.
- 2 DynamoDB tables, each keyed so the concurrency story is a condition expression rather than a lock.
- One Bedrock model, called once, with a JSON schema it must fill or leave null.
- Nothing always-on: no instance, no container, no provisioned capacity.
The system, by service name
- Compute
- Storage
- Database
- Security & identity
Region and account
- Region:
us-east-1. Chosen because SES inbound receipt rules exist in only a subset of regions and this one has the widest Bedrock model availability. If your data has to stay elsewhere, check both constraints before moving: inbound SES is the binding one. - Account: one. This is a small system, and a separate account per environment costs more in wiring than it saves. A
devand aprodstack in the same account, with distinct resource prefixes, is the right size here. - Everything is regional. The only global resources are the IAM roles and policies. There is no CloudFront, no global table and no cross-region replication, because nothing here has a latency or durability requirement that would justify them.
Lambda inventory
| Function | Trigger | Does | Timeout / memory |
|---|---|---|---|
cf-terms | S3 upload of a contract or rate change letter | One model call; writes the quoted rate, what it applies to, the pricing model and the notice period | 60s / 1024 MB |
cf-statement | S3 upload of a statement | One model call to itemised fees; classifies each as interchange, scheme or acquirer margin | 120s / 2048 MB |
cf-join | S3 upload of a transaction export | Parses CSV without a model, drops the PAN, joins fees to transactions, flags downgrades | 60s / 1024 MB |
cf-report | EventBridge, after the monthly close | Computes the effective rate and the margin, ranks downgrade causes, mails the three numbers | 60s / 512 MB |
Splitting this into separate functions is not about modularity. It is that only one of them needs Bedrock permissions and only one is reachable from the public internet, and neither of those is true if it is one handler behind a router.
IAM, scoped
| Role | Allowed | On |
|---|---|---|
cf-terms-role | bedrock:InvokeModel, s3:GetObject, dynamodb:PutItem | One model id; the contracts prefix; accounts |
cf-statement-role | bedrock:InvokeModel, s3:GetObject, dynamodb:PutItem | One model id; the statements prefix; fees |
cf-join-role | s3:GetObject, dynamodb:Query, dynamodb:UpdateItem | The exports prefix; both tables |
cf-report-role | dynamodb:Query, ses:SendEmail | Both tables; one verified identity |
No role has a Resource: “*” on anything that writes, and every GetSecretValue grant names a single secret arn. That is why there is more than one secret rather than one JSON blob with everything in it.
DynamoDB schemas
Table: accounts
PK account_id S
SK ’#terms’ S one item per merchant account
pricing_model S blended | ic_plus | ic_plus_plus
quoted_rate N the number on the front of the quote
quoted_applies_to S the card type and entry mode it assumes
auth_fee N per authorisation, charged whatever the outcome
monthly_fixed M terminal rental, PCI, gateway, minimum charge
notice_months N
notice_by S precomputed, because it is the only date that matters
quoted_applies_to is stored as text because the honest answer is
usually a sentence: consumer debit, chip and PIN, UK-issued.
Table: fees
PK account_id#period S
SK fee_code S one item per distinct fee on the statement
category S interchange | scheme | acquirer | fixed
basis S per_txn | pct_of_value | monthly | annual
amount N
txn_count N what it was charged on, where the statement says
value_charged N
matched_rate N from the contract, or null if the fee is not in it
downgrade_reason S corporate | international | keyed | data_missing | none
category is the field the whole page turns on: interchange cannot be
negotiated, acquirer margin can, and mixing them makes every
comparison meaningless.
Inbound and outbound
- Statements arrive by upload or by mailbox and both land in the same function. Nothing depends on an acquirer portal that could change or be withdrawn.
- Transaction exports are parsed, not read by a model. They are fixed-header CSV, they are large, and a model would be both slower and less accurate than a parser.
- The PAN is dropped at ingest. The card type and entry mode are what the analysis needs; keeping the number would put this system in PCI scope for no benefit.
- The report waits for the monthly close, because an effective rate computed on a partial month is the number that starts an argument you then lose.
The model call
- Two calls, both per document. One per contract or rate change letter, one per statement. Nothing per transaction.
- A capable model for statements, deliberately. These are the worst documents in the series — multi-column, abbreviated, often scanned — and cheap extraction produces confidently wrong fee bases.
- A JSON schema with nulls allowed. A fee the model cannot categorise becomes a question for a human rather than a guess, because guessing interchange as margin inverts the conclusion.
- The rate arithmetic is arithmetic. Total fees over total value, per category. No model is asked to compute or judge a percentage.
- Downgrade causes come from the transaction data, not from the model. The statement rarely says why; the card type and entry mode do.
Things worth knowing before you build it
- Compute the effective rate, never quote it. Total fees over total card turnover, including every monthly fixed cost, is the only figure that compares two providers.
- Separate interchange from acquirer margin before comparing anything. Interchange is set by the schemes and identical whoever you use; a provider discounting it is discounting nothing.
- Include authorisation fees on declined transactions. They are charged on the attempt, they do not appear against any sale, and on a high-decline book they are material.
- Read the minimum monthly service charge. On a quiet month it is the whole bill, and it is the fee most often left out of a comparison.
- Drop the card number at ingest. Nothing in this system needs it, and keeping it changes what compliance regime the system sits in.
That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.
All posts