Skip to content

Part 7 of 7 · CIS deduction checker series ~7 min read

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

The CIS deduction checker drawn with AWS service namesThree boxes across the top sit outside the AWS account. An invoices mailbox, receiving through SES inbound with attachments. Verification results from HMRC's service, imported per payee. And the return and statements, for a person to file and to send to the subcontractors. Each connects to the AWS account container below. Inside are three components. S3 with EventBridge, holding mail and attachments and triggering the close on the 6th. Five Lambda functions covering read, verify, deduct, VAT and close. And three DynamoDB tables holding subcontractors, invoices and months. A note says eu-west-1, one account, invoices are read as they arrive, and the close on the 6th freezes the month into a version that is never edited.AWS ACCOUNTInvoices mailboxSES inbound, withattachmentsVerification resultsfrom HMRC's service,imported per payeeReturn and statementsfor a person to file,and to the subsS3 + EventBridgemail, attachments,the close on the 6thLambda x5read, verify, deduct,vat, closeDynamoDB x3subcontractors,invoices, monthsingroundsouteu-west-1. One account. Invoices are read as they arrive; the close on the 6th freezes the month into a version never edited.
Fig 1. The same shape as Part 1 with the service names filled in. Nothing here is new; it is the same three groups, named.
  • 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 dev and a prod stack 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

FunctionTriggerDoesTimeout / memory
cis-readS3 put, inbound mail prefixSplits an invoice into labour, materials, plant, VAT and wording; totals attached receipts90s / 1024MB
cis-verifyNew UTR seen, or a verification importDerives the tax-year window and writes verification rows as versions30s / 512MB
cis-deductStep after read and verifyComputes the base and the deduction, and sets the materials state30s / 512MB
cis-vatStep after readDecides the reverse-charge treatment and holds invoices charging VAT in error30s / 512MB
cis-closeEventBridge, 6th of each monthFreezes the tax month, builds the return or nil return, statements and the pack300s / 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

RoleAllowedOn
cis-read-rolebedrock:InvokeModel, s3:GetObject, dynamodb:PutItemOne model id; the inbound mail prefix; invoices
cis-verify-roles3:GetObject, dynamodb:Query, dynamodb:PutItemThe verification import prefix; subcontractors. No access to invoices
cis-deduct-roledynamodb:Query, dynamodb:UpdateItem, ses:SendEmailsubcontractors read; invoices write; one verified identity for receipt requests
cis-vat-roledynamodb:GetItem, dynamodb:UpdateItem, ses:SendEmailinvoices only; one verified identity for credit note requests
cis-close-roledynamodb:Query, dynamodb:PutItem, s3:PutObject, ses:SendEmailAll 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