← Projects

Casual Services Invoice Automation

What it is

A monthly job that turns the previous month's service dates into an invoice and a Gmail draft, without my computer being on. Preparing the draft is automated. Sending it is not - I review it and send it myself.

It is small on purpose: two sites, one PDF template, one invoice a month. The work went into the parts that are easy to get wrong - invoice numbering, retries against an API that can fail without saying whether it succeeded, and not creating the same draft twice.

Casual services invoice automation: a scheduled Lambda prepares an invoice and a Gmail draft, and a person sends it Two CloudFormation stacks in one AWS account. The storage stack holds a private S3 bucket, a DynamoDB ledger and a Secrets Manager secret, and is kept if the stack is deleted. The runtime stack holds EventBridge Scheduler, a Lambda function, an SNS topic, CloudWatch alarms and an SQS dead-letter queue. On day three of the month the scheduler invokes the Lambda. It reads the configuration and PDF template from S3, reserves an invoice number in a DynamoDB transaction, saves the PDF and the complete email to S3, writes a creating marker, and creates one Gmail draft. SNS emails the operator, who reads the draft, sends it by hand and confirms the send from a command line tool. The ledger moves through reserved, prepared, creating, done and sent confirmed. If the outcome of the Gmail request is unknown, a retry looks for the operation ID in the existing drafts and adopts a single match; with none or several it stops for a person. Billable days come from working weekdays, regional public holidays and overrides, and five fields of the PDF template are replaced. Casual services invoice automation A scheduled Lambda prepares the invoice and a Gmail draft - sending it stays a human action AWS account S3 bucket Config, template, PDF, saved email private · versioned · TLS only DynamoDB ledger Numbers, month state, alert claims on-demand · conditional writes Secrets Manager Gmail OAuth credentials refreshed and written back Ledger - one table, three rows counter highest reserved month#YYYY-MM number, phase event#<key> alert claim Counter and month row are written in one conditional transaction. A failed number is never reused. Storage stack - kept if deleted EventBridge Scheduler Monthly generate, daily monitor Australia/Melbourne Lambda function Calculates, renders, creates draft Python 3.12 · 512 MB · 180 s SNS topic Emails me when a draft is ready also alarms and reminders CloudWatch Logs kept 14 days, two alarms Lambda errors · DLQ messages SQS dead-letter queue Failed and exhausted invocations one retry · 1 h event age Runtime stack - replaceable Gmail API Creates and inspects drafts only no send call in the code Human review I read the draft and send it myself cloud_state.confirm_sent Outside AWS 1 2 3 4 5 6 unsent draft 7 sent confirmation, entered by me from the command line Invoice state in the ledger The stored phase decides what a repeated run is allowed to do. Reserved Number taken in one transaction counter + month Prepared PDF and email saved under operation ID frozen MIME in S3 Creating Marker saved before the Gmail request claim, then POST Done Draft ID recorded, ready alert sent awaiting_approval Sent confirmed Set only by me, not inferred from Gmail sent_confirmed outcome unknown exactly one draft found Reconcile before retrying A retry searches the drafts for the operation ID and adopts a single match. None or several: the job stops and waits for me. No blind second request. Billable days - how the amount is worked out Working weekdays set per site Public holidays regional, per year Leave and overrides site beats global Money Decimal, half-up to cents A year with no holiday calendar stops the run. It does not guess. PDF template - five fields replaced Invoice number reserved in the ledger Issue date generation date Two site amounts days × daily rate Total sum of both Nothing else on the page changes. Tests compare pixels outside the fields. Legend Compute Events and messaging Data Security Monitoring Outside AWS Invoice path State change Failure path or manual step Flow 1 EventBridge Scheduler invokes the Lambda on day three of the month at 21:00 Melbourne time. 2 It reads the configuration and PDF template from S3, works out the charges and reserves the invoice number. 3 The PDF and the complete email are saved to S3, then a creating marker is written to the ledger. 4 With OAuth credentials from Secrets Manager it creates one Gmail draft. That request is not retried automatically. 5 The draft ID is recorded and SNS emails me that the invoice is ready. 6 I read the draft in Gmail and send it myself. The code has no send path. 7 I confirm the send from the command line. Only then does the ledger move to sent_confirmed.
The monthly invoice run, step by step A vertical path of one monthly run. EventBridge Scheduler invokes a Lambda function on day three of the month. The Lambda works out the charges, reserves an invoice number in a DynamoDB transaction, saves the PDF and the complete email to S3 with a creating marker, and creates one Gmail draft. SNS emails the operator, who reads the draft and sends it by hand, then confirms the send from the command line so the ledger moves to sent confirmed. The monthly run Prepared on AWS, sent by me EventBridge Scheduler Day three of the month, 21:00 Australia/Melbourne 1 Lambda function Reads config, works out the charges Python 3.12 · 512 MB · 180 s 2 Invoice number reserved DynamoDB, one conditional transaction counter + month#YYYY-MM 3 PDF and email saved S3, then a creating marker in the ledger frozen MIME, operation ID 4 Gmail draft created One request, never retried blindly no send call in the code 5 SNS emails me Draft ID recorded, ready alert sent awaiting_approval 6 I read it and send it In Gmail, by hand the only step that issues it 7 Send confirmed Entered by me from the command line sent_confirmed Legend Compute Events and messaging Data Outside AWS Invoice path Manual step
Invoice state in the ledger, and the rules behind the amount The ledger moves an invoice through five states: reserved, prepared, creating, done and sent confirmed. The creating marker is written before the Gmail request. If the outcome of that request is unknown, a retry looks for the operation ID in the existing drafts: one match is adopted, none or several stops the job for a person. Sent confirmed is set only by the operator. Billable days come from each site's working weekdays, regional public holidays and overrides, with amounts in Decimal rounded half-up to cents. Five fields of the PDF template are replaced: invoice number, issue date, two site amounts and the total. Invoice state What a repeated run finds in the ledger Reserved Invoice number taken in one transaction counter + month row Prepared PDF and email saved under an operation ID frozen MIME in S3 Creating Marker written before the Gmail request claim, then POST Done Draft ID recorded, ready alert published awaiting_approval Sent confirmed Set only by me, never inferred from Gmail sent_confirmed If the Gmail outcome is unknown One matching draft adopt it, then done No match stop, wait for me Several matches stop, wait for me A retry never sends a blind second request. Billable days Working weekdays set per site Public holidays regional, per year Leave and overrides site beats global Money Decimal, half-up A year with no holiday calendar stops it. PDF template - five fields replaced Invoice number from the ledger Issue date generation date Two site amounts days × daily rate Total sum of both Nothing else on the page changes. Legend State change Manual step
A scheduled Lambda reserves the invoice number, saves the PDF and the email, and leaves one Gmail draft. I send it and confirm the send myself. If the Gmail request has an unknown outcome, a retry looks for the existing draft before doing anything else.

What I built

Decisions

The job creates a draft and stops. The code has no send path.

Rejected: Sending the invoice automatically once it's generated.

Preparing the invoice is the repetitive part. Issuing it is the part I want to stay answerable for, so that step stays manual. One limit: the Gmail compose permission would allow sending at Google's end, even though the code never calls it.

When a draft request has an uncertain outcome, the job stops and waits for me.

Rejected: Retrying the Gmail request automatically.

A blind retry can produce two drafts for the same invoice. I chose duplicate prevention over automatic recovery, and the cost is that some failures need me to step in.

Two CloudFormation stacks - one for stored data, one for the runtime.

Rejected: A single stack for everything.

The runtime can be replaced without resetting invoice numbering, and the bucket and table are retained if a stack is deleted.

The email is saved in full before the remote call and reused on retry.

Rejected: Rebuilding the email from current configuration on each attempt.

If a rate, an address or the template changes between attempts, the retry still carries the invoice that was originally prepared.

Stack

Python · AWS (Lambda, EventBridge Scheduler, DynamoDB, S3, Secrets Manager, SNS, SQS, CloudWatch) · CloudFormation · Gmail API

github.com/nalinka1/casual-invoice-automation