Merge pull request #30 from MealCraft/feature/stripe

Feature/stripe
This commit is contained in:
Jun-te Kim 2026-01-21 20:10:22 +00:00 committed by GitHub
commit 446ff7c311
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 1772 additions and 792 deletions

View file

@ -43,9 +43,17 @@ RUN wget https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform
&& unzip terraform_${TERRAFORM_VERSION}_linux_amd64.zip \
&& mv terraform /usr/local/bin/terraform \
&& rm terraform_${TERRAFORM_VERSION}_linux_amd64.zip
RUN terraform version
# Install stripe
RUN curl -s https://packages.stripe.dev/api/security/keypair/stripe-cli-gpg/public | gpg --dearmor | sudo tee /usr/share/keyrings/stripe.gpg
RUN echo "deb [signed-by=/usr/share/keyrings/stripe.gpg] https://packages.stripe.dev/stripe-cli-debian-local stable main" | sudo tee -a /etc/apt/sources.list.d/stripe.list
RUN sudo apt update
RUN sudo apt install stripe
# Set the working directory
WORKDIR /workspaces/monorepo

View file

@ -1 +0,0 @@
# Place holder

View file

@ -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

View file

@ -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

View file

@ -1,81 +1,267 @@
# name: Build & Deploy stripe-to-invoice
name: Build & Deploy stripe-to-invoice (with DB secrets + migrations)
# on:
# push:
# branches:
# - main
# - feature/**
# - release/**
# tags:
# - "*"
on:
push:
branches:
- main
- feature/**
- release/**
tags:
- "*"
workflow_dispatch:
# jobs:
# build:
# runs-on: ubuntu-22.04
# steps:
# - uses: actions/checkout@v4
jobs:
# --------------------------------------------------
# BUILD IMAGE
# --------------------------------------------------
build:
runs-on: ubuntu-22.04
# - name: Inject slug variables
# uses: rlespinasse/github-slug-action@v4
steps:
- uses: actions/checkout@v4
# - name: Login to Docker Hub
# uses: docker/login-action@v3
# with:
# username: ${{ secrets.DOCKER_HUB_USERNAME }}
# password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: Inject slug variables
uses: rlespinasse/github-slug-action@v4
# - name: Build image
# run: |
# docker build \
# -f stripe_to_invoice/deployment/Dockerfile \
# -t docker.io/kimjunte/stripe_to_invoice:$GITHUB_REF_SLUG \
# .
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
# - name: Push image
# run: |
# docker push docker.io/kimjunte/stripe_to_invoice:$GITHUB_REF_SLUG
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# deploy:
# runs-on: mealcraft-runners
# needs: build
- name: Build & push image
uses: docker/build-push-action@v6
with:
context: .
file: stripe_to_invoice/deployment/Dockerfile
push: true
tags: docker.io/kimjunte/stripe_to_invoice:${{ env.GITHUB_REF_SLUG }}
# steps:
# - uses: actions/checkout@v4
# --------------------------------------------------
# APPLY DB + APP SECRETS
# --------------------------------------------------
secrets:
name: Apply runtime secrets
runs-on: mealcraft-runners
needs: build
# - name: Install kubectl
# run: |
# sudo apt-get update
# sudo apt-get install -y curl ca-certificates gettext
# curl -LO "https://dl.k8s.io/release/$(curl -sL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
# sudo install -m 0755 kubectl /usr/local/bin/kubectl
steps:
- uses: actions/checkout@v4
# - name: Configure kubeconfig
# run: |
# KUBE_HOST="https://$KUBERNETES_SERVICE_HOST:$KUBERNETES_SERVICE_PORT"
# SA_TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
# CA_CERT=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
# NAMESPACE=$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace)
- name: Install kubectl
run: |
sudo apt-get update
sudo apt-get install -y curl ca-certificates gettext
curl -LO "https://dl.k8s.io/release/$(curl -sL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
sudo install -m 0755 kubectl /usr/local/bin/kubectl
# kubectl config set-cluster microk8s --server="$KUBE_HOST" --certificate-authority="$CA_CERT"
# kubectl config set-credentials runner --token="$SA_TOKEN"
# kubectl config set-context runner-context --cluster=microk8s --user=runner --namespace="$NAMESPACE"
# kubectl config use-context runner-context
- name: Configure kubeconfig (in-cluster)
run: |
KUBE_HOST="https://$KUBERNETES_SERVICE_HOST:$KUBERNETES_SERVICE_PORT"
SA_TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
CA_CERT=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
# - name: Inject slug variables
# uses: rlespinasse/github-slug-action@v4
kubectl config set-cluster microk8s \
--server="$KUBE_HOST" \
--certificate-authority="$CA_CERT"
# - name: Set environment
# run: |
# if [[ "$GITHUB_REF" == refs/heads/release/* || "$GITHUB_REF" == refs/tags/* ]]; then
# echo "NAMESPACE=default" >> $GITHUB_ENV
# echo "DB_ENV=prod" >> $GITHUB_ENV
# else
# echo "NAMESPACE=dev" >> $GITHUB_ENV
# echo "DB_ENV=dev" >> $GITHUB_ENV
# fi
kubectl config set-credentials runner --token="$SA_TOKEN"
# - name: Deploy
# run: |
# export IMAGE="docker.io/kimjunte/stripe_to_invoice:$GITHUB_REF_SLUG"
# export NAMESPACE DB_ENV
# envsubst < stripe_to_invoice/deployment/deployment.yaml | kubectl apply -f -
kubectl config set-context runner-context \
--cluster=microk8s \
--user=runner
kubectl config use-context runner-context
- name: Decide environment
run: |
if [[ "$GITHUB_REF" == refs/heads/main || "$GITHUB_REF" == refs/tags/* || "$GITHUB_REF" == refs/heads/release/* ]]; then
echo "ENV=prod" >> $GITHUB_ENV
echo "NAMESPACE=default" >> $GITHUB_ENV
echo "RUNTIME_SECRET=postgres-prod" >> $GITHUB_ENV
echo "POSTGRES_HOST=postgres-prod.default.svc.cluster.local" >> $GITHUB_ENV
echo "POSTGRES_DB=stripe_invoice" >> $GITHUB_ENV
else
echo "ENV=dev" >> $GITHUB_ENV
echo "NAMESPACE=dev" >> $GITHUB_ENV
echo "RUNTIME_SECRET=postgres-dev" >> $GITHUB_ENV
echo "POSTGRES_HOST=postgres-dev.dev.svc.cluster.local" >> $GITHUB_ENV
echo "POSTGRES_DB=stripe_invoice" >> $GITHUB_ENV
fi
- name: Apply DB secret
run: |
set -a
source db/.env
set +a
if [[ "$ENV" == "prod" ]]; then
USER="$PROD_POSTGRES_USER"
PASS="$PROD_POSTGRES_PASSWORD"
else
USER="$DEV_POSTGRES_USER"
PASS="$DEV_POSTGRES_PASSWORD"
fi
DATABASE_URL="postgres://${USER}:${PASS}@${POSTGRES_HOST}:5432/${POSTGRES_DB}?sslmode=disable"
kubectl create secret generic $RUNTIME_SECRET \
--namespace $NAMESPACE \
--from-literal=DATABASE_URL="$DATABASE_URL" \
--dry-run=client -o yaml | kubectl apply -f -
- name: Apply app secrets
run: |
set -e
set -a
source stripe_to_invoice/deployment/secrets/.env
set +a
if [[ "$ENV" == "prod" ]]; then
STRIPE_SECRET_KEY="$PROD_STRIPE_SECRET_KEY"
STRIPE_CLIENT_ID="$PROD_STRIPE_CLIENT_ID"
STRIPE_REDIRECT_URI="$PROD_STRIPE_REDIRECT_URI"
APP_URL="$PROD_APP_URL"
XERO_CLIENT_ID="$PROD_XERO_CLIENT_ID"
XERO_CLIENT_SECRET="$PROD_XERO_SECRET_KEY"
XERO_REDIRECT_URI="$PROD_REDIRECT_URI"
AWS_REGION="$PROD_AWS_REGION"
STRIPE_WEBHOOK_SECRET="$PROD_STRIPE_WEBHOOK_SECRET"
else
STRIPE_SECRET_KEY="$DEV_STRIPE_SECRET_KEY"
STRIPE_CLIENT_ID="$DEV_STRIPE_CLIENT_ID"
STRIPE_REDIRECT_URI="$DEV_STRIPE_REDIRECT_URI"
APP_URL="$DEV_APP_URL"
XERO_CLIENT_ID="$DEV_XERO_CLIENT_ID"
XERO_CLIENT_SECRET="$DEV_XERO_SECRET_KEY"
XERO_REDIRECT_URI="$DEV_XERO_REDIRECT_URI"
AWS_REGION="$DEV_AWS_REGION"
STRIPE_WEBHOOK_SECRET="$DEV_STRIPE_WEBHOOK_SECRET"
fi
: "${STRIPE_SECRET_KEY:?missing STRIPE_SECRET_KEY}"
: "${STRIPE_CLIENT_ID:?missing STRIPE_CLIENT_ID}"
: "${APP_URL:?missing APP_URL}"
: "${STRIPE_REDIRECT_URI:?missing STRIPE_REDIRECT_URI}"
: "${XERO_CLIENT_ID:?missing XERO_CLIENT_ID}"
: "${XERO_CLIENT_SECRET:?missing XERO_CLIENT_SECRET}"
: "${XERO_REDIRECT_URI:?missing XERO_REDIRECT_URI}"
: "${AWS_REGION:?missing AWS_REGION}"
export \
STRIPE_SECRET_KEY \
STRIPE_CLIENT_ID \
STRIPE_REDIRECT_URI \
APP_URL \
XERO_CLIENT_ID \
XERO_CLIENT_SECRET \
XERO_REDIRECT_URI \
AWS_REGION \
STRIPE_WEBHOOK_SECRET \
NAMESPACE
envsubst < stripe_to_invoice/deployment/secrets/stripe-secrets.yaml \
| kubectl apply -f -
# --------------------------------------------------
# RUN ATLAS MIGRATIONS
# --------------------------------------------------
migrate:
name: Run DB migrations (Atlas)
runs-on: mealcraft-runners
needs: secrets
steps:
- uses: actions/checkout@v4
- name: Install Atlas
uses: ariga/setup-atlas@v0
- name: Decide environment
run: |
if [[ "$GITHUB_REF" == refs/heads/main || "$GITHUB_REF" == refs/tags/* || "$GITHUB_REF" == refs/heads/release/* ]]; then
echo "ENV=prod" >> $GITHUB_ENV
echo "POSTGRES_HOST=postgres-prod.default.svc.cluster.local" >> $GITHUB_ENV
echo "POSTGRES_DB=stripe_invoice" >> $GITHUB_ENV
else
echo "ENV=dev" >> $GITHUB_ENV
echo "POSTGRES_HOST=postgres-dev.dev.svc.cluster.local" >> $GITHUB_ENV
echo "POSTGRES_DB=stripe_invoice" >> $GITHUB_ENV
fi
- name: Run migrations
run: |
set -e
set -a
source db/.env
set +a
if [[ "$ENV" == "prod" ]]; then
USER="$PROD_POSTGRES_USER"
PASS="$PROD_POSTGRES_PASSWORD"
else
USER="$DEV_POSTGRES_USER"
PASS="$DEV_POSTGRES_PASSWORD"
fi
DATABASE_URL="postgres://${USER}:${PASS}@${POSTGRES_HOST}:5432/${POSTGRES_DB}?sslmode=disable"
atlas migrate apply \
--dir file://db/atlas/stripe_invoice/migrations \
--url "$DATABASE_URL"
# --------------------------------------------------
# DEPLOY APPLICATION
# --------------------------------------------------
deploy:
runs-on: mealcraft-runners
needs:
- build
- secrets
- migrate
steps:
- uses: actions/checkout@v4
- name: Install kubectl
run: |
sudo apt-get update
sudo apt-get install -y curl ca-certificates gettext
curl -LO "https://dl.k8s.io/release/$(curl -sL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
sudo install -m 0755 kubectl /usr/local/bin/kubectl
- name: Configure kubeconfig (in-cluster)
run: |
KUBE_HOST="https://$KUBERNETES_SERVICE_HOST:$KUBERNETES_SERVICE_PORT"
SA_TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
CA_CERT=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
NAMESPACE=$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace)
kubectl config set-cluster microk8s --server="$KUBE_HOST" --certificate-authority="$CA_CERT"
kubectl config set-credentials runner --token="$SA_TOKEN"
kubectl config set-context runner-context --cluster=microk8s --user=runner --namespace="$NAMESPACE"
kubectl config use-context runner-context
- name: Inject slug variables
uses: rlespinasse/github-slug-action@v4
- name: Decide environment
run: |
if [[ "$GITHUB_REF" == refs/heads/release/* || "$GITHUB_REF" == refs/tags/* ]]; then
echo "NAMESPACE=default" >> $GITHUB_ENV
echo "DB_ENV=prod" >> $GITHUB_ENV
echo "HOSTNAME=stripe-to-invoice.juntekim.com" >> $GITHUB_ENV
else
echo "NAMESPACE=dev" >> $GITHUB_ENV
echo "DB_ENV=dev" >> $GITHUB_ENV
echo "HOSTNAME=stripe-to-invoice.dev.juntekim.com" >> $GITHUB_ENV
fi
- name: Deploy application
run: |
export IMAGE="docker.io/kimjunte/stripe_to_invoice:$GITHUB_REF_SLUG"
export NAMESPACE DB_ENV HOSTNAME
envsubst < stripe_to_invoice/deployment/deployment.yaml | kubectl apply -f -

View file

@ -21,13 +21,7 @@
"<C-j>": false,
"<C-S-c>": 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
View 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

View file

@ -7,7 +7,7 @@ env "stripe_invoice_dev" {
}
env "stripe_invoice_prod" {
url = "postgres://${getenv("POSTGRES_USER")}:${getenv("POSTGRES_PASSWORD")}@postgres-prod.default.svc.cluster.local:5432/stripe_invoice_prod?sslmode=disable"
url = "postgres://${getenv("POSTGRES_USER")}:${getenv("POSTGRES_PASSWORD")}@postgres-prod.default.svc.cluster.local:5432/stripe_invoice?sslmode=disable"
migration {

View file

@ -1 +1,2 @@
atlas migrate new add_used_at_to_login_tokens
atlas migrate new add_invoice_code

View file

@ -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);

View file

@ -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);

View file

@ -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;

View file

@ -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();

View file

@ -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()
);

View file

@ -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;

View file

@ -1,7 +1,13 @@
h1:RjeUC9UfXpaaJorJ+072tmUmM0yLI4yO71Cuad9tjA4=
h1:ZGGgmFGh8vPWzpumfnp/KWIz6dmAFtIg/tJmVP+w0CU=
0001_init.sql h1:gzb02ZbjrrJkXOC+2qIZsngnj7A+29O2/b4awScPlPs=
0002_auth.sql h1:4NhBu26dIBMy9gxMxM3tf6Z2CS2kfKlGjFBj07T/aBw=
0003_stripe_xero.sql h1:E2bcdUDnondsXwbdIwVlZqR4DQwzcoDiyeRFJwVxXwg=
0004_login_tokens.sql h1:rj1KcWu/0znh2YvtI7JV8Z2nwtL5rZzONbPwX1P+/PI=
20251228182659_add_used_at_to_login_tokens.sql h1:/0puYQvwBFzpfSKjiZj2XR/7Mui39lS/IbFZW1TPQOc=
20251230154354_add_used_at_to_login_tokens.sql h1:FIP2MMRnfhi4hmFC3VBuABZZrxZQ1icranrXy0ljERc=
20260118151944_add_unique_index_to_stripe_accounts.sql h1:PyI8cM8Xyn4bG7BBlD7YRwK1iRQ8HPfzf0r1+Swfe1Y=
20260118165004_add_unique_for_xero.sql h1:gdsqkAeuGG2SmeCRGEBw39RAAGAoZiF5LF/0HfTBZ0w=
20260118191050_add_more_info_on_xero_for_refresh_token.sql h1:cIQZ81Q7mBX4o8Xb6k3CGSFFw9KoVbZgfYxhOtxxvu4=
20260118211854_add_last_updated_at.sql h1:y01AhrpxYmYWIIn9La73twwrfJteCj0r5PovRCiQoh4=
20260120223114_add_stripe_history.sql h1:+l14lHGfyoNBGh1w9TqOuxmETe1Bgo1sry1aXrvt4bU=
20260120230059_add_invoice_code.sql h1:9uItaHRhcuSuxnoqMOwxyPxiOUdm2+gadRZDeSwLmSY=

View file

@ -53,7 +53,7 @@ spec:
pg_dump \
-h postgres-prod.default.svc.cluster.local \
-U $POSTGRES_USER \
stripe_invoice_prod \
stripe_invoice \
| gzip \
| aws s3 cp - s3://$S3_BUCKET/prod/stripe_invoice/$(date +%F).sql.gz
envFrom:

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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
View 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

View file

@ -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 Dashboard App Tutorial — <https://nextjs.org/learn/dashboard-app>
- MDN: Using Promises — <https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises>

View 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>
);
}

View file

@ -0,0 +1 @@
my editing_set_up requires one windows computer so i can carry on using one desk

View 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>
);
}

View file

@ -0,0 +1,2 @@
Write about my dev set up that uses ubunutu as the devcontainer, i just ssh

View file

@ -24,6 +24,7 @@ mkdir -p "$BACKUP_DIR"
# NEVER touch raw Postgres data
TAR_EXCLUDES=(
"$K8S_STORAGE_ROOT/postgres"
"$K8S_STORAGE_ROOT/lost+found"
)
# ==================================================
@ -31,7 +32,7 @@ TAR_EXCLUDES=(
# ==================================================
case "$ENVIRONMENT" in
dev)
PG_SECRET_NAME="postgres-secret"
PG_SECRET_NAME="postgres-dev"
PG_POD_SELECTOR="app=postgres-dev"
S3_PREFIX="dev"
NAMESPACE="dev"
@ -43,7 +44,7 @@ case "$ENVIRONMENT" in
exit 1
fi
PG_SECRET_NAME="postgres-prod-secret"
PG_SECRET_NAME="postgres-prod"
PG_POD_SELECTOR="app=postgres-prod"
S3_PREFIX="prod"
NAMESPACE="default"
@ -56,14 +57,15 @@ esac
echo "=== Backup started ($(date -u)) ==="
echo "Environment: $ENVIRONMENT"
echo "Namespace: $NAMESPACE"
# ==================================================
# POSTGRES DUMP (SAFE)
# LOCATE POSTGRES POD
# ==================================================
POSTGRES_POD=$(kubectl get pods \
-n "$NAMESPACE" \
-l "$PG_POD_SELECTOR" \
-o jsonpath='{.items[*].metadata.name}' | awk '{print $1}')
-o jsonpath='{.items[0].metadata.name}')
if [[ -z "$POSTGRES_POD" ]]; then
echo "❌ No Postgres pod found for selector: $PG_POD_SELECTOR"
@ -71,26 +73,36 @@ if [[ -z "$POSTGRES_POD" ]]; then
exit 1
fi
POSTGRES_USER=$(kubectl get secret "$PG_SECRET_NAME" \
-n "$NAMESPACE" \
-o jsonpath='{.data.POSTGRES_USER}' | base64 -d)
echo "Using Postgres pod: $POSTGRES_POD"
POSTGRES_DB=$(kubectl get secret "$PG_SECRET_NAME" \
# ==================================================
# READ DATABASE_URL FROM SECRET
# ==================================================
DATABASE_URL=$(kubectl get secret "$PG_SECRET_NAME" \
-n "$NAMESPACE" \
-o jsonpath='{.data.POSTGRES_DB}' 2>/dev/null | base64 -d || true)
-o jsonpath='{.data.DATABASE_URL}' | base64 -d)
if [[ -z "$POSTGRES_DB" ]]; then
echo "POSTGRES_DB missing in secret $PG_SECRET_NAME"
if [[ -z "$DATABASE_URL" ]]; then
echo "DATABASE_URL missing in secret $PG_SECRET_NAME"
exit 1
fi
echo "Dumping database: $POSTGRES_DB"
# Parse DATABASE_URL
POSTGRES_USER="$(echo "$DATABASE_URL" | sed -E 's|.*://([^:]+):.*|\1|')"
POSTGRES_DB="$(echo "$DATABASE_URL" | sed -E 's|.*/([^?]+).*|\1|')"
if [[ -z "$POSTGRES_USER" || -z "$POSTGRES_DB" ]]; then
echo "❌ Failed to parse DATABASE_URL"
exit 1
fi
echo "Dumping database: $POSTGRES_DB (user: $POSTGRES_USER)"
# ==================================================
# POSTGRES LOGICAL DUMP (SAFE)
# ==================================================
kubectl exec -n "$NAMESPACE" "$POSTGRES_POD" -- \
pg_dump \
-h localhost \
-U "$POSTGRES_USER" \
"$POSTGRES_DB" \
pg_dump "$POSTGRES_DB" \
> "$BACKUP_DIR/postgres.sql"
echo "✔ pg_dump complete ($(du -h "$BACKUP_DIR/postgres.sql" | cut -f1))"
@ -145,6 +157,6 @@ echo " sudo tar -xzf k8s_storage_$DATE.tar.gz -C /"
echo ""
echo "Restore Postgres:"
echo " kubectl exec -n $NAMESPACE -i $POSTGRES_POD -- \\"
echo " psql -U $POSTGRES_USER $POSTGRES_DB < postgres.sql"
echo " psql $POSTGRES_DB < postgres.sql"
echo ""
echo "=== Backup completed successfully ==="

1
package.json Normal file
View file

@ -0,0 +1 @@
{}

View file

@ -1,190 +1,3 @@
# 🚀 MVP Next Steps Post SES Setup
This document outlines the concrete next steps to build the MVP now that
Amazon SES email delivery is fully configured and verified.
---
## ✅ Phase 0 — Email Infrastructure (COMPLETED)
**Status: DONE**
- SES domain verified (`juntekim.com`)
- DKIM, SPF, DMARC configured
- Custom MAIL FROM domain enabled
- Test email delivered to Gmail inbox
- SES production access requested
- SMTP credentials generated and stored securely
No further SES work is required for MVP.
---
## 🔐 Phase 1 — Magic Link Authentication (Core MVP)
### 1⃣ Define Authentication Model
**Decisions**
- Email-only authentication (no passwords)
- Magic links are:
- Single-use
- Time-limited (e.g. 15 minutes)
- Hashed before storage
- No persistent email storage
**Outcome**
- Clear security model before implementation
---
### 2⃣ Create Magic Link Token Table
**Required fields**
- `id`
- `email`
- `token_hash`
- `expires_at`
- `used_at`
- `created_at`
**Rules**
- Never store raw tokens
- Reject expired tokens
- Reject reused tokens
- Mark token as used immediately after login
**Outcome**
- Database migration + model ready
---
### 3⃣ Build Email Sending Adapter (SES SMTP)
**Requirements**
- Uses Amazon SES SMTP credentials
- Sends from `no-reply@juntekim.com`
- Generates secure magic link URLs
- Plain-text email (HTML later)
**Example responsibility**
- `sendMagicLink(email, url)`
**Outcome**
- Single reusable email-sending utility
---
## 🔑 Phase 2 — NextAuth Integration
### 4⃣ Configure NextAuth (Email Provider)
**Actions**
- Enable NextAuth Email provider
- Configure SES SMTP transport
- Disable default token storage
- Use custom DB token table
**Outcome**
- NextAuth initialized and functional
---
### 5⃣ Implement `/auth/callback` Logic
**Flow**
1. User clicks magic link
2. Token is hashed and validated
3. Token expiry checked
4. Token marked as used
5. Session created
6. Redirect to app
**Outcome**
- End-to-end login flow works
---
### 6⃣ Minimal Authentication UI
**Pages**
- Email input form
- “Check your email” confirmation screen
- Error states:
- Invalid token
- Expired token
- Already-used token
**Outcome**
- Usable authentication UX
---
## 🛡 Phase 3 — MVP Hardening (Still Lightweight)
### 7⃣ Rate Limiting
Add limits for:
- Magic link requests per email
- Magic link requests per IP
Purpose:
- Prevent abuse
- Protect SES reputation
---
### 8⃣ Basic Logging
Log only:
- Email requested
- Email send success/failure
- Login success/failure
Do **not** store email content.
---
### 9⃣ Production Sanity Checks
Before real users:
- Test login on mobile + desktop
- Test Gmail + Outlook
- Test expired link behavior
- Test reused link rejection
---
## 🚦 MVP Definition of Done
The MVP is considered complete when:
- User enters email
- User receives magic link
- User clicks link
- User is authenticated
- Session persists
No additional features are required to ship.
---
## 🧠 Guiding Principles
- Infrastructure first (done)
- Security before UX polish
- Ship working flows early
- Avoid overbuilding before user feedback
---
## 🧩 Post-MVP (Optional, Later)
Do NOT block MVP on:
- HTML email templates
- Branded emails
- Email analytics
- Admin dashboards
- Multi-provider auth
- Password fallback
Ship first, iterate later.
NEXT:
- Set up Stripe webhook endpoint, so when a test payment is done i can see it
- make it produce something so i can see it

View 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)
);
}

View 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);
}

View 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 });
}

View 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)
);
}

View 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}`
);
}

View 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>
);
}

View file

@ -1,40 +1,13 @@
"use client";
import { Suspense } from "react";
import { useEffect, useRef } from "react";
import { useSearchParams, useRouter } from "next/navigation";
export const dynamic = "force-dynamic";
import AuthCallbackClient from "./AuthCallbackClient";
export default function AuthCallbackPage() {
const params = useSearchParams();
const router = useRouter();
const ran = useRef(false);
useEffect(() => {
if (ran.current) return;
ran.current = true;
const token = params.get("token");
if (!token) {
router.replace("/login");
return;
}
fetch("/api/auth/callback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
})
.then(async (res) => {
if (!res.ok) throw new Error(await res.text());
router.replace("/app");
})
.catch(() => {
router.replace("/login");
});
}, [params, router]);
return (
<main className="min-h-screen flex items-center justify-center">
<p className="text-sm text-gray-500">Signing you in</p>
</main>
<Suspense fallback={null}>
<AuthCallbackClient />
</Suspense>
);
}

View 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>
);
}

View 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>
);
}

View 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>Youll be redirected to Xero</li>
<li>Youll choose which organisation to connect</li>
<li>Youll 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>
);
}

View 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>
);
}

View file

@ -44,7 +44,7 @@ export default function LoginPage() {
<input
type="email"
placeholder="you@company.com"
placeholder="enter@email.com"
className="w-full border rounded p-2"
value={email}
onChange={(e) => setEmail(e.target.value)}

View file

@ -67,7 +67,6 @@ export default async function Home() {
Log in
</a>
</section>
</main>
);
}

View file

@ -11,10 +11,17 @@ RUN npm ci
# ---------- builder ----------
FROM base AS builder
WORKDIR /app/stripe_to_invoice # 🔥 THIS WAS MISSING
WORKDIR /app/stripe_to_invoice
COPY --from=deps /app/stripe_to_invoice/node_modules ./node_modules
COPY stripe_to_invoice .
# ✅ Build-time only (safe placeholder)
ENV DATABASE_URL="postgres://build:build@localhost:5432/build"
ENV STRIPE_SECRET_KEY="sk_build_placeholder"
ENV STRIPE_WEBHOOK_SECRET="whsec_build_placeholder"
ENV NEXT_TELEMETRY_DISABLED=1
RUN node -e "require('typescript')"
RUN npm run build

View file

@ -24,7 +24,7 @@ spec:
containerPort: 3000
env:
- name: NODE_ENV
value: "production"
value: "${DB_ENV}"
# ---- Database ----
- name: DATABASE_URL
@ -40,6 +40,74 @@ spec:
name: stripe-secrets
key: STRIPE_SECRET_KEY
- name: STRIPE_CLIENT_ID
valueFrom:
secretKeyRef:
name: stripe-secrets
key: STRIPE_CLIENT_ID
# ---- App ----
- name: APP_URL
valueFrom:
secretKeyRef:
name: stripe-secrets
key: APP_URL
# ---- AWS / SES ----
- name: AWS_REGION
valueFrom:
secretKeyRef:
name: stripe-secrets
key: AWS_REGION
- name: AWS_ACCESS_KEY_ID
valueFrom:
secretKeyRef:
name: stripe-secrets
key: AWS_ACCESS_KEY_ID
- name: AWS_SECRET_ACCESS_KEY
valueFrom:
secretKeyRef:
name: stripe-secrets
key: AWS_SECRET_ACCESS_KEY
- name: SES_FROM_EMAIL
valueFrom:
secretKeyRef:
name: stripe-secrets
key: SES_FROM_EMAIL
- name: STRIPE_REDIRECT_URI
valueFrom:
secretKeyRef:
name: stripe-secrets
key: STRIPE_REDIRECT_URI
- name: XERO_CLIENT_ID
valueFrom:
secretKeyRef:
name: stripe-secrets
key: XERO_CLIENT_ID
- name: XERO_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: stripe-secrets
key: XERO_CLIENT_SECRET
- name: XERO_REDIRECT_URI
valueFrom:
secretKeyRef:
name: stripe-secrets
key: XERO_REDIRECT_URI
- name: STRIPE_WEBHOOK_SECRET
valueFrom:
secretKeyRef:
name: stripe-secrets
key: STRIPE_WEBHOOK_SECRET
imagePullSecrets:
- name: registrypullsecret

View 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=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
DEV_SES_FROM_EMAIL=no-reply@juntekim.com
DEV_STRIPE_REDIRECT_URI=https://stripe-to-invoice.dev.juntekim.com/api/stripe/callback
DEV_STRIPE_WEBHOOK_SECRET=whsec_e6e760a5abf0cde5b31a005f754172a445ff1d710b4ee58c79f87ff7344ff08d
DEV_XERO_CLIENT_ID=4C24EEA5583046519AD39B3905ED2BD3
DEV_XERO_SECRET_KEY=PAYDhzqMLvNtPrN5vDC7iwtXkgu99yG8Gbu86IlrdHH8hGjA
DEV_XERO_REDIRECT_URI=https://stripe-to-invoice.dev.juntekim.com/api/xero/callback
# Prod
PROD_STRIPE_SECRET_KEY=sk_test_51Mo6PnBUc0gyz8XqrZqvWQWRQSUQbjt7zxP56lhdqgIG4qxn5zDuistUJJq8Chl7AxmyCy8xMRAh1Zf25jK0lYCb00QsQqNEsc
PROD_STRIPE_CLIENT_ID=ca_NZFa6CNybMItWKir9Uk6ojevnYcP7Rbz
PROD_APP_URL=https://stripe-to-invoice.juntekim.com
PROD_AWS_REGION=eu-west-2
PROD_AWS_ACCESS_KEY_ID=AKIAQL67W6HI2547OPVG
PROD_AWS_SECRET_ACCESS_KEY=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
PROD_SES_FROM_EMAIL=no-reply@juntekim.com
PROD_STRIPE_REDIRECT_URI=https://stripe-to-invoice.dev.juntekim.com/api/stripe/callback
PROD_STRIPE_WEBHOOK_SECRET=whsec_VP3DJmcdCWHkOWwUH1WRmdzu6Y71uZle
PROD_XERO_CLIENT_ID=4C24EEA5583046519AD39B3905ED2BD3
PROD_XERO_SECRET_KEY=PAYDhzqMLvNtPrN5vDC7iwtXkgu99yG8Gbu86IlrdHH8hGjA
PROD_XERO_REDIRECT_URI=https://stripe-to-invoice.juntekim.com/api/xero/callback

View 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}

View file

@ -1,8 +1,7 @@
// lib/db.ts
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";
// Fail fast if env is missing
if (!process.env.DATABASE_URL) {
throw new Error("DATABASE_URL is not set");
}
@ -15,5 +14,4 @@ const pool = new Pool({
: false,
});
// Export a single db instance
export const db = drizzle(pool);
export const db = drizzle(pool, { schema });

View file

@ -1,3 +1,6 @@
// lib/schema/index.ts
export * from "./users";
export * from "./loginTokens";
export * from "./stripeAccounts";
export * from "./xeroConnections";
export * from "./processedStripeEvents";

View 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(),
});

View 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(),
});

View 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(),
});

View 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;
}

View 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;
}

View 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;
}

View file

@ -15,8 +15,10 @@
"pg": "^8.16.3",
"react": "19.2.1",
"react-dom": "19.2.1",
"stripe": "^20.2.0",
"tailwindcss": "^4",
"typescript": "^5.5.0"
"typescript": "^5.5.0",
"xero-node": "^13.3.1"
},
"devDependencies": {
"@types/node": "^20",
@ -4487,6 +4489,12 @@
"node": ">= 0.4"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@ -4513,6 +4521,17 @@
"node": ">=4"
}
},
"node_modules/axios": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@ -4634,7 +4653,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@ -4648,7 +4666,6 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@ -4734,6 +4751,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@ -4892,6 +4921,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@ -5059,7 +5097,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
@ -5170,7 +5207,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -5180,7 +5216,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -5218,7 +5253,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@ -5231,7 +5265,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@ -5919,6 +5952,26 @@
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@ -5935,11 +5988,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@ -6000,7 +6068,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@ -6025,7 +6092,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
@ -6113,7 +6179,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -6184,7 +6249,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -6197,7 +6261,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@ -6213,7 +6276,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@ -6747,6 +6809,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -7182,7 +7253,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -7212,6 +7282,27 @@
"node": ">=8.6"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@ -7380,11 +7471,19 @@
"node": ">=0.10.0"
}
},
"node_modules/object-hash": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -7493,6 +7592,48 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/oidc-token-hash": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz",
"integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==",
"license": "MIT",
"engines": {
"node": "^10.13.0 || >=12.0.0"
}
},
"node_modules/openid-client": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
"license": "MIT",
"dependencies": {
"jose": "^4.15.9",
"lru-cache": "^6.0.0",
"object-hash": "^2.2.0",
"oidc-token-hash": "^5.0.3"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/openid-client/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/openid-client/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@ -7808,6 +7949,12 @@
"react-is": "^16.13.1"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@ -7818,6 +7965,21 @@
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@ -8192,7 +8354,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@ -8212,7 +8373,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@ -8229,7 +8389,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
@ -8248,7 +8407,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
@ -8460,6 +8618,26 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/stripe": {
"version": "20.2.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-20.2.0.tgz",
"integrity": "sha512-m8niTfdm3nPP/yQswRWMwQxqEUcTtB3RTJQ9oo6NINDzgi7aPOadsH/fPXIIfL1Sc5+lqQFKSk7WiO6CXmvaeA==",
"license": "MIT",
"dependencies": {
"qs": "^6.14.1"
},
"engines": {
"node": ">=16"
},
"peerDependencies": {
"@types/node": ">=16"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/strnum": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
@ -8991,6 +9169,16 @@
"node": ">=0.10.0"
}
},
"node_modules/xero-node": {
"version": "13.3.1",
"resolved": "https://registry.npmjs.org/xero-node/-/xero-node-13.3.1.tgz",
"integrity": "sha512-80BpuVUpcn+9xYlxWk5/bjdwvJJ+cxJboz7xVtEu6clRxU2NXUL8bFHrRlgmT+GBxNKfNRX2MtkppaTzZfF+tg==",
"license": "MIT",
"dependencies": {
"axios": "^1.12.0",
"openid-client": "^5.7.0"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View file

@ -16,8 +16,10 @@
"pg": "^8.16.3",
"react": "19.2.1",
"react-dom": "19.2.1",
"stripe": "^20.2.0",
"tailwindcss": "^4",
"typescript": "^5.5.0"
"typescript": "^5.5.0",
"xero-node": "^13.3.1"
},
"devDependencies": {
"@types/node": "^20",

View file

@ -6,3 +6,13 @@ metadata:
data:
.dockerconfigjson: ewoJImF1dGhzIjogewoJCSJodHRwczovL2luZGV4LmRvY2tlci5pby92MS8iOiB7CgkJCSJhdXRoIjogImEybHRhblZ1ZEdVNlpHTnJjbDl3WVhSZmJVdFNibkJ0TVZselJVOHRSRU5PVnpNelQwcG5hVGQ0WkdkQiIKCQl9Cgl9Cn0=
type: kubernetes.io/dockerconfigjson
apiVersion: v1
kind: Secret
metadata:
name: registrypullsecret
namespace: dev
data:
.dockerconfigjson: ewoJImF1dGhzIjogewoJCSJodHRwczovL2luZGV4LmRvY2tlci5pby92MS8iOiB7CgkJCSJhdXRoIjogImEybHRhblZ1ZEdVNlpHTnJjbDl3WVhSZmJVdFNibkJ0TVZselJVOHRSRU5PVnpNelQwcG5hVGQ0WkdkQiIKCQl9Cgl9Cn0=
type: kubernetes.io/dockerconfigjson