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/.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/.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..0e351cc 100644 --- a/.github/workflows/stripe-to-invoice.yml +++ b/.github/workflows/stripe-to-invoice.yml @@ -1,81 +1,267 @@ -# name: Build & Deploy stripe-to-invoice +name: Build & Deploy stripe-to-invoice (with DB secrets + migrations) -# on: -# push: -# branches: -# - main -# - feature/** -# - release/** -# tags: -# - "*" +on: + push: + branches: + - main + - feature/** + - release/** + tags: + - "*" + workflow_dispatch: -# jobs: -# build: -# runs-on: ubuntu-22.04 -# steps: -# - uses: actions/checkout@v4 +jobs: + # -------------------------------------------------- + # BUILD IMAGE + # -------------------------------------------------- + build: + runs-on: ubuntu-22.04 -# - name: Inject slug variables -# uses: rlespinasse/github-slug-action@v4 + steps: + - uses: actions/checkout@v4 -# - name: Login to Docker Hub -# uses: docker/login-action@v3 -# with: -# username: ${{ secrets.DOCKER_HUB_USERNAME }} -# password: ${{ secrets.DOCKER_HUB_TOKEN }} + - name: Inject slug variables + uses: rlespinasse/github-slug-action@v4 -# - name: Build image -# run: | -# docker build \ -# -f stripe_to_invoice/deployment/Dockerfile \ -# -t docker.io/kimjunte/stripe_to_invoice:$GITHUB_REF_SLUG \ -# . + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_TOKEN }} -# - name: Push image -# run: | -# docker push docker.io/kimjunte/stripe_to_invoice:$GITHUB_REF_SLUG + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 -# deploy: -# runs-on: mealcraft-runners -# needs: build + - 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 }} -# steps: -# - uses: actions/checkout@v4 + # -------------------------------------------------- + # APPLY DB + APP SECRETS + # -------------------------------------------------- + secrets: + name: Apply runtime secrets + runs-on: mealcraft-runners + needs: build -# - 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 + steps: + - uses: actions/checkout@v4 -# - 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: 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 -# 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: 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 -# - name: Inject slug variables -# uses: rlespinasse/github-slug-action@v4 + kubectl config set-cluster microk8s \ + --server="$KUBE_HOST" \ + --certificate-authority="$CA_CERT" -# - 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 + kubectl config set-credentials runner --token="$SA_TOKEN" -# - 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 - + 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" >> $GITHUB_ENV + fi + + - name: Apply DB 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 - + + - name: Apply app 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" + STRIPE_REDIRECT_URI="$PROD_STRIPE_REDIRECT_URI" + APP_URL="$PROD_APP_URL" + XERO_CLIENT_ID="$PROD_XERO_CLIENT_ID" + XERO_CLIENT_SECRET="$PROD_XERO_SECRET_KEY" + XERO_REDIRECT_URI="$PROD_REDIRECT_URI" + 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" + 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_REDIRECT_URI="$DEV_XERO_REDIRECT_URI" + AWS_REGION="$DEV_AWS_REGION" + STRIPE_WEBHOOK_SECRET="$DEV_STRIPE_WEBHOOK_SECRET" + 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}" + : "${AWS_REGION:?missing AWS_REGION}" + + export \ + STRIPE_SECRET_KEY \ + STRIPE_CLIENT_ID \ + STRIPE_REDIRECT_URI \ + APP_URL \ + XERO_CLIENT_ID \ + XERO_CLIENT_SECRET \ + XERO_REDIRECT_URI \ + AWS_REGION \ + STRIPE_WEBHOOK_SECRET \ + NAMESPACE + + + envsubst < stripe_to_invoice/deployment/secrets/stripe-secrets.yaml \ + | kubectl apply -f - + + # -------------------------------------------------- + # RUN ATLAS MIGRATIONS + # -------------------------------------------------- + migrate: + name: Run DB migrations (Atlas) + runs-on: mealcraft-runners + 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://db/atlas/stripe_invoice/migrations \ + --url "$DATABASE_URL" + + # -------------------------------------------------- + # DEPLOY APPLICATION + # -------------------------------------------------- + deploy: + runs-on: mealcraft-runners + needs: + - build + - secrets + - migrate + + 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) + 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: Inject slug variables + uses: rlespinasse/github-slug-action@v4 + + - name: Decide 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 application + run: | + export IMAGE="docker.io/kimjunte/stripe_to_invoice:$GITHUB_REF_SLUG" + export NAMESPACE DB_ENV HOSTNAME + envsubst < stripe_to_invoice/deployment/deployment.yaml | kubectl apply -f - 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/db/.env b/db/.env new file mode 100644 index 0000000..d7f3828 --- /dev/null +++ b/db/.env @@ -0,0 +1,8 @@ +# Dev Stripe-to-invoice +# postgres-dev.dev.svc.cluster.local +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 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/atlas/stripe_invoice/add_new_migration.sh b/db/atlas/stripe_invoice/add_new_migration.sh index 5d623b5..543094f 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_invoice_code + 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); 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/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..75eed7b --- /dev/null +++ b/db/atlas/stripe_invoice/migrations/20260118191050_add_more_info_on_xero_for_refresh_token.sql @@ -0,0 +1,19 @@ +-- 1. Add columns as nullable +ALTER TABLE public.xero_connections +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/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/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 bdfc6b7..5136272 100644 --- a/db/atlas/stripe_invoice/migrations/atlas.sum +++ b/db/atlas/stripe_invoice/migrations/atlas.sum @@ -1,7 +1,13 @@ -h1:RjeUC9UfXpaaJorJ+072tmUmM0yLI4yO71Cuad9tjA4= +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= 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: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= +20260120223114_add_stripe_history.sql h1:+l14lHGfyoNBGh1w9TqOuxmETe1Bgo1sry1aXrvt4bU= +20260120230059_add_invoice_code.sql h1:9uItaHRhcuSuxnoqMOwxyPxiOUdm2+gadRZDeSwLmSY= 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 deleted file mode 100644 index 0d0eedc..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_dev"] -# 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 b772077..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_prod"] -# 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/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/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 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 — 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 + 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 ===" 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/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 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..d4f6d79 --- /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.APP_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..97adf3b --- /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_write", + 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/api/stripe/webhook/route.ts b/stripe_to_invoice/app/api/stripe/webhook/route.ts new file mode 100644 index 0000000..558e8b1 --- /dev/null +++ b/stripe_to_invoice/app/api/stripe/webhook/route.ts @@ -0,0 +1,225 @@ +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +import { NextRequest, NextResponse } from "next/server"; +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"; + +const stripe = getStripe(); + +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") ?? + (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 + // -------------------------------------------------- + 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) { + 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) { + return NextResponse.json( + { error: "User has no Xero connection" }, + { status: 500 } + ); + } + + if (!xeroConn.salesAccountCode) { + throw new Error("Sales account code not configured"); + } + + // -------------------------------------------------- + // 5️⃣ Get VALID Xero access token + // -------------------------------------------------- + const accessToken = await getValidXeroAccessToken(stripeAccount.userId); + const xero = getXeroClient(accessToken); + + // -------------------------------------------------- + // 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 (NO PAYMENT) + // -------------------------------------------------- + if (!session.amount_total || !session.currency) { + throw new Error("Stripe session missing amount or currency"); + } + + const amount = session.amount_total / 100; + const currencyKey = session.currency.toUpperCase() as keyof typeof CurrencyCode; + + if (!(currencyKey in CurrencyCode)) { + throw new Error(`Unsupported currency: ${session.currency}`); + } + + const today = new Date().toISOString().slice(0, 10); + + const invoiceResponse = await xero.accountingApi.createInvoices( + xeroConn.tenantId, + { + invoices: [ + { + type: Invoice.TypeEnum.ACCREC, + status: Invoice.StatusEnum.AUTHORISED, + contact: { contactID: contact.contactID }, + date: today, + dueDate: today, + lineItems: [ + { + description: `Stripe payment (${session.id})`, + quantity: 1, + unitAmount: amount, + accountCode: xeroConn.salesAccountCode, + }, + ], + currencyCode: CurrencyCode[currencyKey], + reference: session.id, + }, + ], + } + ); + + const invoice = invoiceResponse.body.invoices?.[0]; + if (!invoice?.invoiceID) { + throw new Error("Failed to create Xero invoice"); + } + + // -------------------------------------------------- + // 8️⃣ Record idempotency (LAST STEP) + // -------------------------------------------------- + await db.insert(processedStripeEvents).values({ + stripeEventId: event.id, + stripeAccountId, + }); + + console.log("✅ Stripe → Xero invoice created", { + eventId: event.id, + invoiceId: invoice.invoiceID, + stripeAccountId, + }); + + return NextResponse.json({ received: true }); +} 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..dd1e7aa --- /dev/null +++ b/stripe_to_invoice/app/api/xero/callback/route.ts @@ -0,0 +1,114 @@ +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; + expires_in: number; // seconds +}; + +type XeroTenant = { + tenantId: string; +}; + +export async function GET(req: NextRequest) { + const cookieStore = await cookies(); + const session = cookieStore.get("session"); + + 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 } + ); + } + + // 1️⃣ Exchange code for tokens + 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; + + // 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; + + if (!tenantId) { + return NextResponse.json( + { error: "No Xero organisation found" }, + { status: 400 } + ); + } + + 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, + salesAccountCode: "200", + stripeClearingAccountCode: "610", + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: xeroConnections.userId, + set: { + tenantId, + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token, + expiresAt, + updatedAt: new Date(), + // ⚠️ deliberately NOT updating account codes + }, + }); + + 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/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…

-
+ + + ); } 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 new file mode 100644 index 0000000..85efe16 --- /dev/null +++ b/stripe_to_invoice/app/connect/stripe/success/page.tsx @@ -0,0 +1,44 @@ +import Link from "next/link"; + +export default function StripeSuccessPage() { + return ( +
+

+ Stripe connected 🎉 +

+ +

+ Your Stripe account is now linked. We can now detect successful + payments and automatically reconcile invoices in Xero. +

+ + {/* Progress */} +
    +
  1. + + Logged in +
  2. + +
  3. + + Stripe connected +
  4. + +
  5. + + Connect Xero +
  6. +
+ + {/* Primary CTA */} +
+ + 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/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/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/Dockerfile b/stripe_to_invoice/deployment/Dockerfile index 80123e3..60beaad 100644 --- a/stripe_to_invoice/deployment/Dockerfile +++ b/stripe_to_invoice/deployment/Dockerfile @@ -11,10 +11,17 @@ 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 STRIPE_SECRET_KEY="sk_build_placeholder" +ENV STRIPE_WEBHOOK_SECRET="whsec_build_placeholder" ENV NEXT_TELEMETRY_DISABLED=1 + RUN node -e "require('typescript')" RUN npm run build diff --git a/stripe_to_invoice/deployment/deployment.yaml b/stripe_to_invoice/deployment/deployment.yaml index 3544346..5d2c961 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 @@ -40,6 +40,74 @@ spec: name: stripe-secrets key: STRIPE_SECRET_KEY + - name: STRIPE_CLIENT_ID + valueFrom: + secretKeyRef: + 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 + + - name: STRIPE_REDIRECT_URI + valueFrom: + secretKeyRef: + 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 + + - 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 new file mode 100644 index 0000000..effbcad --- /dev/null +++ b/stripe_to_invoice/deployment/secrets/.env @@ -0,0 +1,31 @@ +# Test mode for deployment +DEV_STRIPE_SECRET_KEY=sk_test_51Mo6PnBUc0gyz8XqrZqvWQWRQSUQbjt7zxP56lhdqgIG4qxn5zDuistUJJq8Chl7AxmyCy8xMRAh1Zf25jK0lYCb00QsQqNEsc +DEV_STRIPE_CLIENT_ID=ca_NZFa6CNybMItWKir9Uk6ojevnYcP7Rbz +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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 +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 + + + +# Prod +PROD_STRIPE_SECRET_KEY=sk_test_51Mo6PnBUc0gyz8XqrZqvWQWRQSUQbjt7zxP56lhdqgIG4qxn5zDuistUJJq8Chl7AxmyCy8xMRAh1Zf25jK0lYCb00QsQqNEsc +PROD_STRIPE_CLIENT_ID=ca_NZFa6CNybMItWKir9Uk6ojevnYcP7Rbz +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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 +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_VP3DJmcdCWHkOWwUH1WRmdzu6Y71uZle +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 new file mode 100644 index 0000000..d213c85 --- /dev/null +++ b/stripe_to_invoice/deployment/secrets/stripe-secrets.yaml @@ -0,0 +1,19 @@ +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} + 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} + STRIPE_REDIRECT_URI: ${STRIPE_REDIRECT_URI} + XERO_CLIENT_ID: ${XERO_CLIENT_ID} + XERO_CLIENT_SECRET: ${XERO_CLIENT_SECRET} + XERO_REDIRECT_URI: ${XERO_REDIRECT_URI} + STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET} 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 }); diff --git a/stripe_to_invoice/lib/schema/index.ts b/stripe_to_invoice/lib/schema/index.ts index 58cd6d0..cbfa066 100644 --- a/stripe_to_invoice/lib/schema/index.ts +++ b/stripe_to_invoice/lib/schema/index.ts @@ -1,3 +1,6 @@ // lib/schema/index.ts export * from "./users"; export * from "./loginTokens"; +export * from "./stripeAccounts"; +export * from "./xeroConnections"; +export * from "./processedStripeEvents"; 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/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 diff --git a/stripe_to_invoice/lib/schema/xeroConnections.ts b/stripe_to_invoice/lib/schema/xeroConnections.ts new file mode 100644 index 0000000..f1f8092 --- /dev/null +++ b/stripe_to_invoice/lib/schema/xeroConnections.ts @@ -0,0 +1,23 @@ +import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; + +export const xeroConnections = pgTable("xero_connections", { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id").notNull(), + tenantId: text("tenant_id").notNull(), + + accessToken: text("access_token").notNull(), + refreshToken: text("refresh_token").notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + + // ✅ ADD THESE + salesAccountCode: text("sales_account_code"), + stripeClearingAccountCode: text("stripe_clearing_account_code"), + + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); 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/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; +} diff --git a/stripe_to_invoice/lib/xero/service.ts b/stripe_to_invoice/lib/xero/service.ts new file mode 100644 index 0000000..c4d1ac5 --- /dev/null +++ b/stripe_to_invoice/lib/xero/service.ts @@ -0,0 +1,23 @@ +import { XeroClient } from "xero-node"; + +/** + * 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; +} 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", 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