Merge pull request #87 from MealCraft/faeture/stripe_to_invoice

Faeture/stripe to invoice
This commit is contained in:
Jun-te Kim 2026-03-01 17:27:20 +00:00 committed by GitHub
commit 9a557971c6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 784 additions and 280 deletions

View file

@ -51,9 +51,8 @@ RUN echo "deb [signed-by=/usr/share/keyrings/stripe.gpg] https://packages.stripe
RUN sudo apt update RUN sudo apt update
RUN sudo apt install stripe RUN sudo apt install stripe
# Install code server
RUN curl -fsSL https://code-server.dev/install.sh | sh
# Set the working directory # Set the working directory
WORKDIR /workspaces/monorepo WORKDIR /workspaces/monorepo

View file

@ -2,9 +2,10 @@
"name": "Basic Python", "name": "Basic Python",
"dockerComposeFile": "docker-compose.yml", "dockerComposeFile": "docker-compose.yml",
"service": "one_repo_to_rule_them_all", "service": "one_repo_to_rule_them_all",
"remoteUser": "vscode", // "remoteUser": "vscode",
"workspaceFolder": "/workspaces/monorepo", "workspaceFolder": "/workspaces/monorepo",
"postStartCommand": "bash .devcontainer/post-install.sh", "postStartCommand": "bash .devcontainer/stripe-to-invoice/post-install.sh",
"forwardPorts": [8080],
"features": { "features": {
// "ghcr.io/devcontainers/features/ssh-agent:1": {} // "ghcr.io/devcontainers/features/ssh-agent:1": {}
@ -12,7 +13,7 @@
"mounts": [ "mounts": [
// Optional convenience mount // Optional convenience mount
"source=${localEnv:HOME},target=/workspaces/home,type=bind" "source=${localEnv:HOME},target=/home/vscode,type=bind"
], ],
"customizations": { "customizations": {

View file

@ -2,12 +2,14 @@ version: '3.8'
services: services:
one_repo_to_rule_them_all: one_repo_to_rule_them_all:
user: "${UID}:${GID}"
build: build:
context: ../.. context: ../..
dockerfile: .devcontainer/stripe-to-invoice/Dockerfile 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: volumes:
- ../..:/workspaces/monorepo - ../..:/workspaces/monorepo
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
ports:
- "8080:8080"

View file

@ -1 +1 @@
cv stripe_to_invoice && npm install; cd stripe_to_invoice && npm install;

View file

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

View file

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

View file

@ -1,46 +1,66 @@
# ================================ # ==========================================
# EXCALIDRAW - STATELESS # JS PAINT (STATIC DEPLOYMENT)
# https://excalidraw.com # ==========================================
# ================================
--- ---
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: excalidraw name: jspaint
labels:
app: excalidraw
spec: spec:
replicas: 1 replicas: 1
selector: selector:
matchLabels: matchLabels:
app: excalidraw app: jspaint
template: template:
metadata: metadata:
labels: labels:
app: excalidraw app: jspaint
spec: 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: containers:
- name: excalidraw - name: nginx
image: excalidraw/excalidraw:latest image: nginx:alpine
ports: ports:
- containerPort: 80 - containerPort: 80
volumeMounts:
- name: web-content
mountPath: /usr/share/nginx/html
resources: resources:
requests: requests:
cpu: "100m" cpu: "50m"
memory: "128Mi" memory: "64Mi"
limits: limits:
cpu: "300m" cpu: "200m"
memory: "256Mi" memory: "128Mi"
--- ---
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: excalidraw name: jspaint
spec: spec:
selector: selector:
app: excalidraw app: jspaint
ports: ports:
- port: 80 - port: 80
targetPort: 80 targetPort: 80
@ -49,17 +69,16 @@ spec:
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
metadata: metadata:
name: excalidraw-ingressroute name: jspaint-ingress
spec: spec:
entryPoints: entryPoints:
- websecure - websecure
routes: routes:
- match: Host(`draw.juntekim.com`) - match: Host(`jspaint.juntekim.com`)
kind: Rule kind: Rule
services: services:
- name: excalidraw - name: jspaint
port: 80 port: 80
passHostHeader: true
tls: tls:
certResolver: myresolver certResolver: myresolver
domains:
- main: draw.juntekim.com

375
exercise/exercise.yaml Normal file
View file

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

285
recipes/recipes.yaml Normal file
View file

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

View file

@ -1,248 +0,0 @@
Got you — heres a clean, founder-brain-friendly summary of **Stripe → Invoice (Stripe → Xero)** based on everything youve been working through, plus **tight next steps** that fit your nights/weekends reality.
---
## 🧾 What Stripe → Invoice Is (current state)
* **Problem youre 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 its 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 youre in (important)
* Youre 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)
* 510 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 **~35 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)
* Youre *not* early anymore — youre **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 — dont 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.