Engineering reference: the CIS deduction checker architecture
The same system with the service names filled in: what reads an invoice, what keeps the verification window, what calculates the base and checks the reverse charge, and what closes the month.
Key takeaways
- Single region, single account. Every resource is regional; nothing is global except the IAM roles.
- 5 Lambda functions, each with its own execution role. No shared role, no wildcards on resources.
- 3 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
- App integration
- Security & identity
- Analytics
Region and account
- Region:
eu-west-1. Ireland, because subcontractor invoices arrive by email and SES inbound receipt rules are available here, and it is close to the UK contractors this is for. Nothing in CIS requires the data to sit in the UK. Bedrock model availability is checked here rather than assumed; the invoice read is the only step that depends on it. - 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 |
|---|---|---|---|
cis-read | S3 put, inbound mail prefix | Splits an invoice into labour, materials, plant, VAT and wording; totals attached receipts | 90s / 1024MB |
cis-verify | New UTR seen, or a verification import | Derives the tax-year window and writes verification rows as versions | 30s / 512MB |
cis-deduct | Step after read and verify | Computes the base and the deduction, and sets the materials state | 30s / 512MB |
cis-vat | Step after read | Decides the reverse-charge treatment and holds invoices charging VAT in error | 30s / 512MB |
cis-close | EventBridge, 6th of each month | Freezes the tax month, builds the return or nil return, statements and the pack | 300s / 2048MB |
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 |
|---|---|---|
cis-read-role | bedrock:InvokeModel, s3:GetObject, dynamodb:PutItem | One model id; the inbound mail prefix; invoices |
cis-verify-role | s3:GetObject, dynamodb:Query, dynamodb:PutItem | The verification import prefix; subcontractors. No access to invoices |
cis-deduct-role | dynamodb:Query, dynamodb:UpdateItem, ses:SendEmail | subcontractors read; invoices write; one verified identity for receipt requests |
cis-vat-role | dynamodb:GetItem, dynamodb:UpdateItem, ses:SendEmail | invoices only; one verified identity for credit note requests |
cis-close-role | dynamodb:Query, dynamodb:PutItem, s3:PutObject, ses:SendEmail | All three tables read; months write; the returns prefix write-once; 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: subcontractors
PK org_id#utr S
SK effective_from S append-only; a change notice is a new row
name_as_verified S exactly as submitted to HMRC
trading_names SS seen on invoices since
treatment S gross | standard | higher
rate N the percentage on this date
verification_number S including any letters for unmatched
source S verification | carried_over | change_notice
verified_on S
last_return_tax_year S e.g. 2025-26; the window derives from it
vat_registered BOOL
status_considered_on S the employment status decision, dated
effective_from is the sort key because a change notice names a date and
payments either side of it take different rates. Overwriting the rate
would make every payment before the notice look wrongly deducted, and
nobody could say afterwards which rate had been in force when.
Table: invoices
PK org_id#utr S
SK received_on#invoice_no S
content_hash S duplicate test, before the read
lines L [{kind, amount, text}]
gross_ex_vat N
allowed_costs N materials, consumables, plant, fuel
materials_state S evidenced | estimated | unsupported
materials_basis S receipt keys, or the estimate reason
base N gross_ex_vat less allowed_costs
rate N
deduction N
verification_sk S the subcontractors row it used
vat_treatment S reverse_charge | standard |
zero_rated | not_registered
held_reason S set when the VAT or wording is wrong
tax_month S from the payment date, not the invoice
verification_sk pins the deduction to the verification row that was in
force, so a later status change can never silently re-rate a payment
already made. tax_month comes from the date of payment, because the
return reports payments: an invoice dated 28 August and paid on 8
September belongs to the tax month ending 5 October.
Table: months
PK org_id#tax_month S e.g. 2026-09-05, the month’s last day
SK version#utr S version 1 is never edited
gross N payments excluding VAT
materials N
deducted N
verification_number S printed on the statement if unmatched
invoice_keys L the invoice rows this line summed
statement_key S the statement sent, and when
kind S return | nil_return
state S draft | frozen | filed | superseded
correction_reason S set on version 2 onwards
invoice_keys is what makes the compliance trace in part 5 a query. A
return line can say GBP 524 deducted, but only the keys lead back to the
invoice, the base, the receipts and the verification row behind it, and
without them the trail stops at the total.
Inbound and outbound
- Invoices arrive by email at a dedicated address on an SES receipt rule, and the raw message lands in S3 with its attachments. A content hash is tested before the read, so a subcontractor who sends the same invoice twice costs nothing.
- Verification results are imported, not fetched. The verification is made through HMRC’s online service or the contractor’s CIS software, and its result — treatment, verification number and date — arrives as a structured file written as a versioned row.
- Queries go out through SES. A request for receipts, a request for a credit note, and the monthly statement, each from one verified identity and each recorded against the invoice or month it concerns.
- Nothing is filed automatically. The close produces the return figures, the statements and the amount to pay; a person files and pays, because the return carries declarations and a declaration is somebody’s decision.
The model call
- One call per invoice. Attached receipts go into the same call and only their totals come back. Nothing per subcontractor, per month or per return.
- A mid-tier model. Sorting a few invoice lines into labour, materials, plant, consumables, fuel and travel is classification with messy formatting, not reasoning.
- It classifies; it never calculates. Lines come back with their printed amounts and a kind. The base, the deduction and the VAT are computed in code, because a model that rounds a subtraction produces a CIS error with no trail.
- Lump sums stay lumps. A single figure covering labour and materials comes back as one line of kind mixed, which routes to a query. The prompt forbids estimating a split, because a guessed split is one of the two errors part 3 is about.
- Absent means null. No UTR printed, no reverse-charge wording, no VAT number: each comes back null rather than inferred from the supplier’s name. A plausible UTR is worse than a missing one.
Things worth knowing before you build it
- Apply the rate to the right base. The CIS errors that cost a contractor real money in this design come from the materials line, not the percentage.
- Store the materials state with every figure. Estimates are allowed; an estimate nobody checked is what HMRC can seek from the contractor.
- Compute the verification window in tax years, not months. It jumps every 6 April, and the subcontractors who fall out of it are the occasional ones whose details changed.
- Version the verification record by effective date. A change notice applies after its date, and an overwritten rate re-rates every payment before it.
- Treat a VAT line on a subcontractor invoice as a question. For a contractor making onward supplies the reverse charge nearly always applies, and VAT charged in error is not something to pay and reclaim.
- Produce a nil return in a month with no payments, or record that HMRC was told in advance. Since 6 April 2026 the requirement is back, and a close that outputs nothing is a penalty.
That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.
All posts