diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 689de49..ad3cc80 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,6 +1,8 @@ FROM library/python:3.12-bullseye # Personal access token 'mist _runner' ENV GITHUB_PAT=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 +ENV TERRAFORM_VERSION=1.6.6 + ARG USER=vscode ARG DEBIAN_FRONTEND=noninteractive @@ -37,15 +39,12 @@ RUN ./aws/install # Install terraform RUN apt-get update && sudo apt-get install -y gnupg software-properties-common -RUN wget -O- https://apt.releases.hashicorp.com/gpg | \ -gpg --dearmor | \ -sudo tee /usr/share/keyrings/hashicorp-archive-keyring.gpg > /dev/null -RUN echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] \ -https://apt.releases.hashicorp.com $(lsb_release -cs) main" | \ -tee /etc/apt/sources.list.d/hashicorp.list -RUN apt update -RUN apt-get install terraform -RUN terraform -install-autocomplete +RUN wget https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_amd64.zip \ + && unzip terraform_${TERRAFORM_VERSION}_linux_amd64.zip \ + && mv terraform /usr/local/bin/terraform \ + && rm terraform_${TERRAFORM_VERSION}_linux_amd64.zip + +RUN terraform version # Set the working directory diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 2352c9c..d42940f 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -29,7 +29,8 @@ "4ops.terraform", "fabiospampinato.vscode-todo-plus", "jgclark.vscode-todo-highlight", - "corentinartaud.pdfpreview" + "corentinartaud.pdfpreview", + "github.vscode-github-actions" ] } } diff --git a/.github/workflows/ses-juntekim.yml b/.github/workflows/ses-juntekim.yml new file mode 100644 index 0000000..7359b04 --- /dev/null +++ b/.github/workflows/ses-juntekim.yml @@ -0,0 +1,67 @@ +name: SES - juntekim.com [Simple Email Service] + +on: + pull_request: + + push: + branches: + - main + paths: + - "aws_environment/ses-juntekim/**" + + workflow_dispatch: + +env: + TF_VERSION: "1.6.6" + WORKING_DIR: "aws_environment/ses-juntekim" + +jobs: + terraform: + name: Terraform SES + runs-on: mealcraft-runners + + permissions: + contents: read + pull-requests: write + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Install modern Node.js + run: | + curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - + sudo apt-get install -y nodejs + node --version + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: ${{ env.TF_VERSION }} + + - name: Install AWS CLI + run: | + sudo apt-get update + sudo apt-get install -y awscli + + # Optional but recommended once + - name: Verify AWS identity + run: aws sts get-caller-identity + + - name: Terraform Init + working-directory: ${{ env.WORKING_DIR }} + run: terraform init + + - name: Terraform Validate + working-directory: ${{ env.WORKING_DIR }} + run: terraform validate + + - name: Terraform Plan + if: github.event_name == 'pull_request' + working-directory: ${{ env.WORKING_DIR }} + run: terraform plan -input=false + + - name: Terraform Apply + if: github.ref == 'refs/heads/main' + working-directory: ${{ env.WORKING_DIR }} + run: terraform apply -auto-approve -input=false diff --git a/aws_environment/ses-juntekim/README.md b/aws_environment/ses-juntekim/README.md new file mode 100644 index 0000000..a763929 --- /dev/null +++ b/aws_environment/ses-juntekim/README.md @@ -0,0 +1 @@ +Files to set up a simple email service in AWS \ No newline at end of file diff --git a/aws_environment/ses-juntekim/backend.tf b/aws_environment/ses-juntekim/backend.tf new file mode 100644 index 0000000..fa1dbb1 --- /dev/null +++ b/aws_environment/ses-juntekim/backend.tf @@ -0,0 +1,9 @@ +terraform { + backend "s3" { + bucket = "juntekim-terraform-state" + key = "ses/terraform.tfstate" + region = "eu-west-2" + dynamodb_table = "terraform-locks" + encrypt = true + } +} diff --git a/aws_environment/ses-juntekim/iam.tf b/aws_environment/ses-juntekim/iam.tf new file mode 100644 index 0000000..5418c6a --- /dev/null +++ b/aws_environment/ses-juntekim/iam.tf @@ -0,0 +1,25 @@ +resource "aws_iam_user" "ses_smtp" { + name = "ses-smtp-${replace(var.email_domain, ".", "-")}" +} + +resource "aws_iam_user_policy" "ses_policy" { + user = aws_iam_user.ses_smtp.name + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "ses:SendEmail", + "ses:SendRawEmail" + ] + Resource = "*" + } + ] + }) +} + +resource "aws_iam_access_key" "ses_smtp" { + user = aws_iam_user.ses_smtp.name +} diff --git a/aws_environment/ses-juntekim/outputs.tf b/aws_environment/ses-juntekim/outputs.tf new file mode 100644 index 0000000..0e503aa --- /dev/null +++ b/aws_environment/ses-juntekim/outputs.tf @@ -0,0 +1,30 @@ +output "domain_verification_record" { + value = { + name = "_amazonses.${var.email_domain}" + type = "TXT" + value = aws_ses_domain_identity.this.verification_token + } +} + +output "dkim_records" { + value = [ + for token in aws_ses_domain_dkim.this.dkim_tokens : { + name = "${token}._domainkey.${var.email_domain}" + type = "CNAME" + value = "${token}.dkim.amazonses.com" + } + ] +} + +output "mail_from_domain" { + value = aws_ses_domain_mail_from.this.mail_from_domain +} + +output "smtp_username" { + value = aws_iam_access_key.ses_smtp.id +} + +output "smtp_secret" { + value = aws_iam_access_key.ses_smtp.secret + sensitive = true +} diff --git a/aws_environment/ses-juntekim/provider.tf b/aws_environment/ses-juntekim/provider.tf new file mode 100644 index 0000000..523bc37 --- /dev/null +++ b/aws_environment/ses-juntekim/provider.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.5" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +provider "aws" { + region = var.aws_region +} \ No newline at end of file diff --git a/aws_environment/ses-juntekim/ses.tf b/aws_environment/ses-juntekim/ses.tf new file mode 100644 index 0000000..fa3de7c --- /dev/null +++ b/aws_environment/ses-juntekim/ses.tf @@ -0,0 +1,12 @@ +resource "aws_ses_domain_identity" "this" { + domain = var.email_domain +} + +resource "aws_ses_domain_dkim" "this" { + domain = aws_ses_domain_identity.this.domain +} + +resource "aws_ses_domain_mail_from" "this" { + domain = aws_ses_domain_identity.this.domain + mail_from_domain = "${var.mail_from_subdomain}.${var.email_domain}" +} diff --git a/aws_environment/ses-juntekim/terraform.tfvars b/aws_environment/ses-juntekim/terraform.tfvars new file mode 100644 index 0000000..bb5eb1d --- /dev/null +++ b/aws_environment/ses-juntekim/terraform.tfvars @@ -0,0 +1 @@ +email_domain = "juntekim.com" diff --git a/aws_environment/ses-juntekim/variables.tf b/aws_environment/ses-juntekim/variables.tf new file mode 100644 index 0000000..b2fb037 --- /dev/null +++ b/aws_environment/ses-juntekim/variables.tf @@ -0,0 +1,14 @@ +variable "aws_region" { + type = string + default = "eu-west-2" +} + +variable "email_domain" { + type = string + description = "Domain used for SES email sending" +} + +variable "mail_from_subdomain" { + type = string + default = "mail" +} \ No newline at end of file diff --git a/db/atlas/stripe_invoice/add_new_migration.sh b/db/atlas/stripe_invoice/add_new_migration.sh new file mode 100644 index 0000000..5d623b5 --- /dev/null +++ b/db/atlas/stripe_invoice/add_new_migration.sh @@ -0,0 +1 @@ +atlas migrate new add_used_at_to_login_tokens diff --git a/db/atlas/stripe_invoice/migrations/20251228182659_add_used_at_to_login_tokens.sql b/db/atlas/stripe_invoice/migrations/20251228182659_add_used_at_to_login_tokens.sql new file mode 100644 index 0000000..8717f7c --- /dev/null +++ b/db/atlas/stripe_invoice/migrations/20251228182659_add_used_at_to_login_tokens.sql @@ -0,0 +1,6 @@ +ALTER TABLE login_tokens +ADD COLUMN IF NOT EXISTS used_at TIMESTAMPTZ; + +CREATE INDEX IF NOT EXISTS idx_login_tokens_unused +ON login_tokens (id) +WHERE used_at IS NULL; diff --git a/db/atlas/stripe_invoice/migrations/atlas.sum b/db/atlas/stripe_invoice/migrations/atlas.sum index 37a363f..227351f 100644 --- a/db/atlas/stripe_invoice/migrations/atlas.sum +++ b/db/atlas/stripe_invoice/migrations/atlas.sum @@ -1,5 +1,6 @@ -h1:ELwFHTBDb63mdRBhmjXMMSpy05pUSVxH03zuUuHYAto= +h1:dTHZRXvfJ8E0dSqq2PAuMLfFFRSDvt3OzgJKEGeXz2g= 0001_init.sql h1:gzb02ZbjrrJkXOC+2qIZsngnj7A+29O2/b4awScPlPs= 0002_auth.sql h1:4NhBu26dIBMy9gxMxM3tf6Z2CS2kfKlGjFBj07T/aBw= 0003_stripe_xero.sql h1:E2bcdUDnondsXwbdIwVlZqR4DQwzcoDiyeRFJwVxXwg= 0004_login_tokens.sql h1:rj1KcWu/0znh2YvtI7JV8Z2nwtL5rZzONbPwX1P+/PI= +20251228182659_add_used_at_to_login_tokens.sql h1:/0puYQvwBFzpfSKjiZj2XR/7Mui39lS/IbFZW1TPQOc= diff --git a/stripe_to_invoice/app/page.tsx b/stripe_to_invoice/app/page.tsx index 931e83d..d68bb80 100644 --- a/stripe_to_invoice/app/page.tsx +++ b/stripe_to_invoice/app/page.tsx @@ -1,57 +1,251 @@ // app/page.tsx +// This page doubles as: +// 1. A landing page +// 2. A product spec +// 3. A reminder to future-me what the hell I was building +// +// If you’re reading this months later: hi 👋 +// The product is the automation, not the UI. export default function Home() { return ( -
+
- {/* What this is */} + {/* -------------------------------------------------- + Intro + -------------------------------------------------- */}

Stripe → Xero automation

+

Automatically create and mark Xero invoices as paid when a Stripe payment succeeds. - +
Built for people who value time more than pressing buttons.

- {/* Steps */} + {/* -------------------------------------------------- + High-level flow (human readable) + -------------------------------------------------- */}
-

How it works

+

How it works (high level)

+
    -
  1. Log in ( Set up magic link, db has been set up)
  2. -
  3. Connect Stripe
  4. -
  5. Connect Xero
  6. -
  7. Make a payment
  8. -
  9. Invoice appears in Xero as paid
  10. +
  11. Log in via magic link (passwordless)
  12. +
  13. Connect your Stripe account
  14. +
  15. Connect your Xero organisation
  16. +
  17. A Stripe payment succeeds
  18. +
  19. An invoice appears in Xero as paid
- {/* Proof */} + {/* -------------------------------------------------- + Magic link auth – detailed flow + -------------------------------------------------- */}
-

Proof, not promises

+

Login flow (magic link)

+

- Your next Stripe payment will automatically reconcile in Xero. - No manual matching. No “awaiting payment”. + Authentication is passwordless. We only store intent and proof of login. +

+ + {/* Text-based flow diagram (easy to read + copy) */} +
+{`Browser
+  |
+  | POST /auth/login (email)
+  v
+Backend
+  - find or create user
+  - generate token
+  - hash token
+  - store login_tokens row
+  - send email (SES)
+  |
+  v
+Email (magic link)
+  |
+  | GET /auth/callback?token=XYZ
+  v
+Backend
+  - hash token
+  - validate token (unused + not expired)
+  - mark token as used
+  - create session
+  |
+  v
+Set session cookie
+`}
+        
+ + {/* Step-by-step breakdown */} +
    +
  1. + User enters their email address. +
  2. + +
  3. + Backend creates (or finds) a user record and stores a one-time login token + in login_tokens. +
  4. + +
  5. + An email is sent containing a short-lived magic link. +
  6. + +
  7. + When the link is clicked, the token is validated, marked as used, + and a session is created. +
  8. + +
  9. + A secure session cookie is set. No passwords. No OAuth popups. +
  10. +
+
+ + {/* -------------------------------------------------- + Stripe → Xero automation flow + -------------------------------------------------- */} +
+

Stripe → Xero automation flow

+ +
+{`Stripe payment succeeds
+  |
+  | Webhook
+  v
+Backend
+  - verify Stripe event
+  - map payment to customer
+  - create Xero invoice
+  - mark invoice as paid
+  |
+  v
+Xero (reconciled automatically)
+`}
+        
+ +

+ Once connected, everything runs automatically. + No manual reconciliation. No “awaiting payment” state.

- {/* Pricing */} + {/* -------------------------------------------------- + Proof + -------------------------------------------------- */} +
+

Proof, not promises

+ +

+ Your next Stripe payment will automatically reconcile in Xero. +
+ No manual matching. No bookkeeping busywork. +

+
+ + {/* -------------------------------------------------- + Pricing + -------------------------------------------------- */}

Pricing

+

£200 / month — unlimited invoices.

- {/* CTA */} + {/* -------------------------------------------------- + Footer / reminder + -------------------------------------------------- */}

- This page is a placeholder. The product is the automation. + This page is intentionally simple. +
+ The product is the automation, not the UI.

+ +
+

Implementation notes (for future me)

+ +

+ These are the only docs needed to implement magic-link auth with Next.js + AWS SES. +

+ + +
+
) }