commit
446ff7c311
58 changed files with 1772 additions and 792 deletions
|
|
@ -43,9 +43,17 @@ RUN wget https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform
|
||||||
&& unzip terraform_${TERRAFORM_VERSION}_linux_amd64.zip \
|
&& unzip terraform_${TERRAFORM_VERSION}_linux_amd64.zip \
|
||||||
&& mv terraform /usr/local/bin/terraform \
|
&& mv terraform /usr/local/bin/terraform \
|
||||||
&& rm terraform_${TERRAFORM_VERSION}_linux_amd64.zip
|
&& rm terraform_${TERRAFORM_VERSION}_linux_amd64.zip
|
||||||
|
|
||||||
RUN terraform version
|
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
|
# Set the working directory
|
||||||
WORKDIR /workspaces/monorepo
|
WORKDIR /workspaces/monorepo
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
# Place holder
|
|
||||||
96
.github/workflows/deploy-postgres-dev.yml
vendored
96
.github/workflows/deploy-postgres-dev.yml
vendored
|
|
@ -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
|
|
||||||
82
.github/workflows/deploy-postgres-prod.yml
vendored
82
.github/workflows/deploy-postgres-prod.yml
vendored
|
|
@ -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
|
|
||||||
320
.github/workflows/stripe-to-invoice.yml
vendored
320
.github/workflows/stripe-to-invoice.yml
vendored
|
|
@ -1,81 +1,267 @@
|
||||||
# name: Build & Deploy stripe-to-invoice
|
name: Build & Deploy stripe-to-invoice (with DB secrets + migrations)
|
||||||
|
|
||||||
# on:
|
on:
|
||||||
# push:
|
push:
|
||||||
# branches:
|
branches:
|
||||||
# - main
|
- main
|
||||||
# - feature/**
|
- feature/**
|
||||||
# - release/**
|
- release/**
|
||||||
# tags:
|
tags:
|
||||||
# - "*"
|
- "*"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
# jobs:
|
jobs:
|
||||||
# build:
|
# --------------------------------------------------
|
||||||
# runs-on: ubuntu-22.04
|
# BUILD IMAGE
|
||||||
# steps:
|
# --------------------------------------------------
|
||||||
# - uses: actions/checkout@v4
|
build:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
|
||||||
# - name: Inject slug variables
|
steps:
|
||||||
# uses: rlespinasse/github-slug-action@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
# - name: Login to Docker Hub
|
- name: Inject slug variables
|
||||||
# uses: docker/login-action@v3
|
uses: rlespinasse/github-slug-action@v4
|
||||||
# with:
|
|
||||||
# username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
|
||||||
# password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
|
||||||
|
|
||||||
# - name: Build image
|
- name: Login to Docker Hub
|
||||||
# run: |
|
uses: docker/login-action@v3
|
||||||
# docker build \
|
with:
|
||||||
# -f stripe_to_invoice/deployment/Dockerfile \
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
# -t docker.io/kimjunte/stripe_to_invoice:$GITHUB_REF_SLUG \
|
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||||
# .
|
|
||||||
|
|
||||||
# - name: Push image
|
- name: Set up Docker Buildx
|
||||||
# run: |
|
uses: docker/setup-buildx-action@v3
|
||||||
# docker push docker.io/kimjunte/stripe_to_invoice:$GITHUB_REF_SLUG
|
|
||||||
|
|
||||||
# deploy:
|
- name: Build & push image
|
||||||
# runs-on: mealcraft-runners
|
uses: docker/build-push-action@v6
|
||||||
# needs: build
|
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
|
steps:
|
||||||
# run: |
|
- uses: actions/checkout@v4
|
||||||
# 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
|
- name: Install kubectl
|
||||||
# run: |
|
run: |
|
||||||
# KUBE_HOST="https://$KUBERNETES_SERVICE_HOST:$KUBERNETES_SERVICE_PORT"
|
sudo apt-get update
|
||||||
# SA_TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
|
sudo apt-get install -y curl ca-certificates gettext
|
||||||
# CA_CERT=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
|
curl -LO "https://dl.k8s.io/release/$(curl -sL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
|
||||||
# NAMESPACE=$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace)
|
sudo install -m 0755 kubectl /usr/local/bin/kubectl
|
||||||
|
|
||||||
# kubectl config set-cluster microk8s --server="$KUBE_HOST" --certificate-authority="$CA_CERT"
|
- name: Configure kubeconfig (in-cluster)
|
||||||
# kubectl config set-credentials runner --token="$SA_TOKEN"
|
run: |
|
||||||
# kubectl config set-context runner-context --cluster=microk8s --user=runner --namespace="$NAMESPACE"
|
KUBE_HOST="https://$KUBERNETES_SERVICE_HOST:$KUBERNETES_SERVICE_PORT"
|
||||||
# kubectl config use-context runner-context
|
SA_TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
|
||||||
|
CA_CERT=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
|
||||||
|
|
||||||
# - name: Inject slug variables
|
kubectl config set-cluster microk8s \
|
||||||
# uses: rlespinasse/github-slug-action@v4
|
--server="$KUBE_HOST" \
|
||||||
|
--certificate-authority="$CA_CERT"
|
||||||
|
|
||||||
# - name: Set environment
|
kubectl config set-credentials runner --token="$SA_TOKEN"
|
||||||
# run: |
|
|
||||||
# if [[ "$GITHUB_REF" == refs/heads/release/* || "$GITHUB_REF" == refs/tags/* ]]; then
|
|
||||||
# echo "NAMESPACE=default" >> $GITHUB_ENV
|
|
||||||
# echo "DB_ENV=prod" >> $GITHUB_ENV
|
|
||||||
# else
|
|
||||||
# echo "NAMESPACE=dev" >> $GITHUB_ENV
|
|
||||||
# echo "DB_ENV=dev" >> $GITHUB_ENV
|
|
||||||
# fi
|
|
||||||
|
|
||||||
# - name: Deploy
|
kubectl config set-context runner-context \
|
||||||
# run: |
|
--cluster=microk8s \
|
||||||
# export IMAGE="docker.io/kimjunte/stripe_to_invoice:$GITHUB_REF_SLUG"
|
--user=runner
|
||||||
# export NAMESPACE DB_ENV
|
|
||||||
# envsubst < stripe_to_invoice/deployment/deployment.yaml | kubectl apply -f -
|
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 -
|
||||||
|
|
|
||||||
8
.vscode/settings.json
vendored
8
.vscode/settings.json
vendored
|
|
@ -21,13 +21,7 @@
|
||||||
"<C-j>": false,
|
"<C-j>": false,
|
||||||
"<C-S-c>": false,
|
"<C-S-c>": false,
|
||||||
"<C-k>": false
|
"<C-k>": 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"
|
|
||||||
], */
|
|
||||||
|
|
||||||
}
|
}
|
||||||
8
db/.env
Normal file
8
db/.env
Normal file
|
|
@ -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
|
||||||
|
|
@ -7,7 +7,7 @@ env "stripe_invoice_dev" {
|
||||||
}
|
}
|
||||||
|
|
||||||
env "stripe_invoice_prod" {
|
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 {
|
migration {
|
||||||
|
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
atlas migrate new add_used_at_to_login_tokens
|
atlas migrate new add_invoice_code
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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()
|
||||||
|
);
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
h1:RjeUC9UfXpaaJorJ+072tmUmM0yLI4yO71Cuad9tjA4=
|
h1:ZGGgmFGh8vPWzpumfnp/KWIz6dmAFtIg/tJmVP+w0CU=
|
||||||
0001_init.sql h1:gzb02ZbjrrJkXOC+2qIZsngnj7A+29O2/b4awScPlPs=
|
0001_init.sql h1:gzb02ZbjrrJkXOC+2qIZsngnj7A+29O2/b4awScPlPs=
|
||||||
0002_auth.sql h1:4NhBu26dIBMy9gxMxM3tf6Z2CS2kfKlGjFBj07T/aBw=
|
0002_auth.sql h1:4NhBu26dIBMy9gxMxM3tf6Z2CS2kfKlGjFBj07T/aBw=
|
||||||
0003_stripe_xero.sql h1:E2bcdUDnondsXwbdIwVlZqR4DQwzcoDiyeRFJwVxXwg=
|
0003_stripe_xero.sql h1:E2bcdUDnondsXwbdIwVlZqR4DQwzcoDiyeRFJwVxXwg=
|
||||||
0004_login_tokens.sql h1:rj1KcWu/0znh2YvtI7JV8Z2nwtL5rZzONbPwX1P+/PI=
|
0004_login_tokens.sql h1:rj1KcWu/0znh2YvtI7JV8Z2nwtL5rZzONbPwX1P+/PI=
|
||||||
20251228182659_add_used_at_to_login_tokens.sql h1:/0puYQvwBFzpfSKjiZj2XR/7Mui39lS/IbFZW1TPQOc=
|
20251228182659_add_used_at_to_login_tokens.sql h1:/0puYQvwBFzpfSKjiZj2XR/7Mui39lS/IbFZW1TPQOc=
|
||||||
20251230154354_add_used_at_to_login_tokens.sql h1:FIP2MMRnfhi4hmFC3VBuABZZrxZQ1icranrXy0ljERc=
|
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=
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ spec:
|
||||||
pg_dump \
|
pg_dump \
|
||||||
-h postgres-prod.default.svc.cluster.local \
|
-h postgres-prod.default.svc.cluster.local \
|
||||||
-U $POSTGRES_USER \
|
-U $POSTGRES_USER \
|
||||||
stripe_invoice_prod \
|
stripe_invoice \
|
||||||
| gzip \
|
| gzip \
|
||||||
| aws s3 cp - s3://$S3_BUCKET/prod/stripe_invoice/$(date +%F).sql.gz
|
| aws s3 cp - s3://$S3_BUCKET/prod/stripe_invoice/$(date +%F).sql.gz
|
||||||
envFrom:
|
envFrom:
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
142
dbeaver/dbeaver.yaml
Normal file
142
dbeaver/dbeaver.yaml
Normal file
|
|
@ -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
|
||||||
|
|
@ -1,3 +1,10 @@
|
||||||
|
- Pandas
|
||||||
|
|
||||||
|
- Setting up a nas computer DIY
|
||||||
|
https://www.youtube.com/watch?v=8_1OBOeuBsA
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
- Next.js Pages Router Tutorial — <https://nextjs.org/learn/pages-router>
|
- Next.js Pages Router Tutorial — <https://nextjs.org/learn/pages-router>
|
||||||
- Next.js Dashboard App Tutorial — <https://nextjs.org/learn/dashboard-app>
|
- Next.js Dashboard App Tutorial — <https://nextjs.org/learn/dashboard-app>
|
||||||
- MDN: Using Promises — <https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises>
|
- MDN: Using Promises — <https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises>
|
||||||
|
|
|
||||||
14
juntekim_frontend/app/Youtube/my_editing_setup/page.tsx
Normal file
14
juntekim_frontend/app/Youtube/my_editing_setup/page.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="p-8">
|
||||||
|
<MarkdownRenderer content={markdown} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
juntekim_frontend/app/Youtube/my_editing_setup/script.md
Normal file
1
juntekim_frontend/app/Youtube/my_editing_setup/script.md
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
my editing_set_up requires one windows computer so i can carry on using one desk
|
||||||
14
juntekim_frontend/app/Youtube/my_new_dev_setup/page.tsx
Normal file
14
juntekim_frontend/app/Youtube/my_new_dev_setup/page.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="p-8">
|
||||||
|
<MarkdownRenderer content={markdown} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
juntekim_frontend/app/Youtube/my_new_dev_setup/script.md
Normal file
2
juntekim_frontend/app/Youtube/my_new_dev_setup/script.md
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
Write about my dev set up that uses ubunutu as the devcontainer, i just ssh
|
||||||
|
|
||||||
|
|
@ -24,6 +24,7 @@ mkdir -p "$BACKUP_DIR"
|
||||||
# NEVER touch raw Postgres data
|
# NEVER touch raw Postgres data
|
||||||
TAR_EXCLUDES=(
|
TAR_EXCLUDES=(
|
||||||
"$K8S_STORAGE_ROOT/postgres"
|
"$K8S_STORAGE_ROOT/postgres"
|
||||||
|
"$K8S_STORAGE_ROOT/lost+found"
|
||||||
)
|
)
|
||||||
|
|
||||||
# ==================================================
|
# ==================================================
|
||||||
|
|
@ -31,7 +32,7 @@ TAR_EXCLUDES=(
|
||||||
# ==================================================
|
# ==================================================
|
||||||
case "$ENVIRONMENT" in
|
case "$ENVIRONMENT" in
|
||||||
dev)
|
dev)
|
||||||
PG_SECRET_NAME="postgres-secret"
|
PG_SECRET_NAME="postgres-dev"
|
||||||
PG_POD_SELECTOR="app=postgres-dev"
|
PG_POD_SELECTOR="app=postgres-dev"
|
||||||
S3_PREFIX="dev"
|
S3_PREFIX="dev"
|
||||||
NAMESPACE="dev"
|
NAMESPACE="dev"
|
||||||
|
|
@ -43,7 +44,7 @@ case "$ENVIRONMENT" in
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
PG_SECRET_NAME="postgres-prod-secret"
|
PG_SECRET_NAME="postgres-prod"
|
||||||
PG_POD_SELECTOR="app=postgres-prod"
|
PG_POD_SELECTOR="app=postgres-prod"
|
||||||
S3_PREFIX="prod"
|
S3_PREFIX="prod"
|
||||||
NAMESPACE="default"
|
NAMESPACE="default"
|
||||||
|
|
@ -56,14 +57,15 @@ esac
|
||||||
|
|
||||||
echo "=== Backup started ($(date -u)) ==="
|
echo "=== Backup started ($(date -u)) ==="
|
||||||
echo "Environment: $ENVIRONMENT"
|
echo "Environment: $ENVIRONMENT"
|
||||||
|
echo "Namespace: $NAMESPACE"
|
||||||
|
|
||||||
# ==================================================
|
# ==================================================
|
||||||
# POSTGRES DUMP (SAFE)
|
# LOCATE POSTGRES POD
|
||||||
# ==================================================
|
# ==================================================
|
||||||
POSTGRES_POD=$(kubectl get pods \
|
POSTGRES_POD=$(kubectl get pods \
|
||||||
-n "$NAMESPACE" \
|
-n "$NAMESPACE" \
|
||||||
-l "$PG_POD_SELECTOR" \
|
-l "$PG_POD_SELECTOR" \
|
||||||
-o jsonpath='{.items[*].metadata.name}' | awk '{print $1}')
|
-o jsonpath='{.items[0].metadata.name}')
|
||||||
|
|
||||||
if [[ -z "$POSTGRES_POD" ]]; then
|
if [[ -z "$POSTGRES_POD" ]]; then
|
||||||
echo "❌ No Postgres pod found for selector: $PG_POD_SELECTOR"
|
echo "❌ No Postgres pod found for selector: $PG_POD_SELECTOR"
|
||||||
|
|
@ -71,27 +73,37 @@ if [[ -z "$POSTGRES_POD" ]]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
POSTGRES_USER=$(kubectl get secret "$PG_SECRET_NAME" \
|
echo "Using Postgres pod: $POSTGRES_POD"
|
||||||
-n "$NAMESPACE" \
|
|
||||||
-o jsonpath='{.data.POSTGRES_USER}' | base64 -d)
|
|
||||||
|
|
||||||
POSTGRES_DB=$(kubectl get secret "$PG_SECRET_NAME" \
|
# ==================================================
|
||||||
|
# READ DATABASE_URL FROM SECRET
|
||||||
|
# ==================================================
|
||||||
|
DATABASE_URL=$(kubectl get secret "$PG_SECRET_NAME" \
|
||||||
-n "$NAMESPACE" \
|
-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
|
if [[ -z "$DATABASE_URL" ]]; then
|
||||||
echo "❌ POSTGRES_DB missing in secret $PG_SECRET_NAME"
|
echo "❌ DATABASE_URL missing in secret $PG_SECRET_NAME"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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" -- \
|
kubectl exec -n "$NAMESPACE" "$POSTGRES_POD" -- \
|
||||||
pg_dump \
|
pg_dump "$POSTGRES_DB" \
|
||||||
-h localhost \
|
> "$BACKUP_DIR/postgres.sql"
|
||||||
-U "$POSTGRES_USER" \
|
|
||||||
"$POSTGRES_DB" \
|
|
||||||
> "$BACKUP_DIR/postgres.sql"
|
|
||||||
|
|
||||||
echo "✔ pg_dump complete ($(du -h "$BACKUP_DIR/postgres.sql" | cut -f1))"
|
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 ""
|
||||||
echo "Restore Postgres:"
|
echo "Restore Postgres:"
|
||||||
echo " kubectl exec -n $NAMESPACE -i $POSTGRES_POD -- \\"
|
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 ""
|
||||||
echo "=== Backup completed successfully ==="
|
echo "=== Backup completed successfully ==="
|
||||||
|
|
|
||||||
1
package.json
Normal file
1
package.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{}
|
||||||
|
|
@ -1,190 +1,3 @@
|
||||||
# 🚀 MVP Next Steps – Post SES Setup
|
NEXT:
|
||||||
|
- Set up Stripe webhook endpoint, so when a test payment is done i can see it
|
||||||
This document outlines the concrete next steps to build the MVP now that
|
- make it produce something so i can see it
|
||||||
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.
|
|
||||||
88
stripe_to_invoice/app/api/stripe/callback/route.ts
Normal file
88
stripe_to_invoice/app/api/stripe/callback/route.ts
Normal file
|
|
@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
26
stripe_to_invoice/app/api/stripe/connect/route.ts
Normal file
26
stripe_to_invoice/app/api/stripe/connect/route.ts
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
225
stripe_to_invoice/app/api/stripe/webhook/route.ts
Normal file
225
stripe_to_invoice/app/api/stripe/webhook/route.ts
Normal file
|
|
@ -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 });
|
||||||
|
}
|
||||||
114
stripe_to_invoice/app/api/xero/callback/route.ts
Normal file
114
stripe_to_invoice/app/api/xero/callback/route.ts
Normal file
|
|
@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
20
stripe_to_invoice/app/api/xero/connect/route.ts
Normal file
20
stripe_to_invoice/app/api/xero/connect/route.ts
Normal file
|
|
@ -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}`
|
||||||
|
);
|
||||||
|
}
|
||||||
40
stripe_to_invoice/app/auth/callback/AuthCallbackClient.tsx
Normal file
40
stripe_to_invoice/app/auth/callback/AuthCallbackClient.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<main className="min-h-screen flex items-center justify-center">
|
||||||
|
<p className="text-sm text-gray-500">Signing you in…</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,40 +1,13 @@
|
||||||
"use client";
|
import { Suspense } from "react";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
export const dynamic = "force-dynamic";
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
|
||||||
|
import AuthCallbackClient from "./AuthCallbackClient";
|
||||||
|
|
||||||
export default function AuthCallbackPage() {
|
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 (
|
return (
|
||||||
<main className="min-h-screen flex items-center justify-center">
|
<Suspense fallback={null}>
|
||||||
<p className="text-sm text-gray-500">Signing you in…</p>
|
<AuthCallbackClient />
|
||||||
</main>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
23
stripe_to_invoice/app/connect/stripe/refresh/page.tsx
Normal file
23
stripe_to_invoice/app/connect/stripe/refresh/page.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
export default function StripeRefreshPage() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="max-w-md text-center space-y-4">
|
||||||
|
<h1 className="text-xl font-semibold">
|
||||||
|
Stripe connection incomplete
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-gray-600">
|
||||||
|
Something interrupted the Stripe onboarding.
|
||||||
|
Please try again.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/connect/stripe"
|
||||||
|
className="inline-block rounded-md bg-black px-4 py-2 text-white"
|
||||||
|
>
|
||||||
|
Retry Stripe setup
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
stripe_to_invoice/app/connect/stripe/success/page.tsx
Normal file
44
stripe_to_invoice/app/connect/stripe/success/page.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function StripeSuccessPage() {
|
||||||
|
return (
|
||||||
|
<main className="max-w-2xl mx-auto p-8 space-y-10">
|
||||||
|
<h1 className="text-2xl font-semibold">
|
||||||
|
Stripe connected 🎉
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-gray-600">
|
||||||
|
Your Stripe account is now linked. We can now detect successful
|
||||||
|
payments and automatically reconcile invoices in Xero.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
<ol className="space-y-4">
|
||||||
|
<li className="flex items-center gap-3">
|
||||||
|
<span className="text-green-600">✔</span>
|
||||||
|
<span>Logged in</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li className="flex items-center gap-3">
|
||||||
|
<span className="text-green-600">✔</span>
|
||||||
|
<span>Stripe connected</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li className="flex items-center gap-3 text-blue-600">
|
||||||
|
<span>→</span>
|
||||||
|
<span className="font-medium">Connect Xero</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
{/* Primary CTA */}
|
||||||
|
<div className="pt-6 border-t">
|
||||||
|
<Link
|
||||||
|
href="/connect/xero"
|
||||||
|
className="inline-block rounded bg-black text-white px-5 py-3"
|
||||||
|
>
|
||||||
|
Continue → Connect Xero
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
stripe_to_invoice/app/connect/xero/page.tsx
Normal file
74
stripe_to_invoice/app/connect/xero/page.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<main className="max-w-2xl mx-auto p-8 space-y-10">
|
||||||
|
{/* --------------------------------------------------
|
||||||
|
Header
|
||||||
|
-------------------------------------------------- */}
|
||||||
|
<section>
|
||||||
|
<h1 className="text-2xl font-semibold">
|
||||||
|
Connect Xero
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="mt-3 text-gray-700">
|
||||||
|
We need access to your Xero organisation so we can automatically
|
||||||
|
create invoices and mark them as paid when Stripe payments succeed.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* --------------------------------------------------
|
||||||
|
What will happen
|
||||||
|
-------------------------------------------------- */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-medium">
|
||||||
|
What happens next
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<ul className="mt-3 space-y-2 list-disc list-inside text-gray-700">
|
||||||
|
<li>You’ll be redirected to Xero</li>
|
||||||
|
<li>You’ll choose which organisation to connect</li>
|
||||||
|
<li>You’ll be sent back here once connected</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* --------------------------------------------------
|
||||||
|
Trust / reassurance
|
||||||
|
-------------------------------------------------- */}
|
||||||
|
<section className="text-sm text-gray-600">
|
||||||
|
<p>
|
||||||
|
We never see your Xero password.
|
||||||
|
<br />
|
||||||
|
Access can be revoked at any time from Xero.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* --------------------------------------------------
|
||||||
|
Primary action
|
||||||
|
-------------------------------------------------- */}
|
||||||
|
<section className="pt-4 border-t">
|
||||||
|
<a
|
||||||
|
href="/api/xero/connect"
|
||||||
|
className="inline-block px-6 py-3 bg-black text-white rounded text-sm"
|
||||||
|
>
|
||||||
|
Connect Xero →
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
stripe_to_invoice/app/connect/xero/success/page.tsx
Normal file
44
stripe_to_invoice/app/connect/xero/success/page.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function XeroSuccessPage() {
|
||||||
|
return (
|
||||||
|
<main className="max-w-2xl mx-auto p-8 space-y-10">
|
||||||
|
<h1 className="text-2xl font-semibold">
|
||||||
|
Xero connected 🎉
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-gray-600">
|
||||||
|
Your Xero organisation is now linked. We can now automatically
|
||||||
|
create invoices and mark them as paid when Stripe payments succeed.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
<ol className="space-y-4">
|
||||||
|
<li className="flex items-center gap-3">
|
||||||
|
<span className="text-green-600">✔</span>
|
||||||
|
<span>Logged in</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li className="flex items-center gap-3">
|
||||||
|
<span className="text-green-600">✔</span>
|
||||||
|
<span>Stripe connected</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li className="flex items-center gap-3">
|
||||||
|
<span className="text-green-600">✔</span>
|
||||||
|
<span>Xero connected</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
{/* Primary CTA */}
|
||||||
|
<div className="pt-6 border-t">
|
||||||
|
<Link
|
||||||
|
href="/app"
|
||||||
|
className="inline-block rounded bg-black text-white px-5 py-3"
|
||||||
|
>
|
||||||
|
Go to dashboard →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -44,7 +44,7 @@ export default function LoginPage() {
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="you@company.com"
|
placeholder="enter@email.com"
|
||||||
className="w-full border rounded p-2"
|
className="w-full border rounded p-2"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,6 @@ export default async function Home() {
|
||||||
Log in →
|
Log in →
|
||||||
</a>
|
</a>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,17 @@ RUN npm ci
|
||||||
|
|
||||||
# ---------- builder ----------
|
# ---------- builder ----------
|
||||||
FROM base AS 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 --from=deps /app/stripe_to_invoice/node_modules ./node_modules
|
||||||
COPY stripe_to_invoice .
|
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
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
RUN node -e "require('typescript')"
|
RUN node -e "require('typescript')"
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ spec:
|
||||||
containerPort: 3000
|
containerPort: 3000
|
||||||
env:
|
env:
|
||||||
- name: NODE_ENV
|
- name: NODE_ENV
|
||||||
value: "production"
|
value: "${DB_ENV}"
|
||||||
|
|
||||||
# ---- Database ----
|
# ---- Database ----
|
||||||
- name: DATABASE_URL
|
- name: DATABASE_URL
|
||||||
|
|
@ -40,6 +40,74 @@ spec:
|
||||||
name: stripe-secrets
|
name: stripe-secrets
|
||||||
key: STRIPE_SECRET_KEY
|
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:
|
imagePullSecrets:
|
||||||
- name: registrypullsecret
|
- name: registrypullsecret
|
||||||
|
|
||||||
|
|
|
||||||
31
stripe_to_invoice/deployment/secrets/.env
Normal file
31
stripe_to_invoice/deployment/secrets/.env
Normal file
|
|
@ -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=qCTirw/OCdw6P2aVknGlyh8MQVMmOkrm0NrXTz4j
|
||||||
|
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=qCTirw/OCdw6P2aVknGlyh8MQVMmOkrm0NrXTz4j
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
19
stripe_to_invoice/deployment/secrets/stripe-secrets.yaml
Normal file
19
stripe_to_invoice/deployment/secrets/stripe-secrets.yaml
Normal file
|
|
@ -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}
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
// lib/db.ts
|
|
||||||
import { drizzle } from "drizzle-orm/node-postgres";
|
import { drizzle } from "drizzle-orm/node-postgres";
|
||||||
import { Pool } from "pg";
|
import { Pool } from "pg";
|
||||||
|
import * as schema from "./schema";
|
||||||
|
|
||||||
// Fail fast if env is missing
|
|
||||||
if (!process.env.DATABASE_URL) {
|
if (!process.env.DATABASE_URL) {
|
||||||
throw new Error("DATABASE_URL is not set");
|
throw new Error("DATABASE_URL is not set");
|
||||||
}
|
}
|
||||||
|
|
@ -15,5 +14,4 @@ const pool = new Pool({
|
||||||
: false,
|
: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Export a single db instance
|
export const db = drizzle(pool, { schema });
|
||||||
export const db = drizzle(pool);
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
// lib/schema/index.ts
|
// lib/schema/index.ts
|
||||||
export * from "./users";
|
export * from "./users";
|
||||||
export * from "./loginTokens";
|
export * from "./loginTokens";
|
||||||
|
export * from "./stripeAccounts";
|
||||||
|
export * from "./xeroConnections";
|
||||||
|
export * from "./processedStripeEvents";
|
||||||
|
|
|
||||||
10
stripe_to_invoice/lib/schema/processedStripeEvents.ts
Normal file
10
stripe_to_invoice/lib/schema/processedStripeEvents.ts
Normal file
|
|
@ -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(),
|
||||||
|
});
|
||||||
13
stripe_to_invoice/lib/schema/stripeAccounts.ts
Normal file
13
stripe_to_invoice/lib/schema/stripeAccounts.ts
Normal file
|
|
@ -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(),
|
||||||
|
});
|
||||||
23
stripe_to_invoice/lib/schema/xeroConnections.ts
Normal file
23
stripe_to_invoice/lib/schema/xeroConnections.ts
Normal file
|
|
@ -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(),
|
||||||
|
});
|
||||||
19
stripe_to_invoice/lib/stripe/service.ts
Normal file
19
stripe_to_invoice/lib/stripe/service.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
66
stripe_to_invoice/lib/xero/auth.ts
Normal file
66
stripe_to_invoice/lib/xero/auth.ts
Normal file
|
|
@ -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<string> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
23
stripe_to_invoice/lib/xero/service.ts
Normal file
23
stripe_to_invoice/lib/xero/service.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
230
stripe_to_invoice/package-lock.json
generated
230
stripe_to_invoice/package-lock.json
generated
|
|
@ -15,8 +15,10 @@
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"react": "19.2.1",
|
"react": "19.2.1",
|
||||||
"react-dom": "19.2.1",
|
"react-dom": "19.2.1",
|
||||||
|
"stripe": "^20.2.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5.5.0"
|
"typescript": "^5.5.0",
|
||||||
|
"xero-node": "^13.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
|
@ -4487,6 +4489,12 @@
|
||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/available-typed-arrays": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
||||||
|
|
@ -4513,6 +4521,17 @@
|
||||||
"node": ">=4"
|
"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": {
|
"node_modules/axobject-query": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||||
|
|
@ -4634,7 +4653,6 @@
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
|
|
@ -4648,7 +4666,6 @@
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
|
@ -4734,6 +4751,18 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
|
|
@ -4892,6 +4921,15 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
|
|
@ -5059,7 +5097,6 @@
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.1",
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
|
|
@ -5170,7 +5207,6 @@
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
|
|
@ -5180,7 +5216,6 @@
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
|
|
@ -5218,7 +5253,6 @@
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0"
|
"es-errors": "^1.3.0"
|
||||||
|
|
@ -5231,7 +5265,6 @@
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
|
|
@ -5919,6 +5952,26 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/for-each": {
|
||||||
"version": "0.3.5",
|
"version": "0.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||||
|
|
@ -5935,11 +5988,26 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
|
@ -6000,7 +6068,6 @@
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
|
@ -6025,7 +6092,6 @@
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dunder-proto": "^1.0.1",
|
"dunder-proto": "^1.0.1",
|
||||||
|
|
@ -6113,7 +6179,6 @@
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
|
|
@ -6184,7 +6249,6 @@
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
|
|
@ -6197,7 +6261,6 @@
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"has-symbols": "^1.0.3"
|
"has-symbols": "^1.0.3"
|
||||||
|
|
@ -6213,7 +6276,6 @@
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"function-bind": "^1.1.2"
|
"function-bind": "^1.1.2"
|
||||||
|
|
@ -6747,6 +6809,15 @@
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
"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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
|
|
@ -7182,7 +7253,6 @@
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
|
|
@ -7212,6 +7282,27 @@
|
||||||
"node": ">=8.6"
|
"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": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||||
|
|
@ -7380,11 +7471,19 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
|
|
@ -7493,6 +7592,48 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
|
|
@ -7808,6 +7949,12 @@
|
||||||
"react-is": "^16.13.1"
|
"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": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
|
|
@ -7818,6 +7965,21 @@
|
||||||
"node": ">=6"
|
"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": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
|
|
@ -8192,7 +8354,6 @@
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
|
|
@ -8212,7 +8373,6 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
|
|
@ -8229,7 +8389,6 @@
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bound": "^1.0.2",
|
"call-bound": "^1.0.2",
|
||||||
|
|
@ -8248,7 +8407,6 @@
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bound": "^1.0.2",
|
"call-bound": "^1.0.2",
|
||||||
|
|
@ -8460,6 +8618,26 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/strnum": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
|
||||||
|
|
@ -8991,6 +9169,16 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,10 @@
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"react": "19.2.1",
|
"react": "19.2.1",
|
||||||
"react-dom": "19.2.1",
|
"react-dom": "19.2.1",
|
||||||
|
"stripe": "^20.2.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5.5.0"
|
"typescript": "^5.5.0",
|
||||||
|
"xero-node": "^13.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
|
|
||||||
|
|
@ -6,3 +6,13 @@ metadata:
|
||||||
data:
|
data:
|
||||||
.dockerconfigjson: ewoJImF1dGhzIjogewoJCSJodHRwczovL2luZGV4LmRvY2tlci5pby92MS8iOiB7CgkJCSJhdXRoIjogImEybHRhblZ1ZEdVNlpHTnJjbDl3WVhSZmJVdFNibkJ0TVZselJVOHRSRU5PVnpNelQwcG5hVGQ0WkdkQiIKCQl9Cgl9Cn0=
|
.dockerconfigjson: ewoJImF1dGhzIjogewoJCSJodHRwczovL2luZGV4LmRvY2tlci5pby92MS8iOiB7CgkJCSJhdXRoIjogImEybHRhblZ1ZEdVNlpHTnJjbDl3WVhSZmJVdFNibkJ0TVZselJVOHRSRU5PVnpNelQwcG5hVGQ0WkdkQiIKCQl9Cgl9Cn0=
|
||||||
type: kubernetes.io/dockerconfigjson
|
type: kubernetes.io/dockerconfigjson
|
||||||
|
|
||||||
|
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: registrypullsecret
|
||||||
|
namespace: dev
|
||||||
|
data:
|
||||||
|
.dockerconfigjson: ewoJImF1dGhzIjogewoJCSJodHRwczovL2luZGV4LmRvY2tlci5pby92MS8iOiB7CgkJCSJhdXRoIjogImEybHRhblZ1ZEdVNlpHTnJjbDl3WVhSZmJVdFNibkJ0TVZselJVOHRSRU5PVnpNelQwcG5hVGQ0WkdkQiIKCQl9Cgl9Cn0=
|
||||||
|
type: kubernetes.io/dockerconfigjson
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue