From 5e43d985c1d836e0777c02c05b1cdeb8361a995d Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 11 Jan 2026 12:01:24 +0000 Subject: [PATCH 01/59] save this to stripe work as well --- .../app/Youtube/my_editing_setup/page.tsx | 14 ++++++++++++++ .../app/Youtube/my_editing_setup/script.md | 1 + .../app/Youtube/my_new_dev_setup/page.tsx | 14 ++++++++++++++ .../app/Youtube/my_new_dev_setup/script.md | 2 ++ 4 files changed, 31 insertions(+) create mode 100644 juntekim_frontend/app/Youtube/my_editing_setup/page.tsx create mode 100644 juntekim_frontend/app/Youtube/my_editing_setup/script.md create mode 100644 juntekim_frontend/app/Youtube/my_new_dev_setup/page.tsx create mode 100644 juntekim_frontend/app/Youtube/my_new_dev_setup/script.md diff --git a/juntekim_frontend/app/Youtube/my_editing_setup/page.tsx b/juntekim_frontend/app/Youtube/my_editing_setup/page.tsx new file mode 100644 index 0000000..1bbf3ca --- /dev/null +++ b/juntekim_frontend/app/Youtube/my_editing_setup/page.tsx @@ -0,0 +1,14 @@ +import fs from "fs"; +import path from "path"; +import MarkdownRenderer from "../../components/MardownRenderer"; + +export default function YoutubePage() { + const filePath = path.join(process.cwd(), "app/Youtube/robot_vaccume_only_starts_when_I_leave_the_house/script.md"); + const markdown = fs.readFileSync(filePath, "utf8"); + + return ( +
+ +
+ ); +} \ No newline at end of file diff --git a/juntekim_frontend/app/Youtube/my_editing_setup/script.md b/juntekim_frontend/app/Youtube/my_editing_setup/script.md new file mode 100644 index 0000000..4617f08 --- /dev/null +++ b/juntekim_frontend/app/Youtube/my_editing_setup/script.md @@ -0,0 +1 @@ +my editing_set_up requires one windows computer so i can carry on using one desk \ No newline at end of file diff --git a/juntekim_frontend/app/Youtube/my_new_dev_setup/page.tsx b/juntekim_frontend/app/Youtube/my_new_dev_setup/page.tsx new file mode 100644 index 0000000..1bbf3ca --- /dev/null +++ b/juntekim_frontend/app/Youtube/my_new_dev_setup/page.tsx @@ -0,0 +1,14 @@ +import fs from "fs"; +import path from "path"; +import MarkdownRenderer from "../../components/MardownRenderer"; + +export default function YoutubePage() { + const filePath = path.join(process.cwd(), "app/Youtube/robot_vaccume_only_starts_when_I_leave_the_house/script.md"); + const markdown = fs.readFileSync(filePath, "utf8"); + + return ( +
+ +
+ ); +} \ No newline at end of file diff --git a/juntekim_frontend/app/Youtube/my_new_dev_setup/script.md b/juntekim_frontend/app/Youtube/my_new_dev_setup/script.md new file mode 100644 index 0000000..85e896e --- /dev/null +++ b/juntekim_frontend/app/Youtube/my_new_dev_setup/script.md @@ -0,0 +1,2 @@ +Write about my dev set up that uses ubunutu as the devcontainer, i just ssh + From 8c238f0efb111d15589b162549089d4db1dde60e Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 10:53:33 +0000 Subject: [PATCH 02/59] save --- .devcontainer/post-install.sh | 1 - .vscode/settings.json | 8 +------- juntekim_frontend/app/Learning/Todo.md | 7 +++++++ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.devcontainer/post-install.sh b/.devcontainer/post-install.sh index 68ef990..e69de29 100644 --- a/.devcontainer/post-install.sh +++ b/.devcontainer/post-install.sh @@ -1 +0,0 @@ -# Place holder \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index fc7cd3e..f8f2387 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -21,13 +21,7 @@ "": false, "": false, "": false - }, + } - // Terminal copy/paste via Ctrl+Shift+C / Ctrl+Shift+V -/* "terminal.integrated.copyOnSelection": false, - "terminal.integrated.commandsToSkipShell": [ - "workbench.action.terminal.copySelection", - "workbench.action.terminal.paste" - ], */ } \ No newline at end of file diff --git a/juntekim_frontend/app/Learning/Todo.md b/juntekim_frontend/app/Learning/Todo.md index 2ba8347..a02110f 100644 --- a/juntekim_frontend/app/Learning/Todo.md +++ b/juntekim_frontend/app/Learning/Todo.md @@ -1,3 +1,10 @@ +- Pandas + +- Setting up a nas computer DIY + https://www.youtube.com/watch?v=8_1OBOeuBsA + + + - Next.js Pages Router Tutorial — - Next.js Dashboard App Tutorial — - MDN: Using Promises — From b652fcb97b9c417cee3d536ddb6e969890cdda90 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 11:15:52 +0000 Subject: [PATCH 03/59] lets have a go --- .github/workflows/deploy-postgres-dev.yml | 96 ------------- .github/workflows/deploy-postgres-prod.yml | 82 ----------- .github/workflows/stripe-to-invoice.yml | 137 ++++++++++--------- stripe_to_invoice/deployment/deployment.yaml | 2 +- 4 files changed, 70 insertions(+), 247 deletions(-) delete mode 100644 .github/workflows/deploy-postgres-dev.yml delete mode 100644 .github/workflows/deploy-postgres-prod.yml diff --git a/.github/workflows/deploy-postgres-dev.yml b/.github/workflows/deploy-postgres-dev.yml deleted file mode 100644 index 1ba1ca2..0000000 --- a/.github/workflows/deploy-postgres-dev.yml +++ /dev/null @@ -1,96 +0,0 @@ -name: Deploy DEV DB Infrastructure - -on: - push: - branches: - - "feature/*" -jobs: - deploy: - runs-on: mealcraft-runners - - steps: - - uses: actions/checkout@v4 - - - name: Install kubectl - run: | - sudo apt-get update - sudo apt-get install -y curl ca-certificates - curl -LO "https://dl.k8s.io/release/$(curl -sL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" - sudo install -m 0755 kubectl /usr/local/bin/kubectl - - - name: Configure kubeconfig (in-cluster) - run: | - KUBE_HOST="https://$KUBERNETES_SERVICE_HOST:$KUBERNETES_SERVICE_PORT" - SA_TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token) - CA_CERT=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt - NAMESPACE=$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace) - - kubectl config set-cluster microk8s --server="$KUBE_HOST" --certificate-authority="$CA_CERT" - kubectl config set-credentials runner --token="$SA_TOKEN" - kubectl config set-context runner-context --cluster=microk8s --user=runner --namespace="$NAMESPACE" - kubectl config use-context runner-context - - - name: Deploy DEV Postgres - run: kubectl apply -f db/k8s/postgres/postgres-dev-stripe-to-invoice.yaml - - migrate: - runs-on: mealcraft-runners - needs: deploy - - steps: - - uses: actions/checkout@v4 - - - name: Install kubectl - run: | - sudo apt-get update - sudo apt-get install -y curl ca-certificates - curl -LO "https://dl.k8s.io/release/$(curl -sL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" - sudo install -m 0755 kubectl /usr/local/bin/kubectl - - - name: Configure kubeconfig (in-cluster) - run: | - KUBE_HOST="https://$KUBERNETES_SERVICE_HOST:$KUBERNETES_SERVICE_PORT" - SA_TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token) - CA_CERT=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt - NAMESPACE=$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace) - - kubectl config set-cluster microk8s --server="$KUBE_HOST" --certificate-authority="$CA_CERT" - kubectl config set-credentials runner --token="$SA_TOKEN" - kubectl config set-context runner-context --cluster=microk8s --user=runner --namespace="$NAMESPACE" - kubectl config use-context runner-context - - - name: Install Atlas - run: curl -sSf https://atlasgo.sh | sh - - - name: Load DEV DB creds - run: | - DB_NAMESPACE=dev - SECRET_NAME=postgres-secret - - POSTGRES_USER=$(kubectl get secret $SECRET_NAME \ - --namespace $DB_NAMESPACE \ - -o jsonpath='{.data.POSTGRES_USER}' | base64 -d) - - POSTGRES_PASSWORD=$(kubectl get secret $SECRET_NAME \ - --namespace $DB_NAMESPACE \ - -o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d) - - POSTGRES_DB=$(kubectl get secret $SECRET_NAME \ - --namespace $DB_NAMESPACE \ - -o jsonpath='{.data.POSTGRES_DB}' | base64 -d) - - POSTGRES_HOST=postgres-dev.stripe-invoice-dev.svc.cluster.local - POSTGRES_PORT=5432 - - DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}?sslmode=disable" - - echo "POSTGRES_USER=$POSTGRES_USER" >> $GITHUB_ENV - echo "POSTGRES_PASSWORD=$POSTGRES_PASSWORD" >> $GITHUB_ENV - echo "POSTGRES_DB=$POSTGRES_DB" >> $GITHUB_ENV - echo "DATABASE_URL=$DATABASE_URL" >> $GITHUB_ENV - - - name: Run Atlas migrations (DEV) - run: | - atlas migrate apply \ - --config file://./db/atlas/atlas.hcl \ - --env stripe_invoice_dev diff --git a/.github/workflows/deploy-postgres-prod.yml b/.github/workflows/deploy-postgres-prod.yml deleted file mode 100644 index 67d9be9..0000000 --- a/.github/workflows/deploy-postgres-prod.yml +++ /dev/null @@ -1,82 +0,0 @@ -name: Deploy PROD DB Infrastructure - -on: - push: - branches: - - main - workflow_dispatch: - -jobs: - deploy: - runs-on: mealcraft-runners - - steps: - - uses: actions/checkout@v4 - - - name: Install kubectl - run: | - sudo apt-get update - sudo apt-get install -y curl ca-certificates - curl -LO "https://dl.k8s.io/release/$(curl -sL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" - sudo install -m 0755 kubectl /usr/local/bin/kubectl - - - name: Configure kubeconfig (in-cluster) - run: | - KUBE_HOST="https://$KUBERNETES_SERVICE_HOST:$KUBERNETES_SERVICE_PORT" - SA_TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token) - CA_CERT=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt - NAMESPACE=$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace) - - kubectl config set-cluster microk8s --server="$KUBE_HOST" --certificate-authority="$CA_CERT" - kubectl config set-credentials runner --token="$SA_TOKEN" - kubectl config set-context runner-context --cluster=microk8s --user=runner --namespace="$NAMESPACE" - kubectl config use-context runner-context - - - name: Deploy PROD Postgres - run: kubectl apply -f db/k8s/postgres/ - - # - name: Deploy PROD backups - # run: kubectl apply -f db/k8s/backups/ - - migrate: - runs-on: mealcraft-runners - needs: deploy - - steps: - - uses: actions/checkout@v4 - - - name: Install kubectl - run: | - sudo apt-get update - sudo apt-get install -y curl ca-certificates - curl -LO "https://dl.k8s.io/release/$(curl -sL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" - sudo install -m 0755 kubectl /usr/local/bin/kubectl - - - name: Configure kubeconfig (in-cluster) - run: | - KUBE_HOST="https://$KUBERNETES_SERVICE_HOST:$KUBERNETES_SERVICE_PORT" - SA_TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token) - CA_CERT=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt - NAMESPACE=$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace) - - kubectl config set-cluster microk8s --server="$KUBE_HOST" --certificate-authority="$CA_CERT" - kubectl config set-credentials runner --token="$SA_TOKEN" - kubectl config set-context runner-context --cluster=microk8s --user=runner --namespace="$NAMESPACE" - kubectl config use-context runner-context - - - name: Install Atlas - run: curl -sSf https://atlasgo.sh | sh - - - name: Load PROD DB creds - run: | - export POSTGRES_USER=$(kubectl get secret postgres-prod-secret -o jsonpath='{.data.POSTGRES_USER}' | base64 -d) - export POSTGRES_PASSWORD=$(kubectl get secret postgres-prod-secret -o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d) - - echo "POSTGRES_USER=$POSTGRES_USER" >> $GITHUB_ENV - echo "POSTGRES_PASSWORD=$POSTGRES_PASSWORD" >> $GITHUB_ENV - - - name: Run Atlas migrations (PROD) - run: | - atlas migrate apply \ - --config file://./db/atlas/atlas.hcl \ - --env stripe_invoice_prod diff --git a/.github/workflows/stripe-to-invoice.yml b/.github/workflows/stripe-to-invoice.yml index 3cd01c0..83d074d 100644 --- a/.github/workflows/stripe-to-invoice.yml +++ b/.github/workflows/stripe-to-invoice.yml @@ -1,81 +1,82 @@ -# name: Build & Deploy stripe-to-invoice +name: Build & Deploy stripe-to-invoice -# on: -# push: -# branches: -# - main -# - feature/** -# - release/** -# tags: -# - "*" +on: + push: + branches: + - main + - feature/** + - release/** + tags: + - "*" -# jobs: -# build: -# runs-on: ubuntu-22.04 -# steps: -# - uses: actions/checkout@v4 +jobs: + build: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 -# - name: Inject slug variables -# uses: rlespinasse/github-slug-action@v4 + - name: Inject slug variables + uses: rlespinasse/github-slug-action@v4 -# - name: Login to Docker Hub -# uses: docker/login-action@v3 -# with: -# username: ${{ secrets.DOCKER_HUB_USERNAME }} -# password: ${{ secrets.DOCKER_HUB_TOKEN }} + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_TOKEN }} -# - name: Build image -# run: | -# docker build \ -# -f stripe_to_invoice/deployment/Dockerfile \ -# -t docker.io/kimjunte/stripe_to_invoice:$GITHUB_REF_SLUG \ -# . + - name: Build image + run: | + docker build \ + -f stripe_to_invoice/deployment/Dockerfile \ + -t docker.io/kimjunte/stripe_to_invoice:$GITHUB_REF_SLUG \ + . -# - name: Push image -# run: | -# docker push docker.io/kimjunte/stripe_to_invoice:$GITHUB_REF_SLUG + - name: Push image + run: | + docker push docker.io/kimjunte/stripe_to_invoice:$GITHUB_REF_SLUG -# deploy: -# runs-on: mealcraft-runners -# needs: build + deploy: + runs-on: mealcraft-runners + needs: build -# steps: -# - uses: actions/checkout@v4 + steps: + - uses: actions/checkout@v4 -# - name: Install kubectl -# run: | -# sudo apt-get update -# sudo apt-get install -y curl ca-certificates gettext -# curl -LO "https://dl.k8s.io/release/$(curl -sL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" -# sudo install -m 0755 kubectl /usr/local/bin/kubectl + - name: Install kubectl + run: | + sudo apt-get update + sudo apt-get install -y curl ca-certificates gettext + curl -LO "https://dl.k8s.io/release/$(curl -sL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" + sudo install -m 0755 kubectl /usr/local/bin/kubectl -# - name: Configure kubeconfig -# run: | -# KUBE_HOST="https://$KUBERNETES_SERVICE_HOST:$KUBERNETES_SERVICE_PORT" -# SA_TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token) -# CA_CERT=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt -# NAMESPACE=$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace) + - name: Configure kubeconfig + run: | + KUBE_HOST="https://$KUBERNETES_SERVICE_HOST:$KUBERNETES_SERVICE_PORT" + SA_TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token) + CA_CERT=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt + NAMESPACE=$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace) -# kubectl config set-cluster microk8s --server="$KUBE_HOST" --certificate-authority="$CA_CERT" -# kubectl config set-credentials runner --token="$SA_TOKEN" -# kubectl config set-context runner-context --cluster=microk8s --user=runner --namespace="$NAMESPACE" -# kubectl config use-context runner-context + kubectl config set-cluster microk8s --server="$KUBE_HOST" --certificate-authority="$CA_CERT" + kubectl config set-credentials runner --token="$SA_TOKEN" + kubectl config set-context runner-context --cluster=microk8s --user=runner --namespace="$NAMESPACE" + kubectl config use-context runner-context -# - name: Inject slug variables -# uses: rlespinasse/github-slug-action@v4 + - name: Inject slug variables + uses: rlespinasse/github-slug-action@v4 -# - name: Set environment -# run: | -# if [[ "$GITHUB_REF" == refs/heads/release/* || "$GITHUB_REF" == refs/tags/* ]]; then -# echo "NAMESPACE=default" >> $GITHUB_ENV -# echo "DB_ENV=prod" >> $GITHUB_ENV -# else -# echo "NAMESPACE=dev" >> $GITHUB_ENV -# echo "DB_ENV=dev" >> $GITHUB_ENV -# fi - -# - name: Deploy -# run: | -# export IMAGE="docker.io/kimjunte/stripe_to_invoice:$GITHUB_REF_SLUG" -# export NAMESPACE DB_ENV -# envsubst < stripe_to_invoice/deployment/deployment.yaml | kubectl apply -f - + - name: Set environment + run: | + if [[ "$GITHUB_REF" == refs/heads/release/* || "$GITHUB_REF" == refs/tags/* ]]; then + echo "NAMESPACE=default" >> $GITHUB_ENV + echo "DB_ENV=prod" >> $GITHUB_ENV + echo "HOSTNAME=stripe-to-invoice.juntekim.com" >> $GITHUB_ENV + else + echo "NAMESPACE=dev" >> $GITHUB_ENV + echo "DB_ENV=dev" >> $GITHUB_ENV + echo "HOSTNAME=stripe-to-invoice.dev.juntekim.com" >> $GITHUB_ENV + fi + - name: Deploy + run: | + export IMAGE="docker.io/kimjunte/stripe_to_invoice:$GITHUB_REF_SLUG" + export NAMESPACE DB_ENV + envsubst < stripe_to_invoice/deployment/deployment.yaml | kubectl apply -f - diff --git a/stripe_to_invoice/deployment/deployment.yaml b/stripe_to_invoice/deployment/deployment.yaml index 3544346..56b5257 100644 --- a/stripe_to_invoice/deployment/deployment.yaml +++ b/stripe_to_invoice/deployment/deployment.yaml @@ -24,7 +24,7 @@ spec: containerPort: 3000 env: - name: NODE_ENV - value: "production" + value: "${DB_ENV}" # ---- Database ---- - name: DATABASE_URL From 27d5b44251987e049767e570087cdbd1d27d0a7d Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 11:16:07 +0000 Subject: [PATCH 04/59] lets have a go --- .github/workflows/deploy-postgres-env.yml | 92 +++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 .github/workflows/deploy-postgres-env.yml diff --git a/.github/workflows/deploy-postgres-env.yml b/.github/workflows/deploy-postgres-env.yml new file mode 100644 index 0000000..486f08b --- /dev/null +++ b/.github/workflows/deploy-postgres-env.yml @@ -0,0 +1,92 @@ +name: Deploy Postgres (dev & prod, .env MVP) + +on: + push: + branches: + - main + - "feature/*" + tags: + - "*" + workflow_dispatch: + +jobs: + deploy: + runs-on: mealcraft-runners + + steps: + - uses: actions/checkout@v4 + + # ----------------------------- + # kubectl + # ----------------------------- + - name: Install kubectl + run: | + sudo apt-get update + sudo apt-get install -y curl ca-certificates gettext + curl -LO "https://dl.k8s.io/release/$(curl -sL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" + sudo install -m 0755 kubectl /usr/local/bin/kubectl + + - name: Configure kubeconfig (in-cluster) + run: | + KUBE_HOST="https://$KUBERNETES_SERVICE_HOST:$KUBERNETES_SERVICE_PORT" + SA_TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token) + CA_CERT=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt + + kubectl config set-cluster microk8s \ + --server="$KUBE_HOST" \ + --certificate-authority="$CA_CERT" + + kubectl config set-credentials runner --token="$SA_TOKEN" + + kubectl config set-context runner-context \ + --cluster=microk8s \ + --user=runner + + kubectl config use-context runner-context + + # ----------------------------- + # Decide env + # ----------------------------- + - name: Set environment + run: | + if [[ "$GITHUB_REF" == refs/heads/main || "$GITHUB_REF" == refs/tags/* ]]; then + echo "ENV=prod" >> $GITHUB_ENV + echo "NAMESPACE=default" >> $GITHUB_ENV + echo "RUNTIME_SECRET=postgres-prod" >> $GITHUB_ENV + echo "POSTGRES_HOST=postgres-prod.default.svc.cluster.local" >> $GITHUB_ENV + echo "POSTGRES_DB=stripe_invoice" >> $GITHUB_ENV + else + echo "ENV=dev" >> $GITHUB_ENV + echo "NAMESPACE=dev" >> $GITHUB_ENV + echo "RUNTIME_SECRET=postgres-dev" >> $GITHUB_ENV + echo "POSTGRES_HOST=postgres-dev.dev.svc.cluster.local" >> $GITHUB_ENV + echo "POSTGRES_DB=stripe_invoice_dev" >> $GITHUB_ENV + fi + + - name: Load DB creds from db/.env + run: | + set -a + source db/.env + set +a + + if [[ "$ENV" == "prod" ]]; then + USER="$PROD_POSTGRES_USER" + PASS="$PROD_POSTGRES_PASSWORD" + else + USER="$DEV_POSTGRES_USER" + PASS="$DEV_POSTGRES_PASSWORD" + fi + + DATABASE_URL="postgres://${USER}:${PASS}@${POSTGRES_HOST}:5432/${POSTGRES_DB}?sslmode=disable" + + echo "DATABASE_URL=$DATABASE_URL" >> $GITHUB_ENV + + # ----------------------------- + # Create runtime secret + # ----------------------------- + - name: Apply runtime DATABASE_URL secret + run: | + kubectl create secret generic $RUNTIME_SECRET \ + --namespace $NAMESPACE \ + --from-literal=DATABASE_URL="$DATABASE_URL" \ + --dry-run=client -o yaml | kubectl apply -f - From 69d9613e224cc59729f9664dd0c9f9288208284d Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 11:17:45 +0000 Subject: [PATCH 05/59] add db.env --- db/.env | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 db/.env diff --git a/db/.env b/db/.env new file mode 100644 index 0000000..4dcaee0 --- /dev/null +++ b/db/.env @@ -0,0 +1,7 @@ +# Dev Stripe-to-invoice +DEV_POSTGRES_USER=postgres +DEV_POSTGRES_PASSWORD=averysecretpasswordPersonAppleWinter938 + +# Prod Stripe-to-invoice +PROD_POSTGRES_USER=postgres +PROD_POSTGRES_PASSWORD=productionPassword1142M@ke!tH@rd2Br3akWith$ymb0ls \ No newline at end of file From 042deb0f6ce6e9ac1b56baf823741dbe2db7a15d Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 11:39:38 +0000 Subject: [PATCH 06/59] migration and deployment aligned --- .github/workflows/deploy-postgres-env.yml | 92 ------------------ .github/workflows/stripe-to-invoice.yml | 96 +++++++++++++++++-- .../scripts/backup_k8s_storage_to_s3.sh | 48 ++++++---- 3 files changed, 120 insertions(+), 116 deletions(-) delete mode 100644 .github/workflows/deploy-postgres-env.yml diff --git a/.github/workflows/deploy-postgres-env.yml b/.github/workflows/deploy-postgres-env.yml deleted file mode 100644 index 486f08b..0000000 --- a/.github/workflows/deploy-postgres-env.yml +++ /dev/null @@ -1,92 +0,0 @@ -name: Deploy Postgres (dev & prod, .env MVP) - -on: - push: - branches: - - main - - "feature/*" - tags: - - "*" - workflow_dispatch: - -jobs: - deploy: - runs-on: mealcraft-runners - - steps: - - uses: actions/checkout@v4 - - # ----------------------------- - # kubectl - # ----------------------------- - - name: Install kubectl - run: | - sudo apt-get update - sudo apt-get install -y curl ca-certificates gettext - curl -LO "https://dl.k8s.io/release/$(curl -sL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" - sudo install -m 0755 kubectl /usr/local/bin/kubectl - - - name: Configure kubeconfig (in-cluster) - run: | - KUBE_HOST="https://$KUBERNETES_SERVICE_HOST:$KUBERNETES_SERVICE_PORT" - SA_TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token) - CA_CERT=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt - - kubectl config set-cluster microk8s \ - --server="$KUBE_HOST" \ - --certificate-authority="$CA_CERT" - - kubectl config set-credentials runner --token="$SA_TOKEN" - - kubectl config set-context runner-context \ - --cluster=microk8s \ - --user=runner - - kubectl config use-context runner-context - - # ----------------------------- - # Decide env - # ----------------------------- - - name: Set environment - run: | - if [[ "$GITHUB_REF" == refs/heads/main || "$GITHUB_REF" == refs/tags/* ]]; then - echo "ENV=prod" >> $GITHUB_ENV - echo "NAMESPACE=default" >> $GITHUB_ENV - echo "RUNTIME_SECRET=postgres-prod" >> $GITHUB_ENV - echo "POSTGRES_HOST=postgres-prod.default.svc.cluster.local" >> $GITHUB_ENV - echo "POSTGRES_DB=stripe_invoice" >> $GITHUB_ENV - else - echo "ENV=dev" >> $GITHUB_ENV - echo "NAMESPACE=dev" >> $GITHUB_ENV - echo "RUNTIME_SECRET=postgres-dev" >> $GITHUB_ENV - echo "POSTGRES_HOST=postgres-dev.dev.svc.cluster.local" >> $GITHUB_ENV - echo "POSTGRES_DB=stripe_invoice_dev" >> $GITHUB_ENV - fi - - - name: Load DB creds from db/.env - run: | - set -a - source db/.env - set +a - - if [[ "$ENV" == "prod" ]]; then - USER="$PROD_POSTGRES_USER" - PASS="$PROD_POSTGRES_PASSWORD" - else - USER="$DEV_POSTGRES_USER" - PASS="$DEV_POSTGRES_PASSWORD" - fi - - DATABASE_URL="postgres://${USER}:${PASS}@${POSTGRES_HOST}:5432/${POSTGRES_DB}?sslmode=disable" - - echo "DATABASE_URL=$DATABASE_URL" >> $GITHUB_ENV - - # ----------------------------- - # Create runtime secret - # ----------------------------- - - name: Apply runtime DATABASE_URL secret - run: | - kubectl create secret generic $RUNTIME_SECRET \ - --namespace $NAMESPACE \ - --from-literal=DATABASE_URL="$DATABASE_URL" \ - --dry-run=client -o yaml | kubectl apply -f - diff --git a/.github/workflows/stripe-to-invoice.yml b/.github/workflows/stripe-to-invoice.yml index 83d074d..920c384 100644 --- a/.github/workflows/stripe-to-invoice.yml +++ b/.github/workflows/stripe-to-invoice.yml @@ -1,4 +1,4 @@ -name: Build & Deploy stripe-to-invoice +name: Build & Deploy stripe-to-invoice (with DB secrets) on: push: @@ -8,10 +8,15 @@ on: - release/** tags: - "*" + workflow_dispatch: jobs: + # -------------------------------------------------- + # BUILD IMAGE + # -------------------------------------------------- build: runs-on: ubuntu-22.04 + steps: - uses: actions/checkout@v4 @@ -35,7 +40,11 @@ jobs: run: | docker push docker.io/kimjunte/stripe_to_invoice:$GITHUB_REF_SLUG - deploy: + # -------------------------------------------------- + # APPLY DB SECRETS + # -------------------------------------------------- + secrets: + name: Apply runtime DB secret runs-on: mealcraft-runners needs: build @@ -49,7 +58,81 @@ jobs: curl -LO "https://dl.k8s.io/release/$(curl -sL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" sudo install -m 0755 kubectl /usr/local/bin/kubectl - - name: Configure kubeconfig + - name: Configure kubeconfig (in-cluster) + run: | + KUBE_HOST="https://$KUBERNETES_SERVICE_HOST:$KUBERNETES_SERVICE_PORT" + SA_TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token) + CA_CERT=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt + + kubectl config set-cluster microk8s \ + --server="$KUBE_HOST" \ + --certificate-authority="$CA_CERT" + + kubectl config set-credentials runner --token="$SA_TOKEN" + + kubectl config set-context runner-context \ + --cluster=microk8s \ + --user=runner + + kubectl config use-context runner-context + + - name: Decide environment + run: | + if [[ "$GITHUB_REF" == refs/heads/main || "$GITHUB_REF" == refs/tags/* || "$GITHUB_REF" == refs/heads/release/* ]]; then + echo "ENV=prod" >> $GITHUB_ENV + echo "NAMESPACE=default" >> $GITHUB_ENV + echo "RUNTIME_SECRET=postgres-prod" >> $GITHUB_ENV + echo "POSTGRES_HOST=postgres-prod.default.svc.cluster.local" >> $GITHUB_ENV + echo "POSTGRES_DB=stripe_invoice" >> $GITHUB_ENV + else + echo "ENV=dev" >> $GITHUB_ENV + echo "NAMESPACE=dev" >> $GITHUB_ENV + echo "RUNTIME_SECRET=postgres-dev" >> $GITHUB_ENV + echo "POSTGRES_HOST=postgres-dev.dev.svc.cluster.local" >> $GITHUB_ENV + echo "POSTGRES_DB=stripe_invoice_dev" >> $GITHUB_ENV + fi + + - name: Load DB creds from db/.env and apply secret + run: | + set -a + source db/.env + set +a + + if [[ "$ENV" == "prod" ]]; then + USER="$PROD_POSTGRES_USER" + PASS="$PROD_POSTGRES_PASSWORD" + else + USER="$DEV_POSTGRES_USER" + PASS="$DEV_POSTGRES_PASSWORD" + fi + + DATABASE_URL="postgres://${USER}:${PASS}@${POSTGRES_HOST}:5432/${POSTGRES_DB}?sslmode=disable" + + kubectl create secret generic $RUNTIME_SECRET \ + --namespace $NAMESPACE \ + --from-literal=DATABASE_URL="$DATABASE_URL" \ + --dry-run=client -o yaml | kubectl apply -f - + + # -------------------------------------------------- + # DEPLOY APP + # -------------------------------------------------- + deploy: + runs-on: mealcraft-runners + needs: + - build + - secrets + + steps: + - uses: actions/checkout@v4 + + - name: Install kubectl + run: | + sudo apt-get update + sudo apt-get install -y curl ca-certificates gettext + curl -LO "https://dl.k8s.io/release/$(curl -sL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" + sudo install -m 0755 kubectl /usr/local/bin/kubectl + + - name: Configure kubeconfig (in-cluster) run: | KUBE_HOST="https://$KUBERNETES_SERVICE_HOST:$KUBERNETES_SERVICE_PORT" SA_TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token) @@ -64,7 +147,7 @@ jobs: - name: Inject slug variables uses: rlespinasse/github-slug-action@v4 - - name: Set environment + - name: Decide environment run: | if [[ "$GITHUB_REF" == refs/heads/release/* || "$GITHUB_REF" == refs/tags/* ]]; then echo "NAMESPACE=default" >> $GITHUB_ENV @@ -75,8 +158,9 @@ jobs: echo "DB_ENV=dev" >> $GITHUB_ENV echo "HOSTNAME=stripe-to-invoice.dev.juntekim.com" >> $GITHUB_ENV fi - - name: Deploy + + - name: Deploy application run: | export IMAGE="docker.io/kimjunte/stripe_to_invoice:$GITHUB_REF_SLUG" - export NAMESPACE DB_ENV + export NAMESPACE DB_ENV HOSTNAME envsubst < stripe_to_invoice/deployment/deployment.yaml | kubectl apply -f - diff --git a/mist_infra/scripts/backup_k8s_storage_to_s3.sh b/mist_infra/scripts/backup_k8s_storage_to_s3.sh index ec620ab..5027ae1 100644 --- a/mist_infra/scripts/backup_k8s_storage_to_s3.sh +++ b/mist_infra/scripts/backup_k8s_storage_to_s3.sh @@ -24,6 +24,7 @@ mkdir -p "$BACKUP_DIR" # NEVER touch raw Postgres data TAR_EXCLUDES=( "$K8S_STORAGE_ROOT/postgres" + "$K8S_STORAGE_ROOT/lost+found" ) # ================================================== @@ -31,7 +32,7 @@ TAR_EXCLUDES=( # ================================================== case "$ENVIRONMENT" in dev) - PG_SECRET_NAME="postgres-secret" + PG_SECRET_NAME="postgres-dev" PG_POD_SELECTOR="app=postgres-dev" S3_PREFIX="dev" NAMESPACE="dev" @@ -43,7 +44,7 @@ case "$ENVIRONMENT" in exit 1 fi - PG_SECRET_NAME="postgres-prod-secret" + PG_SECRET_NAME="postgres-prod" PG_POD_SELECTOR="app=postgres-prod" S3_PREFIX="prod" NAMESPACE="default" @@ -56,14 +57,15 @@ esac echo "=== Backup started ($(date -u)) ===" echo "Environment: $ENVIRONMENT" +echo "Namespace: $NAMESPACE" # ================================================== -# POSTGRES DUMP (SAFE) +# LOCATE POSTGRES POD # ================================================== POSTGRES_POD=$(kubectl get pods \ -n "$NAMESPACE" \ -l "$PG_POD_SELECTOR" \ - -o jsonpath='{.items[*].metadata.name}' | awk '{print $1}') + -o jsonpath='{.items[0].metadata.name}') if [[ -z "$POSTGRES_POD" ]]; then echo "❌ No Postgres pod found for selector: $PG_POD_SELECTOR" @@ -71,27 +73,37 @@ if [[ -z "$POSTGRES_POD" ]]; then exit 1 fi -POSTGRES_USER=$(kubectl get secret "$PG_SECRET_NAME" \ - -n "$NAMESPACE" \ - -o jsonpath='{.data.POSTGRES_USER}' | base64 -d) +echo "Using Postgres pod: $POSTGRES_POD" -POSTGRES_DB=$(kubectl get secret "$PG_SECRET_NAME" \ +# ================================================== +# READ DATABASE_URL FROM SECRET +# ================================================== +DATABASE_URL=$(kubectl get secret "$PG_SECRET_NAME" \ -n "$NAMESPACE" \ - -o jsonpath='{.data.POSTGRES_DB}' 2>/dev/null | base64 -d || true) + -o jsonpath='{.data.DATABASE_URL}' | base64 -d) -if [[ -z "$POSTGRES_DB" ]]; then - echo "❌ POSTGRES_DB missing in secret $PG_SECRET_NAME" +if [[ -z "$DATABASE_URL" ]]; then + echo "❌ DATABASE_URL missing in secret $PG_SECRET_NAME" exit 1 fi -echo "Dumping database: $POSTGRES_DB" +# Parse DATABASE_URL +POSTGRES_USER="$(echo "$DATABASE_URL" | sed -E 's|.*://([^:]+):.*|\1|')" +POSTGRES_DB="$(echo "$DATABASE_URL" | sed -E 's|.*/([^?]+).*|\1|')" +if [[ -z "$POSTGRES_USER" || -z "$POSTGRES_DB" ]]; then + echo "❌ Failed to parse DATABASE_URL" + exit 1 +fi + +echo "Dumping database: $POSTGRES_DB (user: $POSTGRES_USER)" + +# ================================================== +# POSTGRES LOGICAL DUMP (SAFE) +# ================================================== kubectl exec -n "$NAMESPACE" "$POSTGRES_POD" -- \ - pg_dump \ - -h localhost \ - -U "$POSTGRES_USER" \ - "$POSTGRES_DB" \ - > "$BACKUP_DIR/postgres.sql" + pg_dump "$POSTGRES_DB" \ + > "$BACKUP_DIR/postgres.sql" echo "✔ pg_dump complete ($(du -h "$BACKUP_DIR/postgres.sql" | cut -f1))" @@ -145,6 +157,6 @@ echo " sudo tar -xzf k8s_storage_$DATE.tar.gz -C /" echo "" echo "Restore Postgres:" echo " kubectl exec -n $NAMESPACE -i $POSTGRES_POD -- \\" -echo " psql -U $POSTGRES_USER $POSTGRES_DB < postgres.sql" +echo " psql $POSTGRES_DB < postgres.sql" echo "" echo "=== Backup completed successfully ===" From 3fbac8fdf5b5d549dd6daaf22cb4a03bf582befc Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 11:44:12 +0000 Subject: [PATCH 07/59] temp database url --- stripe_to_invoice/deployment/Dockerfile | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/stripe_to_invoice/deployment/Dockerfile b/stripe_to_invoice/deployment/Dockerfile index 80123e3..bd2e7d5 100644 --- a/stripe_to_invoice/deployment/Dockerfile +++ b/stripe_to_invoice/deployment/Dockerfile @@ -11,10 +11,15 @@ RUN npm ci # ---------- builder ---------- FROM base AS builder -WORKDIR /app/stripe_to_invoice # 🔥 THIS WAS MISSING +WORKDIR /app/stripe_to_invoice + COPY --from=deps /app/stripe_to_invoice/node_modules ./node_modules COPY stripe_to_invoice . + +# ✅ Build-time only (safe placeholder) +ENV DATABASE_URL="postgres://build:build@localhost:5432/build" ENV NEXT_TELEMETRY_DISABLED=1 + RUN node -e "require('typescript')" RUN npm run build From ec77dea850e3fcf311f97f692916e71af2b2aff3 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 11:49:40 +0000 Subject: [PATCH 08/59] save --- .../app/auth/callback/AuthCallbackClient.tsx | 40 ++++++++++++++++++ stripe_to_invoice/app/auth/callback/page.tsx | 41 ++++--------------- 2 files changed, 47 insertions(+), 34 deletions(-) create mode 100644 stripe_to_invoice/app/auth/callback/AuthCallbackClient.tsx diff --git a/stripe_to_invoice/app/auth/callback/AuthCallbackClient.tsx b/stripe_to_invoice/app/auth/callback/AuthCallbackClient.tsx new file mode 100644 index 0000000..b8cc1ea --- /dev/null +++ b/stripe_to_invoice/app/auth/callback/AuthCallbackClient.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useSearchParams, useRouter } from "next/navigation"; + +export default function AuthCallbackClient() { + const params = useSearchParams(); + const router = useRouter(); + const ran = useRef(false); + + useEffect(() => { + if (ran.current) return; + ran.current = true; + + const token = params.get("token"); + if (!token) { + router.replace("/login"); + return; + } + + fetch("/api/auth/callback", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token }), + }) + .then(async (res) => { + if (!res.ok) throw new Error(await res.text()); + router.replace("/app"); + }) + .catch(() => { + router.replace("/login"); + }); + }, [params, router]); + + return ( +
+

Signing you in…

+
+ ); +} diff --git a/stripe_to_invoice/app/auth/callback/page.tsx b/stripe_to_invoice/app/auth/callback/page.tsx index 1f76a57..3945658 100644 --- a/stripe_to_invoice/app/auth/callback/page.tsx +++ b/stripe_to_invoice/app/auth/callback/page.tsx @@ -1,40 +1,13 @@ -"use client"; +import { Suspense } from "react"; -import { useEffect, useRef } from "react"; -import { useSearchParams, useRouter } from "next/navigation"; +export const dynamic = "force-dynamic"; + +import AuthCallbackClient from "./AuthCallbackClient"; export default function AuthCallbackPage() { - const params = useSearchParams(); - const router = useRouter(); - const ran = useRef(false); - - useEffect(() => { - if (ran.current) return; - ran.current = true; - - const token = params.get("token"); - if (!token) { - router.replace("/login"); - return; - } - - fetch("/api/auth/callback", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token }), - }) - .then(async (res) => { - if (!res.ok) throw new Error(await res.text()); - router.replace("/app"); - }) - .catch(() => { - router.replace("/login"); - }); - }, [params, router]); - return ( -
-

Signing you in…

-
+ + + ); } From 7d01b184940fe57e006ce7b9e6cbc1829528f125 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 11:56:39 +0000 Subject: [PATCH 09/59] push garantee --- .github/workflows/stripe-to-invoice.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/stripe-to-invoice.yml b/.github/workflows/stripe-to-invoice.yml index 920c384..24c402c 100644 --- a/.github/workflows/stripe-to-invoice.yml +++ b/.github/workflows/stripe-to-invoice.yml @@ -29,16 +29,16 @@ jobs: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_TOKEN }} - - name: Build image - run: | - docker build \ - -f stripe_to_invoice/deployment/Dockerfile \ - -t docker.io/kimjunte/stripe_to_invoice:$GITHUB_REF_SLUG \ - . + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 - - name: Push image - run: | - docker push docker.io/kimjunte/stripe_to_invoice:$GITHUB_REF_SLUG + - name: Build & push image + uses: docker/build-push-action@v6 + with: + context: . + file: stripe_to_invoice/deployment/Dockerfile + push: true + tags: docker.io/kimjunte/stripe_to_invoice:${{ env.GITHUB_REF_SLUG }} # -------------------------------------------------- # APPLY DB SECRETS From 734e134c17ad04df4429009a0cb3ded94fe4a660 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 12:19:23 +0000 Subject: [PATCH 10/59] added secrets --- .github/workflows/stripe-to-invoice.yml | 24 ++++ .../postgres-dev-stripe-to-invoice.yaml | 100 ---------------- .../postgres-prod-stripe-to-invoice.yaml | 111 ------------------ stripe_to_invoice/deployment/secrets/.env | 6 + .../deployment/secrets/stipe-secrets.yaml | 9 ++ .../docker-credentials.yml | 10 ++ 6 files changed, 49 insertions(+), 211 deletions(-) delete mode 100644 db/k8s/postgres/postgres-dev-stripe-to-invoice.yaml delete mode 100644 db/k8s/postgres/postgres-prod-stripe-to-invoice.yaml create mode 100644 stripe_to_invoice/deployment/secrets/.env create mode 100644 stripe_to_invoice/deployment/secrets/stipe-secrets.yaml diff --git a/.github/workflows/stripe-to-invoice.yml b/.github/workflows/stripe-to-invoice.yml index 24c402c..6fda9ad 100644 --- a/.github/workflows/stripe-to-invoice.yml +++ b/.github/workflows/stripe-to-invoice.yml @@ -112,6 +112,30 @@ jobs: --namespace $NAMESPACE \ --from-literal=DATABASE_URL="$DATABASE_URL" \ --dry-run=client -o yaml | kubectl apply -f - + + - name: Apply Stripe secrets + run: | + set -e + set -a + source stripe_to_invoice/deployment/secrets/.env + set +a + + if [[ "$ENV" == "prod" ]]; then + STRIPE_SECRET_KEY="$PROD_STRIPE_SECRET_KEY" + STRIPE_CLIENT_ID="$PROD_STRIPE_CLIENT_ID" + else + STRIPE_SECRET_KEY="$DEV_STRIPE_SECRET_KEY" + STRIPE_CLIENT_ID="$DEV_STRIPE_CLIENT_ID" + fi + + : "${STRIPE_SECRET_KEY:?missing STRIPE_SECRET_KEY}" + : "${STRIPE_CLIENT_ID:?missing STRIPE_CLIENT_ID}" + : "${NAMESPACE:?missing NAMESPACE}" + + export STRIPE_SECRET_KEY STRIPE_CLIENT_ID NAMESPACE + + envsubst < stripe_to_invoice/deployment/stripe-secrets.yaml \ + | kubectl apply -f - # -------------------------------------------------- # DEPLOY APP diff --git a/db/k8s/postgres/postgres-dev-stripe-to-invoice.yaml b/db/k8s/postgres/postgres-dev-stripe-to-invoice.yaml deleted file mode 100644 index 347ad93..0000000 --- a/db/k8s/postgres/postgres-dev-stripe-to-invoice.yaml +++ /dev/null @@ -1,100 +0,0 @@ -# -------------------------------------------------- -# PersistentVolume (local disk on mist) -# -------------------------------------------------- -apiVersion: v1 -kind: PersistentVolume -metadata: - name: postgres-dev-pv -spec: - capacity: - storage: 20Gi - accessModes: - - ReadWriteOnce - persistentVolumeReclaimPolicy: Retain - storageClassName: local-storage - hostPath: - path: /home/kimjunte/k8s_storage/postgres/stripe_invoice_dev - ---- -# -------------------------------------------------- -# PersistentVolumeClaim -# -------------------------------------------------- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: postgres-pvc - namespace: dev -spec: - accessModes: - - ReadWriteOnce - resources: - requests: - storage: 20Gi - storageClassName: local-storage - ---- -# -------------------------------------------------- -# PostgreSQL Deployment -# -------------------------------------------------- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: postgres-dev - namespace: dev -spec: - replicas: 1 - selector: - matchLabels: - app: postgres-dev - template: - metadata: - labels: - app: postgres-dev - spec: - containers: - - name: postgres - image: postgres:16 - ports: - - containerPort: 5432 - envFrom: - - secretRef: - name: postgres-secret - volumeMounts: - - name: postgres-data - mountPath: /var/lib/postgresql/data - volumes: - - name: postgres-data - persistentVolumeClaim: - claimName: postgres-pvc - ---- -# -------------------------------------------------- -# PostgreSQL Service (internal only) -# -------------------------------------------------- -apiVersion: v1 -kind: Service -metadata: - name: postgres-dev - namespace: dev -spec: - type: ClusterIP - selector: - app: postgres-dev - ports: - - port: 5432 - targetPort: 5432 - ---- -# -------------------------------------------------- -# Secret -# -------------------------------------------------- -apiVersion: v1 -kind: Secret -metadata: - name: postgres-secret - namespace: dev -type: Opaque -stringData: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: averysecretpasswordPersonAppleWinter938 - POSTGRES_DB: stripe_invoice diff --git a/db/k8s/postgres/postgres-prod-stripe-to-invoice.yaml b/db/k8s/postgres/postgres-prod-stripe-to-invoice.yaml deleted file mode 100644 index 4bb53f1..0000000 --- a/db/k8s/postgres/postgres-prod-stripe-to-invoice.yaml +++ /dev/null @@ -1,111 +0,0 @@ -# -------------------------------------------------- -# PersistentVolume (local disk on mist) — PROD -# -------------------------------------------------- -apiVersion: v1 -kind: PersistentVolume -metadata: - name: postgres-prod-pv -spec: - capacity: - storage: 20Gi - accessModes: - - ReadWriteOnce - persistentVolumeReclaimPolicy: Retain - storageClassName: local-storage - hostPath: - path: /home/kimjunte/k8s_storage/postgres/stripe_invoice_prod - ---- -# -------------------------------------------------- -# PersistentVolumeClaim — PROD -# -------------------------------------------------- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: postgres-prod-pvc - namespace: default -spec: - accessModes: - - ReadWriteOnce - resources: - requests: - storage: 20Gi - storageClassName: local-storage - ---- -# -------------------------------------------------- -# PostgreSQL Secret — PROD -# (DO NOT COMMIT real values) -# -------------------------------------------------- -apiVersion: v1 -kind: Secret -metadata: - name: postgres-prod-secret - namespace: default -type: Opaque -stringData: - POSTGRES_USER: stripe_invoice_prod - POSTGRES_PASSWORD: productionPassword1142M@ke!tH@rd2Br3akWith$ymb0ls - POSTGRES_DB: stripe_invoice_prod - ---- -# -------------------------------------------------- -# PostgreSQL Deployment — PROD -# -------------------------------------------------- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: postgres-prod - namespace: default -spec: - replicas: 1 - selector: - matchLabels: - app: postgres-prod - template: - metadata: - labels: - app: postgres-prod - spec: - containers: - - name: postgres - image: postgres:16 - ports: - - containerPort: 5432 - envFrom: - - secretRef: - name: postgres-prod-secret - volumeMounts: - - name: postgres-data - mountPath: /var/lib/postgresql/data - readinessProbe: - tcpSocket: - port: 5432 - initialDelaySeconds: 10 - periodSeconds: 5 - livenessProbe: - tcpSocket: - port: 5432 - initialDelaySeconds: 30 - periodSeconds: 10 - volumes: - - name: postgres-data - persistentVolumeClaim: - claimName: postgres-prod-pvc - ---- -# -------------------------------------------------- -# PostgreSQL Service (cluster-internal only) — PROD -# -------------------------------------------------- -apiVersion: v1 -kind: Service -metadata: - name: postgres-prod - namespace: default -spec: - type: ClusterIP - selector: - app: postgres-prod - ports: - - port: 5432 - targetPort: 5432 diff --git a/stripe_to_invoice/deployment/secrets/.env b/stripe_to_invoice/deployment/secrets/.env new file mode 100644 index 0000000..8c487ff --- /dev/null +++ b/stripe_to_invoice/deployment/secrets/.env @@ -0,0 +1,6 @@ +# Test mode for deployment +DEV_STRIPE_SECRET_KEY=sk_test_51Mo6PnBUc0gyz8XqrZqvWQWRQSUQbjt7zxP56lhdqgIG4qxn5zDuistUJJq8Chl7AxmyCy8xMRAh1Zf25jK0lYCb00QsQqNEsc +DEV_STRIPE_CLIENT_ID=ca_NZFa6CNybMItWKir9Uk6ojevnYcP7Rbz + +PROD_STRIPE_SECRET_KEY=sk_test_51Mo6PnBUc0gyz8XqrZqvWQWRQSUQbjt7zxP56lhdqgIG4qxn5zDuistUJJq8Chl7AxmyCy8xMRAh1Zf25jK0lYCb00QsQqNEsc +PROD_STRIPE_CLIENT_ID=ca_NZFa6CNybMItWKir9Uk6ojevnYcP7Rbz \ No newline at end of file diff --git a/stripe_to_invoice/deployment/secrets/stipe-secrets.yaml b/stripe_to_invoice/deployment/secrets/stipe-secrets.yaml new file mode 100644 index 0000000..aa9873f --- /dev/null +++ b/stripe_to_invoice/deployment/secrets/stipe-secrets.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Secret +metadata: + name: stripe-secrets + namespace: ${NAMESPACE} +type: Opaque +stringData: + STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} + STRIPE_CLIENT_ID: ${STRIPE_CLIENT_ID} \ No newline at end of file diff --git a/traefik/docker-registry-credentials/docker-credentials.yml b/traefik/docker-registry-credentials/docker-credentials.yml index 7b7b9db..7ea8229 100644 --- a/traefik/docker-registry-credentials/docker-credentials.yml +++ b/traefik/docker-registry-credentials/docker-credentials.yml @@ -6,3 +6,13 @@ metadata: data: .dockerconfigjson: ewoJImF1dGhzIjogewoJCSJodHRwczovL2luZGV4LmRvY2tlci5pby92MS8iOiB7CgkJCSJhdXRoIjogImEybHRhblZ1ZEdVNlpHTnJjbDl3WVhSZmJVdFNibkJ0TVZselJVOHRSRU5PVnpNelQwcG5hVGQ0WkdkQiIKCQl9Cgl9Cn0= type: kubernetes.io/dockerconfigjson + + +apiVersion: v1 +kind: Secret +metadata: + name: registrypullsecret + namespace: dev +data: + .dockerconfigjson: ewoJImF1dGhzIjogewoJCSJodHRwczovL2luZGV4LmRvY2tlci5pby92MS8iOiB7CgkJCSJhdXRoIjogImEybHRhblZ1ZEdVNlpHTnJjbDl3WVhSZmJVdFNibkJ0TVZselJVOHRSRU5PVnpNelQwcG5hVGQ0WkdkQiIKCQl9Cgl9Cn0= +type: kubernetes.io/dockerconfigjson From 604f46065122d5e30f7755e5055345a34d69f422 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 12:22:00 +0000 Subject: [PATCH 11/59] added secrets --- .github/workflows/stripe-to-invoice.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stripe-to-invoice.yml b/.github/workflows/stripe-to-invoice.yml index 6fda9ad..ac2fefc 100644 --- a/.github/workflows/stripe-to-invoice.yml +++ b/.github/workflows/stripe-to-invoice.yml @@ -134,7 +134,7 @@ jobs: export STRIPE_SECRET_KEY STRIPE_CLIENT_ID NAMESPACE - envsubst < stripe_to_invoice/deployment/stripe-secrets.yaml \ + envsubst < stripe_to_invoice/deployment/secrets/stripe-secrets.yaml \ | kubectl apply -f - # -------------------------------------------------- From d5a77c326465539ad5630a5e0c2e3250b6856d4a Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 12:27:31 +0000 Subject: [PATCH 12/59] stripe secrets sopelling --- .../secrets/{stipe-secrets.yaml => stripe-secrets.yaml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename stripe_to_invoice/deployment/secrets/{stipe-secrets.yaml => stripe-secrets.yaml} (100%) diff --git a/stripe_to_invoice/deployment/secrets/stipe-secrets.yaml b/stripe_to_invoice/deployment/secrets/stripe-secrets.yaml similarity index 100% rename from stripe_to_invoice/deployment/secrets/stipe-secrets.yaml rename to stripe_to_invoice/deployment/secrets/stripe-secrets.yaml From 6a427963b574f2ce8bf76107285a433b5c7a5fc0 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 12:31:53 +0000 Subject: [PATCH 13/59] stripe secrets sopelling --- stripe_to_invoice/deployment/deployment.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/stripe_to_invoice/deployment/deployment.yaml b/stripe_to_invoice/deployment/deployment.yaml index 56b5257..9107004 100644 --- a/stripe_to_invoice/deployment/deployment.yaml +++ b/stripe_to_invoice/deployment/deployment.yaml @@ -40,6 +40,12 @@ spec: name: stripe-secrets key: STRIPE_SECRET_KEY + - name: STRIPE_CLIENT_ID + valueFrom: + secretKeyRef: + name: stripe-secrets + key: STRIPE_CLIENT_ID + imagePullSecrets: - name: registrypullsecret From a93ba8d8ec909ca42565b7a604c489b1f5c0ab60 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 12:37:09 +0000 Subject: [PATCH 14/59] stripe secrets sopelling --- .github/workflows/stripe-to-invoice.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stripe-to-invoice.yml b/.github/workflows/stripe-to-invoice.yml index ac2fefc..d6a7f0e 100644 --- a/.github/workflows/stripe-to-invoice.yml +++ b/.github/workflows/stripe-to-invoice.yml @@ -89,7 +89,7 @@ jobs: echo "NAMESPACE=dev" >> $GITHUB_ENV echo "RUNTIME_SECRET=postgres-dev" >> $GITHUB_ENV echo "POSTGRES_HOST=postgres-dev.dev.svc.cluster.local" >> $GITHUB_ENV - echo "POSTGRES_DB=stripe_invoice_dev" >> $GITHUB_ENV + echo "POSTGRES_DB=stripe_invoice" >> $GITHUB_ENV fi - name: Load DB creds from db/.env and apply secret From c1ddbee66ab395e84fbc3eaf703ef3539fbfea7d Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 12:50:40 +0000 Subject: [PATCH 15/59] stripe secrets sopelling --- .github/workflows/stripe-to-invoice.yml | 20 ++++++++++-- db/atlas/atlas.hcl | 2 +- db/k8s/backups/pg-backup-cronjob.yaml | 2 +- .../stripe-to-invoice-dev-migrate.yaml | 2 +- .../stripe-to-invoice-prod-migrate.yaml | 2 +- stripe_to_invoice/deployment/deployment.yaml | 32 +++++++++++++++++++ stripe_to_invoice/deployment/secrets/.env | 15 ++++++++- .../deployment/secrets/stripe-secrets.yaml | 7 +++- 8 files changed, 73 insertions(+), 9 deletions(-) diff --git a/.github/workflows/stripe-to-invoice.yml b/.github/workflows/stripe-to-invoice.yml index d6a7f0e..e66bee2 100644 --- a/.github/workflows/stripe-to-invoice.yml +++ b/.github/workflows/stripe-to-invoice.yml @@ -113,7 +113,7 @@ jobs: --from-literal=DATABASE_URL="$DATABASE_URL" \ --dry-run=client -o yaml | kubectl apply -f - - - name: Apply Stripe secrets + - name: Apply Next env/secrets run: | set -e set -a @@ -123,16 +123,30 @@ jobs: if [[ "$ENV" == "prod" ]]; then STRIPE_SECRET_KEY="$PROD_STRIPE_SECRET_KEY" STRIPE_CLIENT_ID="$PROD_STRIPE_CLIENT_ID" + APP_URL=$PROD_APP_URL + AWS_REGION=$PROD_AWS_REGION + AWS_ACCESS_KEY_ID=$PROD_AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY=$PROD_AWS_SECRET_ACCESS_KEY + SES_FROM_EMAIL=$PROD_SES_FROM_EMAIL else STRIPE_SECRET_KEY="$DEV_STRIPE_SECRET_KEY" STRIPE_CLIENT_ID="$DEV_STRIPE_CLIENT_ID" + APP_URL=$DEV_APP_URL + AWS_REGION=$DEV_AWS_REGION + AWS_ACCESS_KEY_ID=$DEV_AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY=$DEV_AWS_SECRET_ACCESS_KEY + SES_FROM_EMAIL=$DEV_SES_FROM_EMAIL fi - : "${STRIPE_SECRET_KEY:?missing STRIPE_SECRET_KEY}" : "${STRIPE_CLIENT_ID:?missing STRIPE_CLIENT_ID}" : "${NAMESPACE:?missing NAMESPACE}" + : "${APP_URL:?missing APP_URL}" + : "${AWS_REGION:?missing AWS_REGION}" + : "${AWS_ACCESS_KEY_ID:?missing AWS_ACCESS_KEY_ID}" + : "${AWS_SECRET_ACCESS_KEY:?missing AWS_SECRET_ACCESS_KEY}" + : "${SES_FROM_EMAIL:?missing SES_FROM_EMAIL}" - export STRIPE_SECRET_KEY STRIPE_CLIENT_ID NAMESPACE + export STRIPE_SECRET_KEY STRIPE_CLIENT_ID NAMESPACE APP_URL AWS_REGION AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY SES_FROM_EMAIL envsubst < stripe_to_invoice/deployment/secrets/stripe-secrets.yaml \ | kubectl apply -f - diff --git a/db/atlas/atlas.hcl b/db/atlas/atlas.hcl index 1ea6f2b..6e06a27 100644 --- a/db/atlas/atlas.hcl +++ b/db/atlas/atlas.hcl @@ -7,7 +7,7 @@ env "stripe_invoice_dev" { } env "stripe_invoice_prod" { - url = "postgres://${getenv("POSTGRES_USER")}:${getenv("POSTGRES_PASSWORD")}@postgres-prod.default.svc.cluster.local:5432/stripe_invoice_prod?sslmode=disable" + url = "postgres://${getenv("POSTGRES_USER")}:${getenv("POSTGRES_PASSWORD")}@postgres-prod.default.svc.cluster.local:5432/stripe_invoice?sslmode=disable" migration { diff --git a/db/k8s/backups/pg-backup-cronjob.yaml b/db/k8s/backups/pg-backup-cronjob.yaml index 5d7c265..e7616b2 100644 --- a/db/k8s/backups/pg-backup-cronjob.yaml +++ b/db/k8s/backups/pg-backup-cronjob.yaml @@ -53,7 +53,7 @@ spec: pg_dump \ -h postgres-prod.default.svc.cluster.local \ -U $POSTGRES_USER \ - stripe_invoice_prod \ + stripe_invoice \ | gzip \ | aws s3 cp - s3://$S3_BUCKET/prod/stripe_invoice/$(date +%F).sql.gz envFrom: diff --git a/db/k8s/migrations/stripe-to-invoice-dev-migrate.yaml b/db/k8s/migrations/stripe-to-invoice-dev-migrate.yaml index 0d0eedc..9619e1b 100644 --- a/db/k8s/migrations/stripe-to-invoice-dev-migrate.yaml +++ b/db/k8s/migrations/stripe-to-invoice-dev-migrate.yaml @@ -10,7 +10,7 @@ # - name: atlas # image: arigaio/atlas:latest # command: ["/atlas"] -# args: ["migrate", "apply", "--env", "stripe_invoice_dev"] +# args: ["migrate", "apply", "--env", "stripe_invoice"] # envFrom: # - secretRef: # name: postgres-secret diff --git a/db/k8s/migrations/stripe-to-invoice-prod-migrate.yaml b/db/k8s/migrations/stripe-to-invoice-prod-migrate.yaml index b772077..16aa865 100644 --- a/db/k8s/migrations/stripe-to-invoice-prod-migrate.yaml +++ b/db/k8s/migrations/stripe-to-invoice-prod-migrate.yaml @@ -9,7 +9,7 @@ # containers: # - name: atlas # image: arigaio/atlas:latest -# command: ["migrate", "apply", "--env", "stripe_invoice_prod"] +# command: ["migrate", "apply", "--env", "stripe_invoice"] # envFrom: # - secretRef: # name: postgres-secret diff --git a/stripe_to_invoice/deployment/deployment.yaml b/stripe_to_invoice/deployment/deployment.yaml index 9107004..d39a919 100644 --- a/stripe_to_invoice/deployment/deployment.yaml +++ b/stripe_to_invoice/deployment/deployment.yaml @@ -46,6 +46,38 @@ spec: name: stripe-secrets key: STRIPE_CLIENT_ID + # ---- App ---- + - name: APP_URL + valueFrom: + secretKeyRef: + name: stripe-secrets + key: APP_URL + + # ---- AWS / SES ---- + - name: AWS_REGION + valueFrom: + secretKeyRef: + name: stripe-secrets + key: AWS_REGION + + - name: AWS_ACCESS_KEY_ID + valueFrom: + secretKeyRef: + name: stripe-secrets + key: AWS_ACCESS_KEY_ID + + - name: AWS_SECRET_ACCESS_KEY + valueFrom: + secretKeyRef: + name: stripe-secrets + key: AWS_SECRET_ACCESS_KEY + + - name: SES_FROM_EMAIL + valueFrom: + secretKeyRef: + name: stripe-secrets + key: SES_FROM_EMAIL + imagePullSecrets: - name: registrypullsecret diff --git a/stripe_to_invoice/deployment/secrets/.env b/stripe_to_invoice/deployment/secrets/.env index 8c487ff..92441dc 100644 --- a/stripe_to_invoice/deployment/secrets/.env +++ b/stripe_to_invoice/deployment/secrets/.env @@ -1,6 +1,19 @@ # Test mode for deployment DEV_STRIPE_SECRET_KEY=sk_test_51Mo6PnBUc0gyz8XqrZqvWQWRQSUQbjt7zxP56lhdqgIG4qxn5zDuistUJJq8Chl7AxmyCy8xMRAh1Zf25jK0lYCb00QsQqNEsc DEV_STRIPE_CLIENT_ID=ca_NZFa6CNybMItWKir9Uk6ojevnYcP7Rbz +DEV_APP_URL=stripe-to-invoice.dev.juntekim.com +DEV_AWS_REGION=eu-west-2 +DEV_AWS_ACCESS_KEY_ID=AKIAQL67W6HI2547OPVG +DEV_AWS_SECRET_ACCESS_KEY=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 +DEV_SES_FROM_EMAIL=no-reply@juntekim.com + +# Prod PROD_STRIPE_SECRET_KEY=sk_test_51Mo6PnBUc0gyz8XqrZqvWQWRQSUQbjt7zxP56lhdqgIG4qxn5zDuistUJJq8Chl7AxmyCy8xMRAh1Zf25jK0lYCb00QsQqNEsc -PROD_STRIPE_CLIENT_ID=ca_NZFa6CNybMItWKir9Uk6ojevnYcP7Rbz \ No newline at end of file +PROD_STRIPE_CLIENT_ID=ca_NZFa6CNybMItWKir9Uk6ojevnYcP7Rbz +PROD_APP_URL=stripe-to-invoice.juntekim.com +PROD_AWS_REGION=eu-west-2 +PROD_AWS_ACCESS_KEY_ID=AKIAQL67W6HI2547OPVG +PROD_AWS_SECRET_ACCESS_KEY=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 +PROD_SES_FROM_EMAIL=no-reply@juntekim.com + diff --git a/stripe_to_invoice/deployment/secrets/stripe-secrets.yaml b/stripe_to_invoice/deployment/secrets/stripe-secrets.yaml index aa9873f..9fa8ae9 100644 --- a/stripe_to_invoice/deployment/secrets/stripe-secrets.yaml +++ b/stripe_to_invoice/deployment/secrets/stripe-secrets.yaml @@ -6,4 +6,9 @@ metadata: type: Opaque stringData: STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} - STRIPE_CLIENT_ID: ${STRIPE_CLIENT_ID} \ No newline at end of file + STRIPE_CLIENT_ID: ${STRIPE_CLIENT_ID} + APP_URL: ${APP_URL} + AWS_REGION: ${AWS_REGION} + AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID} + AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY} + SES_FROM_EMAIL: ${SES_FROM_EMAIL} \ No newline at end of file From 3511fabc519cdd86ea9dbae14ea5e593f1b2cd69 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 14:27:25 +0000 Subject: [PATCH 16/59] added stripe callback --- .github/workflows/stripe-to-invoice.yml | 5 +- .../app/api/stripe/callback/route.ts | 88 +++++++++++++++++++ .../app/api/stripe/connect/route.ts | 26 ++++++ stripe_to_invoice/app/login/page.tsx | 2 +- stripe_to_invoice/deployment/deployment.yaml | 6 ++ stripe_to_invoice/deployment/secrets/.env | 2 + 6 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 stripe_to_invoice/app/api/stripe/callback/route.ts create mode 100644 stripe_to_invoice/app/api/stripe/connect/route.ts diff --git a/.github/workflows/stripe-to-invoice.yml b/.github/workflows/stripe-to-invoice.yml index e66bee2..0381277 100644 --- a/.github/workflows/stripe-to-invoice.yml +++ b/.github/workflows/stripe-to-invoice.yml @@ -128,6 +128,7 @@ jobs: AWS_ACCESS_KEY_ID=$PROD_AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY=$PROD_AWS_SECRET_ACCESS_KEY SES_FROM_EMAIL=$PROD_SES_FROM_EMAIL + STRIPE_REDIRECT_URI=$PROD_SES_STRIPE_REDIRECT_URI else STRIPE_SECRET_KEY="$DEV_STRIPE_SECRET_KEY" STRIPE_CLIENT_ID="$DEV_STRIPE_CLIENT_ID" @@ -136,6 +137,7 @@ jobs: AWS_ACCESS_KEY_ID=$DEV_AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY=$DEV_AWS_SECRET_ACCESS_KEY SES_FROM_EMAIL=$DEV_SES_FROM_EMAIL + STRIPE_REDIRECT_URI=$DEV_SES_STRIPE_REDIRECT_URI fi : "${STRIPE_SECRET_KEY:?missing STRIPE_SECRET_KEY}" : "${STRIPE_CLIENT_ID:?missing STRIPE_CLIENT_ID}" @@ -145,8 +147,9 @@ jobs: : "${AWS_ACCESS_KEY_ID:?missing AWS_ACCESS_KEY_ID}" : "${AWS_SECRET_ACCESS_KEY:?missing AWS_SECRET_ACCESS_KEY}" : "${SES_FROM_EMAIL:?missing SES_FROM_EMAIL}" + : "${STRIPE_REDIRECT_URI:?missing STRIPE_REDIRECT_URI}" - export STRIPE_SECRET_KEY STRIPE_CLIENT_ID NAMESPACE APP_URL AWS_REGION AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY SES_FROM_EMAIL + export STRIPE_SECRET_KEY STRIPE_CLIENT_ID NAMESPACE APP_URL AWS_REGION AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY SES_FROM_EMAIL STRIPE_REDIRECT_URI envsubst < stripe_to_invoice/deployment/secrets/stripe-secrets.yaml \ | kubectl apply -f - diff --git a/stripe_to_invoice/app/api/stripe/callback/route.ts b/stripe_to_invoice/app/api/stripe/callback/route.ts new file mode 100644 index 0000000..443eaac --- /dev/null +++ b/stripe_to_invoice/app/api/stripe/callback/route.ts @@ -0,0 +1,88 @@ +import { cookies } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; + +type StripeOAuthResponse = { + access_token: string; + refresh_token: string; + stripe_user_id: string; + scope: string; +}; + +export async function GET(req: NextRequest) { + const cookieStore = await cookies(); + const session = cookieStore.get("session"); + + // Safety: user must still be logged in + if (!session) { + return NextResponse.redirect( + new URL("/login", process.env.NEXT_PUBLIC_BASE_URL) + ); + } + + const { searchParams } = new URL(req.url); + const code = searchParams.get("code"); + const error = searchParams.get("error"); + + if (error) { + console.error("Stripe OAuth error:", error); + return NextResponse.redirect( + new URL("/connect/stripe?error=oauth_failed", process.env.NEXT_PUBLIC_BASE_URL) + ); + } + + if (!code) { + return NextResponse.json( + { error: "Missing OAuth code" }, + { status: 400 } + ); + } + + // Exchange code for access token + const tokenRes = await fetch("https://connect.stripe.com/oauth/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + client_secret: process.env.STRIPE_SECRET_KEY!, + }), + }); + + if (!tokenRes.ok) { + const text = await tokenRes.text(); + console.error("Stripe token exchange failed:", text); + + return NextResponse.redirect( + new URL("/connect/stripe?error=token_exchange_failed", process.env.NEXT_PUBLIC_BASE_URL) + ); + } + + const data = (await tokenRes.json()) as StripeOAuthResponse; + + /** + * TODO (NEXT STEP): + * - Encrypt tokens + * - Persist to DB against the current user + * + * Required fields: + * - data.stripe_user_id (acct_...) + * - data.access_token + * - data.refresh_token + * - mode: "test" + */ + + console.log("Stripe OAuth success", { + stripe_account_id: data.stripe_user_id, + scope: data.scope, + has_access_token: Boolean(data.access_token), + has_refresh_token: Boolean(data.refresh_token), + access_token_preview: data.access_token?.slice(0, 8) + "...", + }); + + // MVP success redirect + return NextResponse.redirect( + new URL("/connect/stripe/success", process.env.NEXT_PUBLIC_BASE_URL) + ); +} diff --git a/stripe_to_invoice/app/api/stripe/connect/route.ts b/stripe_to_invoice/app/api/stripe/connect/route.ts new file mode 100644 index 0000000..c065a95 --- /dev/null +++ b/stripe_to_invoice/app/api/stripe/connect/route.ts @@ -0,0 +1,26 @@ +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; + +export async function GET() { + const cookieStore = await cookies(); + const session = cookieStore.get("session"); + + // Safety: must be logged in + if (!session) { + return NextResponse.redirect( + new URL("/login", process.env.NEXT_PUBLIC_BASE_URL) + ); + } + + const params = new URLSearchParams({ + response_type: "code", + client_id: process.env.STRIPE_CLIENT_ID!, + scope: "read_only", + redirect_uri: process.env.STRIPE_REDIRECT_URI!, + }); + + const stripeAuthUrl = + `https://connect.stripe.com/oauth/authorize?${params.toString()}`; + + return NextResponse.redirect(stripeAuthUrl); +} diff --git a/stripe_to_invoice/app/login/page.tsx b/stripe_to_invoice/app/login/page.tsx index 913b4b7..e505048 100644 --- a/stripe_to_invoice/app/login/page.tsx +++ b/stripe_to_invoice/app/login/page.tsx @@ -44,7 +44,7 @@ export default function LoginPage() { setEmail(e.target.value)} diff --git a/stripe_to_invoice/deployment/deployment.yaml b/stripe_to_invoice/deployment/deployment.yaml index d39a919..750ccf6 100644 --- a/stripe_to_invoice/deployment/deployment.yaml +++ b/stripe_to_invoice/deployment/deployment.yaml @@ -78,6 +78,12 @@ spec: name: stripe-secrets key: SES_FROM_EMAIL + - name: STRIPE_REDIRECT_URI + valueFrom: + secretKeyRef: + name: stripe-secrets + key: STRIPE_REDIRECT_URI + imagePullSecrets: - name: registrypullsecret diff --git a/stripe_to_invoice/deployment/secrets/.env b/stripe_to_invoice/deployment/secrets/.env index 92441dc..5eae4b1 100644 --- a/stripe_to_invoice/deployment/secrets/.env +++ b/stripe_to_invoice/deployment/secrets/.env @@ -6,6 +6,7 @@ DEV_AWS_REGION=eu-west-2 DEV_AWS_ACCESS_KEY_ID=AKIAQL67W6HI2547OPVG DEV_AWS_SECRET_ACCESS_KEY=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 DEV_SES_FROM_EMAIL=no-reply@juntekim.com +PROD_STRIPE_REDIRECT_URI=https://stripe-to-invoice.dev.juntekim.com/api/stripe/callback # Prod @@ -16,4 +17,5 @@ PROD_AWS_REGION=eu-west-2 PROD_AWS_ACCESS_KEY_ID=AKIAQL67W6HI2547OPVG PROD_AWS_SECRET_ACCESS_KEY=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 PROD_SES_FROM_EMAIL=no-reply@juntekim.com +PROD_STRIPE_REDIRECT_URI=https://stripe-to-invoice.dev.juntekim.com/api/stripe/callback From b18cee80a041a38152df38e522c2830fee8a34d2 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 14:32:24 +0000 Subject: [PATCH 17/59] prod/dev spelling mistake for stripe redirect uri --- stripe_to_invoice/deployment/secrets/.env | 2 +- stripe_to_invoice/deployment/secrets/stripe-secrets.yaml | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/stripe_to_invoice/deployment/secrets/.env b/stripe_to_invoice/deployment/secrets/.env index 5eae4b1..0d292ce 100644 --- a/stripe_to_invoice/deployment/secrets/.env +++ b/stripe_to_invoice/deployment/secrets/.env @@ -6,7 +6,7 @@ DEV_AWS_REGION=eu-west-2 DEV_AWS_ACCESS_KEY_ID=AKIAQL67W6HI2547OPVG DEV_AWS_SECRET_ACCESS_KEY=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 DEV_SES_FROM_EMAIL=no-reply@juntekim.com -PROD_STRIPE_REDIRECT_URI=https://stripe-to-invoice.dev.juntekim.com/api/stripe/callback +DEV_STRIPE_REDIRECT_URI=https://stripe-to-invoice.dev.juntekim.com/api/stripe/callback # Prod diff --git a/stripe_to_invoice/deployment/secrets/stripe-secrets.yaml b/stripe_to_invoice/deployment/secrets/stripe-secrets.yaml index 9fa8ae9..33d7184 100644 --- a/stripe_to_invoice/deployment/secrets/stripe-secrets.yaml +++ b/stripe_to_invoice/deployment/secrets/stripe-secrets.yaml @@ -11,4 +11,5 @@ stringData: AWS_REGION: ${AWS_REGION} AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID} AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY} - SES_FROM_EMAIL: ${SES_FROM_EMAIL} \ No newline at end of file + SES_FROM_EMAIL: ${SES_FROM_EMAIL} + STRIPE_REDIRECT_URI: ${STRIPE_REDIRECT_URI} \ No newline at end of file From 7ff2bb4daf8f8369a8ee49fa1cba20b72ccb2e9e Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 14:36:48 +0000 Subject: [PATCH 18/59] prod/dev spelling mistake for stripe redirect uri --- .github/workflows/stripe-to-invoice.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/stripe-to-invoice.yml b/.github/workflows/stripe-to-invoice.yml index 0381277..a6debbf 100644 --- a/.github/workflows/stripe-to-invoice.yml +++ b/.github/workflows/stripe-to-invoice.yml @@ -128,7 +128,7 @@ jobs: AWS_ACCESS_KEY_ID=$PROD_AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY=$PROD_AWS_SECRET_ACCESS_KEY SES_FROM_EMAIL=$PROD_SES_FROM_EMAIL - STRIPE_REDIRECT_URI=$PROD_SES_STRIPE_REDIRECT_URI + STRIPE_REDIRECT_URI=$PROD_STRIPE_REDIRECT_URI else STRIPE_SECRET_KEY="$DEV_STRIPE_SECRET_KEY" STRIPE_CLIENT_ID="$DEV_STRIPE_CLIENT_ID" @@ -137,7 +137,7 @@ jobs: AWS_ACCESS_KEY_ID=$DEV_AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY=$DEV_AWS_SECRET_ACCESS_KEY SES_FROM_EMAIL=$DEV_SES_FROM_EMAIL - STRIPE_REDIRECT_URI=$DEV_SES_STRIPE_REDIRECT_URI + STRIPE_REDIRECT_URI=$DEV_STRIPE_REDIRECT_URI fi : "${STRIPE_SECRET_KEY:?missing STRIPE_SECRET_KEY}" : "${STRIPE_CLIENT_ID:?missing STRIPE_CLIENT_ID}" From 4f741841aba406743e4b7b4b10465e6b76f94ea5 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 14:40:47 +0000 Subject: [PATCH 19/59] prod/dev spelling mistake for stripe redirect uri --- stripe_to_invoice/app/api/stripe/connect/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stripe_to_invoice/app/api/stripe/connect/route.ts b/stripe_to_invoice/app/api/stripe/connect/route.ts index c065a95..97adf3b 100644 --- a/stripe_to_invoice/app/api/stripe/connect/route.ts +++ b/stripe_to_invoice/app/api/stripe/connect/route.ts @@ -15,7 +15,7 @@ export async function GET() { const params = new URLSearchParams({ response_type: "code", client_id: process.env.STRIPE_CLIENT_ID!, - scope: "read_only", + scope: "read_write", redirect_uri: process.env.STRIPE_REDIRECT_URI!, }); From 42833cd2ebb41daf4a43a95eb18c655697d4a8f5 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 14:47:31 +0000 Subject: [PATCH 20/59] added a success page --- .../app/connect/stripe/success/page.tsx | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 stripe_to_invoice/app/connect/stripe/success/page.tsx diff --git a/stripe_to_invoice/app/connect/stripe/success/page.tsx b/stripe_to_invoice/app/connect/stripe/success/page.tsx new file mode 100644 index 0000000..65a5a39 --- /dev/null +++ b/stripe_to_invoice/app/connect/stripe/success/page.tsx @@ -0,0 +1,23 @@ +export default function StripeSuccessPage() { + return ( +
+
+

+ Stripe Connected 🎉 +

+ +

+ Your Stripe account has been successfully connected. + You can now receive payments. +

+ + + Go to dashboard + +
+
+ ); +} From 94e50e1cc2efa118f130cc70318191ff4a7cb3bb Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 14:52:35 +0000 Subject: [PATCH 21/59] use app url --- stripe_to_invoice/app/api/stripe/callback/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stripe_to_invoice/app/api/stripe/callback/route.ts b/stripe_to_invoice/app/api/stripe/callback/route.ts index 443eaac..d4f6d79 100644 --- a/stripe_to_invoice/app/api/stripe/callback/route.ts +++ b/stripe_to_invoice/app/api/stripe/callback/route.ts @@ -83,6 +83,6 @@ export async function GET(req: NextRequest) { // MVP success redirect return NextResponse.redirect( - new URL("/connect/stripe/success", process.env.NEXT_PUBLIC_BASE_URL) + new URL("/connect/stripe/success", process.env.APP_URL) ); } From 6eb1aefdb4cc05e9a1990b3d8dc9af40cf431672 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 14:57:47 +0000 Subject: [PATCH 22/59] use app url --- stripe_to_invoice/deployment/secrets/.env | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stripe_to_invoice/deployment/secrets/.env b/stripe_to_invoice/deployment/secrets/.env index 0d292ce..f3f109b 100644 --- a/stripe_to_invoice/deployment/secrets/.env +++ b/stripe_to_invoice/deployment/secrets/.env @@ -1,7 +1,7 @@ # Test mode for deployment DEV_STRIPE_SECRET_KEY=sk_test_51Mo6PnBUc0gyz8XqrZqvWQWRQSUQbjt7zxP56lhdqgIG4qxn5zDuistUJJq8Chl7AxmyCy8xMRAh1Zf25jK0lYCb00QsQqNEsc DEV_STRIPE_CLIENT_ID=ca_NZFa6CNybMItWKir9Uk6ojevnYcP7Rbz -DEV_APP_URL=stripe-to-invoice.dev.juntekim.com +DEV_APP_URL=https://stripe-to-invoice.dev.juntekim.com DEV_AWS_REGION=eu-west-2 DEV_AWS_ACCESS_KEY_ID=AKIAQL67W6HI2547OPVG DEV_AWS_SECRET_ACCESS_KEY=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 @@ -12,7 +12,7 @@ DEV_STRIPE_REDIRECT_URI=https://stripe-to-invoice.dev.juntekim.com/api/stripe/ca # Prod PROD_STRIPE_SECRET_KEY=sk_test_51Mo6PnBUc0gyz8XqrZqvWQWRQSUQbjt7zxP56lhdqgIG4qxn5zDuistUJJq8Chl7AxmyCy8xMRAh1Zf25jK0lYCb00QsQqNEsc PROD_STRIPE_CLIENT_ID=ca_NZFa6CNybMItWKir9Uk6ojevnYcP7Rbz -PROD_APP_URL=stripe-to-invoice.juntekim.com +PROD_APP_URL=https://stripe-to-invoice.juntekim.com PROD_AWS_REGION=eu-west-2 PROD_AWS_ACCESS_KEY_ID=AKIAQL67W6HI2547OPVG PROD_AWS_SECRET_ACCESS_KEY=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 From eb7ac127e1442c41accae15ec4db6a987da4cea1 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 15:13:45 +0000 Subject: [PATCH 23/59] added stripe credentials to backend --- .../app/api/stripe/callback/route.ts | 63 ++++++++++--------- .../app/connect/stripe/refresh/page.tsx | 23 +++++++ .../app/connect/stripe/success/page.tsx | 56 ++++++++++++----- .../lib/schema/stripeAccounts.ts | 13 ++++ 4 files changed, 113 insertions(+), 42 deletions(-) create mode 100644 stripe_to_invoice/app/connect/stripe/refresh/page.tsx create mode 100644 stripe_to_invoice/lib/schema/stripeAccounts.ts diff --git a/stripe_to_invoice/app/api/stripe/callback/route.ts b/stripe_to_invoice/app/api/stripe/callback/route.ts index d4f6d79..e73565f 100644 --- a/stripe_to_invoice/app/api/stripe/callback/route.ts +++ b/stripe_to_invoice/app/api/stripe/callback/route.ts @@ -1,24 +1,26 @@ import { cookies } from "next/headers"; import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import { stripeAccounts } from "@/lib/schema/stripeAccounts"; +import { eq } from "drizzle-orm"; type StripeOAuthResponse = { - access_token: string; - refresh_token: string; - stripe_user_id: string; - scope: string; + stripe_user_id: string; // acct_... }; export async function GET(req: NextRequest) { const cookieStore = await cookies(); const session = cookieStore.get("session"); - // Safety: user must still be logged in + // 🔒 Must be logged in if (!session) { return NextResponse.redirect( new URL("/login", process.env.NEXT_PUBLIC_BASE_URL) ); } + const userId = session.value; + const { searchParams } = new URL(req.url); const code = searchParams.get("code"); const error = searchParams.get("error"); @@ -26,7 +28,10 @@ export async function GET(req: NextRequest) { if (error) { console.error("Stripe OAuth error:", error); return NextResponse.redirect( - new URL("/connect/stripe?error=oauth_failed", process.env.NEXT_PUBLIC_BASE_URL) + new URL( + "/connect/stripe?error=oauth_failed", + process.env.NEXT_PUBLIC_BASE_URL + ) ); } @@ -37,7 +42,7 @@ export async function GET(req: NextRequest) { ); } - // Exchange code for access token + // 🔁 Exchange OAuth code const tokenRes = await fetch("https://connect.stripe.com/oauth/token", { method: "POST", headers: { @@ -55,34 +60,36 @@ export async function GET(req: NextRequest) { console.error("Stripe token exchange failed:", text); return NextResponse.redirect( - new URL("/connect/stripe?error=token_exchange_failed", process.env.NEXT_PUBLIC_BASE_URL) + new URL( + "/connect/stripe?error=token_exchange_failed", + process.env.NEXT_PUBLIC_BASE_URL + ) ); } const data = (await tokenRes.json()) as StripeOAuthResponse; - /** - * TODO (NEXT STEP): - * - Encrypt tokens - * - Persist to DB against the current user - * - * Required fields: - * - data.stripe_user_id (acct_...) - * - data.access_token - * - data.refresh_token - * - mode: "test" - */ - - console.log("Stripe OAuth success", { - stripe_account_id: data.stripe_user_id, - scope: data.scope, - has_access_token: Boolean(data.access_token), - has_refresh_token: Boolean(data.refresh_token), - access_token_preview: data.access_token?.slice(0, 8) + "...", + // ✅ Persist Stripe account → user (UPSERT) + await db + .insert(stripeAccounts) + .values({ + userId, + stripeAccountId: data.stripe_user_id, + }) + .onConflictDoUpdate({ + target: stripeAccounts.userId, + set: { + stripeAccountId: data.stripe_user_id, + }, }); - // MVP success redirect + console.log("Stripe connected", { + userId, + stripeAccountId: data.stripe_user_id, + }); + + // ✅ Success redirect return NextResponse.redirect( - new URL("/connect/stripe/success", process.env.APP_URL) + new URL("/connect/stripe/success", process.env.NEXT_PUBLIC_BASE_URL) ); } diff --git a/stripe_to_invoice/app/connect/stripe/refresh/page.tsx b/stripe_to_invoice/app/connect/stripe/refresh/page.tsx new file mode 100644 index 0000000..92af400 --- /dev/null +++ b/stripe_to_invoice/app/connect/stripe/refresh/page.tsx @@ -0,0 +1,23 @@ +export default function StripeRefreshPage() { + return ( +
+
+

+ Stripe connection incomplete +

+ +

+ Something interrupted the Stripe onboarding. + Please try again. +

+ + + Retry Stripe setup + +
+
+ ); +} diff --git a/stripe_to_invoice/app/connect/stripe/success/page.tsx b/stripe_to_invoice/app/connect/stripe/success/page.tsx index 65a5a39..c46b272 100644 --- a/stripe_to_invoice/app/connect/stripe/success/page.tsx +++ b/stripe_to_invoice/app/connect/stripe/success/page.tsx @@ -1,23 +1,51 @@ +import Link from "next/link"; + export default function StripeSuccessPage() { return ( -
-
-

- Stripe Connected 🎉 -

+
+

+ Stripe connected 🎉 +

-

- Your Stripe account has been successfully connected. - You can now receive payments. -

+

+ Your Stripe account is now linked. We can now automate payments and + reconciliation for you. +

- +
  • + + Logged in +
  • + +
  • + + Stripe connected +
  • + +
  • + + Connect Xero +
  • + + + {/* Primary CTA */} +
    -
    + ); } diff --git a/stripe_to_invoice/lib/schema/stripeAccounts.ts b/stripe_to_invoice/lib/schema/stripeAccounts.ts new file mode 100644 index 0000000..4385192 --- /dev/null +++ b/stripe_to_invoice/lib/schema/stripeAccounts.ts @@ -0,0 +1,13 @@ +import { pgTable, uuid, text, timestamp } from "drizzle-orm/pg-core"; +import { users } from "./users"; + +export const stripeAccounts = pgTable("stripe_accounts", { + id: uuid("id").defaultRandom().primaryKey(), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + stripeAccountId: text("stripe_account_id").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); \ No newline at end of file From 4721a6e4598259698bd410563d39660ca60c8b10 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 15:20:24 +0000 Subject: [PATCH 24/59] run this on dev --- db/atlas/stripe_invoice/migrations/atlas.sum | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/db/atlas/stripe_invoice/migrations/atlas.sum b/db/atlas/stripe_invoice/migrations/atlas.sum index bdfc6b7..c28c70a 100644 --- a/db/atlas/stripe_invoice/migrations/atlas.sum +++ b/db/atlas/stripe_invoice/migrations/atlas.sum @@ -1,7 +1,8 @@ -h1:RjeUC9UfXpaaJorJ+072tmUmM0yLI4yO71Cuad9tjA4= +h1:DH3Cz/OQIGx4+qZEfbzkxAp/iyx2FtxHJMhqGZTWVXM= 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= 20251230154354_add_used_at_to_login_tokens.sql h1:FIP2MMRnfhi4hmFC3VBuABZZrxZQ1icranrXy0ljERc= +20260118151944_add_unique_index_to_stripe_accounts.sql h1:ledGh0mcsKtthfhg25kSglX4p69Chp8R3eoaLbDFpMY= From 914b7b0971284519ea39ae90ae8a66901e108c32 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 15:20:39 +0000 Subject: [PATCH 25/59] add new migration --- .../20260118151944_add_unique_index_to_stripe_accounts.sql | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 db/atlas/stripe_invoice/migrations/20260118151944_add_unique_index_to_stripe_accounts.sql diff --git a/db/atlas/stripe_invoice/migrations/20260118151944_add_unique_index_to_stripe_accounts.sql b/db/atlas/stripe_invoice/migrations/20260118151944_add_unique_index_to_stripe_accounts.sql new file mode 100644 index 0000000..f35d277 --- /dev/null +++ b/db/atlas/stripe_invoice/migrations/20260118151944_add_unique_index_to_stripe_accounts.sql @@ -0,0 +1,7 @@ +-- Ensure one Stripe account per user +CREATE UNIQUE INDEX stripe_accounts_user_unique +ON stripe_accounts (user_id); + +-- Prevent the same Stripe account being linked twice +CREATE UNIQUE INDEX stripe_accounts_stripe_account_unique +ON stripe_accounts (stripe_account_id); From dd1d5ca7c61a2cf3023f17de27e9924d2c8ba50c Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 15:26:43 +0000 Subject: [PATCH 26/59] add new migration --- .github/workflows/stripe-to-invoice.yml | 85 +++++++++++++------ .../stripe-to-invoice-dev-migrate.yaml | 26 ------ .../stripe-to-invoice-prod-migrate.yaml | 25 ------ 3 files changed, 59 insertions(+), 77 deletions(-) delete mode 100644 db/k8s/migrations/stripe-to-invoice-dev-migrate.yaml delete mode 100644 db/k8s/migrations/stripe-to-invoice-prod-migrate.yaml diff --git a/.github/workflows/stripe-to-invoice.yml b/.github/workflows/stripe-to-invoice.yml index a6debbf..6e77844 100644 --- a/.github/workflows/stripe-to-invoice.yml +++ b/.github/workflows/stripe-to-invoice.yml @@ -1,4 +1,4 @@ -name: Build & Deploy stripe-to-invoice (with DB secrets) +name: Build & Deploy stripe-to-invoice (with DB secrets + migrations) on: push: @@ -41,10 +41,10 @@ jobs: tags: docker.io/kimjunte/stripe_to_invoice:${{ env.GITHUB_REF_SLUG }} # -------------------------------------------------- - # APPLY DB SECRETS + # APPLY DB + APP SECRETS # -------------------------------------------------- secrets: - name: Apply runtime DB secret + name: Apply runtime secrets runs-on: mealcraft-runners needs: build @@ -92,7 +92,7 @@ jobs: echo "POSTGRES_DB=stripe_invoice" >> $GITHUB_ENV fi - - name: Load DB creds from db/.env and apply secret + - name: Apply DB secret run: | set -a source db/.env @@ -112,8 +112,8 @@ jobs: --namespace $NAMESPACE \ --from-literal=DATABASE_URL="$DATABASE_URL" \ --dry-run=client -o yaml | kubectl apply -f - - - - name: Apply Next env/secrets + + - name: Apply app secrets run: | set -e set -a @@ -123,45 +123,78 @@ jobs: if [[ "$ENV" == "prod" ]]; then STRIPE_SECRET_KEY="$PROD_STRIPE_SECRET_KEY" STRIPE_CLIENT_ID="$PROD_STRIPE_CLIENT_ID" - APP_URL=$PROD_APP_URL - AWS_REGION=$PROD_AWS_REGION - AWS_ACCESS_KEY_ID=$PROD_AWS_ACCESS_KEY_ID - AWS_SECRET_ACCESS_KEY=$PROD_AWS_SECRET_ACCESS_KEY - SES_FROM_EMAIL=$PROD_SES_FROM_EMAIL - STRIPE_REDIRECT_URI=$PROD_STRIPE_REDIRECT_URI + APP_URL="$PROD_APP_URL" else STRIPE_SECRET_KEY="$DEV_STRIPE_SECRET_KEY" STRIPE_CLIENT_ID="$DEV_STRIPE_CLIENT_ID" - APP_URL=$DEV_APP_URL - AWS_REGION=$DEV_AWS_REGION - AWS_ACCESS_KEY_ID=$DEV_AWS_ACCESS_KEY_ID - AWS_SECRET_ACCESS_KEY=$DEV_AWS_SECRET_ACCESS_KEY - SES_FROM_EMAIL=$DEV_SES_FROM_EMAIL - STRIPE_REDIRECT_URI=$DEV_STRIPE_REDIRECT_URI + APP_URL="$DEV_APP_URL" fi + : "${STRIPE_SECRET_KEY:?missing STRIPE_SECRET_KEY}" : "${STRIPE_CLIENT_ID:?missing STRIPE_CLIENT_ID}" - : "${NAMESPACE:?missing NAMESPACE}" : "${APP_URL:?missing APP_URL}" - : "${AWS_REGION:?missing AWS_REGION}" - : "${AWS_ACCESS_KEY_ID:?missing AWS_ACCESS_KEY_ID}" - : "${AWS_SECRET_ACCESS_KEY:?missing AWS_SECRET_ACCESS_KEY}" - : "${SES_FROM_EMAIL:?missing SES_FROM_EMAIL}" - : "${STRIPE_REDIRECT_URI:?missing STRIPE_REDIRECT_URI}" - export STRIPE_SECRET_KEY STRIPE_CLIENT_ID NAMESPACE APP_URL AWS_REGION AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY SES_FROM_EMAIL STRIPE_REDIRECT_URI + export STRIPE_SECRET_KEY STRIPE_CLIENT_ID APP_URL NAMESPACE envsubst < stripe_to_invoice/deployment/secrets/stripe-secrets.yaml \ | kubectl apply -f - # -------------------------------------------------- - # DEPLOY APP + # RUN ATLAS MIGRATIONS + # -------------------------------------------------- + migrate: + name: Run DB migrations (Atlas) + runs-on: ubuntu-22.04 + needs: secrets + + steps: + - uses: actions/checkout@v4 + + - name: Install Atlas + uses: ariga/setup-atlas@v0 + + - name: Decide environment + run: | + if [[ "$GITHUB_REF" == refs/heads/main || "$GITHUB_REF" == refs/tags/* || "$GITHUB_REF" == refs/heads/release/* ]]; then + echo "ENV=prod" >> $GITHUB_ENV + echo "POSTGRES_HOST=postgres-prod.default.svc.cluster.local" >> $GITHUB_ENV + echo "POSTGRES_DB=stripe_invoice" >> $GITHUB_ENV + else + echo "ENV=dev" >> $GITHUB_ENV + echo "POSTGRES_HOST=postgres-dev.dev.svc.cluster.local" >> $GITHUB_ENV + echo "POSTGRES_DB=stripe_invoice" >> $GITHUB_ENV + fi + + - name: Run migrations + run: | + set -e + set -a + source db/.env + set +a + + if [[ "$ENV" == "prod" ]]; then + USER="$PROD_POSTGRES_USER" + PASS="$PROD_POSTGRES_PASSWORD" + else + USER="$DEV_POSTGRES_USER" + PASS="$DEV_POSTGRES_PASSWORD" + fi + + DATABASE_URL="postgres://${USER}:${PASS}@${POSTGRES_HOST}:5432/${POSTGRES_DB}?sslmode=disable" + + atlas migrate apply \ + --dir file://migrations \ + --url "$DATABASE_URL" + + # -------------------------------------------------- + # DEPLOY APPLICATION # -------------------------------------------------- deploy: runs-on: mealcraft-runners needs: - build - secrets + - migrate steps: - uses: actions/checkout@v4 diff --git a/db/k8s/migrations/stripe-to-invoice-dev-migrate.yaml b/db/k8s/migrations/stripe-to-invoice-dev-migrate.yaml deleted file mode 100644 index 9619e1b..0000000 --- a/db/k8s/migrations/stripe-to-invoice-dev-migrate.yaml +++ /dev/null @@ -1,26 +0,0 @@ -# apiVersion: batch/v1 -# kind: Job -# metadata: -# name: atlas-migrate-dev -# spec: -# template: -# spec: -# restartPolicy: Never -# containers: -# - name: atlas -# image: arigaio/atlas:latest -# command: ["/atlas"] -# args: ["migrate", "apply", "--env", "stripe_invoice"] -# envFrom: -# - secretRef: -# name: postgres-secret - - - - -# # You can run this: -# # kubectl apply -f k8s/migrations/atlas-job.yaml -# # Or later from CI. - - -#doesn't work - 28/12/2025 \ No newline at end of file diff --git a/db/k8s/migrations/stripe-to-invoice-prod-migrate.yaml b/db/k8s/migrations/stripe-to-invoice-prod-migrate.yaml deleted file mode 100644 index 16aa865..0000000 --- a/db/k8s/migrations/stripe-to-invoice-prod-migrate.yaml +++ /dev/null @@ -1,25 +0,0 @@ -# apiVersion: batch/v1 -# kind: Job -# metadata: -# name: atlas-migrate-dev -# spec: -# template: -# spec: -# restartPolicy: Never -# containers: -# - name: atlas -# image: arigaio/atlas:latest -# command: ["migrate", "apply", "--env", "stripe_invoice"] -# envFrom: -# - secretRef: -# name: postgres-secret - - - - -# # You can run this: -# # kubectl apply -f k8s/migrations/atlas-job.yaml -# # Or later from CI. - - -#doesn't work - 28/12/2025 \ No newline at end of file From 8d5359d232f83ba9bd308ef8ab460fddbf37e07f Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 15:34:01 +0000 Subject: [PATCH 27/59] migrateion proper --- .github/workflows/stripe-to-invoice.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/stripe-to-invoice.yml b/.github/workflows/stripe-to-invoice.yml index 6e77844..6177ba1 100644 --- a/.github/workflows/stripe-to-invoice.yml +++ b/.github/workflows/stripe-to-invoice.yml @@ -181,9 +181,8 @@ jobs: fi DATABASE_URL="postgres://${USER}:${PASS}@${POSTGRES_HOST}:5432/${POSTGRES_DB}?sslmode=disable" - atlas migrate apply \ - --dir file://migrations \ + --dir file://db/atlas/stripe_invoice/migrations \ --url "$DATABASE_URL" # -------------------------------------------------- From 3be6faac94d6d5888df93036e02d1953f848b890 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 15:38:47 +0000 Subject: [PATCH 28/59] atlas sum --- db/atlas/stripe_invoice/migrations/atlas.sum | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/db/atlas/stripe_invoice/migrations/atlas.sum b/db/atlas/stripe_invoice/migrations/atlas.sum index c28c70a..ee95fa4 100644 --- a/db/atlas/stripe_invoice/migrations/atlas.sum +++ b/db/atlas/stripe_invoice/migrations/atlas.sum @@ -1,8 +1,8 @@ -h1:DH3Cz/OQIGx4+qZEfbzkxAp/iyx2FtxHJMhqGZTWVXM= +h1:DR4yJ9fatAVhOP+U23Yz+bOzijyhq/720tACLkaFuXw= 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= 20251230154354_add_used_at_to_login_tokens.sql h1:FIP2MMRnfhi4hmFC3VBuABZZrxZQ1icranrXy0ljERc= -20260118151944_add_unique_index_to_stripe_accounts.sql h1:ledGh0mcsKtthfhg25kSglX4p69Chp8R3eoaLbDFpMY= +20260118151944_add_unique_index_to_stripe_accounts.sql h1:PyI8cM8Xyn4bG7BBlD7YRwK1iRQ8HPfzf0r1+Swfe1Y= From 6962d2f3a1e6adaf3cf697ded3f879bc95db2700 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 15:47:32 +0000 Subject: [PATCH 29/59] run on my runnner: --- .github/workflows/stripe-to-invoice.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stripe-to-invoice.yml b/.github/workflows/stripe-to-invoice.yml index 6177ba1..2f82987 100644 --- a/.github/workflows/stripe-to-invoice.yml +++ b/.github/workflows/stripe-to-invoice.yml @@ -144,7 +144,7 @@ jobs: # -------------------------------------------------- migrate: name: Run DB migrations (Atlas) - runs-on: ubuntu-22.04 + runs-on: mealcraft-runners needs: secrets steps: From b02ee5f74b0cdaf31f16e4b53546023e0ffc26ad Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 15:51:43 +0000 Subject: [PATCH 30/59] use app url --- .../app/api/stripe/callback/route.ts | 63 +++++++++---------- 1 file changed, 28 insertions(+), 35 deletions(-) diff --git a/stripe_to_invoice/app/api/stripe/callback/route.ts b/stripe_to_invoice/app/api/stripe/callback/route.ts index e73565f..d4f6d79 100644 --- a/stripe_to_invoice/app/api/stripe/callback/route.ts +++ b/stripe_to_invoice/app/api/stripe/callback/route.ts @@ -1,26 +1,24 @@ import { cookies } from "next/headers"; import { NextRequest, NextResponse } from "next/server"; -import { db } from "@/lib/db"; -import { stripeAccounts } from "@/lib/schema/stripeAccounts"; -import { eq } from "drizzle-orm"; type StripeOAuthResponse = { - stripe_user_id: string; // acct_... + access_token: string; + refresh_token: string; + stripe_user_id: string; + scope: string; }; export async function GET(req: NextRequest) { const cookieStore = await cookies(); const session = cookieStore.get("session"); - // 🔒 Must be logged in + // Safety: user must still be logged in if (!session) { return NextResponse.redirect( new URL("/login", process.env.NEXT_PUBLIC_BASE_URL) ); } - const userId = session.value; - const { searchParams } = new URL(req.url); const code = searchParams.get("code"); const error = searchParams.get("error"); @@ -28,10 +26,7 @@ export async function GET(req: NextRequest) { if (error) { console.error("Stripe OAuth error:", error); return NextResponse.redirect( - new URL( - "/connect/stripe?error=oauth_failed", - process.env.NEXT_PUBLIC_BASE_URL - ) + new URL("/connect/stripe?error=oauth_failed", process.env.NEXT_PUBLIC_BASE_URL) ); } @@ -42,7 +37,7 @@ export async function GET(req: NextRequest) { ); } - // 🔁 Exchange OAuth code + // Exchange code for access token const tokenRes = await fetch("https://connect.stripe.com/oauth/token", { method: "POST", headers: { @@ -60,36 +55,34 @@ export async function GET(req: NextRequest) { console.error("Stripe token exchange failed:", text); return NextResponse.redirect( - new URL( - "/connect/stripe?error=token_exchange_failed", - process.env.NEXT_PUBLIC_BASE_URL - ) + new URL("/connect/stripe?error=token_exchange_failed", process.env.NEXT_PUBLIC_BASE_URL) ); } const data = (await tokenRes.json()) as StripeOAuthResponse; - // ✅ Persist Stripe account → user (UPSERT) - await db - .insert(stripeAccounts) - .values({ - userId, - stripeAccountId: data.stripe_user_id, - }) - .onConflictDoUpdate({ - target: stripeAccounts.userId, - set: { - stripeAccountId: data.stripe_user_id, - }, + /** + * TODO (NEXT STEP): + * - Encrypt tokens + * - Persist to DB against the current user + * + * Required fields: + * - data.stripe_user_id (acct_...) + * - data.access_token + * - data.refresh_token + * - mode: "test" + */ + + console.log("Stripe OAuth success", { + stripe_account_id: data.stripe_user_id, + scope: data.scope, + has_access_token: Boolean(data.access_token), + has_refresh_token: Boolean(data.refresh_token), + access_token_preview: data.access_token?.slice(0, 8) + "...", }); - console.log("Stripe connected", { - userId, - stripeAccountId: data.stripe_user_id, - }); - - // ✅ Success redirect + // MVP success redirect return NextResponse.redirect( - new URL("/connect/stripe/success", process.env.NEXT_PUBLIC_BASE_URL) + new URL("/connect/stripe/success", process.env.APP_URL) ); } From f285e9784325d4b45f1ba1d7a8c43c05850d284e Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 16:53:06 +0000 Subject: [PATCH 31/59] added new migration stuff --- .github/workflows/stripe-to-invoice.yml | 22 +++- db/.env | 1 + .../20260118165004_add_unique_for_xero.sql | 7 ++ db/atlas/stripe_invoice/migrations/atlas.sum | 3 +- .../app/api/xero/callback/route.ts | 102 ++++++++++++++++++ .../app/api/xero/connect/route.ts | 20 ++++ .../app/connect/stripe/success/page.tsx | 17 +-- stripe_to_invoice/app/connect/xero/page.tsx | 74 +++++++++++++ .../app/connect/xero/success/page.tsx | 44 ++++++++ stripe_to_invoice/deployment/deployment.yaml | 18 ++++ stripe_to_invoice/deployment/secrets/.env | 6 ++ .../lib/schema/xeroConnections.ts | 13 +++ 12 files changed, 313 insertions(+), 14 deletions(-) create mode 100644 db/atlas/stripe_invoice/migrations/20260118165004_add_unique_for_xero.sql create mode 100644 stripe_to_invoice/app/api/xero/callback/route.ts create mode 100644 stripe_to_invoice/app/api/xero/connect/route.ts create mode 100644 stripe_to_invoice/app/connect/xero/page.tsx create mode 100644 stripe_to_invoice/app/connect/xero/success/page.tsx create mode 100644 stripe_to_invoice/lib/schema/xeroConnections.ts diff --git a/.github/workflows/stripe-to-invoice.yml b/.github/workflows/stripe-to-invoice.yml index 2f82987..7e66301 100644 --- a/.github/workflows/stripe-to-invoice.yml +++ b/.github/workflows/stripe-to-invoice.yml @@ -124,17 +124,37 @@ jobs: STRIPE_SECRET_KEY="$PROD_STRIPE_SECRET_KEY" STRIPE_CLIENT_ID="$PROD_STRIPE_CLIENT_ID" APP_URL="$PROD_APP_URL" + XERO_CLIENT_ID="$PROD_XERO_CLIENT_ID" + XERO_CLIENT_SECRET="$PROD_CLIENT_SECRET" + XERO_REDIRECT_URI="$PROD_REDIRECT_URI" + + else STRIPE_SECRET_KEY="$DEV_STRIPE_SECRET_KEY" STRIPE_CLIENT_ID="$DEV_STRIPE_CLIENT_ID" APP_URL="$DEV_APP_URL" + XERO_CLIENT_ID="$DEV_XERO_CLIENT_ID" + XERO_CLIENT_SECRET="$DEV_CLIENT_SECRET" + XERO_REDIRECT_URI="$DEV_REDIRECT_URI" fi : "${STRIPE_SECRET_KEY:?missing STRIPE_SECRET_KEY}" : "${STRIPE_CLIENT_ID:?missing STRIPE_CLIENT_ID}" : "${APP_URL:?missing APP_URL}" + : "${STRIPE_REDIRECT_URI:?missing STRIPE_REDIRECT_URI}" + : "${XERO_CLIENT_ID:?missing XERO_CLIENT_ID}" + : "${XERO_CLIENT_SECRET:?missing XERO_CLIENT_SECRET}" + : "${XERO_REDIRECT_URI:?missing XERO_REDIRECT_URI}" - export STRIPE_SECRET_KEY STRIPE_CLIENT_ID APP_URL NAMESPACE + export \ + STRIPE_SECRET_KEY \ + STRIPE_CLIENT_ID \ + STRIPE_REDIRECT_URI \ + APP_URL \ + XERO_CLIENT_ID \ + XERO_CLIENT_SECRET \ + XERO_REDIRECT_URI \ + NAMESPACE envsubst < stripe_to_invoice/deployment/secrets/stripe-secrets.yaml \ | kubectl apply -f - diff --git a/db/.env b/db/.env index 4dcaee0..d7f3828 100644 --- a/db/.env +++ b/db/.env @@ -1,4 +1,5 @@ # Dev Stripe-to-invoice +# postgres-dev.dev.svc.cluster.local DEV_POSTGRES_USER=postgres DEV_POSTGRES_PASSWORD=averysecretpasswordPersonAppleWinter938 diff --git a/db/atlas/stripe_invoice/migrations/20260118165004_add_unique_for_xero.sql b/db/atlas/stripe_invoice/migrations/20260118165004_add_unique_for_xero.sql new file mode 100644 index 0000000..8741258 --- /dev/null +++ b/db/atlas/stripe_invoice/migrations/20260118165004_add_unique_for_xero.sql @@ -0,0 +1,7 @@ +-- Ensure one Xero connection per user +CREATE UNIQUE INDEX xero_connections_user_unique +ON xero_connections (user_id); + +-- Prevent the same Xero organisation being linked twice +CREATE UNIQUE INDEX xero_connections_tenant_unique +ON xero_connections (tenant_id); diff --git a/db/atlas/stripe_invoice/migrations/atlas.sum b/db/atlas/stripe_invoice/migrations/atlas.sum index ee95fa4..5b9f45d 100644 --- a/db/atlas/stripe_invoice/migrations/atlas.sum +++ b/db/atlas/stripe_invoice/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:DR4yJ9fatAVhOP+U23Yz+bOzijyhq/720tACLkaFuXw= +h1:FS8jSKRjrxTpVXMVhNisHxEgUk/fmiQEEpBMvdqVh88= 0001_init.sql h1:gzb02ZbjrrJkXOC+2qIZsngnj7A+29O2/b4awScPlPs= 0002_auth.sql h1:4NhBu26dIBMy9gxMxM3tf6Z2CS2kfKlGjFBj07T/aBw= 0003_stripe_xero.sql h1:E2bcdUDnondsXwbdIwVlZqR4DQwzcoDiyeRFJwVxXwg= @@ -6,3 +6,4 @@ h1:DR4yJ9fatAVhOP+U23Yz+bOzijyhq/720tACLkaFuXw= 20251228182659_add_used_at_to_login_tokens.sql h1:/0puYQvwBFzpfSKjiZj2XR/7Mui39lS/IbFZW1TPQOc= 20251230154354_add_used_at_to_login_tokens.sql h1:FIP2MMRnfhi4hmFC3VBuABZZrxZQ1icranrXy0ljERc= 20260118151944_add_unique_index_to_stripe_accounts.sql h1:PyI8cM8Xyn4bG7BBlD7YRwK1iRQ8HPfzf0r1+Swfe1Y= +20260118165004_add_unique_for_xero.sql h1:/qk/tJiDo6wMnOdDnmEjKMwx2TmxpdQWmpdliaw6xZ8= diff --git a/stripe_to_invoice/app/api/xero/callback/route.ts b/stripe_to_invoice/app/api/xero/callback/route.ts new file mode 100644 index 0000000..0a16570 --- /dev/null +++ b/stripe_to_invoice/app/api/xero/callback/route.ts @@ -0,0 +1,102 @@ +import { cookies } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import { xeroConnections } from "@/lib/schema/xeroConnections"; +import { eq } from "drizzle-orm"; + +type XeroTokenResponse = { + access_token: string; + refresh_token: string; +}; + +type XeroTenant = { + tenantId: string; +}; + +export async function GET(req: NextRequest) { + const cookieStore = await cookies(); + const session = cookieStore.get("session"); + + // Must be logged in + if (!session) { + return NextResponse.redirect( + new URL("/login", process.env.APP_URL) + ); + } + + const userId = session.value; + + const { searchParams } = new URL(req.url); + const code = searchParams.get("code"); + + if (!code) { + return NextResponse.json( + { error: "Missing OAuth code" }, + { status: 400 } + ); + } + + // Exchange code for token + const tokenRes = await fetch("https://identity.xero.com/connect/token", { + method: "POST", + headers: { + Authorization: + "Basic " + + Buffer.from( + `${process.env.XERO_CLIENT_ID}:${process.env.XERO_CLIENT_SECRET}` + ).toString("base64"), + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: process.env.XERO_REDIRECT_URI!, + }), + }); + + if (!tokenRes.ok) { + const text = await tokenRes.text(); + console.error("Xero token exchange failed:", text); + return NextResponse.redirect( + new URL("/connect/xero?error=token_failed", process.env.APP_URL) + ); + } + + const tokenData = (await tokenRes.json()) as XeroTokenResponse; + + // Fetch connected tenants (organisations) + const tenantRes = await fetch( + "https://api.xero.com/connections", + { + headers: { + Authorization: `Bearer ${tokenData.access_token}`, + }, + } + ); + + const tenants = (await tenantRes.json()) as XeroTenant[]; + const tenantId = tenants[0]?.tenantId; + + if (!tenantId) { + return NextResponse.json( + { error: "No Xero organisation found" }, + { status: 400 } + ); + } + + // Save user ↔ tenant (minimal MVP) + await db + .insert(xeroConnections) + .values({ + userId, + tenantId, + }) + .onConflictDoUpdate({ + target: xeroConnections.userId, + set: { tenantId }, + }); + + return NextResponse.redirect( + new URL("/connect/xero/success", process.env.APP_URL) + ); +} diff --git a/stripe_to_invoice/app/api/xero/connect/route.ts b/stripe_to_invoice/app/api/xero/connect/route.ts new file mode 100644 index 0000000..4b81ab2 --- /dev/null +++ b/stripe_to_invoice/app/api/xero/connect/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; + +export async function GET() { + const params = new URLSearchParams({ + response_type: "code", + client_id: process.env.XERO_CLIENT_ID!, + redirect_uri: process.env.XERO_REDIRECT_URI!, + scope: [ + "offline_access", + "accounting.transactions", + "accounting.contacts", + "accounting.settings", + ].join(" "), + state: "xero_oauth", + }); + + return NextResponse.redirect( + `https://login.xero.com/identity/connect/authorize?${params}` + ); +} diff --git a/stripe_to_invoice/app/connect/stripe/success/page.tsx b/stripe_to_invoice/app/connect/stripe/success/page.tsx index c46b272..85efe16 100644 --- a/stripe_to_invoice/app/connect/stripe/success/page.tsx +++ b/stripe_to_invoice/app/connect/stripe/success/page.tsx @@ -8,8 +8,8 @@ export default function StripeSuccessPage() {

    - Your Stripe account is now linked. We can now automate payments and - reconciliation for you. + Your Stripe account is now linked. We can now detect successful + payments and automatically reconcile invoices in Xero.

    {/* Progress */} @@ -31,19 +31,12 @@ export default function StripeSuccessPage() { {/* Primary CTA */} -
    +
    - Continue setup - - - - Go to dashboard + Continue → Connect Xero
    diff --git a/stripe_to_invoice/app/connect/xero/page.tsx b/stripe_to_invoice/app/connect/xero/page.tsx new file mode 100644 index 0000000..30afae5 --- /dev/null +++ b/stripe_to_invoice/app/connect/xero/page.tsx @@ -0,0 +1,74 @@ +// STEP 3 — Connect Xero +// Purpose: +// - Explain why Xero access is needed +// - Make the next step obvious +// - Match the Stripe connect page exactly + +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; + +export default async function ConnectXeroPage() { + const cookieStore = await cookies(); + const session = cookieStore.get("session"); + + // Safety: if not logged in, bounce to login + if (!session) { + redirect("/login"); + } + + return ( +
    + {/* -------------------------------------------------- + Header + -------------------------------------------------- */} +
    +

    + Connect Xero +

    + +

    + We need access to your Xero organisation so we can automatically + create invoices and mark them as paid when Stripe payments succeed. +

    +
    + + {/* -------------------------------------------------- + What will happen + -------------------------------------------------- */} +
    +

    + What happens next +

    + +
      +
    • You’ll be redirected to Xero
    • +
    • You’ll choose which organisation to connect
    • +
    • You’ll be sent back here once connected
    • +
    +
    + + {/* -------------------------------------------------- + Trust / reassurance + -------------------------------------------------- */} +
    +

    + We never see your Xero password. +
    + Access can be revoked at any time from Xero. +

    +
    + + {/* -------------------------------------------------- + Primary action + -------------------------------------------------- */} +
    + + Connect Xero → + +
    +
    + ); +} diff --git a/stripe_to_invoice/app/connect/xero/success/page.tsx b/stripe_to_invoice/app/connect/xero/success/page.tsx new file mode 100644 index 0000000..46dd5c9 --- /dev/null +++ b/stripe_to_invoice/app/connect/xero/success/page.tsx @@ -0,0 +1,44 @@ +import Link from "next/link"; + +export default function XeroSuccessPage() { + return ( +
    +

    + Xero connected 🎉 +

    + +

    + Your Xero organisation is now linked. We can now automatically + create invoices and mark them as paid when Stripe payments succeed. +

    + + {/* Progress */} +
      +
    1. + + Logged in +
    2. + +
    3. + + Stripe connected +
    4. + +
    5. + + Xero connected +
    6. +
    + + {/* Primary CTA */} +
    + + Go to dashboard → + +
    +
    + ); +} diff --git a/stripe_to_invoice/deployment/deployment.yaml b/stripe_to_invoice/deployment/deployment.yaml index 750ccf6..76e867d 100644 --- a/stripe_to_invoice/deployment/deployment.yaml +++ b/stripe_to_invoice/deployment/deployment.yaml @@ -84,6 +84,24 @@ spec: name: stripe-secrets key: STRIPE_REDIRECT_URI + - name: XERO_CLIENT_ID + valueFrom: + secretKeyRef: + name: stripe-secrets + key: XERO_CLIENT_ID + + - name: XERO_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: stripe-secrets + key: XERO_CLIENT_SECRET + + - name: XERO_REDIRECT_URI + valueFrom: + secretKeyRef: + name: stripe-secrets + key: XERO_REDIRECT_URI + imagePullSecrets: - name: registrypullsecret diff --git a/stripe_to_invoice/deployment/secrets/.env b/stripe_to_invoice/deployment/secrets/.env index f3f109b..315b2d3 100644 --- a/stripe_to_invoice/deployment/secrets/.env +++ b/stripe_to_invoice/deployment/secrets/.env @@ -7,6 +7,9 @@ DEV_AWS_ACCESS_KEY_ID=AKIAQL67W6HI2547OPVG DEV_AWS_SECRET_ACCESS_KEY=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 DEV_SES_FROM_EMAIL=no-reply@juntekim.com DEV_STRIPE_REDIRECT_URI=https://stripe-to-invoice.dev.juntekim.com/api/stripe/callback +DEV_XERO_CLIENT_ID=4C24EEA5583046519AD39B3905ED2BD3 +DEV_XERO_SECRET_KEY=PAYDhzqMLvNtPrN5vDC7iwtXkgu99yG8Gbu86IlrdHH8hGjA +DEV_XERO_REDIRECT_URI=https://stripe-to-invoice.dev.juntekim.com/api/connect/xero/callback # Prod @@ -18,4 +21,7 @@ PROD_AWS_ACCESS_KEY_ID=AKIAQL67W6HI2547OPVG PROD_AWS_SECRET_ACCESS_KEY=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 PROD_SES_FROM_EMAIL=no-reply@juntekim.com PROD_STRIPE_REDIRECT_URI=https://stripe-to-invoice.dev.juntekim.com/api/stripe/callback +PROD_XERO_CLIENT_ID=4C24EEA5583046519AD39B3905ED2BD3 +PROD_XERO_SECRET_KEY=PAYDhzqMLvNtPrN5vDC7iwtXkgu99yG8Gbu86IlrdHH8hGjA +PROD_XERO_REDIRECT_URI=https://stripe-to-invoice.juntekim.com/api/connect/xero/callback diff --git a/stripe_to_invoice/lib/schema/xeroConnections.ts b/stripe_to_invoice/lib/schema/xeroConnections.ts new file mode 100644 index 0000000..eeb0c4b --- /dev/null +++ b/stripe_to_invoice/lib/schema/xeroConnections.ts @@ -0,0 +1,13 @@ +import { pgTable, uuid, text, timestamp } from "drizzle-orm/pg-core"; +import { users } from "./users"; + +export const xeroConnections = pgTable("xero_connections", { + id: uuid("id").defaultRandom().primaryKey(), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + tenantId: text("tenant_id").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); From fcc4e8ad57601d8731de1ab1e8e86bd602282aba Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 16:56:16 +0000 Subject: [PATCH 32/59] added new migration stuff --- .github/workflows/stripe-to-invoice.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/stripe-to-invoice.yml b/.github/workflows/stripe-to-invoice.yml index 7e66301..3e6be12 100644 --- a/.github/workflows/stripe-to-invoice.yml +++ b/.github/workflows/stripe-to-invoice.yml @@ -123,6 +123,7 @@ jobs: if [[ "$ENV" == "prod" ]]; then STRIPE_SECRET_KEY="$PROD_STRIPE_SECRET_KEY" STRIPE_CLIENT_ID="$PROD_STRIPE_CLIENT_ID" + STRIPE_REDIRECT_URI="$PROD_STRIPE_REDIRECT_URI" APP_URL="$PROD_APP_URL" XERO_CLIENT_ID="$PROD_XERO_CLIENT_ID" XERO_CLIENT_SECRET="$PROD_CLIENT_SECRET" @@ -132,6 +133,7 @@ jobs: else STRIPE_SECRET_KEY="$DEV_STRIPE_SECRET_KEY" STRIPE_CLIENT_ID="$DEV_STRIPE_CLIENT_ID" + STRIPE_REDIRECT_URI="$DEV_STRIPE_REDIRECT_URI" APP_URL="$DEV_APP_URL" XERO_CLIENT_ID="$DEV_XERO_CLIENT_ID" XERO_CLIENT_SECRET="$DEV_CLIENT_SECRET" From 7487771e999be8ab6b14c6923b18491b33056859 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 17:00:39 +0000 Subject: [PATCH 33/59] added new migration stuff --- .github/workflows/stripe-to-invoice.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/stripe-to-invoice.yml b/.github/workflows/stripe-to-invoice.yml index 3e6be12..41c73be 100644 --- a/.github/workflows/stripe-to-invoice.yml +++ b/.github/workflows/stripe-to-invoice.yml @@ -136,8 +136,8 @@ jobs: STRIPE_REDIRECT_URI="$DEV_STRIPE_REDIRECT_URI" APP_URL="$DEV_APP_URL" XERO_CLIENT_ID="$DEV_XERO_CLIENT_ID" - XERO_CLIENT_SECRET="$DEV_CLIENT_SECRET" - XERO_REDIRECT_URI="$DEV_REDIRECT_URI" + XERO_CLIENT_SECRET="$DEV_XERO_CLIENT_SECRET" + XERO_REDIRECT_URI="$DEV_XERO_REDIRECT_URI" fi : "${STRIPE_SECRET_KEY:?missing STRIPE_SECRET_KEY}" From dc9f175c87efd76a9d0f4b307170a7e99e67a184 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 17:04:45 +0000 Subject: [PATCH 34/59] added new migration stuff --- .github/workflows/stripe-to-invoice.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/stripe-to-invoice.yml b/.github/workflows/stripe-to-invoice.yml index 41c73be..74f6611 100644 --- a/.github/workflows/stripe-to-invoice.yml +++ b/.github/workflows/stripe-to-invoice.yml @@ -126,7 +126,7 @@ jobs: STRIPE_REDIRECT_URI="$PROD_STRIPE_REDIRECT_URI" APP_URL="$PROD_APP_URL" XERO_CLIENT_ID="$PROD_XERO_CLIENT_ID" - XERO_CLIENT_SECRET="$PROD_CLIENT_SECRET" + XERO_CLIENT_SECRET="$PROD_XERO_SECRET_KEY" XERO_REDIRECT_URI="$PROD_REDIRECT_URI" @@ -136,7 +136,7 @@ jobs: STRIPE_REDIRECT_URI="$DEV_STRIPE_REDIRECT_URI" APP_URL="$DEV_APP_URL" XERO_CLIENT_ID="$DEV_XERO_CLIENT_ID" - XERO_CLIENT_SECRET="$DEV_XERO_CLIENT_SECRET" + XERO_CLIENT_SECRET="$DEV_XERO_SECRET_KEY: XERO_REDIRECT_URI="$DEV_XERO_REDIRECT_URI" fi From b6551ad37c0c0ab6ba867df45c4b9220337709d1 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 17:07:08 +0000 Subject: [PATCH 35/59] added new migration stuff --- .github/workflows/stripe-to-invoice.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stripe-to-invoice.yml b/.github/workflows/stripe-to-invoice.yml index 74f6611..f3bc1d0 100644 --- a/.github/workflows/stripe-to-invoice.yml +++ b/.github/workflows/stripe-to-invoice.yml @@ -136,7 +136,7 @@ jobs: STRIPE_REDIRECT_URI="$DEV_STRIPE_REDIRECT_URI" APP_URL="$DEV_APP_URL" XERO_CLIENT_ID="$DEV_XERO_CLIENT_ID" - XERO_CLIENT_SECRET="$DEV_XERO_SECRET_KEY: + XERO_CLIENT_SECRET="$DEV_XERO_SECRET_KEY" XERO_REDIRECT_URI="$DEV_XERO_REDIRECT_URI" fi From be639f6d6e5dddb851e81afe417983677373705e Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 17:09:42 +0000 Subject: [PATCH 36/59] migration atlas.sum --- db/atlas/stripe_invoice/migrations/atlas.sum | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/db/atlas/stripe_invoice/migrations/atlas.sum b/db/atlas/stripe_invoice/migrations/atlas.sum index 5b9f45d..ee86f48 100644 --- a/db/atlas/stripe_invoice/migrations/atlas.sum +++ b/db/atlas/stripe_invoice/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:FS8jSKRjrxTpVXMVhNisHxEgUk/fmiQEEpBMvdqVh88= +h1:vP7u0iEZCJNzW1k3wWjwrXVO7WsP44Hj8aa5BFUfn/c= 0001_init.sql h1:gzb02ZbjrrJkXOC+2qIZsngnj7A+29O2/b4awScPlPs= 0002_auth.sql h1:4NhBu26dIBMy9gxMxM3tf6Z2CS2kfKlGjFBj07T/aBw= 0003_stripe_xero.sql h1:E2bcdUDnondsXwbdIwVlZqR4DQwzcoDiyeRFJwVxXwg= @@ -6,4 +6,4 @@ h1:FS8jSKRjrxTpVXMVhNisHxEgUk/fmiQEEpBMvdqVh88= 20251228182659_add_used_at_to_login_tokens.sql h1:/0puYQvwBFzpfSKjiZj2XR/7Mui39lS/IbFZW1TPQOc= 20251230154354_add_used_at_to_login_tokens.sql h1:FIP2MMRnfhi4hmFC3VBuABZZrxZQ1icranrXy0ljERc= 20260118151944_add_unique_index_to_stripe_accounts.sql h1:PyI8cM8Xyn4bG7BBlD7YRwK1iRQ8HPfzf0r1+Swfe1Y= -20260118165004_add_unique_for_xero.sql h1:/qk/tJiDo6wMnOdDnmEjKMwx2TmxpdQWmpdliaw6xZ8= +20260118165004_add_unique_for_xero.sql h1:gdsqkAeuGG2SmeCRGEBw39RAAGAoZiF5LF/0HfTBZ0w= From e34bf55c57e342d1ea8823f69d93c57bdf48517e Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 17:19:03 +0000 Subject: [PATCH 37/59] migration atlas.sum --- stripe_to_invoice/deployment/secrets/stripe-secrets.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/stripe_to_invoice/deployment/secrets/stripe-secrets.yaml b/stripe_to_invoice/deployment/secrets/stripe-secrets.yaml index 33d7184..b9a0539 100644 --- a/stripe_to_invoice/deployment/secrets/stripe-secrets.yaml +++ b/stripe_to_invoice/deployment/secrets/stripe-secrets.yaml @@ -12,4 +12,7 @@ stringData: AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID} AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY} SES_FROM_EMAIL: ${SES_FROM_EMAIL} - STRIPE_REDIRECT_URI: ${STRIPE_REDIRECT_URI} \ No newline at end of file + STRIPE_REDIRECT_URI: ${STRIPE_REDIRECT_URI} + XERO_CLIENT_ID: ${XERO_CLIENT_ID} + XERO_CLIENT_SECRET: ${XERO_CLIENT_SECRET} + XERO_REDIRECT_URI: ${XERO_REDIRECT_URI} From 18312c939a2fc92c592160a0371733cabb159aea Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 17:26:24 +0000 Subject: [PATCH 38/59] migration atlas.sum --- stripe_to_invoice/deployment/secrets/.env | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/stripe_to_invoice/deployment/secrets/.env b/stripe_to_invoice/deployment/secrets/.env index 315b2d3..0028a16 100644 --- a/stripe_to_invoice/deployment/secrets/.env +++ b/stripe_to_invoice/deployment/secrets/.env @@ -9,7 +9,8 @@ DEV_SES_FROM_EMAIL=no-reply@juntekim.com DEV_STRIPE_REDIRECT_URI=https://stripe-to-invoice.dev.juntekim.com/api/stripe/callback DEV_XERO_CLIENT_ID=4C24EEA5583046519AD39B3905ED2BD3 DEV_XERO_SECRET_KEY=PAYDhzqMLvNtPrN5vDC7iwtXkgu99yG8Gbu86IlrdHH8hGjA -DEV_XERO_REDIRECT_URI=https://stripe-to-invoice.dev.juntekim.com/api/connect/xero/callback +DEV_XERO_REDIRECT_URI=https://stripe-to-invoice.dev.juntekim.com/api/xero/callback + # Prod @@ -23,5 +24,6 @@ PROD_SES_FROM_EMAIL=no-reply@juntekim.com PROD_STRIPE_REDIRECT_URI=https://stripe-to-invoice.dev.juntekim.com/api/stripe/callback PROD_XERO_CLIENT_ID=4C24EEA5583046519AD39B3905ED2BD3 PROD_XERO_SECRET_KEY=PAYDhzqMLvNtPrN5vDC7iwtXkgu99yG8Gbu86IlrdHH8hGjA -PROD_XERO_REDIRECT_URI=https://stripe-to-invoice.juntekim.com/api/connect/xero/callback +PROD_XERO_REDIRECT_URI=https://stripe-to-invoice.juntekim.com/api/xero/callback + From 8bae792d1651abedb1ccd7c67aa1d0ce037df3ad Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 19:19:42 +0000 Subject: [PATCH 39/59] added migration so that we can do refresh token --- ...dd_more_info_on_xero_for_refresh_token.sql | 6 ++ db/atlas/stripe_invoice/migrations/atlas.sum | 3 +- .../app/api/xero/callback/route.ts | 35 ++++++---- .../lib/schema/xeroConnections.ts | 14 ++++ stripe_to_invoice/lib/xero/auth.ts | 66 +++++++++++++++++++ 5 files changed, 110 insertions(+), 14 deletions(-) create mode 100644 db/atlas/stripe_invoice/migrations/20260118191050_add_more_info_on_xero_for_refresh_token.sql create mode 100644 stripe_to_invoice/lib/xero/auth.ts diff --git a/db/atlas/stripe_invoice/migrations/20260118191050_add_more_info_on_xero_for_refresh_token.sql b/db/atlas/stripe_invoice/migrations/20260118191050_add_more_info_on_xero_for_refresh_token.sql new file mode 100644 index 0000000..e29469c --- /dev/null +++ b/db/atlas/stripe_invoice/migrations/20260118191050_add_more_info_on_xero_for_refresh_token.sql @@ -0,0 +1,6 @@ +ALTER TABLE public.xero_connections +ADD COLUMN access_token TEXT NOT NULL, +ADD COLUMN refresh_token TEXT NOT NULL, +ADD COLUMN expires_at TIMESTAMPTZ NOT NULL; +CREATE UNIQUE INDEX xero_connections_user_id_idx +ON public.xero_connections(user_id); diff --git a/db/atlas/stripe_invoice/migrations/atlas.sum b/db/atlas/stripe_invoice/migrations/atlas.sum index ee86f48..9b9d97d 100644 --- a/db/atlas/stripe_invoice/migrations/atlas.sum +++ b/db/atlas/stripe_invoice/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:vP7u0iEZCJNzW1k3wWjwrXVO7WsP44Hj8aa5BFUfn/c= +h1:LpRuw3gJ5nRNyvvsHmpXYiiiMfrzaM73K9Rozybl9gg= 0001_init.sql h1:gzb02ZbjrrJkXOC+2qIZsngnj7A+29O2/b4awScPlPs= 0002_auth.sql h1:4NhBu26dIBMy9gxMxM3tf6Z2CS2kfKlGjFBj07T/aBw= 0003_stripe_xero.sql h1:E2bcdUDnondsXwbdIwVlZqR4DQwzcoDiyeRFJwVxXwg= @@ -7,3 +7,4 @@ h1:vP7u0iEZCJNzW1k3wWjwrXVO7WsP44Hj8aa5BFUfn/c= 20251230154354_add_used_at_to_login_tokens.sql h1:FIP2MMRnfhi4hmFC3VBuABZZrxZQ1icranrXy0ljERc= 20260118151944_add_unique_index_to_stripe_accounts.sql h1:PyI8cM8Xyn4bG7BBlD7YRwK1iRQ8HPfzf0r1+Swfe1Y= 20260118165004_add_unique_for_xero.sql h1:gdsqkAeuGG2SmeCRGEBw39RAAGAoZiF5LF/0HfTBZ0w= +20260118191050_add_more_info_on_xero_for_refresh_token.sql h1:ybrF538zPFYK2mjgatJmrbDZu5MBP5T+aY5no9wkyw0= diff --git a/stripe_to_invoice/app/api/xero/callback/route.ts b/stripe_to_invoice/app/api/xero/callback/route.ts index 0a16570..1046c24 100644 --- a/stripe_to_invoice/app/api/xero/callback/route.ts +++ b/stripe_to_invoice/app/api/xero/callback/route.ts @@ -7,6 +7,7 @@ import { eq } from "drizzle-orm"; type XeroTokenResponse = { access_token: string; refresh_token: string; + expires_in: number; // seconds }; type XeroTenant = { @@ -17,7 +18,6 @@ export async function GET(req: NextRequest) { const cookieStore = await cookies(); const session = cookieStore.get("session"); - // Must be logged in if (!session) { return NextResponse.redirect( new URL("/login", process.env.APP_URL) @@ -36,7 +36,7 @@ export async function GET(req: NextRequest) { ); } - // Exchange code for token + // 1️⃣ Exchange code for tokens const tokenRes = await fetch("https://identity.xero.com/connect/token", { method: "POST", headers: { @@ -64,15 +64,12 @@ export async function GET(req: NextRequest) { const tokenData = (await tokenRes.json()) as XeroTokenResponse; - // Fetch connected tenants (organisations) - const tenantRes = await fetch( - "https://api.xero.com/connections", - { - headers: { - Authorization: `Bearer ${tokenData.access_token}`, - }, - } - ); + // 2️⃣ Fetch tenant + const tenantRes = await fetch("https://api.xero.com/connections", { + headers: { + Authorization: `Bearer ${tokenData.access_token}`, + }, + }); const tenants = (await tenantRes.json()) as XeroTenant[]; const tenantId = tenants[0]?.tenantId; @@ -84,16 +81,28 @@ export async function GET(req: NextRequest) { ); } - // Save user ↔ tenant (minimal MVP) + const expiresAt = new Date(Date.now() + tokenData.expires_in * 1000); + + // 3️⃣ Persist EVERYTHING (this is the fix) await db .insert(xeroConnections) .values({ userId, tenantId, + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token, + expiresAt, + updatedAt: new Date(), }) .onConflictDoUpdate({ target: xeroConnections.userId, - set: { tenantId }, + set: { + tenantId, + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token, + expiresAt, + updatedAt: new Date(), + }, }); return NextResponse.redirect( diff --git a/stripe_to_invoice/lib/schema/xeroConnections.ts b/stripe_to_invoice/lib/schema/xeroConnections.ts index eeb0c4b..173681d 100644 --- a/stripe_to_invoice/lib/schema/xeroConnections.ts +++ b/stripe_to_invoice/lib/schema/xeroConnections.ts @@ -3,11 +3,25 @@ import { users } from "./users"; export const xeroConnections = pgTable("xero_connections", { id: uuid("id").defaultRandom().primaryKey(), + userId: uuid("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), + tenantId: text("tenant_id").notNull(), + + // 🔐 OAuth tokens + accessToken: text("access_token").notNull(), + refreshToken: text("refresh_token").notNull(), + + // When access_token expires + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), + + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), }); diff --git a/stripe_to_invoice/lib/xero/auth.ts b/stripe_to_invoice/lib/xero/auth.ts new file mode 100644 index 0000000..323e981 --- /dev/null +++ b/stripe_to_invoice/lib/xero/auth.ts @@ -0,0 +1,66 @@ +import "server-only"; + +import { db } from "@/lib/db"; +import { xeroConnections } from "@/lib/schema/xeroConnections"; +import { eq } from "drizzle-orm"; + +/** + * Returns a valid Xero access token for the given user. + * Refreshes and persists tokens automatically if expired. + */ +export async function getValidXeroAccessToken(userId: string): Promise { + const conn = await db.query.xeroConnections.findFirst({ + where: (t, { eq }) => eq(t.userId, userId), + }); + + if (!conn) { + throw new Error("No Xero connection"); + } + + const now = Date.now(); + + // Access token still valid (60s safety buffer) + if (now < conn.expiresAt.getTime() - 60_000) { + return conn.accessToken; + } + + // Refresh token + const res = await fetch("https://identity.xero.com/connect/token", { + method: "POST", + headers: { + Authorization: + "Basic " + + Buffer.from( + `${process.env.XERO_CLIENT_ID}:${process.env.XERO_CLIENT_SECRET}` + ).toString("base64"), + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: conn.refreshToken, + }), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Failed to refresh Xero token: ${text}`); + } + + const tokens: { + access_token: string; + refresh_token: string; + expires_in: number; + } = await res.json(); + + await db + .update(xeroConnections) + .set({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, // 🔥 must overwrite + expiresAt: new Date(Date.now() + tokens.expires_in * 1000), + updatedAt: new Date(), + }) + .where(eq(xeroConnections.id, conn.id)); + + return tokens.access_token; +} From 4059150ca23876dd563e8974466e40d41dca7126 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 19:23:08 +0000 Subject: [PATCH 40/59] added schema to index --- stripe_to_invoice/lib/schema/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/stripe_to_invoice/lib/schema/index.ts b/stripe_to_invoice/lib/schema/index.ts index 58cd6d0..746d587 100644 --- a/stripe_to_invoice/lib/schema/index.ts +++ b/stripe_to_invoice/lib/schema/index.ts @@ -1,3 +1,5 @@ // lib/schema/index.ts export * from "./users"; export * from "./loginTokens"; +export * from "./stripeAccounts"; +export * from "./xeroConnections"; From b70fa27725ed498459f219b9bcc16a93cfa52ca3 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 19:28:48 +0000 Subject: [PATCH 41/59] added schema to index --- stripe_to_invoice/lib/schema/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stripe_to_invoice/lib/schema/index.ts b/stripe_to_invoice/lib/schema/index.ts index 746d587..d5936b4 100644 --- a/stripe_to_invoice/lib/schema/index.ts +++ b/stripe_to_invoice/lib/schema/index.ts @@ -2,4 +2,4 @@ export * from "./users"; export * from "./loginTokens"; export * from "./stripeAccounts"; -export * from "./xeroConnections"; +export * from "./xeroConnections"; \ No newline at end of file From 939e91b52cb6da5f2e9a24de94ca43a45bcdeabd Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 19:33:43 +0000 Subject: [PATCH 42/59] fix for type script --- stripe_to_invoice/lib/db.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/stripe_to_invoice/lib/db.ts b/stripe_to_invoice/lib/db.ts index 09a2bf7..d24d259 100644 --- a/stripe_to_invoice/lib/db.ts +++ b/stripe_to_invoice/lib/db.ts @@ -1,8 +1,7 @@ -// lib/db.ts import { drizzle } from "drizzle-orm/node-postgres"; import { Pool } from "pg"; +import * as schema from "./schema"; -// Fail fast if env is missing if (!process.env.DATABASE_URL) { throw new Error("DATABASE_URL is not set"); } @@ -15,5 +14,4 @@ const pool = new Pool({ : false, }); -// Export a single db instance -export const db = drizzle(pool); +export const db = drizzle(pool, { schema }); From 04e794fe86301b101273ebbd951e7a10916954e7 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 19:40:22 +0000 Subject: [PATCH 43/59] run this --- ...dd_more_info_on_xero_for_refresh_token.sql | 23 +++++++++++++++---- db/atlas/stripe_invoice/migrations/atlas.sum | 4 ++-- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/db/atlas/stripe_invoice/migrations/20260118191050_add_more_info_on_xero_for_refresh_token.sql b/db/atlas/stripe_invoice/migrations/20260118191050_add_more_info_on_xero_for_refresh_token.sql index e29469c..75eed7b 100644 --- a/db/atlas/stripe_invoice/migrations/20260118191050_add_more_info_on_xero_for_refresh_token.sql +++ b/db/atlas/stripe_invoice/migrations/20260118191050_add_more_info_on_xero_for_refresh_token.sql @@ -1,6 +1,19 @@ +-- 1. Add columns as nullable ALTER TABLE public.xero_connections -ADD COLUMN access_token TEXT NOT NULL, -ADD COLUMN refresh_token TEXT NOT NULL, -ADD COLUMN expires_at TIMESTAMPTZ NOT NULL; -CREATE UNIQUE INDEX xero_connections_user_id_idx -ON public.xero_connections(user_id); +ADD COLUMN access_token TEXT, +ADD COLUMN refresh_token TEXT, +ADD COLUMN expires_at TIMESTAMPTZ; + +-- 2. Backfill ONLY rows that are missing values +UPDATE public.xero_connections +SET + access_token = 'MIGRATION_PLACEHOLDER', + refresh_token = 'MIGRATION_PLACEHOLDER', + expires_at = NOW() +WHERE access_token IS NULL; + +-- 3. Enforce constraints +ALTER TABLE public.xero_connections +ALTER COLUMN access_token SET NOT NULL, +ALTER COLUMN refresh_token SET NOT NULL, +ALTER COLUMN expires_at SET NOT NULL; diff --git a/db/atlas/stripe_invoice/migrations/atlas.sum b/db/atlas/stripe_invoice/migrations/atlas.sum index 9b9d97d..d85ea63 100644 --- a/db/atlas/stripe_invoice/migrations/atlas.sum +++ b/db/atlas/stripe_invoice/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:LpRuw3gJ5nRNyvvsHmpXYiiiMfrzaM73K9Rozybl9gg= +h1:O+cDEObDAwTIbKQ7pT/b6qANyIhLfqzhXZAZTLXaQVo= 0001_init.sql h1:gzb02ZbjrrJkXOC+2qIZsngnj7A+29O2/b4awScPlPs= 0002_auth.sql h1:4NhBu26dIBMy9gxMxM3tf6Z2CS2kfKlGjFBj07T/aBw= 0003_stripe_xero.sql h1:E2bcdUDnondsXwbdIwVlZqR4DQwzcoDiyeRFJwVxXwg= @@ -7,4 +7,4 @@ h1:LpRuw3gJ5nRNyvvsHmpXYiiiMfrzaM73K9Rozybl9gg= 20251230154354_add_used_at_to_login_tokens.sql h1:FIP2MMRnfhi4hmFC3VBuABZZrxZQ1icranrXy0ljERc= 20260118151944_add_unique_index_to_stripe_accounts.sql h1:PyI8cM8Xyn4bG7BBlD7YRwK1iRQ8HPfzf0r1+Swfe1Y= 20260118165004_add_unique_for_xero.sql h1:gdsqkAeuGG2SmeCRGEBw39RAAGAoZiF5LF/0HfTBZ0w= -20260118191050_add_more_info_on_xero_for_refresh_token.sql h1:ybrF538zPFYK2mjgatJmrbDZu5MBP5T+aY5no9wkyw0= +20260118191050_add_more_info_on_xero_for_refresh_token.sql h1:cIQZ81Q7mBX4o8Xb6k3CGSFFw9KoVbZgfYxhOtxxvu4= From cbb4f93cad3839669cafab0858952ff0271f1257 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 21:20:34 +0000 Subject: [PATCH 44/59] added updated at to xero connection --- .../20260118211854_add_last_updated_at.sql | 14 ++++++++++++++ db/atlas/stripe_invoice/migrations/atlas.sum | 3 ++- 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 db/atlas/stripe_invoice/migrations/20260118211854_add_last_updated_at.sql diff --git a/db/atlas/stripe_invoice/migrations/20260118211854_add_last_updated_at.sql b/db/atlas/stripe_invoice/migrations/20260118211854_add_last_updated_at.sql new file mode 100644 index 0000000..7d70053 --- /dev/null +++ b/db/atlas/stripe_invoice/migrations/20260118211854_add_last_updated_at.sql @@ -0,0 +1,14 @@ +-- 1. Add updated_at as nullable +ALTER TABLE public.xero_connections +ADD COLUMN updated_at TIMESTAMPTZ; + +-- 2. Backfill existing rows +UPDATE public.xero_connections +SET updated_at = COALESCE(updated_at, created_at, NOW()); + +-- 3. Enforce NOT NULL + default +ALTER TABLE public.xero_connections +ALTER COLUMN updated_at SET NOT NULL; + +ALTER TABLE public.xero_connections +ALTER COLUMN updated_at SET DEFAULT NOW(); diff --git a/db/atlas/stripe_invoice/migrations/atlas.sum b/db/atlas/stripe_invoice/migrations/atlas.sum index d85ea63..ff32ce4 100644 --- a/db/atlas/stripe_invoice/migrations/atlas.sum +++ b/db/atlas/stripe_invoice/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:O+cDEObDAwTIbKQ7pT/b6qANyIhLfqzhXZAZTLXaQVo= +h1:puaBSJCOfcifcszA+vjE4WU1cp3sP0wp5sAPlcC1+ys= 0001_init.sql h1:gzb02ZbjrrJkXOC+2qIZsngnj7A+29O2/b4awScPlPs= 0002_auth.sql h1:4NhBu26dIBMy9gxMxM3tf6Z2CS2kfKlGjFBj07T/aBw= 0003_stripe_xero.sql h1:E2bcdUDnondsXwbdIwVlZqR4DQwzcoDiyeRFJwVxXwg= @@ -8,3 +8,4 @@ h1:O+cDEObDAwTIbKQ7pT/b6qANyIhLfqzhXZAZTLXaQVo= 20260118151944_add_unique_index_to_stripe_accounts.sql h1:PyI8cM8Xyn4bG7BBlD7YRwK1iRQ8HPfzf0r1+Swfe1Y= 20260118165004_add_unique_for_xero.sql h1:gdsqkAeuGG2SmeCRGEBw39RAAGAoZiF5LF/0HfTBZ0w= 20260118191050_add_more_info_on_xero_for_refresh_token.sql h1:cIQZ81Q7mBX4o8Xb6k3CGSFFw9KoVbZgfYxhOtxxvu4= +20260118211854_add_last_updated_at.sql h1:y01AhrpxYmYWIIn9La73twwrfJteCj0r5PovRCiQoh4= From e49ca810ab67e057d014a977fd83a36b8d711437 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 18 Jan 2026 21:34:46 +0000 Subject: [PATCH 45/59] next to do list --- stripe_to_invoice/README.md | 193 +----------------------------------- 1 file changed, 3 insertions(+), 190 deletions(-) diff --git a/stripe_to_invoice/README.md b/stripe_to_invoice/README.md index 29d91ae..492e0e9 100644 --- a/stripe_to_invoice/README.md +++ b/stripe_to_invoice/README.md @@ -1,190 +1,3 @@ -# 🚀 MVP Next Steps – Post SES Setup - -This document outlines the concrete next steps to build the MVP now that -Amazon SES email delivery is fully configured and verified. - ---- - -## ✅ Phase 0 — Email Infrastructure (COMPLETED) - -**Status: DONE** - -- SES domain verified (`juntekim.com`) -- DKIM, SPF, DMARC configured -- Custom MAIL FROM domain enabled -- Test email delivered to Gmail inbox -- SES production access requested -- SMTP credentials generated and stored securely - -No further SES work is required for MVP. - ---- - -## 🔐 Phase 1 — Magic Link Authentication (Core MVP) - -### 1️⃣ Define Authentication Model - -**Decisions** -- Email-only authentication (no passwords) -- Magic links are: - - Single-use - - Time-limited (e.g. 15 minutes) - - Hashed before storage -- No persistent email storage - -**Outcome** -- Clear security model before implementation - ---- - -### 2️⃣ Create Magic Link Token Table - -**Required fields** -- `id` -- `email` -- `token_hash` -- `expires_at` -- `used_at` -- `created_at` - -**Rules** -- Never store raw tokens -- Reject expired tokens -- Reject reused tokens -- Mark token as used immediately after login - -**Outcome** -- Database migration + model ready - ---- - -### 3️⃣ Build Email Sending Adapter (SES SMTP) - -**Requirements** -- Uses Amazon SES SMTP credentials -- Sends from `no-reply@juntekim.com` -- Generates secure magic link URLs -- Plain-text email (HTML later) - -**Example responsibility** -- `sendMagicLink(email, url)` - -**Outcome** -- Single reusable email-sending utility - ---- - -## 🔑 Phase 2 — NextAuth Integration - -### 4️⃣ Configure NextAuth (Email Provider) - -**Actions** -- Enable NextAuth Email provider -- Configure SES SMTP transport -- Disable default token storage -- Use custom DB token table - -**Outcome** -- NextAuth initialized and functional - ---- - -### 5️⃣ Implement `/auth/callback` Logic - -**Flow** -1. User clicks magic link -2. Token is hashed and validated -3. Token expiry checked -4. Token marked as used -5. Session created -6. Redirect to app - -**Outcome** -- End-to-end login flow works - ---- - -### 6️⃣ Minimal Authentication UI - -**Pages** -- Email input form -- “Check your email” confirmation screen -- Error states: - - Invalid token - - Expired token - - Already-used token - -**Outcome** -- Usable authentication UX - ---- - -## 🛡 Phase 3 — MVP Hardening (Still Lightweight) - -### 7️⃣ Rate Limiting - -Add limits for: -- Magic link requests per email -- Magic link requests per IP - -Purpose: -- Prevent abuse -- Protect SES reputation - ---- - -### 8️⃣ Basic Logging - -Log only: -- Email requested -- Email send success/failure -- Login success/failure - -Do **not** store email content. - ---- - -### 9️⃣ Production Sanity Checks - -Before real users: -- Test login on mobile + desktop -- Test Gmail + Outlook -- Test expired link behavior -- Test reused link rejection - ---- - -## 🚦 MVP Definition of Done - -The MVP is considered complete when: - -- User enters email -- User receives magic link -- User clicks link -- User is authenticated -- Session persists - -No additional features are required to ship. - ---- - -## 🧠 Guiding Principles - -- Infrastructure first (done) -- Security before UX polish -- Ship working flows early -- Avoid overbuilding before user feedback - ---- - -## 🧩 Post-MVP (Optional, Later) - -Do NOT block MVP on: -- HTML email templates -- Branded emails -- Email analytics -- Admin dashboards -- Multi-provider auth -- Password fallback - -Ship first, iterate later. +NEXT: +- Set up Stripe webhook endpoint, so when a test payment is done i can see it +- make it produce something so i can see it \ No newline at end of file From fe5cffcf8fe90311419f6b6796a6521b43a5626e Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 20 Jan 2026 09:43:01 +0000 Subject: [PATCH 46/59] dbeaver --- dbeaver/dbeaver.yaml | 142 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 dbeaver/dbeaver.yaml diff --git a/dbeaver/dbeaver.yaml b/dbeaver/dbeaver.yaml new file mode 100644 index 0000000..c433981 --- /dev/null +++ b/dbeaver/dbeaver.yaml @@ -0,0 +1,142 @@ +############################################## +# Persistent Volume (hostPath on mist) +############################################## +apiVersion: v1 +kind: PersistentVolume +metadata: + name: dbeaver-pv +spec: + capacity: + storage: 5Gi + volumeMode: Filesystem + accessModes: + - ReadWriteOnce + persistentVolumeReclaimPolicy: Retain + storageClassName: dbeaver-local-storage + local: + path: /home/kimjunte/k8s_storage/dbeaver + nodeAffinity: + required: + nodeSelectorTerms: + - matchExpressions: + - key: kubernetes.io/hostname + operator: In + values: + - mist +--- +############################################## +# Persistent Volume Claim +############################################## +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: dbeaver-pvc + namespace: default +spec: + accessModes: + - ReadWriteOnce + storageClassName: dbeaver-local-storage + resources: + requests: + storage: 5Gi +--- +############################################## +# Deployment (CloudBeaver CE) +############################################## +apiVersion: apps/v1 +kind: Deployment +metadata: + name: dbeaver + namespace: default +spec: + replicas: 1 + selector: + matchLabels: + app: dbeaver + template: + metadata: + labels: + app: dbeaver + spec: + nodeSelector: + kubernetes.io/hostname: mist + + containers: + - name: dbeaver + image: dbeaver/cloudbeaver:latest + ports: + - containerPort: 8978 + + env: + - name: TZ + value: "Europe/London" + + # IMPORTANT: Force Community Edition (no license checks) + - name: CB_DISABLE_LICENSE + value: "true" + - name: CB_SERVER_URL + value: https://dbeaver.juntekim.com + - name: CB_SERVER_PROXY_TRUSTED + value: "true" + - name: CB_SERVER_PROXY_ENABLED + value: "true" + + volumeMounts: + - name: dbeaver-data + mountPath: /opt/cloudbeaver/workspace + + # readinessProbe: + # httpGet: + # path: / + # port: 8978 + # initialDelaySeconds: 15 + # periodSeconds: 10 + + # livenessProbe: + # httpGet: + # path: / + # port: 8978 + # initialDelaySeconds: 30 + # periodSeconds: 20 + + volumes: + - name: dbeaver-data + persistentVolumeClaim: + claimName: dbeaver-pvc +--- +############################################## +# Service +############################################## +apiVersion: v1 +kind: Service +metadata: + name: dbeaver + namespace: default +spec: + selector: + app: dbeaver + ports: + - name: http + port: 8978 + targetPort: 8978 +--- +############################################## +# Traefik IngressRoute +############################################## +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: dbeaver + namespace: default +spec: + entryPoints: + - websecure + routes: + - match: Host(`dbeaver.juntekim.com`) + kind: Rule + services: + - name: dbeaver + port: 8978 + passHostHeader: true + tls: + certResolver: myresolver \ No newline at end of file From 64c8bb321baeaf98e1eaf5a2e32d2b96224f1bfe Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 20 Jan 2026 22:50:51 +0000 Subject: [PATCH 47/59] push to database new changes --- .devcontainer/Dockerfile | 10 +- db/atlas/stripe_invoice/add_new_migration.sh | 3 +- db/atlas/stripe_invoice/migrations/atlas.sum | 3 +- stripe_to_invoice/lib/schema/index.ts | 3 +- stripe_to_invoice/package-lock.json | 230 +++++++++++++++++-- stripe_to_invoice/package.json | 4 +- 6 files changed, 227 insertions(+), 26 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index ad3cc80..554dc1a 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -43,9 +43,17 @@ RUN wget https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform && unzip terraform_${TERRAFORM_VERSION}_linux_amd64.zip \ && mv terraform /usr/local/bin/terraform \ && rm terraform_${TERRAFORM_VERSION}_linux_amd64.zip - RUN terraform version +# Install stripe +RUN curl -s https://packages.stripe.dev/api/security/keypair/stripe-cli-gpg/public | gpg --dearmor | sudo tee /usr/share/keyrings/stripe.gpg +RUN echo "deb [signed-by=/usr/share/keyrings/stripe.gpg] https://packages.stripe.dev/stripe-cli-debian-local stable main" | sudo tee -a /etc/apt/sources.list.d/stripe.list +RUN sudo apt update +RUN sudo apt install stripe + + + + # Set the working directory WORKDIR /workspaces/monorepo \ 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 index 5d623b5..7216dfb 100644 --- a/db/atlas/stripe_invoice/add_new_migration.sh +++ b/db/atlas/stripe_invoice/add_new_migration.sh @@ -1 +1,2 @@ -atlas migrate new add_used_at_to_login_tokens +atlas migrate new add_stripe_history + diff --git a/db/atlas/stripe_invoice/migrations/atlas.sum b/db/atlas/stripe_invoice/migrations/atlas.sum index ff32ce4..129cc14 100644 --- a/db/atlas/stripe_invoice/migrations/atlas.sum +++ b/db/atlas/stripe_invoice/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:puaBSJCOfcifcszA+vjE4WU1cp3sP0wp5sAPlcC1+ys= +h1:0fwTZVrBXWktKhhAG17BTid9zjj4i3TUkkgQY7u5XEU= 0001_init.sql h1:gzb02ZbjrrJkXOC+2qIZsngnj7A+29O2/b4awScPlPs= 0002_auth.sql h1:4NhBu26dIBMy9gxMxM3tf6Z2CS2kfKlGjFBj07T/aBw= 0003_stripe_xero.sql h1:E2bcdUDnondsXwbdIwVlZqR4DQwzcoDiyeRFJwVxXwg= @@ -9,3 +9,4 @@ h1:puaBSJCOfcifcszA+vjE4WU1cp3sP0wp5sAPlcC1+ys= 20260118165004_add_unique_for_xero.sql h1:gdsqkAeuGG2SmeCRGEBw39RAAGAoZiF5LF/0HfTBZ0w= 20260118191050_add_more_info_on_xero_for_refresh_token.sql h1:cIQZ81Q7mBX4o8Xb6k3CGSFFw9KoVbZgfYxhOtxxvu4= 20260118211854_add_last_updated_at.sql h1:y01AhrpxYmYWIIn9La73twwrfJteCj0r5PovRCiQoh4= +20260120223114_add_stripe_history.sql h1:+l14lHGfyoNBGh1w9TqOuxmETe1Bgo1sry1aXrvt4bU= diff --git a/stripe_to_invoice/lib/schema/index.ts b/stripe_to_invoice/lib/schema/index.ts index d5936b4..cbfa066 100644 --- a/stripe_to_invoice/lib/schema/index.ts +++ b/stripe_to_invoice/lib/schema/index.ts @@ -2,4 +2,5 @@ export * from "./users"; export * from "./loginTokens"; export * from "./stripeAccounts"; -export * from "./xeroConnections"; \ No newline at end of file +export * from "./xeroConnections"; +export * from "./processedStripeEvents"; diff --git a/stripe_to_invoice/package-lock.json b/stripe_to_invoice/package-lock.json index 42d776c..75bfde9 100644 --- a/stripe_to_invoice/package-lock.json +++ b/stripe_to_invoice/package-lock.json @@ -15,8 +15,10 @@ "pg": "^8.16.3", "react": "19.2.1", "react-dom": "19.2.1", + "stripe": "^20.2.0", "tailwindcss": "^4", - "typescript": "^5.5.0" + "typescript": "^5.5.0", + "xero-node": "^13.3.1" }, "devDependencies": { "@types/node": "^20", @@ -4487,6 +4489,12 @@ "node": ">= 0.4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -4513,6 +4521,17 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -4634,7 +4653,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -4648,7 +4666,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -4734,6 +4751,18 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -4892,6 +4921,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -5059,7 +5097,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -5170,7 +5207,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5180,7 +5216,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5218,7 +5253,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -5231,7 +5265,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -5919,6 +5952,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -5935,11 +5988,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -6000,7 +6068,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -6025,7 +6092,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -6113,7 +6179,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6184,7 +6249,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6197,7 +6261,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -6213,7 +6276,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -6747,6 +6809,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7182,7 +7253,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7212,6 +7282,27 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -7380,11 +7471,19 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7493,6 +7592,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oidc-token-hash": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", + "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==", + "license": "MIT", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, + "node_modules/openid-client": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", + "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", + "license": "MIT", + "dependencies": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/openid-client/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -7808,6 +7949,12 @@ "react-is": "^16.13.1" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -7818,6 +7965,21 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -8192,7 +8354,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -8212,7 +8373,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -8229,7 +8389,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -8248,7 +8407,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -8460,6 +8618,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "20.2.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.2.0.tgz", + "integrity": "sha512-m8niTfdm3nPP/yQswRWMwQxqEUcTtB3RTJQ9oo6NINDzgi7aPOadsH/fPXIIfL1Sc5+lqQFKSk7WiO6CXmvaeA==", + "license": "MIT", + "dependencies": { + "qs": "^6.14.1" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@types/node": ">=16" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/strnum": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", @@ -8991,6 +9169,16 @@ "node": ">=0.10.0" } }, + "node_modules/xero-node": { + "version": "13.3.1", + "resolved": "https://registry.npmjs.org/xero-node/-/xero-node-13.3.1.tgz", + "integrity": "sha512-80BpuVUpcn+9xYlxWk5/bjdwvJJ+cxJboz7xVtEu6clRxU2NXUL8bFHrRlgmT+GBxNKfNRX2MtkppaTzZfF+tg==", + "license": "MIT", + "dependencies": { + "axios": "^1.12.0", + "openid-client": "^5.7.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/stripe_to_invoice/package.json b/stripe_to_invoice/package.json index 522978c..1ec4fb7 100644 --- a/stripe_to_invoice/package.json +++ b/stripe_to_invoice/package.json @@ -16,8 +16,10 @@ "pg": "^8.16.3", "react": "19.2.1", "react-dom": "19.2.1", + "stripe": "^20.2.0", "tailwindcss": "^4", - "typescript": "^5.5.0" + "typescript": "^5.5.0", + "xero-node": "^13.3.1" }, "devDependencies": { "@types/node": "^20", From 80dd2e57101c2a92b1b9130b83258edde18c6361 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 20 Jan 2026 23:08:49 +0000 Subject: [PATCH 48/59] added new scripts --- db/atlas/stripe_invoice/add_new_migration.sh | 2 +- .../20260120223114_add_stripe_history.sql | 6 + .../20260120230059_add_invoice_code.sql | 9 + db/atlas/stripe_invoice/migrations/atlas.sum | 3 +- package.json | 1 + .../app/api/stripe/webhook/route.ts | 265 ++++++++++++++++++ .../lib/schema/processedStripeEvents.ts | 10 + .../lib/schema/xeroConnections.ts | 25 +- 8 files changed, 302 insertions(+), 19 deletions(-) create mode 100644 db/atlas/stripe_invoice/migrations/20260120223114_add_stripe_history.sql create mode 100644 db/atlas/stripe_invoice/migrations/20260120230059_add_invoice_code.sql create mode 100644 package.json create mode 100644 stripe_to_invoice/app/api/stripe/webhook/route.ts create mode 100644 stripe_to_invoice/lib/schema/processedStripeEvents.ts diff --git a/db/atlas/stripe_invoice/add_new_migration.sh b/db/atlas/stripe_invoice/add_new_migration.sh index 7216dfb..543094f 100644 --- a/db/atlas/stripe_invoice/add_new_migration.sh +++ b/db/atlas/stripe_invoice/add_new_migration.sh @@ -1,2 +1,2 @@ -atlas migrate new add_stripe_history +atlas migrate new add_invoice_code diff --git a/db/atlas/stripe_invoice/migrations/20260120223114_add_stripe_history.sql b/db/atlas/stripe_invoice/migrations/20260120223114_add_stripe_history.sql new file mode 100644 index 0000000..dc7d675 --- /dev/null +++ b/db/atlas/stripe_invoice/migrations/20260120223114_add_stripe_history.sql @@ -0,0 +1,6 @@ +CREATE TABLE processed_stripe_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + stripe_event_id TEXT NOT NULL UNIQUE, + stripe_account_id TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/db/atlas/stripe_invoice/migrations/20260120230059_add_invoice_code.sql b/db/atlas/stripe_invoice/migrations/20260120230059_add_invoice_code.sql new file mode 100644 index 0000000..69ca6da --- /dev/null +++ b/db/atlas/stripe_invoice/migrations/20260120230059_add_invoice_code.sql @@ -0,0 +1,9 @@ +ALTER TABLE xero_connections +ADD COLUMN sales_account_code TEXT, +ADD COLUMN stripe_clearing_account_code TEXT; + +UPDATE xero_connections +SET + sales_account_code = '200', + stripe_clearing_account_code = '090' +WHERE sales_account_code IS NULL; diff --git a/db/atlas/stripe_invoice/migrations/atlas.sum b/db/atlas/stripe_invoice/migrations/atlas.sum index 129cc14..5136272 100644 --- a/db/atlas/stripe_invoice/migrations/atlas.sum +++ b/db/atlas/stripe_invoice/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:0fwTZVrBXWktKhhAG17BTid9zjj4i3TUkkgQY7u5XEU= +h1:ZGGgmFGh8vPWzpumfnp/KWIz6dmAFtIg/tJmVP+w0CU= 0001_init.sql h1:gzb02ZbjrrJkXOC+2qIZsngnj7A+29O2/b4awScPlPs= 0002_auth.sql h1:4NhBu26dIBMy9gxMxM3tf6Z2CS2kfKlGjFBj07T/aBw= 0003_stripe_xero.sql h1:E2bcdUDnondsXwbdIwVlZqR4DQwzcoDiyeRFJwVxXwg= @@ -10,3 +10,4 @@ h1:0fwTZVrBXWktKhhAG17BTid9zjj4i3TUkkgQY7u5XEU= 20260118191050_add_more_info_on_xero_for_refresh_token.sql h1:cIQZ81Q7mBX4o8Xb6k3CGSFFw9KoVbZgfYxhOtxxvu4= 20260118211854_add_last_updated_at.sql h1:y01AhrpxYmYWIIn9La73twwrfJteCj0r5PovRCiQoh4= 20260120223114_add_stripe_history.sql h1:+l14lHGfyoNBGh1w9TqOuxmETe1Bgo1sry1aXrvt4bU= +20260120230059_add_invoice_code.sql h1:9uItaHRhcuSuxnoqMOwxyPxiOUdm2+gadRZDeSwLmSY= diff --git a/package.json b/package.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/package.json @@ -0,0 +1 @@ +{} diff --git a/stripe_to_invoice/app/api/stripe/webhook/route.ts b/stripe_to_invoice/app/api/stripe/webhook/route.ts new file mode 100644 index 0000000..cc19339 --- /dev/null +++ b/stripe_to_invoice/app/api/stripe/webhook/route.ts @@ -0,0 +1,265 @@ +export const runtime = "nodejs"; + +import { NextRequest, NextResponse } from "next/server"; +import Stripe from "stripe"; +import { XeroClient, Invoice, CurrencyCode } from "xero-node"; +import { db } from "@/lib/db"; +import { + stripeAccounts, + xeroConnections, + processedStripeEvents, +} from "@/lib/schema"; +import { eq } from "drizzle-orm"; + + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); + +export async function POST(req: NextRequest) { + // -------------------------------------------------- + // 0️⃣ Verify Stripe signature + // -------------------------------------------------- + const sig = req.headers.get("stripe-signature"); + if (!sig) { + return NextResponse.json({ error: "Missing Stripe signature" }, { status: 400 }); + } + + const body = await req.text(); + let event: Stripe.Event; + + try { + event = stripe.webhooks.constructEvent( + body, + sig, + process.env.STRIPE_WEBHOOK_SECRET! + ); + } catch (err: any) { + console.error("❌ Invalid Stripe signature", err.message); + return NextResponse.json({ error: "Invalid signature" }, { status: 400 }); + } + + // -------------------------------------------------- + // 🔕 Only handle checkout.session.completed + // -------------------------------------------------- + if (event.type !== "checkout.session.completed") { + return NextResponse.json({ ignored: true }); + } + + const session = event.data.object as Stripe.Checkout.Session; + + // -------------------------------------------------- + // 1️⃣ Stripe account context + // -------------------------------------------------- + const stripeAccountId = + req.headers.get("stripe-account") ?? + "acct_1Sds1LB99GOwj1Ea"; // DEV ONLY + + // -------------------------------------------------- + // 2️⃣ IDEMPOTENCY CHECK + // -------------------------------------------------- + const existing = await db + .select() + .from(processedStripeEvents) + .where(eq(processedStripeEvents.stripeEventId, event.id)) + .limit(1); + + if (existing.length > 0) { + console.log("⏭️ Event already processed:", event.id); + return NextResponse.json({ received: true }); + } + + // -------------------------------------------------- + // 3️⃣ Stripe account → user + // -------------------------------------------------- + const [stripeAccount] = await db + .select() + .from(stripeAccounts) + .where(eq(stripeAccounts.stripeAccountId, stripeAccountId)) + .limit(1); + + if (!stripeAccount) { + console.error("❌ Stripe account not registered:", stripeAccountId); + return NextResponse.json( + { error: "Stripe account not registered" }, + { status: 500 } + ); + } + + // -------------------------------------------------- + // 4️⃣ User → Xero connection + // -------------------------------------------------- + const [xeroConn] = await db + .select() + .from(xeroConnections) + .where(eq(xeroConnections.userId, stripeAccount.userId)) + .limit(1); + + if (!xeroConn) { + console.error("❌ No Xero connection for user:", stripeAccount.userId); + return NextResponse.json( + { error: "User has no Xero connection" }, + { status: 500 } + ); + } + + if (!xeroConn.salesAccountCode || !xeroConn.stripeClearingAccountCode) { + throw new Error( + "Xero account codes not configured. User must select Sales and Stripe Clearing accounts." + ); + } + + // -------------------------------------------------- + // 5️⃣ Init Xero client + refresh token if needed + // -------------------------------------------------- + const xero = new XeroClient(); + + xero.setTokenSet({ + access_token: xeroConn.accessToken, + refresh_token: xeroConn.refreshToken, + expires_at: xeroConn.expiresAt.getTime(), + token_type: "Bearer", + }); + + const now = Date.now(); + if (xeroConn.expiresAt.getTime() <= now + 60_000) { + console.log("🔄 Refreshing Xero token"); + + const newTokenSet = await xero.refreshToken(); + + await db + .update(xeroConnections) + .set({ + accessToken: newTokenSet.access_token!, + refreshToken: newTokenSet.refresh_token!, + expiresAt: new Date(newTokenSet.expires_at!), + }) + .where(eq(xeroConnections.id, xeroConn.id)); + + xero.setTokenSet(newTokenSet); + } + + // -------------------------------------------------- + // 6️⃣ Resolve contact (email-only) + // -------------------------------------------------- + const email = session.customer_details?.email; + if (!email) { + return NextResponse.json( + { error: "Missing customer email" }, + { status: 400 } + ); + } + + const name = + session.customer_details?.business_name ?? + session.customer_details?.name ?? + email; + + const contactsResponse = await xero.accountingApi.getContacts( + xeroConn.tenantId, + undefined, + `EmailAddress=="${email}"` + ); + + let contact = contactsResponse.body.contacts?.[0]; + + if (!contact) { + const created = await xero.accountingApi.createContacts( + xeroConn.tenantId, + { + contacts: [{ name, emailAddress: email }], + } + ); + contact = created.body.contacts?.[0]; + } + + if (!contact?.contactID) { + throw new Error("Failed to resolve Xero contact"); + } + + // -------------------------------------------------- + // 7️⃣ Create AUTHORISED invoice + // -------------------------------------------------- + const amount = session.amount_total! / 100; + + if (!session.currency) { + throw new Error("Stripe session missing currency"); + } + + const currencyCode = session.currency.toUpperCase() as keyof typeof CurrencyCode; + + if (!(currencyCode in CurrencyCode)) { + throw new Error(`Unsupported currency: ${session.currency}`); + } + + const invoiceResponse = await xero.accountingApi.createInvoices( + xeroConn.tenantId, + { + invoices: [ + { + type: Invoice.TypeEnum.ACCREC, + status: Invoice.StatusEnum.AUTHORISED, + contact: { contactID: contact.contactID }, + lineItems: [ + { + description: `Stripe payment (${session.id})`, + quantity: 1, + unitAmount: amount, + accountCode: xeroConn.salesAccountCode, + }, + ], + currencyCode: CurrencyCode[currencyCode], + reference: session.id, + }, + ], + } + ); + + const invoice = invoiceResponse.body.invoices?.[0]; + if (!invoice?.invoiceID) { + throw new Error("Failed to create Xero invoice"); + } + + // -------------------------------------------------- + // 8️⃣ Mark invoice as PAID to Stripe Clearing + // + // STRIPE CLEARING (v1 behaviour) + // - Invoices are marked as PAID to a Stripe Clearing account + // - Stripe fees are NOT yet recorded in v1 + // + // TODO (planned): + // - Record Stripe fees as an expense + // - Reconcile Stripe payouts automatically + // -------------------------------------------------- + const paymentReference = + typeof session.payment_intent === "string" + ? session.payment_intent + : session.id; + await xero.accountingApi.createPayments(xeroConn.tenantId, { + payments: [ + { + invoice: { invoiceID: invoice.invoiceID }, + amount, + date: new Date().toISOString().slice(0, 10), + reference: paymentReference, + account: { + code: xeroConn.stripeClearingAccountCode, + }, + }, + ], + }); + + // -------------------------------------------------- + // 9️⃣ Record idempotency AFTER success + // -------------------------------------------------- + await db.insert(processedStripeEvents).values({ + stripeEventId: event.id, + stripeAccountId, + }); + + console.log("✅ Stripe payment fully processed", { + eventId: event.id, + invoiceId: invoice.invoiceID, + stripeAccountId, + }); + + return NextResponse.json({ received: true }); +} diff --git a/stripe_to_invoice/lib/schema/processedStripeEvents.ts b/stripe_to_invoice/lib/schema/processedStripeEvents.ts new file mode 100644 index 0000000..0e9d144 --- /dev/null +++ b/stripe_to_invoice/lib/schema/processedStripeEvents.ts @@ -0,0 +1,10 @@ +import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; + +export const processedStripeEvents = pgTable("processed_stripe_events", { + id: uuid("id").defaultRandom().primaryKey(), + stripeEventId: text("stripe_event_id").notNull().unique(), + stripeAccountId: text("stripe_account_id").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); diff --git a/stripe_to_invoice/lib/schema/xeroConnections.ts b/stripe_to_invoice/lib/schema/xeroConnections.ts index 173681d..572ce66 100644 --- a/stripe_to_invoice/lib/schema/xeroConnections.ts +++ b/stripe_to_invoice/lib/schema/xeroConnections.ts @@ -1,27 +1,18 @@ -import { pgTable, uuid, text, timestamp } from "drizzle-orm/pg-core"; -import { users } from "./users"; +import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; export const xeroConnections = pgTable("xero_connections", { - id: uuid("id").defaultRandom().primaryKey(), - - userId: uuid("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id").notNull(), tenantId: text("tenant_id").notNull(), - // 🔐 OAuth tokens accessToken: text("access_token").notNull(), refreshToken: text("refresh_token").notNull(), - - // When access_token expires expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), + // ✅ ADD THESE + salesAccountCode: text("sales_account_code"), + stripeClearingAccountCode: text("stripe_clearing_account_code"), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), }); From 6f4870f09ed66ba9f8c70b8b1580debe9ec0c18d Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 20 Jan 2026 23:16:28 +0000 Subject: [PATCH 49/59] fix npm run build --- stripe_to_invoice/lib/schema/xeroConnections.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/stripe_to_invoice/lib/schema/xeroConnections.ts b/stripe_to_invoice/lib/schema/xeroConnections.ts index 572ce66..f1f8092 100644 --- a/stripe_to_invoice/lib/schema/xeroConnections.ts +++ b/stripe_to_invoice/lib/schema/xeroConnections.ts @@ -13,6 +13,11 @@ export const xeroConnections = pgTable("xero_connections", { salesAccountCode: text("sales_account_code"), stripeClearingAccountCode: text("stripe_clearing_account_code"), - createdAt: timestamp("created_at", { withTimezone: true }).notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), }); From 1cd96a0b7919f59521ce4bb61c32cd93bce7defe Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 20 Jan 2026 23:23:09 +0000 Subject: [PATCH 50/59] save --- stripe_to_invoice/app/api/stripe/webhook/route.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stripe_to_invoice/app/api/stripe/webhook/route.ts b/stripe_to_invoice/app/api/stripe/webhook/route.ts index cc19339..daf85af 100644 --- a/stripe_to_invoice/app/api/stripe/webhook/route.ts +++ b/stripe_to_invoice/app/api/stripe/webhook/route.ts @@ -1,7 +1,7 @@ export const runtime = "nodejs"; import { NextRequest, NextResponse } from "next/server"; -import Stripe from "stripe"; +import { getStripe } from "@/lib/stripe/service"; import { XeroClient, Invoice, CurrencyCode } from "xero-node"; import { db } from "@/lib/db"; import { @@ -12,7 +12,7 @@ import { import { eq } from "drizzle-orm"; -const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); +const stripe = getStripe(); export async function POST(req: NextRequest) { // -------------------------------------------------- From 853234eb2f843b5edd77605f84a3ec54fb664b5a Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 20 Jan 2026 23:23:29 +0000 Subject: [PATCH 51/59] save --- stripe_to_invoice/app/api/stripe/webhook/route.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/stripe_to_invoice/app/api/stripe/webhook/route.ts b/stripe_to_invoice/app/api/stripe/webhook/route.ts index daf85af..52de0b2 100644 --- a/stripe_to_invoice/app/api/stripe/webhook/route.ts +++ b/stripe_to_invoice/app/api/stripe/webhook/route.ts @@ -3,6 +3,7 @@ export const runtime = "nodejs"; import { NextRequest, NextResponse } from "next/server"; import { getStripe } from "@/lib/stripe/service"; import { XeroClient, Invoice, CurrencyCode } from "xero-node"; +import Stripe from "stripe"; import { db } from "@/lib/db"; import { stripeAccounts, From 6b35b91ab157109311f697811f383564626f999c Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 20 Jan 2026 23:25:03 +0000 Subject: [PATCH 52/59] stripe --- .../app/api/stripe/webhook/route.ts | 3 ++- stripe_to_invoice/lib/stripe/service.ts | 19 +++++++++++++++++++ stripe_to_invoice/lib/xero/service.ts | 5 +++++ 3 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 stripe_to_invoice/lib/stripe/service.ts create mode 100644 stripe_to_invoice/lib/xero/service.ts diff --git a/stripe_to_invoice/app/api/stripe/webhook/route.ts b/stripe_to_invoice/app/api/stripe/webhook/route.ts index 52de0b2..1a10a21 100644 --- a/stripe_to_invoice/app/api/stripe/webhook/route.ts +++ b/stripe_to_invoice/app/api/stripe/webhook/route.ts @@ -11,6 +11,7 @@ import { processedStripeEvents, } from "@/lib/schema"; import { eq } from "drizzle-orm"; +import { getXeroClient } from "@/lib/xero/service"; const stripe = getStripe(); @@ -111,7 +112,7 @@ export async function POST(req: NextRequest) { // -------------------------------------------------- // 5️⃣ Init Xero client + refresh token if needed // -------------------------------------------------- - const xero = new XeroClient(); + const xero = getXeroClient(); xero.setTokenSet({ access_token: xeroConn.accessToken, diff --git a/stripe_to_invoice/lib/stripe/service.ts b/stripe_to_invoice/lib/stripe/service.ts new file mode 100644 index 0000000..d455070 --- /dev/null +++ b/stripe_to_invoice/lib/stripe/service.ts @@ -0,0 +1,19 @@ +import Stripe from "stripe"; + +let stripe: Stripe | null = null; + +/** + * Server-only Stripe client. + * Lazy-initialised to avoid build-time crashes. + */ +export function getStripe(): Stripe { + if (!process.env.STRIPE_SECRET_KEY) { + throw new Error("STRIPE_SECRET_KEY missing"); + } + + if (!stripe) { + stripe = new Stripe(process.env.STRIPE_SECRET_KEY); + } + + return stripe; +} diff --git a/stripe_to_invoice/lib/xero/service.ts b/stripe_to_invoice/lib/xero/service.ts new file mode 100644 index 0000000..ce651d2 --- /dev/null +++ b/stripe_to_invoice/lib/xero/service.ts @@ -0,0 +1,5 @@ +import { XeroClient } from "xero-node"; + +export function getXeroClient(): XeroClient { + return new XeroClient(); +} \ No newline at end of file From 9dc66c517111f7d65050b49cf10b633fd21bb0a9 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 20 Jan 2026 23:28:58 +0000 Subject: [PATCH 53/59] stripe --- stripe_to_invoice/app/api/stripe/webhook/route.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/stripe_to_invoice/app/api/stripe/webhook/route.ts b/stripe_to_invoice/app/api/stripe/webhook/route.ts index 1a10a21..4f9f5fe 100644 --- a/stripe_to_invoice/app/api/stripe/webhook/route.ts +++ b/stripe_to_invoice/app/api/stripe/webhook/route.ts @@ -1,4 +1,6 @@ export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const revalidate = 0; import { NextRequest, NextResponse } from "next/server"; import { getStripe } from "@/lib/stripe/service"; From 22807486518f10dc020721ae6407c3de4744da5c Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 20 Jan 2026 23:29:50 +0000 Subject: [PATCH 54/59] add fake build variables --- stripe_to_invoice/deployment/Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/stripe_to_invoice/deployment/Dockerfile b/stripe_to_invoice/deployment/Dockerfile index bd2e7d5..60beaad 100644 --- a/stripe_to_invoice/deployment/Dockerfile +++ b/stripe_to_invoice/deployment/Dockerfile @@ -18,6 +18,8 @@ COPY stripe_to_invoice . # ✅ Build-time only (safe placeholder) ENV DATABASE_URL="postgres://build:build@localhost:5432/build" +ENV STRIPE_SECRET_KEY="sk_build_placeholder" +ENV STRIPE_WEBHOOK_SECRET="whsec_build_placeholder" ENV NEXT_TELEMETRY_DISABLED=1 RUN node -e "require('typescript')" From c3651b169503d24625c5cde9973a741e718968fb Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 21 Jan 2026 00:02:04 +0000 Subject: [PATCH 55/59] ensure correct env --- .github/workflows/stripe-to-invoice.yml | 7 +- .../app/api/stripe/webhook/route.ts | 87 ++++++------------- stripe_to_invoice/lib/xero/service.ts | 24 ++++- 3 files changed, 53 insertions(+), 65 deletions(-) diff --git a/.github/workflows/stripe-to-invoice.yml b/.github/workflows/stripe-to-invoice.yml index f3bc1d0..73a009f 100644 --- a/.github/workflows/stripe-to-invoice.yml +++ b/.github/workflows/stripe-to-invoice.yml @@ -128,6 +128,7 @@ jobs: XERO_CLIENT_ID="$PROD_XERO_CLIENT_ID" XERO_CLIENT_SECRET="$PROD_XERO_SECRET_KEY" XERO_REDIRECT_URI="$PROD_REDIRECT_URI" + AWS_REGION="$DEV_AWS_REGION" else @@ -138,6 +139,7 @@ jobs: XERO_CLIENT_ID="$DEV_XERO_CLIENT_ID" XERO_CLIENT_SECRET="$DEV_XERO_SECRET_KEY" XERO_REDIRECT_URI="$DEV_XERO_REDIRECT_URI" + AWS_REGION="$PROD_AWS_REGION" fi : "${STRIPE_SECRET_KEY:?missing STRIPE_SECRET_KEY}" @@ -147,6 +149,7 @@ jobs: : "${XERO_CLIENT_ID:?missing XERO_CLIENT_ID}" : "${XERO_CLIENT_SECRET:?missing XERO_CLIENT_SECRET}" : "${XERO_REDIRECT_URI:?missing XERO_REDIRECT_URI}" + : "${AWS_REGION:?missing AWS_REGION}" export \ STRIPE_SECRET_KEY \ @@ -156,7 +159,9 @@ jobs: XERO_CLIENT_ID \ XERO_CLIENT_SECRET \ XERO_REDIRECT_URI \ - NAMESPACE + AWS_REGION \ + NAMESPACE + envsubst < stripe_to_invoice/deployment/secrets/stripe-secrets.yaml \ | kubectl apply -f - diff --git a/stripe_to_invoice/app/api/stripe/webhook/route.ts b/stripe_to_invoice/app/api/stripe/webhook/route.ts index 4f9f5fe..0be4f4f 100644 --- a/stripe_to_invoice/app/api/stripe/webhook/route.ts +++ b/stripe_to_invoice/app/api/stripe/webhook/route.ts @@ -3,18 +3,20 @@ export const dynamic = "force-dynamic"; export const revalidate = 0; import { NextRequest, NextResponse } from "next/server"; -import { getStripe } from "@/lib/stripe/service"; -import { XeroClient, Invoice, CurrencyCode } from "xero-node"; import Stripe from "stripe"; +import { Invoice, CurrencyCode } from "xero-node"; +import { eq } from "drizzle-orm"; + +import { getStripe } from "@/lib/stripe/service"; +import { getXeroClient } from "@/lib/xero/service"; +import { getValidXeroAccessToken } from "@/lib/xero/auth"; + import { db } from "@/lib/db"; import { stripeAccounts, xeroConnections, processedStripeEvents, } from "@/lib/schema"; -import { eq } from "drizzle-orm"; -import { getXeroClient } from "@/lib/xero/service"; - const stripe = getStripe(); @@ -98,7 +100,6 @@ export async function POST(req: NextRequest) { .limit(1); if (!xeroConn) { - console.error("❌ No Xero connection for user:", stripeAccount.userId); return NextResponse.json( { error: "User has no Xero connection" }, { status: 500 } @@ -107,39 +108,15 @@ export async function POST(req: NextRequest) { if (!xeroConn.salesAccountCode || !xeroConn.stripeClearingAccountCode) { throw new Error( - "Xero account codes not configured. User must select Sales and Stripe Clearing accounts." + "Xero account codes not configured (sales / stripe clearing)" ); } // -------------------------------------------------- - // 5️⃣ Init Xero client + refresh token if needed + // 5️⃣ Get VALID Xero access token (refresh handled centrally) // -------------------------------------------------- - const xero = getXeroClient(); - - xero.setTokenSet({ - access_token: xeroConn.accessToken, - refresh_token: xeroConn.refreshToken, - expires_at: xeroConn.expiresAt.getTime(), - token_type: "Bearer", - }); - - const now = Date.now(); - if (xeroConn.expiresAt.getTime() <= now + 60_000) { - console.log("🔄 Refreshing Xero token"); - - const newTokenSet = await xero.refreshToken(); - - await db - .update(xeroConnections) - .set({ - accessToken: newTokenSet.access_token!, - refreshToken: newTokenSet.refresh_token!, - expiresAt: new Date(newTokenSet.expires_at!), - }) - .where(eq(xeroConnections.id, xeroConn.id)); - - xero.setTokenSet(newTokenSet); - } + const accessToken = await getValidXeroAccessToken(stripeAccount.userId); + const xero = getXeroClient(accessToken); // -------------------------------------------------- // 6️⃣ Resolve contact (email-only) @@ -168,9 +145,7 @@ export async function POST(req: NextRequest) { if (!contact) { const created = await xero.accountingApi.createContacts( xeroConn.tenantId, - { - contacts: [{ name, emailAddress: email }], - } + { contacts: [{ name, emailAddress: email }] } ); contact = created.body.contacts?.[0]; } @@ -182,15 +157,14 @@ export async function POST(req: NextRequest) { // -------------------------------------------------- // 7️⃣ Create AUTHORISED invoice // -------------------------------------------------- - const amount = session.amount_total! / 100; - - if (!session.currency) { - throw new Error("Stripe session missing currency"); + if (!session.amount_total || !session.currency) { + throw new Error("Stripe session missing amount or currency"); } - const currencyCode = session.currency.toUpperCase() as keyof typeof CurrencyCode; + const amount = session.amount_total / 100; + const currencyKey = session.currency.toUpperCase() as keyof typeof CurrencyCode; - if (!(currencyCode in CurrencyCode)) { + if (!(currencyKey in CurrencyCode)) { throw new Error(`Unsupported currency: ${session.currency}`); } @@ -210,7 +184,7 @@ export async function POST(req: NextRequest) { accountCode: xeroConn.salesAccountCode, }, ], - currencyCode: CurrencyCode[currencyCode], + currencyCode: CurrencyCode[currencyKey], reference: session.id, }, ], @@ -223,20 +197,13 @@ export async function POST(req: NextRequest) { } // -------------------------------------------------- - // 8️⃣ Mark invoice as PAID to Stripe Clearing - // - // STRIPE CLEARING (v1 behaviour) - // - Invoices are marked as PAID to a Stripe Clearing account - // - Stripe fees are NOT yet recorded in v1 - // - // TODO (planned): - // - Record Stripe fees as an expense - // - Reconcile Stripe payouts automatically + // 8️⃣ Mark invoice as PAID → Stripe Clearing // -------------------------------------------------- const paymentReference = - typeof session.payment_intent === "string" - ? session.payment_intent - : session.id; + typeof session.payment_intent === "string" + ? session.payment_intent + : session.id; + await xero.accountingApi.createPayments(xeroConn.tenantId, { payments: [ { @@ -244,22 +211,20 @@ export async function POST(req: NextRequest) { amount, date: new Date().toISOString().slice(0, 10), reference: paymentReference, - account: { - code: xeroConn.stripeClearingAccountCode, - }, + account: { code: xeroConn.stripeClearingAccountCode }, }, ], }); // -------------------------------------------------- - // 9️⃣ Record idempotency AFTER success + // 9️⃣ Record idempotency (LAST STEP) // -------------------------------------------------- await db.insert(processedStripeEvents).values({ stripeEventId: event.id, stripeAccountId, }); - console.log("✅ Stripe payment fully processed", { + console.log("✅ Stripe → Xero sync complete", { eventId: event.id, invoiceId: invoice.invoiceID, stripeAccountId, diff --git a/stripe_to_invoice/lib/xero/service.ts b/stripe_to_invoice/lib/xero/service.ts index ce651d2..c4d1ac5 100644 --- a/stripe_to_invoice/lib/xero/service.ts +++ b/stripe_to_invoice/lib/xero/service.ts @@ -1,5 +1,23 @@ import { XeroClient } from "xero-node"; -export function getXeroClient(): XeroClient { - return new XeroClient(); -} \ No newline at end of file +/** + * Creates a XeroClient using an already-valid access token. + * + * IMPORTANT: + * - Token refresh is handled elsewhere (auth.ts) + * - This client MUST NOT call refreshToken() + */ +export function getXeroClient(accessToken: string): XeroClient { + if (!accessToken) { + throw new Error("Xero access token missing"); + } + + const xero = new XeroClient(); + + xero.setTokenSet({ + access_token: accessToken, + token_type: "Bearer", + }); + + return xero; +} From dd92550ba798f3d90dc15347b2b7034713972f07 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 21 Jan 2026 01:07:39 +0000 Subject: [PATCH 56/59] save for production --- .../app/api/stripe/webhook/route.ts | 39 +++++-------------- 1 file changed, 10 insertions(+), 29 deletions(-) diff --git a/stripe_to_invoice/app/api/stripe/webhook/route.ts b/stripe_to_invoice/app/api/stripe/webhook/route.ts index 0be4f4f..20db5da 100644 --- a/stripe_to_invoice/app/api/stripe/webhook/route.ts +++ b/stripe_to_invoice/app/api/stripe/webhook/route.ts @@ -83,7 +83,6 @@ export async function POST(req: NextRequest) { .limit(1); if (!stripeAccount) { - console.error("❌ Stripe account not registered:", stripeAccountId); return NextResponse.json( { error: "Stripe account not registered" }, { status: 500 } @@ -106,14 +105,12 @@ export async function POST(req: NextRequest) { ); } - if (!xeroConn.salesAccountCode || !xeroConn.stripeClearingAccountCode) { - throw new Error( - "Xero account codes not configured (sales / stripe clearing)" - ); + if (!xeroConn.salesAccountCode) { + throw new Error("Sales account code not configured"); } // -------------------------------------------------- - // 5️⃣ Get VALID Xero access token (refresh handled centrally) + // 5️⃣ Get VALID Xero access token // -------------------------------------------------- const accessToken = await getValidXeroAccessToken(stripeAccount.userId); const xero = getXeroClient(accessToken); @@ -155,7 +152,7 @@ export async function POST(req: NextRequest) { } // -------------------------------------------------- - // 7️⃣ Create AUTHORISED invoice + // 7️⃣ Create AUTHORISED invoice (NO PAYMENT) // -------------------------------------------------- if (!session.amount_total || !session.currency) { throw new Error("Stripe session missing amount or currency"); @@ -168,6 +165,8 @@ export async function POST(req: NextRequest) { throw new Error(`Unsupported currency: ${session.currency}`); } + const today = new Date().toISOString().slice(0, 10); + const invoiceResponse = await xero.accountingApi.createInvoices( xeroConn.tenantId, { @@ -176,6 +175,8 @@ export async function POST(req: NextRequest) { type: Invoice.TypeEnum.ACCREC, status: Invoice.StatusEnum.AUTHORISED, contact: { contactID: contact.contactID }, + date: today, + dueDate: today, lineItems: [ { description: `Stripe payment (${session.id})`, @@ -197,34 +198,14 @@ export async function POST(req: NextRequest) { } // -------------------------------------------------- - // 8️⃣ Mark invoice as PAID → Stripe Clearing - // -------------------------------------------------- - const paymentReference = - typeof session.payment_intent === "string" - ? session.payment_intent - : session.id; - - await xero.accountingApi.createPayments(xeroConn.tenantId, { - payments: [ - { - invoice: { invoiceID: invoice.invoiceID }, - amount, - date: new Date().toISOString().slice(0, 10), - reference: paymentReference, - account: { code: xeroConn.stripeClearingAccountCode }, - }, - ], - }); - - // -------------------------------------------------- - // 9️⃣ Record idempotency (LAST STEP) + // 8️⃣ Record idempotency (LAST STEP) // -------------------------------------------------- await db.insert(processedStripeEvents).values({ stripeEventId: event.id, stripeAccountId, }); - console.log("✅ Stripe → Xero sync complete", { + console.log("✅ Stripe → Xero invoice created", { eventId: event.id, invoiceId: invoice.invoiceID, stripeAccountId, From ee279dfbfe7f6e47eaf446236857bd2e552c337b Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 21 Jan 2026 19:41:03 +0000 Subject: [PATCH 57/59] deploy to dev --- .github/workflows/stripe-to-invoice.yml | 9 +++++---- stripe_to_invoice/app/api/stripe/webhook/route.ts | 12 +++++++++++- stripe_to_invoice/app/page.tsx | 1 - stripe_to_invoice/deployment/deployment.yaml | 6 ++++++ stripe_to_invoice/deployment/secrets/.env | 2 ++ .../deployment/secrets/stripe-secrets.yaml | 1 + 6 files changed, 25 insertions(+), 6 deletions(-) diff --git a/.github/workflows/stripe-to-invoice.yml b/.github/workflows/stripe-to-invoice.yml index 73a009f..0e351cc 100644 --- a/.github/workflows/stripe-to-invoice.yml +++ b/.github/workflows/stripe-to-invoice.yml @@ -128,9 +128,8 @@ jobs: XERO_CLIENT_ID="$PROD_XERO_CLIENT_ID" XERO_CLIENT_SECRET="$PROD_XERO_SECRET_KEY" XERO_REDIRECT_URI="$PROD_REDIRECT_URI" - AWS_REGION="$DEV_AWS_REGION" - - + AWS_REGION="$PROD_AWS_REGION" + STRIPE_WEBHOOK_SECRET="$PROD_STRIPE_WEBHOOK_SECRET" else STRIPE_SECRET_KEY="$DEV_STRIPE_SECRET_KEY" STRIPE_CLIENT_ID="$DEV_STRIPE_CLIENT_ID" @@ -139,7 +138,8 @@ jobs: XERO_CLIENT_ID="$DEV_XERO_CLIENT_ID" XERO_CLIENT_SECRET="$DEV_XERO_SECRET_KEY" XERO_REDIRECT_URI="$DEV_XERO_REDIRECT_URI" - AWS_REGION="$PROD_AWS_REGION" + AWS_REGION="$DEV_AWS_REGION" + STRIPE_WEBHOOK_SECRET="$DEV_STRIPE_WEBHOOK_SECRET" fi : "${STRIPE_SECRET_KEY:?missing STRIPE_SECRET_KEY}" @@ -160,6 +160,7 @@ jobs: XERO_CLIENT_SECRET \ XERO_REDIRECT_URI \ AWS_REGION \ + STRIPE_WEBHOOK_SECRET \ NAMESPACE diff --git a/stripe_to_invoice/app/api/stripe/webhook/route.ts b/stripe_to_invoice/app/api/stripe/webhook/route.ts index 20db5da..558e8b1 100644 --- a/stripe_to_invoice/app/api/stripe/webhook/route.ts +++ b/stripe_to_invoice/app/api/stripe/webhook/route.ts @@ -57,7 +57,17 @@ export async function POST(req: NextRequest) { // -------------------------------------------------- const stripeAccountId = req.headers.get("stripe-account") ?? - "acct_1Sds1LB99GOwj1Ea"; // DEV ONLY + (process.env.NODE_ENV === "development" + ? "acct_1Sds1LB99GOwj1Ea" // DEV ONLY + : null); + + if (!stripeAccountId) { + console.error("❌ Missing stripe-account header in production"); + return NextResponse.json( + { error: "Missing Stripe account context" }, + { status: 400 } + ); + } // -------------------------------------------------- // 2️⃣ IDEMPOTENCY CHECK diff --git a/stripe_to_invoice/app/page.tsx b/stripe_to_invoice/app/page.tsx index 3c95830..99a477a 100644 --- a/stripe_to_invoice/app/page.tsx +++ b/stripe_to_invoice/app/page.tsx @@ -67,7 +67,6 @@ export default async function Home() { Log in → - ); } diff --git a/stripe_to_invoice/deployment/deployment.yaml b/stripe_to_invoice/deployment/deployment.yaml index 76e867d..5d2c961 100644 --- a/stripe_to_invoice/deployment/deployment.yaml +++ b/stripe_to_invoice/deployment/deployment.yaml @@ -102,6 +102,12 @@ spec: name: stripe-secrets key: XERO_REDIRECT_URI + - name: STRIPE_WEBHOOK_SECRET + valueFrom: + secretKeyRef: + name: stripe-secrets + key: STRIPE_WEBHOOK_SECRET + imagePullSecrets: - name: registrypullsecret diff --git a/stripe_to_invoice/deployment/secrets/.env b/stripe_to_invoice/deployment/secrets/.env index 0028a16..47dbf3b 100644 --- a/stripe_to_invoice/deployment/secrets/.env +++ b/stripe_to_invoice/deployment/secrets/.env @@ -7,6 +7,7 @@ DEV_AWS_ACCESS_KEY_ID=AKIAQL67W6HI2547OPVG DEV_AWS_SECRET_ACCESS_KEY=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 DEV_SES_FROM_EMAIL=no-reply@juntekim.com DEV_STRIPE_REDIRECT_URI=https://stripe-to-invoice.dev.juntekim.com/api/stripe/callback +DEV_STRIPE_WEBHOOK_SECRET=whsec_e6e760a5abf0cde5b31a005f754172a445ff1d710b4ee58c79f87ff7344ff08d DEV_XERO_CLIENT_ID=4C24EEA5583046519AD39B3905ED2BD3 DEV_XERO_SECRET_KEY=PAYDhzqMLvNtPrN5vDC7iwtXkgu99yG8Gbu86IlrdHH8hGjA DEV_XERO_REDIRECT_URI=https://stripe-to-invoice.dev.juntekim.com/api/xero/callback @@ -22,6 +23,7 @@ PROD_AWS_ACCESS_KEY_ID=AKIAQL67W6HI2547OPVG PROD_AWS_SECRET_ACCESS_KEY=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 PROD_SES_FROM_EMAIL=no-reply@juntekim.com PROD_STRIPE_REDIRECT_URI=https://stripe-to-invoice.dev.juntekim.com/api/stripe/callback +PROD_STRIPE_WEBHOOK_SECRET=whsec_e6e760a5abf0cde5b31a005f754172a445ff1d710b4ee58c79f87ff7344ff08d PROD_XERO_CLIENT_ID=4C24EEA5583046519AD39B3905ED2BD3 PROD_XERO_SECRET_KEY=PAYDhzqMLvNtPrN5vDC7iwtXkgu99yG8Gbu86IlrdHH8hGjA PROD_XERO_REDIRECT_URI=https://stripe-to-invoice.juntekim.com/api/xero/callback diff --git a/stripe_to_invoice/deployment/secrets/stripe-secrets.yaml b/stripe_to_invoice/deployment/secrets/stripe-secrets.yaml index b9a0539..d213c85 100644 --- a/stripe_to_invoice/deployment/secrets/stripe-secrets.yaml +++ b/stripe_to_invoice/deployment/secrets/stripe-secrets.yaml @@ -16,3 +16,4 @@ stringData: XERO_CLIENT_ID: ${XERO_CLIENT_ID} XERO_CLIENT_SECRET: ${XERO_CLIENT_SECRET} XERO_REDIRECT_URI: ${XERO_REDIRECT_URI} + STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET} From 4717822490646bdd531861fc32f46717ad7b642b Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 21 Jan 2026 19:44:38 +0000 Subject: [PATCH 58/59] deploy to dev --- stripe_to_invoice/app/api/xero/callback/route.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/stripe_to_invoice/app/api/xero/callback/route.ts b/stripe_to_invoice/app/api/xero/callback/route.ts index 1046c24..dd1e7aa 100644 --- a/stripe_to_invoice/app/api/xero/callback/route.ts +++ b/stripe_to_invoice/app/api/xero/callback/route.ts @@ -92,6 +92,8 @@ export async function GET(req: NextRequest) { accessToken: tokenData.access_token, refreshToken: tokenData.refresh_token, expiresAt, + salesAccountCode: "200", + stripeClearingAccountCode: "610", updatedAt: new Date(), }) .onConflictDoUpdate({ @@ -102,6 +104,7 @@ export async function GET(req: NextRequest) { refreshToken: tokenData.refresh_token, expiresAt, updatedAt: new Date(), + // ⚠️ deliberately NOT updating account codes }, }); From 024b523422b6a5567f3d7dbfe2a95b56acf59168 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 21 Jan 2026 20:04:10 +0000 Subject: [PATCH 59/59] production secret --- stripe_to_invoice/deployment/secrets/.env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stripe_to_invoice/deployment/secrets/.env b/stripe_to_invoice/deployment/secrets/.env index 47dbf3b..effbcad 100644 --- a/stripe_to_invoice/deployment/secrets/.env +++ b/stripe_to_invoice/deployment/secrets/.env @@ -23,7 +23,7 @@ PROD_AWS_ACCESS_KEY_ID=AKIAQL67W6HI2547OPVG PROD_AWS_SECRET_ACCESS_KEY=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 PROD_SES_FROM_EMAIL=no-reply@juntekim.com PROD_STRIPE_REDIRECT_URI=https://stripe-to-invoice.dev.juntekim.com/api/stripe/callback -PROD_STRIPE_WEBHOOK_SECRET=whsec_e6e760a5abf0cde5b31a005f754172a445ff1d710b4ee58c79f87ff7344ff08d +PROD_STRIPE_WEBHOOK_SECRET=whsec_VP3DJmcdCWHkOWwUH1WRmdzu6Y71uZle PROD_XERO_CLIENT_ID=4C24EEA5583046519AD39B3905ED2BD3 PROD_XERO_SECRET_KEY=PAYDhzqMLvNtPrN5vDC7iwtXkgu99yG8Gbu86IlrdHH8hGjA PROD_XERO_REDIRECT_URI=https://stripe-to-invoice.juntekim.com/api/xero/callback