diff --git a/.devcontainer/stripe-to-invoice/Dockerfile b/.devcontainer/stripe-to-invoice/Dockerfile index 554dc1a..ec3a134 100644 --- a/.devcontainer/stripe-to-invoice/Dockerfile +++ b/.devcontainer/stripe-to-invoice/Dockerfile @@ -51,9 +51,8 @@ RUN echo "deb [signed-by=/usr/share/keyrings/stripe.gpg] https://packages.stripe RUN sudo apt update RUN sudo apt install stripe - - - +# Install code server +RUN curl -fsSL https://code-server.dev/install.sh | sh # Set the working directory WORKDIR /workspaces/monorepo \ No newline at end of file diff --git a/.devcontainer/stripe-to-invoice/devcontainer.json b/.devcontainer/stripe-to-invoice/devcontainer.json index d8ae29d..ca42b31 100644 --- a/.devcontainer/stripe-to-invoice/devcontainer.json +++ b/.devcontainer/stripe-to-invoice/devcontainer.json @@ -2,9 +2,10 @@ "name": "Basic Python", "dockerComposeFile": "docker-compose.yml", "service": "one_repo_to_rule_them_all", - "remoteUser": "vscode", + // "remoteUser": "vscode", "workspaceFolder": "/workspaces/monorepo", - "postStartCommand": "bash .devcontainer/post-install.sh", + "postStartCommand": "bash .devcontainer/stripe-to-invoice/post-install.sh", + "forwardPorts": [8080], "features": { // "ghcr.io/devcontainers/features/ssh-agent:1": {} @@ -12,7 +13,7 @@ "mounts": [ // Optional convenience mount - "source=${localEnv:HOME},target=/workspaces/home,type=bind" + "source=${localEnv:HOME},target=/home/vscode,type=bind" ], "customizations": { diff --git a/.devcontainer/stripe-to-invoice/docker-compose.yml b/.devcontainer/stripe-to-invoice/docker-compose.yml index f9d12d9..91264df 100644 --- a/.devcontainer/stripe-to-invoice/docker-compose.yml +++ b/.devcontainer/stripe-to-invoice/docker-compose.yml @@ -2,12 +2,14 @@ version: '3.8' services: one_repo_to_rule_them_all: - user: "${UID}:${GID}" build: context: ../.. dockerfile: .devcontainer/stripe-to-invoice/Dockerfile - command: sleep infinity + command: su - vscode -c "code-server --bind-addr 0.0.0.0:8080" + # command: sleep infinity volumes: - ../..:/workspaces/monorepo extra_hosts: - "host.docker.internal:host-gateway" + ports: + - "8080:8080" diff --git a/.devcontainer/stripe-to-invoice/post-install.sh b/.devcontainer/stripe-to-invoice/post-install.sh index 482fcc1..d0d7dd0 100644 --- a/.devcontainer/stripe-to-invoice/post-install.sh +++ b/.devcontainer/stripe-to-invoice/post-install.sh @@ -1 +1 @@ -cv stripe_to_invoice && npm install; \ No newline at end of file +cd stripe_to_invoice && npm install; \ No newline at end of file diff --git a/code-server/codeserver.yaml b/code-server/codeserver.yaml new file mode 100644 index 0000000..18e1275 --- /dev/null +++ b/code-server/codeserver.yaml @@ -0,0 +1,37 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: dev-juntekim-service +spec: + ports: + - port: 80 + targetPort: 8080 +--- + +apiVersion: v1 +kind: Endpoints +metadata: + name: dev-juntekim-service +subsets: + - addresses: + - ip: 192.168.0.181 # mist node + ports: + - port: 8080 + +--- +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: dev-juntekim-ingress +spec: + entryPoints: + - websecure + routes: + - match: Host(`dev.juntekim.com`) + kind: Rule + services: + - name: dev-juntekim-service + port: 80 + tls: + certResolver: myresolver \ No newline at end of file diff --git a/code-server/sal-codeservery.yaml b/code-server/sal-codeservery.yaml new file mode 100644 index 0000000..010a64b --- /dev/null +++ b/code-server/sal-codeservery.yaml @@ -0,0 +1,34 @@ +apiVersion: v1 +kind: Service +metadata: + name: sal-juntekim-service +spec: + ports: + - port: 80 + targetPort: 8081 +--- +apiVersion: v1 +kind: Endpoints +metadata: + name: sal-juntekim-service +subsets: + - addresses: + - ip: 192.168.0.181 # mist node + ports: + - port: 8081 +--- +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: sal-juntekim-ingress +spec: + entryPoints: + - websecure + routes: + - match: Host(`sal.juntekim.com`) + kind: Rule + services: + - name: sal-juntekim-service + port: 80 + tls: + certResolver: myresolver \ No newline at end of file diff --git a/draw/draw.yaml b/draw/draw.yaml index fb4a523..4d3692f 100644 --- a/draw/draw.yaml +++ b/draw/draw.yaml @@ -1,46 +1,66 @@ -# ================================ -# EXCALIDRAW - STATELESS -# https://excalidraw.com -# ================================ +# ========================================== +# JS PAINT (STATIC DEPLOYMENT) +# ========================================== --- apiVersion: apps/v1 kind: Deployment metadata: - name: excalidraw - labels: - app: excalidraw + name: jspaint spec: replicas: 1 selector: matchLabels: - app: excalidraw + app: jspaint template: metadata: labels: - app: excalidraw + app: jspaint spec: + nodeSelector: + kubernetes.io/hostname: mist + + volumes: + - name: web-content + emptyDir: {} + + initContainers: + - name: fetch-jspaint + image: alpine/git + command: + - sh + - -c + - | + git clone --depth=1 https://github.com/1j01/jspaint.git /tmp/jspaint && \ + cp -r /tmp/jspaint/* /web + volumeMounts: + - name: web-content + mountPath: /web + containers: - - name: excalidraw - image: excalidraw/excalidraw:latest + - name: nginx + image: nginx:alpine ports: - containerPort: 80 + volumeMounts: + - name: web-content + mountPath: /usr/share/nginx/html resources: requests: - cpu: "100m" - memory: "128Mi" + cpu: "50m" + memory: "64Mi" limits: - cpu: "300m" - memory: "256Mi" + cpu: "200m" + memory: "128Mi" --- apiVersion: v1 kind: Service metadata: - name: excalidraw + name: jspaint spec: selector: - app: excalidraw + app: jspaint ports: - port: 80 targetPort: 80 @@ -49,17 +69,16 @@ spec: apiVersion: traefik.io/v1alpha1 kind: IngressRoute metadata: - name: excalidraw-ingressroute + name: jspaint-ingress spec: entryPoints: - websecure routes: - - match: Host(`draw.juntekim.com`) + - match: Host(`jspaint.juntekim.com`) kind: Rule services: - - name: excalidraw + - name: jspaint port: 80 + passHostHeader: true tls: - certResolver: myresolver - domains: - - main: draw.juntekim.com + certResolver: myresolver \ No newline at end of file diff --git a/exercise/exercise.yaml b/exercise/exercise.yaml new file mode 100644 index 0000000..e89501f --- /dev/null +++ b/exercise/exercise.yaml @@ -0,0 +1,375 @@ +# ====================================================== +# WGER - PRODUCTION ARCHITECTURE +# Traefik → nginx → wger → postgres +# ====================================================== + +# ------------------------- +# STORAGE CLASS +# ------------------------- +--- +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: wger-local-storage +provisioner: kubernetes.io/no-provisioner +volumeBindingMode: WaitForFirstConsumer + +# ====================================================== +# PERSISTENT VOLUMES +# ====================================================== + +# ------------------------- +# POSTGRES PV +# ------------------------- +--- +apiVersion: v1 +kind: PersistentVolume +metadata: + name: wger-postgres-pv +spec: + capacity: + storage: 2Gi + accessModes: [ReadWriteOnce] + storageClassName: wger-local-storage + persistentVolumeReclaimPolicy: Retain + local: + path: /home/kimjunte/k8s_storage/wger/postgres + nodeAffinity: + required: + nodeSelectorTerms: + - matchExpressions: + - key: kubernetes.io/hostname + operator: In + values: [mist] +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: wger-postgres-pvc +spec: + accessModes: [ReadWriteOnce] + storageClassName: wger-local-storage + resources: + requests: + storage: 2Gi + +# ------------------------- +# STATIC PV +# ------------------------- +--- +apiVersion: v1 +kind: PersistentVolume +metadata: + name: wger-static-pv +spec: + capacity: + storage: 2Gi + accessModes: [ReadWriteOnce] + storageClassName: wger-local-storage + persistentVolumeReclaimPolicy: Retain + local: + path: /home/kimjunte/k8s_storage/wger/static + nodeAffinity: + required: + nodeSelectorTerms: + - matchExpressions: + - key: kubernetes.io/hostname + operator: In + values: [mist] +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: wger-static-pvc +spec: + accessModes: [ReadWriteOnce] + storageClassName: wger-local-storage + resources: + requests: + storage: 2Gi + +# ------------------------- +# MEDIA PV +# ------------------------- +--- +apiVersion: v1 +kind: PersistentVolume +metadata: + name: wger-media-pv +spec: + capacity: + storage: 5Gi + accessModes: [ReadWriteOnce] + storageClassName: wger-local-storage + persistentVolumeReclaimPolicy: Retain + local: + path: /home/kimjunte/k8s_storage/wger/media + nodeAffinity: + required: + nodeSelectorTerms: + - matchExpressions: + - key: kubernetes.io/hostname + operator: In + values: [mist] +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: wger-media-pvc +spec: + accessModes: [ReadWriteOnce] + storageClassName: wger-local-storage + resources: + requests: + storage: 5Gi + +# ====================================================== +# POSTGRES +# ====================================================== + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: wger-postgres +spec: + replicas: 1 + selector: + matchLabels: + app: wger-postgres + template: + metadata: + labels: + app: wger-postgres + spec: + nodeSelector: + kubernetes.io/hostname: mist + containers: + - name: postgres + image: postgres:15-alpine + env: + - name: POSTGRES_USER + value: wger + - name: POSTGRES_PASSWORD + value: wgerpassword + - name: POSTGRES_DB + value: wger + volumeMounts: + - mountPath: /var/lib/postgresql/data + name: postgres-storage + volumes: + - name: postgres-storage + persistentVolumeClaim: + claimName: wger-postgres-pvc + +--- +apiVersion: v1 +kind: Service +metadata: + name: wger-postgres +spec: + selector: + app: wger-postgres + ports: + - port: 5432 + +# ====================================================== +# REDIS +# ====================================================== + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: wger-redis +spec: + replicas: 1 + selector: + matchLabels: + app: wger-redis + template: + metadata: + labels: + app: wger-redis + spec: + nodeSelector: + kubernetes.io/hostname: mist + containers: + - name: redis + image: redis:7-alpine + +--- +apiVersion: v1 +kind: Service +metadata: + name: wger-redis +spec: + selector: + app: wger-redis + ports: + - port: 6379 + +# ====================================================== +# WGER APP +# ====================================================== + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: wger +spec: + replicas: 1 + selector: + matchLabels: + app: wger + template: + metadata: + labels: + app: wger + spec: + nodeSelector: + kubernetes.io/hostname: mist + containers: + - name: wger + image: wger/server:latest + env: + - name: DATABASE_URL + value: postgres://wger:wgerpassword@wger-postgres:5432/wger + - name: CACHE_URL + value: redis://wger-redis:6379/1 + - name: DJANGO_SECRET_KEY + value: replace-with-long-random-string + - name: ALLOWED_HOSTS + value: "*" + - name: CSRF_TRUSTED_ORIGINS + value: https://exercise.juntekim.com + ports: + - containerPort: 8000 + volumeMounts: + - name: static-storage + mountPath: /home/wger/static + - name: media-storage + mountPath: /home/wger/media + volumes: + - name: static-storage + persistentVolumeClaim: + claimName: wger-static-pvc + - name: media-storage + persistentVolumeClaim: + claimName: wger-media-pvc + +--- +apiVersion: v1 +kind: Service +metadata: + name: wger +spec: + selector: + app: wger + ports: + - port: 8000 + targetPort: 8000 + +# ====================================================== +# NGINX (STATIC + PROXY) +# ====================================================== + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: wger-nginx-config +data: + default.conf: | + server { + listen 80; + + location /static/ { + alias /home/wger/static/; + } + + location /media/ { + alias /home/wger/media/; + } + + location / { + proxy_pass http://wger:8000; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: wger-nginx +spec: + replicas: 1 + selector: + matchLabels: + app: wger-nginx + template: + metadata: + labels: + app: wger-nginx + spec: + nodeSelector: + kubernetes.io/hostname: mist + containers: + - name: nginx + image: nginx:alpine + ports: + - containerPort: 80 + volumeMounts: + - name: static-storage + mountPath: /home/wger/static + - name: media-storage + mountPath: /home/wger/media + - name: nginx-config + mountPath: /etc/nginx/conf.d + volumes: + - name: static-storage + persistentVolumeClaim: + claimName: wger-static-pvc + - name: media-storage + persistentVolumeClaim: + claimName: wger-media-pvc + - name: nginx-config + configMap: + name: wger-nginx-config + +--- +apiVersion: v1 +kind: Service +metadata: + name: wger-nginx +spec: + selector: + app: wger-nginx + ports: + - port: 80 + targetPort: 80 + +# ====================================================== +# TRAEFIK INGRESS +# ====================================================== + +--- +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: wger-ingress +spec: + entryPoints: + - websecure + routes: + - match: Host(`exercise.juntekim.com`) + kind: Rule + services: + - name: wger-nginx + port: 80 + tls: + certResolver: myresolver \ No newline at end of file diff --git a/recipes/recipes.yaml b/recipes/recipes.yaml new file mode 100644 index 0000000..2852255 --- /dev/null +++ b/recipes/recipes.yaml @@ -0,0 +1,285 @@ +# ====================================================== +# TANDOOR RECIPES - PRODUCTION (PINNED TO MIST) +# ====================================================== + +# ------------------------- +# POSTGRES PV +# ------------------------- +--- +apiVersion: v1 +kind: PersistentVolume +metadata: + name: tandoor-postgres-pv +spec: + capacity: + storage: 2Gi + accessModes: + - ReadWriteOnce + storageClassName: tandoor-local-storage + persistentVolumeReclaimPolicy: Retain + local: + path: /home/kimjunte/k8s_storage/tandoor/postgres + nodeAffinity: + required: + nodeSelectorTerms: + - matchExpressions: + - key: kubernetes.io/hostname + operator: In + values: + - mist + +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: tandoor-postgres-pvc +spec: + accessModes: + - ReadWriteOnce + storageClassName: tandoor-local-storage + resources: + requests: + storage: 2Gi + +# ------------------------- +# MEDIA PV +# ------------------------- +--- +apiVersion: v1 +kind: PersistentVolume +metadata: + name: tandoor-media-pv +spec: + capacity: + storage: 5Gi + accessModes: + - ReadWriteOnce + storageClassName: tandoor-local-storage + persistentVolumeReclaimPolicy: Retain + local: + path: /home/kimjunte/k8s_storage/tandoor/media + nodeAffinity: + required: + nodeSelectorTerms: + - matchExpressions: + - key: kubernetes.io/hostname + operator: In + values: + - mist + +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: tandoor-media-pvc +spec: + accessModes: + - ReadWriteOnce + storageClassName: tandoor-local-storage + resources: + requests: + storage: 5Gi + +# ------------------------- +# POSTGRES +# ------------------------- +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: tandoor-postgres +spec: + replicas: 1 + selector: + matchLabels: + app: tandoor-postgres + template: + metadata: + labels: + app: tandoor-postgres + spec: + nodeSelector: + kubernetes.io/hostname: mist + containers: + - name: postgres + image: postgres:15-alpine + env: + - name: POSTGRES_USER + value: tandoor + - name: POSTGRES_PASSWORD + value: tandoorpassword + - name: POSTGRES_DB + value: tandoor + - name: SITE_URL + value: https://mealcraft.com + - name: ALLOWED_HOSTS + value: mealcraft.com + - name: CSRF_TRUSTED_ORIGINS + value: https://mealcraft.com + - name: NGINX_PROXY + value: "1" + - name: DEBUG + value: "1" + - name: SECURE_PROXY_SSL_HEADER + value: HTTP_X_FORWARDED_PROTO,https + volumeMounts: + - mountPath: /var/lib/postgresql/data + name: postgres-storage + volumes: + - name: postgres-storage + persistentVolumeClaim: + claimName: tandoor-postgres-pvc + +--- +apiVersion: v1 +kind: Service +metadata: + name: tandoor-postgres +spec: + selector: + app: tandoor-postgres + ports: + - port: 5432 + +# ------------------------- +# REDIS +# ------------------------- +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: tandoor-redis +spec: + replicas: 1 + selector: + matchLabels: + app: tandoor-redis + template: + metadata: + labels: + app: tandoor-redis + spec: + nodeSelector: + kubernetes.io/hostname: mist + containers: + - name: redis + image: redis:7-alpine + ports: + - containerPort: 6379 + +--- +apiVersion: v1 +kind: Service +metadata: + name: tandoor-redis +spec: + selector: + app: tandoor-redis + ports: + - port: 6379 + +# ------------------------- +# TANDOOR APP +# ------------------------- +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: tandoor +spec: + replicas: 1 + selector: + matchLabels: + app: tandoor + template: + metadata: + labels: + app: tandoor + spec: + nodeSelector: + kubernetes.io/hostname: mist + + enableServiceLinks: false # 🔥 CRITICAL FIX + + containers: + - name: tandoor + image: vabene1111/recipes:1.5.24 + + env: + - name: SECRET_KEY + value: replace-with-long-random-string + + - name: DB_ENGINE + value: django.db.backends.postgresql + - name: GUNICORN_MEDIA + value: "1" + - name: POSTGRES_HOST + value: tandoor-postgres + - name: POSTGRES_PORT + value: "5432" + - name: POSTGRES_DB + value: tandoor + - name: POSTGRES_USER + value: tandoor + - name: POSTGRES_PASSWORD + value: tandoorpassword + + - name: REDIS_URL + value: redis://tandoor-redis:6379/0 + + - name: ALLOWED_HOSTS + value: mealcraft.com + + - name: CSRF_TRUSTED_ORIGINS + value: https://mealcraft.com + + - name: NGINX_PROXY + value: "1" + + - name: DEBUG + value: "0" + + ports: + - containerPort: 8080 + + volumeMounts: + - name: media-storage + mountPath: /opt/recipes/mediafiles + + volumes: + - name: media-storage + persistentVolumeClaim: + claimName: tandoor-media-pvc + +--- +apiVersion: v1 +kind: Service +metadata: + name: tandoor +spec: + selector: + app: tandoor + ports: + - port: 80 + targetPort: 8080 + +# ------------------------- +# TRAEFIK INGRESS +# ------------------------- +--- +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: tandoor-ingress +spec: + entryPoints: + - websecure + routes: + - match: Host(`mealcraft.com`) + kind: Rule + services: + - name: tandoor + port: 80 + passHostHeader: true + tls: + certResolver: myresolver \ No newline at end of file diff --git a/stripe_to_invoice/README.md b/stripe_to_invoice/README.md deleted file mode 100644 index cb41c53..0000000 --- a/stripe_to_invoice/README.md +++ /dev/null @@ -1,248 +0,0 @@ -Got you — here’s a clean, founder-brain-friendly summary of **Stripe → Invoice (Stripe → Xero)** based on everything you’ve been working through, plus **tight next steps** that fit your nights/weekends reality. - ---- - -## 🧾 What Stripe → Invoice Is (current state) - -* **Problem you’re solving** - - * UK VAT-registered small businesses using Stripe struggle with **audit-safe, VAT-correct invoices** in Xero - * Existing tools are overbuilt, accountant-first, or break down on VAT, clearing accounts, or reconciliation - * This is fundamentally a **VAT + audit correctness problem**, not just “sync data” - -* **Who it’s for** - - * UK solo founders / one-person companies / tiny teams - * Using **Stripe only** (Payment Links + Subscriptions) - * Using **Xero** - * Not accountants, not agencies, not complex multi-channel setups - -* **What the MVP does today** - - * Stripe OAuth + Xero OAuth both working - * Webhooks flow end-to-end (validated against real finance manager) - * Automatically: - - * Creates **clean Xero invoices** from Stripe payments - * Applies VAT correctly - * Posts payments via a **Stripe Clearing account** - * Validated by a finance manager → *very happy* (huge signal) - -* **Key MVP constraints (intentional)** - - * UK + GBP only - * Stripe Payment Links + Subscriptions only - * Xero contacts matched/created by **email only** - * Willing to: - - * Run one-off scripts - * Do manual fixes early - * Goal: **first ~5 paying customers**, not scale yet - ---- - -## ✅ Recently fixed - -* **Xero contact creation** — Now checks for existing contacts by email first, reuses if found, only creates if missing -* **Stripe OAuth app reuse** — Added unique constraints on `userId` and `stripeAccountId` to prevent duplicate connections -* **Smart redirect flow** — Users are automatically routed based on connection state: - * Both connected → `/dashboard` - * Only Stripe → `/connect/xero` - * No connections → `/connect/stripe` -* **Connection visibility** — Dashboard now displays connected Stripe account ID and Xero tenant ID - -### Frontend Improvements Details - -**Smart Onboarding Flow** -* Automatic routing based on connection state -* Users never see unnecessary steps -* Seamless progression: Login → Stripe → Xero → Dashboard - -**Dashboard Enhancements** -* Connected account visibility (Stripe account ID + Xero tenant) -* Account code configuration (sales + clearing accounts) -* Real-time save confirmation -* Clean, minimal UI - -**Development Experience** -* Development mode fallback for webhook testing -* Comprehensive logging at each webhook stage -* Environment-aware configuration - ---- - -## ⚠️ Known issues & TODO - -* **CRITICAL: Stripe payment integration** - * Need to implement Stripe Billing API to accept payments - * Currently not accepting any money from users (test mode only) - * Must add subscription checkout flow before going live - * Reference: [Stripe Billing API docs](https://stripe.com/docs/billing) - -* **Missing UX guardrails:** - * No clear **pre-payment checklist** before enabling sync - ---- - -## 🧪 Current mode you’re in (important) - -* You’re correctly running this in **“design partner / friend test” mode** - - * Payments disabled - * Banner: *“Internal test – not a commercial product”* - * Clear paper trail of non-commercial intent -* CFO + finance manager already acting as **design partners** -* This massively de-risks VAT/audit assumptions before charging anyone - ---- - -## ✅ What you should do next (ordered, ruthless, realistic) - -### 1️⃣ Finish the last **correctness blockers** (highest ROI) - -These unlock charging real money. - -* [x] ~~Fix Xero contact creation~~ ✅ DONE - - * ~~Check by email → reuse if exists → only create if missing~~ -* [x] ~~Fix Stripe OAuth app reuse (stop creating new apps)~~ ✅ DONE -* [x] ~~Re-enable "mark invoice as paid" via Stripe Clearing once accounts are valid~~ ✅ DONE - -> Outcome: rock-solid, boring, accountant-approved flow - ---- - -### 2️⃣ Add a tiny **pre-flight checklist UI** (not a full settings page) - -* [x] ~~Dashboard shows connected accounts~~ ✅ DONE - - * ~~Stripe account ID displayed~~ - * ~~Xero tenant ID displayed~~ -* [x] ~~Smart redirect flow based on connection state~~ ✅ DONE -* [ ] VAT status detection -* [ ] Sales account code shown (editable) -* [ ] Stripe clearing account shown (editable) - -> Even basic connection visibility prevents 80% of future support pain - ---- - -### 3️⃣ Implement subscription billing (enables first paid customer) - -* Integrate Stripe Billing for subscription management -* Add usage tracking (invoice count per month) -* Create pricing page and checkout flow -* Implement subscription status checks in webhook handler -* Remove "internal test" banner once billing is live - ---- - -### 4️⃣ Switch from "design partner" → **first paid customer mode** - -* Pick **one**: - - * A founder you already know **OR** - * A cold UK Stripe + Xero business with obvious VAT needs -* Offer: - - * £15/month Starter plan - * "Early access / founder pricing" (50% off for life) - * Manual support included -* Goal is **money changing hands**, not scale - -> You've said it yourself: getting paid energises you — lean into that. - ---- - -### 5️⃣ Do *targeted* cold outreach (low volume, high signal) - -* 5–10 emails max, not a campaign -* Target: - - * UK SaaS / indie founders - * Stripe Payment Links or Subscriptions - * Clearly VAT-registered -* Lead with: - - * "I built this because my accountant hated existing tools" - * Emphasise **audit-safe, VAT-correct invoices** - * Not "automation", not "syncing" - ---- - -### 6️⃣ Future UX polish + automation (after first paying customers) - -* Auto-detect or create Stripe Clearing account in Xero -* Bulk historical invoice sync -* Invoice preview before creation -* Reduce manual fixes you find yourself repeating -* Nothing else until: - - * You have **~3–5 paying users** - * And they're still using it after month 1 - ---- - -## 💳 SaaS Subscription Model (proposed) - -### Pricing Tiers - -**All Plans — £50/month** -* Unlimited invoices/month -* Stripe Payment Links + Subscriptions support -* Full VAT handling and audit compliance -* Email support -* Perfect for: UK businesses using Stripe + Xero, any size - -**Future tiers** (if needed): -* Starter — £30/month (up to 50 invoices) -* Professional — £50/month (up to 200 invoices) -* Business — £100/month (unlimited) - -### Implementation Notes - -* **Billing via Stripe Checkout** (dogfooding our own product) -* **Monthly recurring subscriptions** with automatic renewal -* **14-day free trial** — no credit card required -* **Founder pricing lock-in** — First 50 customers get lifetime 50% off -* **Usage tracking** — Invoice count displayed in dashboard, soft warnings at 80% of limit -* **Graceful degradation** — Over-limit users get notified but sync continues (no hard cutoff) - -### Revenue Model - -* **Target: 100 paying customers in 6 months** - * 60% Starter (£900/mo) - * 30% Professional (£1,050/mo) - * 10% Business (£750/mo) - * Total: ~£2,700/mo MRR - -* **Conservative burn** - * Hosting: £50/mo (Vercel + DB) - * Email: £10/mo (AWS SES) - * Support: Founder time only - * Net: ~£2,640/mo profit margin - -### Next Steps for Monetization - -1. Add Stripe Billing integration to the app -2. Implement usage tracking in webhook handler -3. Create pricing page on landing site -4. Add subscription management in dashboard -5. Enable payments and remove "internal test" banner - ---- - -## 🧠 The big picture (sanity check) - -* You’re *not* early anymore — you’re **post-validation, pre-pricing** -* The hard bit (VAT correctness + finance approval) is already done -* The remaining work is boring plumbing + selling -* This is exactly where most side projects die — don’t overbuild now - -If you want, next we can: - -* Draft the **first cold email** -* Write the **“Why this exists” landing page copy** -* Or map a **2-week nights/weekends execution plan** - -Just say the word.