Compare commits
217 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 17447fb5e1 | |||
| b274fdff8e | |||
| a64dc7ab5e | |||
| 0fb99e6c16 | |||
| a53daddb9a | |||
| 3351d74058 | |||
| adfa34f2c2 | |||
| ade03fdd1c | |||
| 5825174f0d | |||
| 68e6be1985 | |||
| c2a0263ddd | |||
| da96ec7dc4 | |||
| 37798251be | |||
| bc5e03e7a0 | |||
| ee97f64db6 | |||
| 538a5f4f4d | |||
| 4485bf1d5e | |||
| f7bf767da5 | |||
| 2f1833e90d | |||
| b2725fd512 | |||
| 5532b43e38 | |||
| 0be7ccd4b4 | |||
| 6d37cecdba | |||
| 3745f5be69 | |||
| abec954320 | |||
| ec9deb515b | |||
| cfed9b0482 | |||
| 25edd8d5e3 | |||
| bd3cb3b9ab | |||
| 3bedc651c6 | |||
| 138033be9b | |||
| 8ddefe82e4 | |||
| def921f115 | |||
| 43ee1c3531 | |||
| f03d7a33c8 | |||
| 7bf0165fe4 | |||
| ef63c47b7c | |||
| be75c7f254 | |||
| e90637c227 | |||
| 67e60c9ae1 | |||
| a25b673dd6 | |||
| 4e003ba3d0 | |||
| 4996ff7432 | |||
| ffc6c7960d | |||
| cf16415720 | |||
| 33f9e17339 | |||
| 7639be9a41 | |||
| ebe439ce84 | |||
| a663729121 | |||
| 4fc7933e30 | |||
| 6e0cb93ee2 | |||
| 0e4848f8b4 | |||
| bb7010f881 | |||
| 4756e1c1c5 | |||
| 73c038e406 | |||
| 02e34d65bb | |||
| a869bb42d7 | |||
| d77d1b58b8 | |||
| d86c0001eb | |||
| 5cc2bb78e9 | |||
| c9075be6e0 | |||
| 6c297b5e81 | |||
| 80004e4285 | |||
| 94f99595fc | |||
| c8de30ec6e | |||
| c1dc3e77e0 | |||
| 1af98c40ab | |||
| 1aaa8e78fd | |||
| c3bfd3560b | |||
| de2407d985 | |||
| d52fb83296 | |||
| c855575e77 | |||
| 7c45b04dce | |||
| f721918f95 | |||
| 692f42fbbb | |||
| b95f1725c7 | |||
| 70b9d1d6d6 | |||
| f36429936a | |||
| 1b418e7c6f | |||
| 0b31badbcd | |||
| eb579dcaa5 | |||
| 086868d450 | |||
| 63621df0b8 | |||
| 41e6bfdcf5 | |||
| a60859f22f | |||
| 8e8d4a4774 | |||
| e85d757cc6 | |||
| 43cb62a4d6 | |||
| f7e1574176 | |||
| ee6352a2f5 | |||
| 2f37f0501f | |||
| 4c36fd4156 | |||
| c9172f088f | |||
| ac4cba2b0d | |||
| 0c47be8ef3 | |||
| 440f92e96e | |||
| 97bbdf68a5 | |||
| 02e5bee390 | |||
| d475b3876a | |||
| 76bcc53992 | |||
| 470b615528 | |||
| f26f8f7e56 | |||
| 78b7831d43 | |||
| e45b510519 | |||
| f25044ea7e | |||
| b637fd9c11 | |||
| 983ee2c398 | |||
| 8af7b37b38 | |||
| b21a30b2e7 | |||
| 361ad3acc2 | |||
| 5e165d277e | |||
| 6828e4d0a9 | |||
| 0b9dd74f7d | |||
| 7a06f0618b | |||
| 9385463171 | |||
| b658f77f9c | |||
| 8706112be3 | |||
| 00b2b2469b | |||
| 1a464fd77d | |||
| 962e64b72a | |||
| ff91003e90 | |||
| cd733fbc7d | |||
| 1f9086f2f2 | |||
| 59407ae54a | |||
| 8659b99059 | |||
| e82ed5ac12 | |||
| 0d8ee5f386 | |||
| 09864c1a96 | |||
| 3621504c22 | |||
| 24adc7e35b | |||
| 99294ea46d | |||
| a28e9d9dd4 | |||
| d405caceca | |||
| f0d1694a1c | |||
| 6b32197ad2 | |||
| 528887a4a2 | |||
| bca46bf68e | |||
| 5d3b8fc8c2 | |||
| 6e76222b81 | |||
| 65e670a887 | |||
| 63aae4f2eb | |||
| e9bc46121f | |||
| 56d9d5ad2e | |||
| 1966b94a97 | |||
| a33b6a0c30 | |||
| c2b5ccb830 | |||
| 69e1be1560 | |||
| 43673583c1 | |||
| b7b9e987df | |||
| e6ed9d9193 | |||
| f0c60778cc | |||
| 7d31491114 | |||
| aba26b9d2f | |||
| d0cecf9686 | |||
| dfe7b42db3 | |||
| b6df3dc0cb | |||
| 6c09db5478 | |||
| 3f13cb1bf6 | |||
| d4f7194d3f | |||
| ee731c4aa3 | |||
| 98d95a661a | |||
| de120cb429 | |||
| b18cb24ec4 | |||
| 1491974aba | |||
| fe8e2567a2 | |||
| ea8dcad398 | |||
| e9eb9cf489 | |||
| 14ba9d0b82 | |||
| 6b73647689 | |||
| 4f42247bf2 | |||
| d5ee743d84 | |||
| 41380e9526 | |||
| 4c29d8a241 | |||
| 31b7c14719 | |||
| 6b6b9e7d01 | |||
| c62a151210 | |||
| 835aff3522 | |||
| 5588c1b5d8 | |||
| c5ed863ab1 | |||
| 8d0552f73f | |||
| 3a75ee7aee | |||
| 30d670a257 | |||
| cfa4d8fa91 | |||
| 39e8d5c9f9 | |||
| 44c475265e | |||
| 8e1f61214c | |||
| fb1c5fb929 | |||
| 75be08ccf3 | |||
| 5596e22d0c | |||
| f45a49059e | |||
| 47ba602b02 | |||
| 5b12625e3f | |||
| d7a4086647 | |||
| b43ec1fb9b | |||
| 129f0adc96 | |||
| ea789378dd | |||
| 2f096c985a | |||
| ad218c07ec | |||
| fff9f6f63a | |||
| b0ea4767b6 | |||
| c1778074e3 | |||
| 003c62da3e | |||
| 7426ff1909 | |||
| b9a66dfc8b | |||
| 7a1267de79 | |||
| 4415c56a53 | |||
| da8b413f76 | |||
| dd6a683b90 | |||
| cf8e821bdc | |||
| c9be9324cf | |||
| fc99e8a82e | |||
| cb1d926fc4 | |||
| d2337a7ef7 | |||
| b7e7960f35 | |||
| e662ff5fab | |||
| 656c8d3842 | |||
| 853d722044 |
+502
-12
@@ -2,9 +2,9 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, dev, uat]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, dev, uat]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
@@ -13,11 +13,14 @@ concurrency:
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
security-events: write
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: cartsnitch/cartsnitch
|
||||
AUTH_IMAGE_NAME: cartsnitch/auth
|
||||
RECEIPTWITNESS_IMAGE_NAME: cartsnitch/receiptwitness
|
||||
API_IMAGE_NAME: cartsnitch/api
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
@@ -46,9 +49,62 @@ jobs:
|
||||
- name: Run tests
|
||||
run: npx vitest run
|
||||
|
||||
audit:
|
||||
runs-on: runners-cartsnitch
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Check for vulnerabilities
|
||||
run: npm audit --audit-level=high
|
||||
|
||||
e2e:
|
||||
runs-on: runners-cartsnitch
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npx playwright install --with-deps chromium
|
||||
- run: npx playwright test
|
||||
|
||||
lighthouse:
|
||||
runs-on: runners-cartsnitch
|
||||
needs: [test]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- name: Install Chromium for Lighthouse
|
||||
run: |
|
||||
npm install -g playwright
|
||||
npx playwright install --with-deps chromium
|
||||
- name: Start preview server
|
||||
run: |
|
||||
npm run preview &
|
||||
npx wait-on http://localhost:4173/ --timeout 30000
|
||||
- name: Run Lighthouse CI
|
||||
run: |
|
||||
CHROME_PATH=$(find /home/runner/.cache/ms-playwright -name chrome -type f 2>/dev/null | head -1)
|
||||
npm install -g @lhci/cli
|
||||
CHROME_PATH="$CHROME_PATH" lhci autorun --chrome-flags="--headless=new --no-sandbox --disable-gpu --disable-dev-shm-usage"
|
||||
|
||||
build-and-push:
|
||||
runs-on: runners-cartsnitch
|
||||
needs: [lint, test]
|
||||
if: github.event_name == 'push'
|
||||
needs: [lint, test, e2e]
|
||||
outputs:
|
||||
calver_tag: ${{ steps.calver.outputs.version }}
|
||||
sha_tag: sha-${{ github.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -72,13 +128,14 @@ jobs:
|
||||
echo "CalVer tag: $VERSION"
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Log in to GHCR
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
@@ -91,18 +148,46 @@ jobs:
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=sha,prefix=sha-
|
||||
type=sha,prefix=sha-,format=long
|
||||
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
load: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
target: prod
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Scan frontend image for vulnerabilities
|
||||
uses: anchore/scan-action@v5
|
||||
id: scan
|
||||
with:
|
||||
image: "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}"
|
||||
fail-build: true
|
||||
severity-cutoff: high
|
||||
output-format: sarif
|
||||
|
||||
- name: Upload frontend scan results to GitHub Security
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: ${{ steps.scan.outputs.sarif }}
|
||||
|
||||
- name: Push Docker image
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
target: prod
|
||||
cache-from: type=gha
|
||||
|
||||
- name: Create git tag
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
@@ -112,7 +197,11 @@ jobs:
|
||||
|
||||
build-and-push-auth:
|
||||
runs-on: runners-cartsnitch
|
||||
needs: [lint, test]
|
||||
if: github.event_name == 'push'
|
||||
needs: [lint, test, e2e]
|
||||
outputs:
|
||||
calver_tag: ${{ steps.calver.outputs.version }}
|
||||
sha_tag: sha-${{ github.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -134,8 +223,15 @@ jobs:
|
||||
fi
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Log in to GHCR
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
@@ -148,15 +244,409 @@ jobs:
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.AUTH_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=sha,prefix=sha-
|
||||
type=sha,prefix=sha-,format=long
|
||||
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push auth Docker image
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./auth
|
||||
file: ./auth/Dockerfile
|
||||
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
load: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Scan auth image for vulnerabilities
|
||||
uses: anchore/scan-action@v5
|
||||
id: scan
|
||||
with:
|
||||
image: "${{ env.REGISTRY }}/${{ env.AUTH_IMAGE_NAME }}:sha-${{ github.sha }}"
|
||||
fail-build: true
|
||||
severity-cutoff: high
|
||||
output-format: sarif
|
||||
|
||||
- name: Upload auth scan results to GitHub Security
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: ${{ steps.scan.outputs.sarif }}
|
||||
|
||||
- name: Push Docker image
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./auth
|
||||
file: ./auth/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
|
||||
build-and-push-receiptwitness:
|
||||
runs-on: runners-cartsnitch
|
||||
if: github.event_name == 'push'
|
||||
needs: [lint, test]
|
||||
outputs:
|
||||
calver_tag: ${{ steps.calver.outputs.version }}
|
||||
sha_tag: sha-${{ github.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate CalVer tag
|
||||
id: calver
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
DATE_TAG=$(date -u +%Y.%m.%d)
|
||||
EXISTING=$(git tag -l "v${DATE_TAG}*" | sort -V | tail -1)
|
||||
if [ -z "$EXISTING" ]; then VERSION="$DATE_TAG"
|
||||
elif [ "$EXISTING" = "v${DATE_TAG}" ]; then VERSION="${DATE_TAG}.2"
|
||||
else BUILD_NUM=$(echo "$EXISTING" | sed "s/v${DATE_TAG}\.//"); VERSION="${DATE_TAG}.$((BUILD_NUM + 1))"; fi
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Log in to GHCR
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.RECEIPTWITNESS_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=sha,prefix=sha-,format=long
|
||||
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./receiptwitness/Dockerfile
|
||||
load: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Scan receiptwitness image for vulnerabilities
|
||||
uses: anchore/scan-action@v5
|
||||
id: scan
|
||||
with:
|
||||
image: "${{ env.REGISTRY }}/${{ env.RECEIPTWITNESS_IMAGE_NAME }}:sha-${{ github.sha }}"
|
||||
fail-build: true
|
||||
severity-cutoff: high
|
||||
output-format: sarif
|
||||
|
||||
- name: Upload receiptwitness scan results to GitHub Security
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: ${{ steps.scan.outputs.sarif }}
|
||||
|
||||
- name: Push Docker image
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./receiptwitness/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
|
||||
build-and-push-api:
|
||||
runs-on: runners-cartsnitch
|
||||
if: github.event_name == 'push'
|
||||
needs: [lint, test]
|
||||
outputs:
|
||||
calver_tag: ${{ steps.calver.outputs.version }}
|
||||
sha_tag: sha-${{ github.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate CalVer tag
|
||||
id: calver
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
DATE_TAG=$(date -u +%Y.%m.%d)
|
||||
EXISTING=$(git tag -l "v${DATE_TAG}*" | sort -V | tail -1)
|
||||
if [ -z "$EXISTING" ]; then VERSION="$DATE_TAG"
|
||||
elif [ "$EXISTING" = "v${DATE_TAG}" ]; then VERSION="${DATE_TAG}.2"
|
||||
else BUILD_NUM=$(echo "$EXISTING" | sed "s/v${DATE_TAG}\.//"); VERSION="${DATE_TAG}.$((BUILD_NUM + 1))"; fi
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Log in to GHCR
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (API)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.API_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=sha,prefix=sha-,format=long
|
||||
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./api
|
||||
file: ./api/Dockerfile
|
||||
load: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Scan api image for vulnerabilities
|
||||
uses: anchore/scan-action@v5
|
||||
id: scan
|
||||
with:
|
||||
image: "${{ env.REGISTRY }}/${{ env.API_IMAGE_NAME }}:sha-${{ github.sha }}"
|
||||
fail-build: true
|
||||
severity-cutoff: high
|
||||
output-format: sarif
|
||||
|
||||
- name: Upload api scan results to GitHub Security
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: ${{ steps.scan.outputs.sarif }}
|
||||
|
||||
- name: Push Docker image
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./api
|
||||
file: ./api/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
|
||||
deploy-dev:
|
||||
runs-on: runners-cartsnitch
|
||||
needs: [build-and-push, build-and-push-auth, build-and-push-receiptwitness, build-and-push-api]
|
||||
if: always() && !cancelled() && github.event_name == 'push' && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main')
|
||||
steps:
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.CARTSNITCH_APP_ID }}
|
||||
private-key: ${{ secrets.CARTSNITCH_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: infra
|
||||
|
||||
- name: Checkout infra repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: cartsnitch/infra
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
ref: main
|
||||
path: infra
|
||||
|
||||
- name: Install kubectl
|
||||
uses: azure/setup-kubectl@v4
|
||||
|
||||
- name: Install kustomize
|
||||
uses: imranismail/setup-kustomize@v2
|
||||
|
||||
- name: Determine image tag for frontend
|
||||
id: frontend_tag
|
||||
run: |
|
||||
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
|
||||
echo "tag=${{ needs.build-and-push.outputs.calver_tag }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=${{ needs.build-and-push.outputs.sha_tag }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Update frontend image tag
|
||||
if: needs.build-and-push.result == 'success'
|
||||
run: |
|
||||
cd infra/apps/overlays/dev
|
||||
kustomize edit set image ghcr.io/cartsnitch/cartsnitch:${{ steps.frontend_tag.outputs.tag }}
|
||||
|
||||
- name: Determine image tag for auth
|
||||
id: auth_tag
|
||||
run: |
|
||||
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
|
||||
echo "tag=${{ needs.build-and-push-auth.outputs.calver_tag }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=${{ needs.build-and-push-auth.outputs.sha_tag }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Update auth image tag
|
||||
if: needs.build-and-push-auth.result == 'success'
|
||||
run: |
|
||||
cd infra/apps/overlays/dev
|
||||
kustomize edit set image ghcr.io/cartsnitch/auth:${{ steps.auth_tag.outputs.tag }}
|
||||
|
||||
- name: Determine image tag for receiptwitness
|
||||
id: receiptwitness_tag
|
||||
run: |
|
||||
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
|
||||
echo "tag=${{ needs.build-and-push-receiptwitness.outputs.calver_tag }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=${{ needs.build-and-push-receiptwitness.outputs.sha_tag }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Update receiptwitness image tag
|
||||
if: needs.build-and-push-receiptwitness.result == 'success'
|
||||
run: |
|
||||
cd infra/apps/overlays/dev
|
||||
kustomize edit set image ghcr.io/cartsnitch/receiptwitness:${{ steps.receiptwitness_tag.outputs.tag }}
|
||||
|
||||
- name: Determine image tag for api
|
||||
id: api_tag
|
||||
run: |
|
||||
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
|
||||
echo "tag=${{ needs.build-and-push-api.outputs.calver_tag }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=${{ needs.build-and-push-api.outputs.sha_tag }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Update api image tag
|
||||
if: needs.build-and-push-api.result == 'success'
|
||||
run: |
|
||||
cd infra/apps/overlays/dev
|
||||
kustomize edit set image ghcr.io/cartsnitch/api:${{ steps.api_tag.outputs.tag }}
|
||||
|
||||
- name: Commit and push to infra
|
||||
run: |
|
||||
cd infra
|
||||
git config user.name "cartsnitch-ci[bot]"
|
||||
git config user.email "cartsnitch-ci[bot]@users.noreply.github.com"
|
||||
git add apps/overlays/dev/kustomization.yaml
|
||||
git commit -m "ci(dev): update cartsnitch, auth, receiptwitness, and api images"
|
||||
git pull --rebase origin main
|
||||
git push origin main
|
||||
|
||||
deploy-uat:
|
||||
runs-on: runners-cartsnitch
|
||||
needs: [build-and-push, build-and-push-auth, build-and-push-receiptwitness, build-and-push-api]
|
||||
if: always() && !cancelled() && github.event_name == 'push' && (github.ref == 'refs/heads/uat' || github.ref == 'refs/heads/main')
|
||||
steps:
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.CARTSNITCH_APP_ID }}
|
||||
private-key: ${{ secrets.CARTSNITCH_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: infra
|
||||
|
||||
- name: Checkout infra repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: cartsnitch/infra
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
ref: main
|
||||
path: infra
|
||||
|
||||
- name: Install kubectl
|
||||
uses: azure/setup-kubectl@v4
|
||||
|
||||
- name: Install kustomize
|
||||
uses: imranismail/setup-kustomize@v2
|
||||
|
||||
- name: Determine image tag for frontend
|
||||
id: frontend_tag
|
||||
run: |
|
||||
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
|
||||
echo "tag=${{ needs.build-and-push.outputs.calver_tag }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=${{ needs.build-and-push.outputs.sha_tag }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Update frontend image tag
|
||||
if: needs.build-and-push.result == 'success'
|
||||
run: |
|
||||
cd infra/apps/overlays/uat
|
||||
kustomize edit set image ghcr.io/cartsnitch/cartsnitch:${{ steps.frontend_tag.outputs.tag }}
|
||||
|
||||
- name: Determine image tag for auth
|
||||
id: auth_tag
|
||||
run: |
|
||||
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
|
||||
echo "tag=${{ needs.build-and-push-auth.outputs.calver_tag }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=${{ needs.build-and-push-auth.outputs.sha_tag }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Update auth image tag
|
||||
if: needs.build-and-push-auth.result == 'success'
|
||||
run: |
|
||||
cd infra/apps/overlays/uat
|
||||
kustomize edit set image ghcr.io/cartsnitch/auth:${{ steps.auth_tag.outputs.tag }}
|
||||
|
||||
- name: Determine image tag for receiptwitness
|
||||
id: receiptwitness_tag
|
||||
run: |
|
||||
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
|
||||
echo "tag=${{ needs.build-and-push-receiptwitness.outputs.calver_tag }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=${{ needs.build-and-push-receiptwitness.outputs.sha_tag }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Update receiptwitness image tag
|
||||
if: needs.build-and-push-receiptwitness.result == 'success'
|
||||
run: |
|
||||
cd infra/apps/overlays/uat
|
||||
kustomize edit set image ghcr.io/cartsnitch/receiptwitness:${{ steps.receiptwitness_tag.outputs.tag }}
|
||||
|
||||
- name: Determine image tag for api
|
||||
id: api_tag
|
||||
run: |
|
||||
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
|
||||
echo "tag=${{ needs.build-and-push-api.outputs.calver_tag }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=${{ needs.build-and-push-api.outputs.sha_tag }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Update api image tag
|
||||
if: needs.build-and-push-api.result == 'success'
|
||||
run: |
|
||||
cd infra/apps/overlays/uat
|
||||
kustomize edit set image ghcr.io/cartsnitch/api:${{ steps.api_tag.outputs.tag }}
|
||||
|
||||
- name: Commit and push to infra
|
||||
run: |
|
||||
cd infra
|
||||
git config user.name "cartsnitch-ci[bot]"
|
||||
git config user.email "cartsnitch-ci[bot]@users.noreply.github.com"
|
||||
git add apps/overlays/uat/kustomization.yaml
|
||||
git commit -m "ci(uat): update cartsnitch, auth, receiptwitness, and api images"
|
||||
git pull --rebase origin main
|
||||
git push origin main
|
||||
|
||||
@@ -1,45 +1 @@
|
||||
# CartSnitch Monorepo
|
||||
|
||||
CartSnitch is a self-hosted grocery price intelligence platform. This repo consolidates the core services and the flagship frontend PWA.
|
||||
|
||||
## Services
|
||||
|
||||
| Directory | Service | Purpose |
|
||||
|-----------|---------|---------|
|
||||
| `/` (root) | **Frontend** | React 18 PWA — mobile-first price intelligence UI |
|
||||
| `api/` | **API Gateway** | FastAPI — frontend-facing REST API |
|
||||
| `common/` | **Common** | Shared Python models, schemas, Alembic migrations |
|
||||
| `receiptwitness/` | **ReceiptWitness** | Purchase ingestion via retailer scrapers |
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Frontend (root)
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # http://localhost:5173
|
||||
npm run build # production build
|
||||
npm run test # unit tests (Vitest)
|
||||
```
|
||||
|
||||
### Python Services
|
||||
|
||||
Each Python service uses [uv](https://github.com/astral-sh/uv) and has its own `pyproject.toml`:
|
||||
|
||||
```bash
|
||||
cd api # or common / receiptwitness
|
||||
uv sync
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
- **Never push directly to main.** Always open a PR from a feature branch.
|
||||
- Branch naming: `feature/<description>` or `fix/<description>`
|
||||
- Conventional commits: `feat:`, `fix:`, `refactor:`, `docs:`, `chore:`
|
||||
|
||||
## Architecture
|
||||
|
||||
For full details see [CLAUDE.md](./CLAUDE.md) or the per-service `CLAUDE.md` in each subdirectory.
|
||||
|
||||
CartSnitch is a polyrepo-style monorepo: each service can be built and deployed independently, but sharing code between `common/` and the other Python services is done via local path dependencies in `pyproject.toml`.
|
||||
# CartSnitch
|
||||
|
||||
+5
-1
@@ -12,10 +12,14 @@ RUN pip install --no-cache-dir --prefix=/install .
|
||||
|
||||
FROM python:3.12-slim AS prod
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
RUN adduser --system --group --uid 1000 app
|
||||
COPY --from=build /install /usr/local
|
||||
COPY src/ ./src/
|
||||
COPY alembic.ini ./
|
||||
COPY alembic/ ./alembic/
|
||||
|
||||
USER 1000
|
||||
EXPOSE 8000
|
||||
@@ -23,4 +27,4 @@ EXPOSE 8000
|
||||
HEALTHCHECK --interval=30s --timeout=3s \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
|
||||
|
||||
CMD ["uvicorn", "cartsnitch_api.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
CMD ["sh", "-c", "python -m alembic upgrade head && uvicorn cartsnitch_api.main:app --host 0.0.0.0 --port 8000"]
|
||||
|
||||
+14
-2
@@ -18,7 +18,7 @@ if not db_url:
|
||||
"CARTSNITCH_DATABASE_URL_SYNC must be set. "
|
||||
"Example: postgresql://user:pass@localhost:5432/cartsnitch"
|
||||
)
|
||||
config.set_main_option("sqlalchemy.url", db_url)
|
||||
config.set_main_option("sqlalchemy.url", db_url.replace("%", "%%"))
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
@@ -31,6 +31,7 @@ def run_migrations_offline() -> None:
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
version_table_column_width=128,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
@@ -44,9 +45,20 @@ def run_migrations_online() -> None:
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
context.configure(connection=connection, target_metadata=target_metadata, version_table_column_width=128)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
# Create any tables defined in models but not yet created by migrations.
|
||||
# This bootstraps fresh databases that have no legacy schema.
|
||||
# checkfirst=True ensures this is a no-op on existing databases.
|
||||
try:
|
||||
Base.metadata.create_all(bind=connection, checkfirst=True)
|
||||
connection.commit()
|
||||
except Exception as exc:
|
||||
import logging
|
||||
logging.getLogger("alembic.env").warning(
|
||||
"create_all failed (non-fatal, migrations should handle table creation): %s", exc
|
||||
)
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
|
||||
@@ -33,6 +33,21 @@ def _is_fernet_token(value: str) -> bool:
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
# Fresh DB — table created by Base.metadata.create_all with correct TEXT type
|
||||
if not inspector.has_table("user_store_accounts"):
|
||||
return
|
||||
|
||||
# Already migrated? Skip if session_data is already TEXT (not JSON)
|
||||
cols = {c["name"]: c for c in inspector.get_columns("user_store_accounts")}
|
||||
if "session_data" not in cols:
|
||||
return
|
||||
col_type = str(cols["session_data"]["type"]).lower()
|
||||
if "text" in col_type and "json" not in col_type:
|
||||
return # already TEXT — nothing to do
|
||||
|
||||
# Change column type from JSON to TEXT to hold Fernet ciphertext
|
||||
op.alter_column(
|
||||
"user_store_accounts",
|
||||
@@ -43,7 +58,6 @@ def upgrade() -> None:
|
||||
postgresql_using="session_data::text",
|
||||
)
|
||||
|
||||
conn = op.get_bind()
|
||||
rows = conn.execute(
|
||||
text("SELECT id, session_data FROM user_store_accounts WHERE session_data IS NOT NULL")
|
||||
).fetchall()
|
||||
|
||||
@@ -21,81 +21,94 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
# --- Extend users table for Better-Auth compatibility ---
|
||||
op.add_column("users", sa.Column("email_verified", sa.Boolean(), nullable=False, server_default="false"))
|
||||
op.add_column("users", sa.Column("image", sa.Text(), nullable=True))
|
||||
# Guard: on a fresh DB Base.metadata.create_all (called in env.py after migrations)
|
||||
# creates the users table with all columns, so migration 002 must not re-run add_column.
|
||||
if inspector.has_table("users"):
|
||||
existing_user_cols = [c["name"] for c in inspector.get_columns("users")]
|
||||
if "email_verified" not in existing_user_cols:
|
||||
op.add_column("users", sa.Column("email_verified", sa.Boolean(), nullable=False, server_default="false"))
|
||||
if "image" not in existing_user_cols:
|
||||
op.add_column("users", sa.Column("image", sa.Text(), nullable=True))
|
||||
|
||||
# --- Create sessions table ---
|
||||
op.create_table(
|
||||
"sessions",
|
||||
sa.Column("id", sa.Text(), nullable=False),
|
||||
sa.Column("token", sa.Text(), nullable=False),
|
||||
sa.Column("user_id", sa.Text(), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("ip_address", sa.Text(), nullable=True),
|
||||
sa.Column("user_agent", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_sessions_token", "sessions", ["token"], unique=True)
|
||||
op.create_index("ix_sessions_user_id", "sessions", ["user_id"])
|
||||
if not inspector.has_table("sessions"):
|
||||
op.create_table(
|
||||
"sessions",
|
||||
sa.Column("id", sa.Text(), nullable=False),
|
||||
sa.Column("token", sa.Text(), nullable=False),
|
||||
sa.Column("user_id", sa.Text(), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("ip_address", sa.Text(), nullable=True),
|
||||
sa.Column("user_agent", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_sessions_token", "sessions", ["token"], unique=True)
|
||||
op.create_index("ix_sessions_user_id", "sessions", ["user_id"])
|
||||
|
||||
# --- Create accounts table ---
|
||||
op.create_table(
|
||||
"accounts",
|
||||
sa.Column("id", sa.Text(), nullable=False),
|
||||
sa.Column("user_id", sa.Text(), nullable=False),
|
||||
sa.Column("account_id", sa.Text(), nullable=False),
|
||||
sa.Column("provider_id", sa.Text(), nullable=False),
|
||||
sa.Column("access_token", sa.Text(), nullable=True),
|
||||
sa.Column("refresh_token", sa.Text(), nullable=True),
|
||||
sa.Column("access_token_expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("refresh_token_expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("scope", sa.Text(), nullable=True),
|
||||
sa.Column("id_token", sa.Text(), nullable=True),
|
||||
sa.Column("password", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_accounts_user_id", "accounts", ["user_id"])
|
||||
if not inspector.has_table("accounts"):
|
||||
op.create_table(
|
||||
"accounts",
|
||||
sa.Column("id", sa.Text(), nullable=False),
|
||||
sa.Column("user_id", sa.Text(), nullable=False),
|
||||
sa.Column("account_id", sa.Text(), nullable=False),
|
||||
sa.Column("provider_id", sa.Text(), nullable=False),
|
||||
sa.Column("access_token", sa.Text(), nullable=True),
|
||||
sa.Column("refresh_token", sa.Text(), nullable=True),
|
||||
sa.Column("access_token_expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("refresh_token_expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("scope", sa.Text(), nullable=True),
|
||||
sa.Column("id_token", sa.Text(), nullable=True),
|
||||
sa.Column("password", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_accounts_user_id", "accounts", ["user_id"])
|
||||
|
||||
# --- Create verifications table ---
|
||||
op.create_table(
|
||||
"verifications",
|
||||
sa.Column("id", sa.Text(), nullable=False),
|
||||
sa.Column("identifier", sa.Text(), nullable=False),
|
||||
sa.Column("value", sa.Text(), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
if not inspector.has_table("verifications"):
|
||||
op.create_table(
|
||||
"verifications",
|
||||
sa.Column("id", sa.Text(), nullable=False),
|
||||
sa.Column("identifier", sa.Text(), nullable=False),
|
||||
sa.Column("value", sa.Text(), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
# --- Migrate existing password hashes to accounts table ---
|
||||
# For each user with a hashed_password, create a 'credential' account row
|
||||
conn = op.get_bind()
|
||||
users = conn.execute(
|
||||
text("SELECT id, hashed_password FROM users WHERE hashed_password IS NOT NULL")
|
||||
).fetchall()
|
||||
# Only run on existing (non-fresh) DBs that already have users table with data
|
||||
if inspector.has_table("users"):
|
||||
users = conn.execute(
|
||||
text("SELECT id, hashed_password FROM users WHERE hashed_password IS NOT NULL")
|
||||
).fetchall()
|
||||
|
||||
for user_id, hashed_password in users:
|
||||
user_id_str = str(user_id)
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO accounts (id, user_id, account_id, provider_id, password, created_at, updated_at) "
|
||||
"VALUES (gen_random_uuid()::text, :user_id, :account_id, 'credential', :password, now(), now())"
|
||||
),
|
||||
{"user_id": user_id_str, "account_id": user_id_str, "password": hashed_password},
|
||||
)
|
||||
for user_id, hashed_password in users:
|
||||
user_id_str = str(user_id)
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO accounts (id, user_id, account_id, provider_id, password, created_at, updated_at) "
|
||||
"VALUES (gen_random_uuid()::text, :user_id, :account_id, 'credential', :password, now(), now())"
|
||||
),
|
||||
{"user_id": user_id_str, "account_id": user_id_str, "password": hashed_password},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("verifications")
|
||||
op.drop_table("accounts")
|
||||
op.drop_index("ix_sessions_user_id", table_name="sessions")
|
||||
op.drop_index("ix_sessions_token", table_name="sessions")
|
||||
op.drop_table("sessions")
|
||||
op.drop_column("users", "image")
|
||||
op.drop_column("users", "email_verified")
|
||||
op.execute(text("DROP INDEX IF EXISTS ix_accounts_user_id"))
|
||||
op.execute(text("DROP TABLE IF EXISTS verifications"))
|
||||
op.execute(text("DROP TABLE IF EXISTS accounts"))
|
||||
op.execute(text("DROP INDEX IF EXISTS ix_sessions_user_id"))
|
||||
op.execute(text("DROP INDEX IF EXISTS ix_sessions_token"))
|
||||
op.execute(text("DROP TABLE IF EXISTS sessions"))
|
||||
op.execute(text("ALTER TABLE users DROP COLUMN IF EXISTS image"))
|
||||
op.execute(text("ALTER TABLE users DROP COLUMN IF EXISTS email_verified"))
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Make users.hashed_password nullable.
|
||||
|
||||
Better-Auth inserts users without hashed_password (passwords live in the
|
||||
accounts table). This column is now purely optional.
|
||||
|
||||
Revision ID: 003_make_users_hashed_password_nullable
|
||||
Revises: 002_better_auth_tables
|
||||
Create Date: 2026-03-30
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "003_make_users_hashed_password_nullable"
|
||||
down_revision = "002_better_auth_tables"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
# Fresh DB — nothing to alter
|
||||
if not inspector.has_table("users"):
|
||||
return
|
||||
|
||||
cols = {c["name"]: c for c in inspector.get_columns("users")}
|
||||
if "hashed_password" in cols and not cols["hashed_password"]["nullable"]:
|
||||
op.alter_column("users", "hashed_password", existing_type=sa.String(255), nullable=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
if not inspector.has_table("users"):
|
||||
return
|
||||
|
||||
cols = {c["name"]: c for c in inspector.get_columns("users")}
|
||||
if "hashed_password" in cols and cols["hashed_password"]["nullable"]:
|
||||
op.alter_column("users", "hashed_password", existing_type=sa.String(255), nullable=False)
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Fix users.id UUID->text type mismatch for Better-Auth compatibility.
|
||||
|
||||
Better-Auth generates nanoid-style text IDs (e.g. pGud2ln2WAFHC0KYjBVKR4Rc7mM8OcTI),
|
||||
but the users table was using PostgreSQL uuid type. When Better-Auth tries to INSERT
|
||||
a new user, Postgres throws:
|
||||
ERROR: invalid input syntax for type uuid: "pGud2ln2WAFHC0KYjBVKR4Rc7mM8OcTI"
|
||||
|
||||
The sessions, accounts, and verifications tables already use text IDs — only users,
|
||||
user_store_accounts.user_id, and purchases.user_id needed fixing.
|
||||
|
||||
Revision ID: 004_fix_user_id_text
|
||||
Revises: 003_make_users_hashed_password_nullable
|
||||
Create Date: 2026-03-31
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import text
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "004_fix_user_id_text"
|
||||
down_revision = "003_make_users_hashed_password_nullable"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
# Fresh DB — no tables yet, nothing to convert
|
||||
if not inspector.has_table("users"):
|
||||
return
|
||||
|
||||
# Check if already TEXT (Base.metadata.create_all uses TEXT for fresh DB)
|
||||
users_cols = {c["name"]: c for c in inspector.get_columns("users")}
|
||||
if "id" in users_cols:
|
||||
id_type = str(users_cols["id"]["type"]).lower()
|
||||
if "text" in id_type and "uuid" not in id_type:
|
||||
return # already TEXT — nothing to do
|
||||
|
||||
# Step 1: Drop existing FK constraints (ignore if they don't exist)
|
||||
op.execute(text("ALTER TABLE user_store_accounts DROP CONSTRAINT IF EXISTS user_store_accounts_user_id_fkey"))
|
||||
op.execute(text("ALTER TABLE purchases DROP CONSTRAINT IF EXISTS purchases_user_id_fkey"))
|
||||
|
||||
# Step 2: Alter users.id from uuid to text
|
||||
op.alter_column(
|
||||
"users",
|
||||
"id",
|
||||
type_=sa.Text(),
|
||||
existing_type=sa.UUID(),
|
||||
postgresql_using="id::text",
|
||||
)
|
||||
|
||||
# Step 3: Alter user_store_accounts.user_id from uuid to text
|
||||
op.alter_column(
|
||||
"user_store_accounts",
|
||||
"user_id",
|
||||
type_=sa.Text(),
|
||||
existing_type=sa.UUID(),
|
||||
postgresql_using="user_id::text",
|
||||
)
|
||||
|
||||
# Step 4: Alter purchases.user_id from uuid to text
|
||||
op.alter_column(
|
||||
"purchases",
|
||||
"user_id",
|
||||
type_=sa.Text(),
|
||||
existing_type=sa.UUID(),
|
||||
postgresql_using="user_id::text",
|
||||
)
|
||||
|
||||
# Step 5: Re-add FK constraints
|
||||
op.execute(
|
||||
text(
|
||||
"ALTER TABLE user_store_accounts "
|
||||
"ADD CONSTRAINT user_store_accounts_user_id_fkey "
|
||||
"FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE"
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
text(
|
||||
"ALTER TABLE purchases "
|
||||
"ADD CONSTRAINT purchases_user_id_fkey "
|
||||
"FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop FK constraints
|
||||
op.execute(text("ALTER TABLE user_store_accounts DROP CONSTRAINT IF EXISTS user_store_accounts_user_id_fkey"))
|
||||
op.execute(text("ALTER TABLE purchases DROP CONSTRAINT IF EXISTS purchases_user_id_fkey"))
|
||||
|
||||
# Revert users.id from text to uuid
|
||||
op.alter_column(
|
||||
"users",
|
||||
"id",
|
||||
type_=sa.UUID(),
|
||||
existing_type=sa.Text(),
|
||||
postgresql_using="id::uuid",
|
||||
)
|
||||
|
||||
# Revert user_store_accounts.user_id from text to uuid
|
||||
op.alter_column(
|
||||
"user_store_accounts",
|
||||
"user_id",
|
||||
type_=sa.UUID(),
|
||||
existing_type=sa.Text(),
|
||||
postgresql_using="user_id::uuid",
|
||||
)
|
||||
|
||||
# Revert purchases.user_id from text to uuid
|
||||
op.alter_column(
|
||||
"purchases",
|
||||
"user_id",
|
||||
type_=sa.UUID(),
|
||||
existing_type=sa.Text(),
|
||||
postgresql_using="user_id::uuid",
|
||||
)
|
||||
|
||||
# Re-add FK constraints (PostgreSQL will auto-name them)
|
||||
op.execute(
|
||||
text(
|
||||
"ALTER TABLE user_store_accounts "
|
||||
"ADD CONSTRAINT user_store_accounts_user_id_fkey "
|
||||
"FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE"
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
text(
|
||||
"ALTER TABLE purchases "
|
||||
"ADD CONSTRAINT purchases_user_id_fkey "
|
||||
"FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE"
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Add email_inbound_token to users.
|
||||
|
||||
Revision ID: 005_add_email_inbound_token
|
||||
Revises: 004_fix_user_id_text
|
||||
Create Date: 2026-04-02
|
||||
"""
|
||||
|
||||
import secrets
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "005_add_email_inbound_token"
|
||||
down_revision = "004_fix_user_id_text"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
# Guard: on a fresh DB Base.metadata.create_all creates users table with the column already present
|
||||
if not inspector.has_table("users"):
|
||||
return
|
||||
existing_cols = [c["name"] for c in inspector.get_columns("users")]
|
||||
if "email_inbound_token" in existing_cols:
|
||||
return
|
||||
|
||||
# Add column nullable first so existing rows can be backfilled
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("email_inbound_token", sa.String(22), nullable=True),
|
||||
)
|
||||
|
||||
# Backfill existing users with unique tokens
|
||||
result = conn.execute(sa.text("SELECT id FROM users WHERE email_inbound_token IS NULL"))
|
||||
for (user_id,) in result:
|
||||
token = secrets.token_urlsafe(16)
|
||||
conn.execute(
|
||||
sa.text("UPDATE users SET email_inbound_token = :token WHERE id = :id"),
|
||||
{"token": token, "id": user_id},
|
||||
)
|
||||
|
||||
# Now enforce non-null and unique
|
||||
op.alter_column("users", "email_inbound_token", nullable=False)
|
||||
op.create_index(
|
||||
"ix_users_email_inbound_token",
|
||||
"users",
|
||||
["email_inbound_token"],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_users_email_inbound_token", table_name="users")
|
||||
op.drop_column("users", "email_inbound_token")
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Add server_default to users.email_inbound_token.
|
||||
|
||||
Revision ID: 006_email_inbound_token_server_default
|
||||
Revises: 005_add_email_inbound_token
|
||||
Create Date: 2026-04-04
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "006_email_inbound_token_server_default"
|
||||
down_revision = "005_add_email_inbound_token"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
# Guard: on a fresh DB Base.metadata.create_all already sets the server_default
|
||||
if not inspector.has_table("users"):
|
||||
return
|
||||
cols = {c["name"]: c for c in inspector.get_columns("users")}
|
||||
if "email_inbound_token" not in cols:
|
||||
return
|
||||
if cols["email_inbound_token"].get("default") is not None:
|
||||
return
|
||||
op.alter_column(
|
||||
"users",
|
||||
"email_inbound_token",
|
||||
server_default=sa.text(
|
||||
"replace(replace(trim(trailing '=' from encode(gen_random_bytes(16), 'base64')), '+', '-'), '/', '_')"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column(
|
||||
"users",
|
||||
"email_inbound_token",
|
||||
server_default=None,
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Bootstrap users table on fresh databases.
|
||||
|
||||
On fresh databases, migrations 001-006 skip users-table operations because
|
||||
the table does not exist yet. Base.metadata.create_all() in env.py is meant
|
||||
to handle this, but if it fails (import errors, etc.) the table is never
|
||||
created. This migration creates the users table with raw SQL as a safety net.
|
||||
|
||||
Revision ID: 007_bootstrap_users_table
|
||||
Revises: 006_email_inbound_token_server_default
|
||||
Create Date: 2026-04-04
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import text
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "007_bootstrap_users_table"
|
||||
down_revision = "006_email_inbound_token_server_default"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
if inspector.has_table("users"):
|
||||
return # Table already exists (non-fresh DB or create_all already ran)
|
||||
|
||||
conn.execute(text("""
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
hashed_password VARCHAR(255),
|
||||
display_name VARCHAR(100),
|
||||
email_verified BOOLEAN NOT NULL DEFAULT false,
|
||||
image TEXT,
|
||||
email_inbound_token VARCHAR(22) NOT NULL UNIQUE
|
||||
DEFAULT replace(replace(trim(trailing '=' from encode(gen_random_bytes(16), 'base64')), '+', '-'), '/', '_'),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
"""))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(text("DROP TABLE IF EXISTS users"))
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Create domain tables (stores, purchases, coupons, etc.).
|
||||
|
||||
Revision ID: 008_create_domain_tables
|
||||
Revises: 007_bootstrap_users_table
|
||||
Create Date: 2026-04-04
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import text
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "008_create_domain_tables"
|
||||
down_revision = "007_bootstrap_users_table"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
# 1. stores
|
||||
if not inspector.has_table("stores"):
|
||||
op.create_table(
|
||||
"stores",
|
||||
sa.Column("id", sa.Uuid(), server_default=text("gen_random_uuid()"), primary_key=True),
|
||||
sa.Column("name", sa.String(100), nullable=False),
|
||||
sa.Column("slug", sa.String(20), nullable=False, unique=True),
|
||||
sa.Column("logo_url", sa.String(500), nullable=True),
|
||||
sa.Column("website_url", sa.String(500), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
# 2. store_locations
|
||||
if not inspector.has_table("store_locations"):
|
||||
op.create_table(
|
||||
"store_locations",
|
||||
sa.Column("id", sa.Uuid(), server_default=text("gen_random_uuid()"), primary_key=True),
|
||||
sa.Column("store_id", sa.Uuid(), sa.ForeignKey("stores.id"), nullable=False),
|
||||
sa.Column("address", sa.String(300), nullable=False),
|
||||
sa.Column("city", sa.String(100), nullable=False),
|
||||
sa.Column("state", sa.String(2), nullable=False),
|
||||
sa.Column("zip", sa.String(10), nullable=False),
|
||||
sa.Column("lat", sa.Float(), nullable=True),
|
||||
sa.Column("lng", sa.Float(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
# 3. normalized_products
|
||||
if not inspector.has_table("normalized_products"):
|
||||
op.create_table(
|
||||
"normalized_products",
|
||||
sa.Column("id", sa.Uuid(), server_default=text("gen_random_uuid()"), primary_key=True),
|
||||
sa.Column("canonical_name", sa.String(300), nullable=False),
|
||||
sa.Column("category", sa.String(50), nullable=True),
|
||||
sa.Column("subcategory", sa.String(100), nullable=True),
|
||||
sa.Column("brand", sa.String(200), nullable=True),
|
||||
sa.Column("size", sa.String(50), nullable=True),
|
||||
sa.Column("size_unit", sa.String(10), nullable=True),
|
||||
sa.Column("upc_variants", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
# 4. purchases
|
||||
if not inspector.has_table("purchases"):
|
||||
op.create_table(
|
||||
"purchases",
|
||||
sa.Column("id", sa.Uuid(), server_default=text("gen_random_uuid()"), primary_key=True),
|
||||
sa.Column("user_id", sa.Text(), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("store_id", sa.Uuid(), sa.ForeignKey("stores.id"), nullable=False),
|
||||
sa.Column("store_location_id", sa.Uuid(), sa.ForeignKey("store_locations.id"), nullable=True),
|
||||
sa.Column("receipt_id", sa.String(200), nullable=False),
|
||||
sa.Column("purchase_date", sa.Date(), nullable=False),
|
||||
sa.Column("total", sa.Numeric(10, 2), nullable=False),
|
||||
sa.Column("subtotal", sa.Numeric(10, 2), nullable=True),
|
||||
sa.Column("tax", sa.Numeric(10, 2), nullable=True),
|
||||
sa.Column("savings_total", sa.Numeric(10, 2), nullable=True),
|
||||
sa.Column("source_url", sa.String(500), nullable=True),
|
||||
sa.Column("raw_data", sa.JSON(), nullable=True),
|
||||
sa.Column("ingested_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.UniqueConstraint("user_id", "store_id", "receipt_id", name="uq_purchase_receipt"),
|
||||
sa.Index("ix_purchases_user_store", "user_id", "store_id"),
|
||||
)
|
||||
|
||||
# 5. purchase_items
|
||||
if not inspector.has_table("purchase_items"):
|
||||
op.create_table(
|
||||
"purchase_items",
|
||||
sa.Column("id", sa.Uuid(), server_default=text("gen_random_uuid()"), primary_key=True),
|
||||
sa.Column("purchase_id", sa.Uuid(), sa.ForeignKey("purchases.id"), nullable=False),
|
||||
sa.Column("product_name_raw", sa.String(300), nullable=False),
|
||||
sa.Column("upc", sa.String(20), nullable=True),
|
||||
sa.Column("quantity", sa.Numeric(10, 3), nullable=False),
|
||||
sa.Column("unit_price", sa.Numeric(10, 2), nullable=False),
|
||||
sa.Column("extended_price", sa.Numeric(10, 2), nullable=False),
|
||||
sa.Column("regular_price", sa.Numeric(10, 2), nullable=True),
|
||||
sa.Column("sale_price", sa.Numeric(10, 2), nullable=True),
|
||||
sa.Column("coupon_discount", sa.Numeric(10, 2), nullable=True),
|
||||
sa.Column("loyalty_discount", sa.Numeric(10, 2), nullable=True),
|
||||
sa.Column("category_raw", sa.String(100), nullable=True),
|
||||
sa.Column("normalized_product_id", sa.Uuid(), sa.ForeignKey("normalized_products.id"), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
# 6. coupons
|
||||
if not inspector.has_table("coupons"):
|
||||
op.create_table(
|
||||
"coupons",
|
||||
sa.Column("id", sa.Uuid(), server_default=text("gen_random_uuid()"), primary_key=True),
|
||||
sa.Column("store_id", sa.Uuid(), sa.ForeignKey("stores.id"), nullable=False),
|
||||
sa.Column("normalized_product_id", sa.Uuid(), sa.ForeignKey("normalized_products.id"), nullable=True),
|
||||
sa.Column("title", sa.String(300), nullable=False),
|
||||
sa.Column("description", sa.String(1000), nullable=True),
|
||||
sa.Column("discount_type", sa.String(20), nullable=False),
|
||||
sa.Column("discount_value", sa.Numeric(10, 2), nullable=True),
|
||||
sa.Column("min_purchase", sa.Numeric(10, 2), nullable=True),
|
||||
sa.Column("valid_from", sa.Date(), nullable=True),
|
||||
sa.Column("valid_to", sa.Date(), nullable=True),
|
||||
sa.Column("requires_clip", sa.Boolean(), server_default=text("false"), nullable=False),
|
||||
sa.Column("coupon_code", sa.String(100), nullable=True),
|
||||
sa.Column("source_url", sa.String(500), nullable=True),
|
||||
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
# 7. price_history
|
||||
if not inspector.has_table("price_history"):
|
||||
op.create_table(
|
||||
"price_history",
|
||||
sa.Column("id", sa.Uuid(), server_default=text("gen_random_uuid()"), primary_key=True),
|
||||
sa.Column("normalized_product_id", sa.Uuid(), sa.ForeignKey("normalized_products.id"), nullable=False),
|
||||
sa.Column("store_id", sa.Uuid(), sa.ForeignKey("stores.id"), nullable=False),
|
||||
sa.Column("observed_date", sa.Date(), nullable=False),
|
||||
sa.Column("regular_price", sa.Numeric(10, 2), nullable=False),
|
||||
sa.Column("sale_price", sa.Numeric(10, 2), nullable=True),
|
||||
sa.Column("loyalty_price", sa.Numeric(10, 2), nullable=True),
|
||||
sa.Column("coupon_price", sa.Numeric(10, 2), nullable=True),
|
||||
sa.Column("source", sa.String(20), nullable=False),
|
||||
sa.Column("purchase_item_id", sa.Uuid(), sa.ForeignKey("purchase_items.id"), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Index("ix_price_history_product_store_date", "normalized_product_id", "store_id", "observed_date"),
|
||||
)
|
||||
|
||||
# 8. shrinkflation_events
|
||||
if not inspector.has_table("shrinkflation_events"):
|
||||
op.create_table(
|
||||
"shrinkflation_events",
|
||||
sa.Column("id", sa.Uuid(), server_default=text("gen_random_uuid()"), primary_key=True),
|
||||
sa.Column("normalized_product_id", sa.Uuid(), sa.ForeignKey("normalized_products.id"), nullable=False),
|
||||
sa.Column("detected_date", sa.Date(), nullable=False),
|
||||
sa.Column("old_size", sa.String(50), nullable=False),
|
||||
sa.Column("new_size", sa.String(50), nullable=False),
|
||||
sa.Column("old_unit", sa.String(10), nullable=True),
|
||||
sa.Column("new_unit", sa.String(10), nullable=True),
|
||||
sa.Column("price_at_old_size", sa.Numeric(10, 2), nullable=True),
|
||||
sa.Column("price_at_new_size", sa.Numeric(10, 2), nullable=True),
|
||||
sa.Column("confidence", sa.Numeric(3, 2), server_default=text("1.00"), nullable=False),
|
||||
sa.Column("notes", sa.String(1000), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
# 9. user_store_accounts
|
||||
if not inspector.has_table("user_store_accounts"):
|
||||
op.create_table(
|
||||
"user_store_accounts",
|
||||
sa.Column("id", sa.Uuid(), server_default=text("gen_random_uuid()"), primary_key=True),
|
||||
sa.Column("user_id", sa.Text(), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("store_id", sa.Uuid(), sa.ForeignKey("stores.id"), nullable=False),
|
||||
sa.Column("session_data", sa.JSON(), nullable=True),
|
||||
sa.Column("session_expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_sync_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("status", sa.String(20), server_default=text("'active'"), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.UniqueConstraint("user_id", "store_id", name="uq_user_store_account"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
if inspector.has_table("user_store_accounts"):
|
||||
op.drop_table("user_store_accounts")
|
||||
if inspector.has_table("shrinkflation_events"):
|
||||
op.drop_table("shrinkflation_events")
|
||||
if inspector.has_table("price_history"):
|
||||
op.drop_table("price_history")
|
||||
if inspector.has_table("coupons"):
|
||||
op.drop_table("coupons")
|
||||
if inspector.has_table("purchase_items"):
|
||||
op.drop_table("purchase_items")
|
||||
if inspector.has_table("purchases"):
|
||||
op.drop_table("purchases")
|
||||
if inspector.has_table("normalized_products"):
|
||||
op.drop_table("normalized_products")
|
||||
if inspector.has_table("store_locations"):
|
||||
op.drop_table("store_locations")
|
||||
if inspector.has_table("stores"):
|
||||
op.drop_table("stores")
|
||||
@@ -5,8 +5,6 @@ Sessions are verified by querying the shared sessions table directly.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Cookie, Depends, Header, HTTPException, Request, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy import text
|
||||
@@ -21,12 +19,15 @@ bearer_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
# Better-Auth session cookie name
|
||||
SESSION_COOKIE_NAME = "better-auth.session_token"
|
||||
# Secure prefix used by better-auth on HTTPS deployments
|
||||
SECURE_SESSION_COOKIE_NAME = "__Secure-better-auth.session_token"
|
||||
|
||||
|
||||
async def _validate_session_token(token: str, db: AsyncSession) -> UUID:
|
||||
async def _validate_session_token(token: str, db: AsyncSession) -> str:
|
||||
"""Validate a Better-Auth session token against the sessions table.
|
||||
|
||||
Returns the user_id (as UUID) if the session is valid and not expired.
|
||||
Better-Auth stores the raw token in the DB. The cookie/Bearer header
|
||||
carries the same raw token, so we compare directly.
|
||||
"""
|
||||
result = await db.execute(
|
||||
text("SELECT user_id, expires_at FROM sessions WHERE token = :token"),
|
||||
@@ -51,14 +52,14 @@ async def _validate_session_token(token: str, db: AsyncSession) -> UUID:
|
||||
detail="Session expired",
|
||||
)
|
||||
|
||||
return UUID(str(user_id))
|
||||
return str(user_id)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> UUID:
|
||||
) -> str:
|
||||
"""Extract and validate the session token from cookie or Authorization header.
|
||||
|
||||
Checks in order:
|
||||
@@ -67,14 +68,17 @@ async def get_current_user(
|
||||
"""
|
||||
token: str | None = None
|
||||
|
||||
# 1. Check session cookie
|
||||
cookie_token = request.cookies.get(SESSION_COOKIE_NAME)
|
||||
# 1. Check session cookie — prefer __Secure- variant (HTTPS) over plain (HTTP dev)
|
||||
cookie_token = request.cookies.get(SECURE_SESSION_COOKIE_NAME) or request.cookies.get(SESSION_COOKIE_NAME)
|
||||
if cookie_token:
|
||||
token = cookie_token
|
||||
# Better-Auth cookie format is "token.sessionId" — extract just the token part
|
||||
token = cookie_token.split(".")[0] if "." in cookie_token else cookie_token
|
||||
|
||||
# 2. Fall back to Bearer header
|
||||
if not token and credentials:
|
||||
token = credentials.credentials
|
||||
# Callers might pass the compound value here too
|
||||
raw = credentials.credentials
|
||||
token = raw.split(".")[0] if "." in raw else raw
|
||||
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -5,13 +5,14 @@ the Better-Auth service (auth/). This router provides user profile
|
||||
endpoints that query our own user data from the shared database.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from cartsnitch_api.auth.dependencies import get_current_user
|
||||
from cartsnitch_api.database import get_db
|
||||
from cartsnitch_api.models import User
|
||||
from cartsnitch_api.schemas import (
|
||||
UpdateUserRequest,
|
||||
UserResponse,
|
||||
@@ -23,7 +24,7 @@ router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_me(
|
||||
user_id: UUID = Depends(get_current_user),
|
||||
user_id: str = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = AuthService(db)
|
||||
@@ -38,7 +39,7 @@ async def get_me(
|
||||
@router.patch("/me", response_model=UserResponse)
|
||||
async def update_me(
|
||||
body: UpdateUserRequest,
|
||||
user_id: UUID = Depends(get_current_user),
|
||||
user_id: str = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = AuthService(db)
|
||||
@@ -54,7 +55,7 @@ async def update_me(
|
||||
|
||||
@router.delete("/me", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_me(
|
||||
user_id: UUID = Depends(get_current_user),
|
||||
user_id: str = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = AuthService(db)
|
||||
|
||||
@@ -1,26 +1,51 @@
|
||||
"""Redis/DragonflyDB caching helpers."""
|
||||
|
||||
import redis.asyncio as redis
|
||||
|
||||
from cartsnitch_api.config import settings
|
||||
|
||||
|
||||
class CacheClient:
|
||||
"""Stub for Redis/DragonflyDB caching.
|
||||
"""Redis/DragonflyDB caching with connection pooling.
|
||||
|
||||
Will be used for expensive queries: price trends, product comparisons.
|
||||
Cache invalidation via Redis pub/sub events from other services.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.url = settings.redis_url
|
||||
self._pool: redis.ConnectionPool | None = None
|
||||
self._client: redis.Redis | None = None
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize the Redis connection pool."""
|
||||
self._pool = redis.ConnectionPool.from_url(
|
||||
settings.redis_url,
|
||||
max_connections=20,
|
||||
decode_responses=True,
|
||||
)
|
||||
self._client = redis.Redis(connection_pool=self._pool)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the Redis connection pool."""
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
if self._pool:
|
||||
await self._pool.aclose()
|
||||
|
||||
async def get(self, key: str) -> str | None:
|
||||
# TODO: implement with redis-py async
|
||||
return None
|
||||
if not self._client:
|
||||
return None
|
||||
return await self._client.get(key)
|
||||
|
||||
async def set(self, key: str, value: str, ttl_seconds: int = 300) -> None:
|
||||
# TODO: implement with redis-py async
|
||||
pass
|
||||
if not self._client:
|
||||
return
|
||||
await self._client.set(key, value, ex=ttl_seconds)
|
||||
|
||||
async def delete(self, key: str) -> None:
|
||||
# TODO: implement with redis-py async
|
||||
pass
|
||||
if not self._client:
|
||||
return
|
||||
await self._client.delete(key)
|
||||
|
||||
|
||||
cache_client = CacheClient()
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import base64
|
||||
|
||||
from pydantic import model_validator
|
||||
from pydantic import AliasChoices, Field, model_validator
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = {"env_prefix": "CARTSNITCH_"}
|
||||
|
||||
database_url: str = "postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch"
|
||||
database_url: str = Field(
|
||||
default="postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch",
|
||||
validation_alias=AliasChoices("CARTSNITCH_DATABASE_URL", "DATABASE_URL"),
|
||||
)
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
|
||||
jwt_secret_key: str = "change-me-in-production"
|
||||
jwt_secret_key: str
|
||||
jwt_algorithm: str = "HS256"
|
||||
jwt_access_token_expire_minutes: int = 15
|
||||
jwt_refresh_token_expire_days: int = 7
|
||||
|
||||
service_key: str = "change-me-in-production"
|
||||
# Valid Fernet key for local dev — MUST be overridden in production
|
||||
fernet_key: str = "7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8="
|
||||
service_key: str
|
||||
fernet_key: str
|
||||
|
||||
auth_service_url: str = "http://auth:3001"
|
||||
|
||||
@@ -32,9 +34,26 @@ class Settings(BaseSettings):
|
||||
rate_limit_window_seconds: int = 60
|
||||
rate_limit_enabled: bool = True
|
||||
|
||||
_PLACEHOLDER_VALUES = {"change-me-in-production"}
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_fernet_key(self):
|
||||
"""Validate fernet_key is a valid 32-byte url-safe base64 key at startup."""
|
||||
def validate_secrets(self):
|
||||
if not self.jwt_secret_key or self.jwt_secret_key in self._PLACEHOLDER_VALUES:
|
||||
raise ValueError(
|
||||
"CARTSNITCH_JWT_SECRET_KEY must be set to a secure value. "
|
||||
'Generate one with: python -c "import secrets; print(secrets.token_urlsafe(32))"'
|
||||
)
|
||||
if not self.service_key or self.service_key in self._PLACEHOLDER_VALUES:
|
||||
raise ValueError(
|
||||
"CARTSNITCH_SERVICE_KEY must be set to a secure value. "
|
||||
'Generate one with: python -c "import secrets; print(secrets.token_urlsafe(32))"'
|
||||
)
|
||||
if not self.fernet_key or self.fernet_key in self._PLACEHOLDER_VALUES:
|
||||
raise ValueError(
|
||||
"CARTSNITCH_FERNET_KEY must be set to a valid Fernet key. "
|
||||
"Generate one with: python -c "
|
||||
"'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'"
|
||||
)
|
||||
try:
|
||||
decoded = base64.urlsafe_b64decode(self.fernet_key.encode())
|
||||
if len(decoded) != 32:
|
||||
@@ -49,5 +68,12 @@ class Settings(BaseSettings):
|
||||
) from None
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def normalize_database_url(self):
|
||||
"""Normalize postgresql:// → postgresql+asyncpg:// for the asyncpg driver."""
|
||||
if self.database_url.startswith("postgresql://"):
|
||||
self.database_url = self.database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
return self
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@@ -6,7 +6,14 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
|
||||
|
||||
from cartsnitch_api.config import settings
|
||||
|
||||
engine = create_async_engine(settings.database_url, echo=False)
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=False,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=3600,
|
||||
)
|
||||
async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
@@ -14,3 +21,8 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""FastAPI dependency that yields an async DB session."""
|
||||
async with async_session_factory() as session:
|
||||
yield session
|
||||
|
||||
|
||||
async def dispose_engine() -> None:
|
||||
"""Dispose the database engine, closing all pooled connections."""
|
||||
await engine.dispose()
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import APIRouter, FastAPI
|
||||
|
||||
from cartsnitch_api.auth.routes import router as auth_router
|
||||
from cartsnitch_api.cache import cache_client
|
||||
from cartsnitch_api.database import dispose_engine
|
||||
from cartsnitch_api.middleware.cors import add_cors_middleware
|
||||
from cartsnitch_api.middleware.error_handler import add_error_handlers, add_error_monitor_middleware
|
||||
from cartsnitch_api.middleware.rate_limit import add_rate_limit_middleware
|
||||
@@ -18,13 +20,15 @@ from cartsnitch_api.routes.purchases import router as purchases_router
|
||||
from cartsnitch_api.routes.scraping import router as scraping_router
|
||||
from cartsnitch_api.routes.shopping import router as shopping_router
|
||||
from cartsnitch_api.routes.stores import router as stores_router
|
||||
from cartsnitch_api.routes.user import router as user_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# TODO: initialize DB session pool, Redis connection, service clients
|
||||
await cache_client.initialize()
|
||||
yield
|
||||
# TODO: cleanup connections
|
||||
await cache_client.close()
|
||||
await dispose_engine()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
@@ -46,15 +50,20 @@ def create_app() -> FastAPI:
|
||||
# Routers
|
||||
app.include_router(health_router)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(stores_router)
|
||||
app.include_router(purchases_router)
|
||||
app.include_router(products_router)
|
||||
app.include_router(prices_router)
|
||||
app.include_router(coupons_router)
|
||||
app.include_router(shopping_router)
|
||||
app.include_router(alerts_router)
|
||||
app.include_router(scraping_router)
|
||||
app.include_router(public_router)
|
||||
|
||||
# Data endpoints mounted under /api/v1
|
||||
v1_router = APIRouter(prefix="/api/v1")
|
||||
v1_router.include_router(user_router)
|
||||
v1_router.include_router(stores_router)
|
||||
v1_router.include_router(purchases_router)
|
||||
v1_router.include_router(products_router)
|
||||
v1_router.include_router(prices_router)
|
||||
v1_router.include_router(coupons_router)
|
||||
v1_router.include_router(shopping_router)
|
||||
v1_router.include_router(alerts_router)
|
||||
v1_router.include_router(scraping_router)
|
||||
v1_router.include_router(public_router)
|
||||
app.include_router(v1_router)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -11,6 +11,6 @@ def add_cors_middleware(app: FastAPI) -> None:
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
|
||||
allow_headers=["Content-Type", "Authorization", "Accept", "Origin", "X-Requested-With"],
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ Uses in-memory sliding window as fallback, Redis/DragonflyDB when available.
|
||||
Per-IP limiting on public endpoints, per-token limiting on authenticated endpoints.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from threading import Lock
|
||||
@@ -71,8 +72,8 @@ def _get_rate_limit_key(request: Request) -> tuple[str, _SlidingWindowCounter]:
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:]
|
||||
# Use last 16 chars of token as key to avoid storing full tokens
|
||||
return f"token:{token[-16:]}", _auth_limiter
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
return f"token:{token_hash}", _auth_limiter
|
||||
|
||||
# Fallback to IP for unauthenticated non-public endpoints
|
||||
return f"ip:{_get_client_ip(request)}", _public_limiter
|
||||
|
||||
@@ -32,8 +32,8 @@ class Purchase(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
|
||||
__tablename__ = "purchases"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||
store_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("stores.id"), nullable=False)
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||
store_id: Mapped[str] = mapped_column(ForeignKey("stores.id"), nullable=False)
|
||||
store_location_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("store_locations.id"))
|
||||
receipt_id: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
purchase_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""User and UserStoreAccount models."""
|
||||
|
||||
import uuid
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from cartsnitch_api.constants import AccountStatus
|
||||
@@ -16,14 +17,28 @@ if TYPE_CHECKING:
|
||||
from cartsnitch_api.models.store import Store
|
||||
|
||||
|
||||
class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
class User(TimestampMixin, Base):
|
||||
"""Application user."""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
hashed_password: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
display_name: Mapped[str | None] = mapped_column(String(100))
|
||||
email_verified: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, server_default="false"
|
||||
)
|
||||
image: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
email_inbound_token: Mapped[str] = mapped_column(
|
||||
String(22),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
default=lambda: secrets.token_urlsafe(16),
|
||||
server_default=sa.text(
|
||||
"replace(replace(trim(trailing '=' from encode(gen_random_bytes(16), 'base64')), '+', '-'), '/', '_')"
|
||||
),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
store_accounts: Mapped[list["UserStoreAccount"]] = relationship(back_populates="user")
|
||||
@@ -36,8 +51,8 @@ class UserStoreAccount(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
__tablename__ = "user_store_accounts"
|
||||
__table_args__ = (UniqueConstraint("user_id", "store_id", name="uq_user_store_account"),)
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||
store_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("stores.id"), nullable=False)
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||
store_id: Mapped[str] = mapped_column(ForeignKey("stores.id"), nullable=False)
|
||||
session_data: Mapped[dict | None] = mapped_column(EncryptedJSON)
|
||||
session_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""User routes: per-user account endpoints (email-in address, etc.)."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from cartsnitch_api.auth.dependencies import get_current_user
|
||||
from cartsnitch_api.database import get_db
|
||||
from cartsnitch_api.schemas import EmailInAddressResponse
|
||||
from cartsnitch_api.services.auth import AuthService
|
||||
|
||||
router = APIRouter(tags=["user"])
|
||||
|
||||
|
||||
@router.get("/me/email-in-address", response_model=EmailInAddressResponse)
|
||||
async def get_email_in_address(
|
||||
user_id: str = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = AuthService(db)
|
||||
try:
|
||||
email_address = await svc.get_email_in_address(user_id)
|
||||
return EmailInAddressResponse(
|
||||
email_address=email_address,
|
||||
instructions=(
|
||||
"Forward your digital receipt emails to this address. "
|
||||
"We currently support Meijer, Kroger, and Target receipt emails."
|
||||
),
|
||||
)
|
||||
except LookupError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
||||
) from None
|
||||
@@ -16,12 +16,17 @@ class UpdateUserRequest(BaseModel):
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: UUID
|
||||
id: str
|
||||
email: str
|
||||
display_name: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class EmailInAddressResponse(BaseModel):
|
||||
email_address: str
|
||||
instructions: str
|
||||
|
||||
|
||||
# ---------- Stores ----------
|
||||
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ handled by the Better-Auth service (auth/). This service provides
|
||||
user lookup and profile update operations for the API gateway.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -15,7 +13,7 @@ class AuthService:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def get_user(self, user_id: UUID) -> dict:
|
||||
async def get_user(self, user_id: str) -> dict:
|
||||
from cartsnitch_api.models import User
|
||||
|
||||
result = await self.db.execute(select(User).where(User.id == user_id))
|
||||
@@ -30,7 +28,7 @@ class AuthService:
|
||||
"created_at": user.created_at,
|
||||
}
|
||||
|
||||
async def update_user(self, user_id: UUID, **fields) -> dict:
|
||||
async def update_user(self, user_id: str, **fields) -> dict:
|
||||
from cartsnitch_api.models import User
|
||||
|
||||
result = await self.db.execute(select(User).where(User.id == user_id))
|
||||
@@ -58,7 +56,7 @@ class AuthService:
|
||||
"created_at": user.created_at,
|
||||
}
|
||||
|
||||
async def delete_user(self, user_id: UUID) -> None:
|
||||
async def delete_user(self, user_id: str) -> None:
|
||||
from cartsnitch_api.models import User
|
||||
|
||||
result = await self.db.execute(select(User).where(User.id == user_id))
|
||||
@@ -68,3 +66,14 @@ class AuthService:
|
||||
|
||||
await self.db.delete(user)
|
||||
await self.db.commit()
|
||||
|
||||
async def get_email_in_address(self, user_id: str) -> str:
|
||||
"""Return the per-user email-in address for receipt forwarding."""
|
||||
from cartsnitch_api.models import User
|
||||
|
||||
result = await self.db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise LookupError("User not found")
|
||||
|
||||
return f"receipts+{user.email_inbound_token}@receipts.cartsnitch.com"
|
||||
|
||||
+36
-8
@@ -19,6 +19,25 @@ from cartsnitch_api.database import get_db
|
||||
from cartsnitch_api.main import create_app
|
||||
from cartsnitch_api.models import Base
|
||||
|
||||
TEST_JWT_SECRET = secrets.token_urlsafe(32)
|
||||
TEST_SERVICE_KEY = secrets.token_urlsafe(32)
|
||||
TEST_FERNET_KEY = "7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8="
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_test_settings():
|
||||
original_jwt = cartsnitch_settings.jwt_secret_key
|
||||
original_service = cartsnitch_settings.service_key
|
||||
original_fernet = cartsnitch_settings.fernet_key
|
||||
cartsnitch_settings.jwt_secret_key = TEST_JWT_SECRET
|
||||
cartsnitch_settings.service_key = TEST_SERVICE_KEY
|
||||
cartsnitch_settings.fernet_key = TEST_FERNET_KEY
|
||||
yield
|
||||
cartsnitch_settings.jwt_secret_key = original_jwt
|
||||
cartsnitch_settings.service_key = original_service
|
||||
cartsnitch_settings.fernet_key = original_fernet
|
||||
|
||||
|
||||
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
|
||||
|
||||
|
||||
@@ -60,7 +79,8 @@ async def db_engine():
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
# Create Better-Auth tables (not managed by SQLAlchemy models)
|
||||
await conn.execute(text("""
|
||||
await conn.execute(
|
||||
text("""
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
@@ -71,8 +91,10 @@ async def db_engine():
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
)
|
||||
"""))
|
||||
await conn.execute(text("""
|
||||
""")
|
||||
)
|
||||
await conn.execute(
|
||||
text("""
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -88,8 +110,10 @@ async def db_engine():
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
)
|
||||
"""))
|
||||
await conn.execute(text("""
|
||||
""")
|
||||
)
|
||||
await conn.execute(
|
||||
text("""
|
||||
CREATE TABLE IF NOT EXISTS verifications (
|
||||
id TEXT PRIMARY KEY,
|
||||
identifier TEXT NOT NULL,
|
||||
@@ -98,7 +122,8 @@ async def db_engine():
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
)
|
||||
"""))
|
||||
""")
|
||||
)
|
||||
|
||||
yield engine
|
||||
|
||||
@@ -133,10 +158,13 @@ async def client(db_engine):
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
async def _create_test_user_and_session(client: AsyncClient, db_engine, **user_overrides) -> tuple[dict, str]:
|
||||
async def _create_test_user_and_session(
|
||||
client: AsyncClient, db_engine, **user_overrides
|
||||
) -> tuple[dict, str]:
|
||||
"""Create a test user and a valid session directly in the DB.
|
||||
|
||||
Returns (user_dict, session_token).
|
||||
Returns (user_dict, session_token). Better-Auth stores the raw token
|
||||
in the DB, so we insert it as-is.
|
||||
"""
|
||||
user_id = str(uuid.uuid4())
|
||||
email = user_overrides.get("email", "test@example.com")
|
||||
|
||||
@@ -71,6 +71,56 @@ async def test_delete_me(client, auth_headers):
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_me_compound_cookie(client, db_engine):
|
||||
"""Compound cookie value (token.sessionId) must be parsed to extract the token part."""
|
||||
from tests.conftest import _create_test_user_and_session
|
||||
|
||||
_, session_token = await _create_test_user_and_session(
|
||||
client, db_engine, email="compound@example.com", display_name="Compound User"
|
||||
)
|
||||
compound = f"{session_token}.B0atkJCFxK1rZlwWPMK97nVO2LnyDun7"
|
||||
resp = await client.get(
|
||||
"/auth/me",
|
||||
headers={"Cookie": f"better-auth.session_token={compound}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["email"] == "compound@example.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_me_raw_token_cookie(client, db_engine):
|
||||
"""Raw token (no dot) in cookie must still work — regression guard."""
|
||||
from tests.conftest import _create_test_user_and_session
|
||||
|
||||
_, session_token = await _create_test_user_and_session(
|
||||
client, db_engine, email="rawcookie@example.com", display_name="Raw Cookie User"
|
||||
)
|
||||
resp = await client.get(
|
||||
"/auth/me",
|
||||
headers={"Cookie": f"better-auth.session_token={session_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["email"] == "rawcookie@example.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_me_compound_bearer(client, db_engine):
|
||||
"""Compound Bearer token (token.sessionId) must be parsed to extract the token part."""
|
||||
from tests.conftest import _create_test_user_and_session
|
||||
|
||||
_, session_token = await _create_test_user_and_session(
|
||||
client, db_engine, email="compoundbearer@example.com", display_name="Compound Bearer User"
|
||||
)
|
||||
compound = f"{session_token}.B0atkJCFxK1rZlwWPMK97nVO2LnyDun7"
|
||||
resp = await client.get(
|
||||
"/auth/me",
|
||||
headers={"Authorization": f"Bearer {compound}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["email"] == "compoundbearer@example.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expired_session_rejected(client, db_engine):
|
||||
"""Expired sessions must be rejected."""
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Tests for Settings config, specifically the database_url env var fallback."""
|
||||
|
||||
import os
|
||||
|
||||
from cartsnitch_api.config import Settings
|
||||
|
||||
|
||||
def test_database_url_prefers_cartsnitch_prefix():
|
||||
"""CARTSNITCH_DATABASE_URL takes precedence over DATABASE_URL."""
|
||||
env = {
|
||||
"CARTSNITCH_DATABASE_URL": "postgresql+asyncpg://user1:pass1@host1:5432/db1",
|
||||
"DATABASE_URL": "postgresql://user2:pass2@host2:5432/db2",
|
||||
}
|
||||
settings = Settings(**env)
|
||||
assert settings.database_url == "postgresql+asyncpg://user1:pass1@host1:5432/db1"
|
||||
|
||||
|
||||
def test_database_url_falls_back_to_database_url():
|
||||
"""When CARTSNITCH_DATABASE_URL is absent, DATABASE_URL is accepted."""
|
||||
env = {
|
||||
"DATABASE_URL": "postgresql://user:pass@dbhost:5432/mydb",
|
||||
}
|
||||
settings = Settings(**env)
|
||||
assert settings.database_url == "postgresql+asyncpg://user:pass@dbhost:5432/mydb"
|
||||
|
||||
|
||||
def test_database_url_normalizes_plain_postgresql_prefix():
|
||||
"""DATABASE_URL with plain postgresql:// is normalized to postgresql+asyncpg://."""
|
||||
env = {
|
||||
"DATABASE_URL": "postgresql://cartsnitch:cartsnitch@localhost:5432/cartsnitch",
|
||||
}
|
||||
settings = Settings(**env)
|
||||
assert settings.database_url == "postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch"
|
||||
|
||||
|
||||
def test_database_url_preserves_asyncpg_prefix():
|
||||
"""CARTSNITCH_DATABASE_URL with postgresql+asyncpg:// is left unchanged."""
|
||||
env = {
|
||||
"CARTSNITCH_DATABASE_URL": "postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch",
|
||||
}
|
||||
settings = Settings(**env)
|
||||
assert settings.database_url == "postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch"
|
||||
|
||||
|
||||
def test_database_url_default():
|
||||
"""When neither env var is set, the hardcoded default is used."""
|
||||
settings = Settings()
|
||||
assert settings.database_url == "postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch"
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Tests for GET /api/v1/me/email-in-address endpoint."""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_email_in_address_authenticated(client: AsyncClient, auth_headers: dict):
|
||||
"""Authenticated user gets their email-in address."""
|
||||
response = await client.get(
|
||||
"/api/v1/me/email-in-address",
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "email_address" in data
|
||||
assert data["email_address"].startswith("receipts+")
|
||||
assert data["email_address"].endswith("@receipts.cartsnitch.com")
|
||||
assert len(data["email_address"]) > len("receipts+@receipts.cartsnitch.com")
|
||||
assert "instructions" in data
|
||||
assert "Meijer" in data["instructions"]
|
||||
assert "Kroger" in data["instructions"]
|
||||
assert "Target" in data["instructions"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_email_in_address_unauthenticated(client: AsyncClient):
|
||||
"""Unauthenticated request returns 401."""
|
||||
response = await client.get("/api/v1/me/email-in-address")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_email_in_address_invalid_token(client: AsyncClient):
|
||||
"""Invalid JWT token returns 401."""
|
||||
response = await client.get(
|
||||
"/api/v1/me/email-in-address",
|
||||
headers={"Authorization": "Bearer invalid-token-xyz"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_email_address_format(client: AsyncClient, auth_headers: dict):
|
||||
"""Email address format is receipts+{22-char-urlsafe-token}@receipts.cartsnitch.com."""
|
||||
response = await client.get(
|
||||
"/api/v1/me/email-in-address",
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
email = data["email_address"]
|
||||
# Format: receipts+<22-char-urlsafe-token>@receipts.cartsnitch.com
|
||||
assert email.startswith("receipts+")
|
||||
assert email.endswith("@receipts.cartsnitch.com")
|
||||
# token_urlsafe(16) produces 22 chars
|
||||
middle = email[len("receipts+") : -len("@receipts.cartsnitch.com")]
|
||||
assert len(middle) == 22
|
||||
assert "@" not in middle
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Tests for rate limiting middleware."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from cartsnitch_api.middleware.rate_limit import _SlidingWindowCounter
|
||||
from cartsnitch_api.middleware.rate_limit import _SlidingWindowCounter, _get_rate_limit_key
|
||||
|
||||
|
||||
class TestSlidingWindowCounter:
|
||||
@@ -53,3 +55,32 @@ async def test_health_skips_rate_limit(client):
|
||||
resp = await client.get("/health")
|
||||
assert resp.status_code == 200
|
||||
assert "x-ratelimit-limit" not in resp.headers
|
||||
|
||||
|
||||
class TestGetRateLimitKey:
|
||||
def _make_request(self, auth_header: str = "") -> MagicMock:
|
||||
req = MagicMock()
|
||||
req.url.path = "/purchases"
|
||||
req.headers = {"authorization": auth_header} if auth_header else {}
|
||||
return req
|
||||
|
||||
def test_distinct_tokens_produce_distinct_keys(self):
|
||||
req1 = self._make_request("Bearer token_alpha_12345")
|
||||
req2 = self._make_request("Bearer token_beta_67890")
|
||||
key1, _ = _get_rate_limit_key(req1)
|
||||
key2, _ = _get_rate_limit_key(req2)
|
||||
assert key1 != key2
|
||||
|
||||
def test_same_token_produces_same_key(self):
|
||||
req1 = self._make_request("Bearer same_token_value_abc")
|
||||
req2 = self._make_request("Bearer same_token_value_abc")
|
||||
key1, _ = _get_rate_limit_key(req1)
|
||||
key2, _ = _get_rate_limit_key(req2)
|
||||
assert key1 == key2
|
||||
|
||||
def test_key_does_not_contain_raw_token_suffix(self):
|
||||
raw_token = "my_secret_jwt_token_xyz"
|
||||
req = self._make_request(f"Bearer {raw_token}")
|
||||
key, _ = _get_rate_limit_key(req)
|
||||
assert raw_token[-16:] not in key
|
||||
assert raw_token not in key
|
||||
|
||||
@@ -6,13 +6,14 @@ from httpx import ASGITransport, AsyncClient
|
||||
from cartsnitch_api.main import app
|
||||
|
||||
EXPECTED_ROUTES = [
|
||||
# Auth (6)
|
||||
# Auth (7)
|
||||
("post", "/auth/register"),
|
||||
("post", "/auth/login"),
|
||||
("post", "/auth/refresh"),
|
||||
("get", "/auth/me"),
|
||||
("patch", "/auth/me"),
|
||||
("delete", "/auth/me"),
|
||||
("get", "/auth/me/email-in-address"),
|
||||
# Stores (4)
|
||||
("get", "/stores"),
|
||||
("get", "/me/stores"),
|
||||
@@ -89,4 +90,4 @@ async def test_route_count():
|
||||
if method in ("get", "post", "put", "delete", "patch"):
|
||||
count += 1
|
||||
|
||||
assert count == 33, f"Expected 33 routes, found {count}"
|
||||
assert count == 34, f"Expected 34 routes, found {count}"
|
||||
|
||||
Generated
+1754
File diff suppressed because it is too large
Load Diff
+22
-6
@@ -4,17 +4,23 @@ import pg from "pg";
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString:
|
||||
process.env.DATABASE_URL ??
|
||||
"postgresql://cartsnitch:cartsnitch@localhost:5432/cartsnitch",
|
||||
});
|
||||
|
||||
const secret = process.env.BETTER_AUTH_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error("BETTER_AUTH_SECRET environment variable is required");
|
||||
}
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) {
|
||||
console.warn(
|
||||
"WARNING: DATABASE_URL is not set — using default localhost connection. " +
|
||||
"Set DATABASE_URL for production deployments."
|
||||
);
|
||||
}
|
||||
|
||||
export const pool = new Pool({
|
||||
connectionString: databaseUrl ?? "postgresql://cartsnitch:cartsnitch@localhost:5432/cartsnitch",
|
||||
});
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: pool,
|
||||
basePath: "/auth",
|
||||
@@ -36,6 +42,15 @@ export const auth = betterAuth({
|
||||
},
|
||||
|
||||
session: {
|
||||
modelName: "sessions",
|
||||
fields: {
|
||||
userId: "user_id",
|
||||
expiresAt: "expires_at",
|
||||
ipAddress: "ip_address",
|
||||
userAgent: "user_agent",
|
||||
createdAt: "created_at",
|
||||
updatedAt: "updated_at",
|
||||
},
|
||||
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
||||
updateAge: 60 * 60 * 24, // refresh after 1 day
|
||||
cookieCache: {
|
||||
@@ -86,5 +101,6 @@ export const auth = betterAuth({
|
||||
"https://cartsnitch.com",
|
||||
"https://cartsnitch.farh.net",
|
||||
"https://cartsnitch.dev.farh.net",
|
||||
"https://cartsnitch.uat.farh.net",
|
||||
],
|
||||
});
|
||||
|
||||
+17
-3
@@ -1,6 +1,6 @@
|
||||
import { createServer } from "node:http";
|
||||
import { toNodeHandler } from "better-auth/node";
|
||||
import { auth } from "./auth.js";
|
||||
import { auth, pool } from "./auth.js";
|
||||
|
||||
const port = parseInt(process.env.PORT ?? "3001", 10);
|
||||
|
||||
@@ -9,8 +9,22 @@ const handler = toNodeHandler(auth);
|
||||
const server = createServer(async (req, res) => {
|
||||
// Health check
|
||||
if (req.url === "/health" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "ok" }));
|
||||
try {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await Promise.race([
|
||||
client.query("SELECT 1"),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error("DB timeout")), 2000)),
|
||||
]);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "ok", db: "connected" }));
|
||||
} catch {
|
||||
res.writeHead(503, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "error", db: "unreachable" }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Submodule
+1
Submodule cartsnitch added at a53daddb9a
@@ -0,0 +1,28 @@
|
||||
# CartSnitch Common
|
||||
|
||||
Shared models, schemas, and utilities for CartSnitch services.
|
||||
|
||||
## Test Users
|
||||
|
||||
The following users are seeded by `cartsnitch-seed` and can be used for local development and UAT.
|
||||
|
||||
| Email | Password | Display Name | Notes |
|
||||
|---|---|---|---|
|
||||
| `uat@cartsnitch.com` | `CartSnitch-UAT-2026!` | UAT Tester | Primary UAT account. Use for regression testing in the CartSnitch frontend. Created by the seed runner via Better-Auth's bcrypt path — credentials work against the live auth service. Idempotent; re-running the seed skips this user if it already exists. |
|
||||
|
||||
### Running the Seed
|
||||
|
||||
```bash
|
||||
# Install with seed dependencies
|
||||
pip install -e "cartsnitch-common[seed]"
|
||||
|
||||
# Run (requires CARTSNITCH_DATABASE_URL_SYNC)
|
||||
CARTSNITCH_DATABASE_URL_SYNC=postgresql://user:pass@localhost:5432/cartsnitch \
|
||||
cartsnitch-seed
|
||||
```
|
||||
|
||||
### Architecture
|
||||
|
||||
- **Models** live in `src/cartsnitch_common/models/`
|
||||
- **Alembic migrations** run via the `api` service (`api/alembic/`)
|
||||
- **Seed runner** runs via `cartsnitch-seed` (installed as a package entry point)
|
||||
@@ -14,7 +14,7 @@ if config.config_file_name is not None:
|
||||
|
||||
db_url = os.environ.get("CARTSNITCH_DATABASE_URL_SYNC")
|
||||
if db_url:
|
||||
config.set_main_option("sqlalchemy.url", db_url)
|
||||
config.set_main_option("sqlalchemy.url", db_url.replace("%", "%%"))
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Add email_inbound_token to users.
|
||||
|
||||
Revision ID: 001_add_email_inbound_token
|
||||
Revises:
|
||||
Create Date: 2026-04-02
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "001_add_email_inbound_token"
|
||||
down_revision: str | None = None
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("users", sa.Column("email_inbound_token", sa.String(22), nullable=True))
|
||||
op.create_unique_constraint("uq_users_email_inbound_token", "users", ["email_inbound_token"])
|
||||
|
||||
# Backfill existing users with generated tokens (PostgreSQL)
|
||||
op.execute(
|
||||
"UPDATE users SET email_inbound_token = "
|
||||
"substring(replace(gen_random_uuid()::text, '-', ''), 1, 22) "
|
||||
"WHERE email_inbound_token IS NULL"
|
||||
)
|
||||
|
||||
# Alter to non-nullable
|
||||
op.alter_column("users", "email_inbound_token", nullable=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("uq_users_email_inbound_token", "users", type_="unique")
|
||||
op.drop_column("users", "email_inbound_token")
|
||||
@@ -27,6 +27,7 @@ dev = [
|
||||
]
|
||||
seed = [
|
||||
"faker>=33.0,<34.0",
|
||||
"bcrypt>=4.0,<6.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""User and UserStoreAccount models."""
|
||||
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint
|
||||
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from cartsnitch_common.constants import AccountStatus
|
||||
@@ -21,7 +22,16 @@ class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
email_inbound_token: Mapped[str] = mapped_column(
|
||||
String(22),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
default=lambda: secrets.token_urlsafe(16),
|
||||
server_default=text(
|
||||
"replace(replace(trim(trailing '=' from encode(gen_random_bytes(16), 'base64')), '+', '-'), '/', '_')"
|
||||
),
|
||||
)
|
||||
hashed_password: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
display_name: Mapped[str | None] = mapped_column(String(100))
|
||||
email_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
|
||||
image: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
@@ -20,6 +20,7 @@ class UserRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
email: str
|
||||
display_name: str | None
|
||||
email_inbound_token: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import random
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import bcrypt
|
||||
from faker import Faker
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -184,6 +186,65 @@ def run_seed(
|
||||
|
||||
session.commit()
|
||||
|
||||
_seed_uat_user(session)
|
||||
|
||||
elapsed = time.monotonic() - t0
|
||||
_log("")
|
||||
_log(f"Seed complete in {elapsed:.1f}s")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UAT seed user
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
UAT_EMAIL = "uat@cartsnitch.com"
|
||||
UAT_PASSWORD = "CartSnitch-UAT-2026!"
|
||||
UAT_DISPLAY_NAME = "UAT Tester"
|
||||
UAT_USER_ID = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
||||
|
||||
|
||||
def _seed_uat_user(session: Session) -> None:
|
||||
"""Insert or verify the dedicated UAT test user.
|
||||
|
||||
The user is created via Better-Auth's bcrypt hashing path so credentials
|
||||
work against the live auth service. Idempotent — skips if the user already
|
||||
exists.
|
||||
"""
|
||||
existing = session.execute(
|
||||
text("SELECT id FROM users WHERE email = :email"),
|
||||
{"email": UAT_EMAIL},
|
||||
).fetchone()
|
||||
|
||||
if existing is not None:
|
||||
_log(f"UAT user {UAT_EMAIL} already exists — skipping")
|
||||
return
|
||||
|
||||
password_hash = bcrypt.hashpw(UAT_PASSWORD.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
session.execute(
|
||||
text(
|
||||
"INSERT INTO users (id, email, hashed_password, display_name, email_verified, created_at, updated_at) "
|
||||
"VALUES (:id, :email, :hashed_password, :display_name, true, now(), now())"
|
||||
),
|
||||
{
|
||||
"id": str(UAT_USER_ID),
|
||||
"email": UAT_EMAIL,
|
||||
"hashed_password": password_hash,
|
||||
"display_name": UAT_DISPLAY_NAME,
|
||||
},
|
||||
)
|
||||
|
||||
session.execute(
|
||||
text(
|
||||
"INSERT INTO accounts (id, user_id, account_id, provider_id, password, created_at, updated_at) "
|
||||
"VALUES (gen_random_uuid()::text, :user_id, :account_id, 'credential', :password, now(), now())"
|
||||
),
|
||||
{
|
||||
"user_id": str(UAT_USER_ID),
|
||||
"account_id": str(UAT_USER_ID),
|
||||
"password": password_hash,
|
||||
},
|
||||
)
|
||||
|
||||
session.commit()
|
||||
_log(f"UAT user {UAT_EMAIL} created")
|
||||
|
||||
@@ -147,6 +147,40 @@ class TestStoreLocationModel:
|
||||
assert loc.lat == pytest.approx(42.2808)
|
||||
|
||||
|
||||
class TestUserModel:
|
||||
def test_email_inbound_token_auto_populated(self, session):
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
email="token_test@example.com",
|
||||
hashed_password="hashed",
|
||||
created_at=datetime.now(UTC),
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
assert user.email_inbound_token is not None
|
||||
assert len(user.email_inbound_token) == 22
|
||||
|
||||
def test_email_inbound_token_unique(self, session):
|
||||
user1 = User(
|
||||
id=uuid.uuid4(),
|
||||
email="user1@example.com",
|
||||
hashed_password="hashed",
|
||||
created_at=datetime.now(UTC),
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
user2 = User(
|
||||
id=uuid.uuid4(),
|
||||
email="user2@example.com",
|
||||
hashed_password="hashed",
|
||||
created_at=datetime.now(UTC),
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
session.add_all([user1, user2])
|
||||
session.commit()
|
||||
assert user1.email_inbound_token != user2.email_inbound_token
|
||||
|
||||
|
||||
class TestUserStoreAccountModel:
|
||||
def test_account_status_enum(self, session):
|
||||
user = User(
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# About CartSnitch
|
||||
|
||||
## Our Mission
|
||||
|
||||
We believe consumers deserve to know what they're really paying for at the grocery store.
|
||||
|
||||
Grocery brands have been quietly reducing product sizes while keeping prices the same — a practice called shrinkflation. Most shoppers don't notice because the shelf price doesn't change. But the unit price goes up, and families end up paying more for less.
|
||||
|
||||
CartSnitch exists to make that visible.
|
||||
|
||||
---
|
||||
|
||||
## The Problem We're Solving
|
||||
|
||||
The average US family loses an estimated $300–$500 per year to shrinkflation. It's not dramatic. It happens slowly, product by product, category by category. A cereal box that's 10% smaller. A chip bag with 15% less in it. A detergent bottle that doesn't fill the dispenser the way it used to.
|
||||
|
||||
These changes are legal. Manufacturers don't have to announce them. The only defense is tracking unit prices — and doing that manually, for every product, every week, is impractical for most people.
|
||||
|
||||
So we built CartSnitch to do it automatically.
|
||||
|
||||
---
|
||||
|
||||
## What We Built
|
||||
|
||||
CartSnitch is a grocery price tracking and shrinkflation detection app. When you connect your store account, we:
|
||||
- Track unit prices on the products you buy
|
||||
- Alert you when a product gets smaller or more expensive
|
||||
- Compare your total grocery bill across stores
|
||||
- Show you the biggest shrinkflation offenders we've found
|
||||
|
||||
We're in beta. We're adding more products and stores every week.
|
||||
|
||||
---
|
||||
|
||||
## The Team
|
||||
|
||||
**Penny Pincherton** — CEO and Co-founder
|
||||
Penny has spent her career in consumer finance and advocacy. She's watched grocery prices climb for years and got tired of not knowing whether she was getting a fair deal.
|
||||
|
||||
**Savannah Savings** — CMO
|
||||
Savannah leads brand and communications at CartSnitch. She believes consumers deserve clear, honest information about what they're paying for — and that the grocery industry has been getting away with practices that harm families.
|
||||
|
||||
**Chip Overstock** — CTO
|
||||
Chip has built data infrastructure at scale. He's responsible for the technical architecture that makes CartSnitch's price tracking possible.
|
||||
|
||||
We're a small team. We care about this problem. We use the product ourselves.
|
||||
|
||||
---
|
||||
|
||||
## Our Approach
|
||||
|
||||
- **Consumer-first.** Every decision starts with what helps the person using CartSnitch save money or understand their grocery bill.
|
||||
- **Data-backed.** Every claim we make is backed by numbers. We track unit prices, not shelf prices.
|
||||
- **Transparent.** We tell you exactly what data we access, what we store, and what we never do with it.
|
||||
- **Honest about scope.** CartSnitch focuses on shrinkflation detection. Price gouging monitoring is not currently in scope.
|
||||
|
||||
---
|
||||
|
||||
## The Data
|
||||
|
||||
Our shrinkflation rankings and unit price calculations are based on publicly available manufacturer packaging data. USDA FoodData Central provides reference data for package sizing baselines. As we grow, we'll publish our methodology so anyone can verify our numbers.
|
||||
|
||||
Production data will refine and validate our estimates. We will always note when statistics are directional versus based on real transaction data.
|
||||
|
||||
---
|
||||
|
||||
## Get In Touch
|
||||
|
||||
- **General:** hello@cartsnitch.app
|
||||
- **Press:** press@cartsnitch.app
|
||||
- **Partnerships:** partners@cartsnitch.app
|
||||
- **Bug reports:** We use in-app feedback
|
||||
@@ -0,0 +1,100 @@
|
||||
# App Store / PWA Listing Copy
|
||||
|
||||
**Target:** April 24, 2026
|
||||
|
||||
---
|
||||
|
||||
## iOS App Store
|
||||
|
||||
**App Name:** CartSnitch — Grocery Price Tracker
|
||||
|
||||
**Subtitle:** Track prices. Catch shrinkflation.
|
||||
|
||||
**Short description (170 characters max):**
|
||||
Know when your groceries get smaller or more expensive.
|
||||
|
||||
**Full description (4000 characters max):**
|
||||
You go to the grocery store. You buy the same things you always buy. But lately, the cereal box feels lighter. The chips bag seems smaller. The detergent bottle doesn't fill up like it used to.
|
||||
|
||||
You're not imagining it. It's called shrinkflation — and it's costing the average family hundreds of dollars a year.
|
||||
|
||||
CartSnitch helps you catch it.
|
||||
|
||||
**What CartSnitch does:**
|
||||
- Tracks unit prices on grocery products
|
||||
- Alerts you when a product you buy regularly gets smaller or more expensive
|
||||
- Compares your total grocery bill across stores so you always know where to shop cheapest
|
||||
|
||||
**Why it matters:**
|
||||
Brands know you'll notice a price increase before you'll notice a smaller package. So instead of raising prices, they shrink products. The shelf price stays the same. You pay more per ounce without realizing it.
|
||||
|
||||
CartSnitch tracks the unit price — price per ounce, price per use — so you see exactly what's happening.
|
||||
|
||||
**Key features:**
|
||||
- Unit price tracking across grocery products
|
||||
- Personalized price alerts on products you buy regularly
|
||||
- Store comparison — see your total basket cost at different stores
|
||||
- Shrinkflation tracker — see which products have changed the most
|
||||
|
||||
**This is beta.** We're adding more products and stores every week.
|
||||
|
||||
---
|
||||
|
||||
## Google Play Store
|
||||
|
||||
**Tagline:** Track prices. Catch shrinkflation.
|
||||
|
||||
**Short description (80 characters):**
|
||||
Know when your groceries get smaller or more expensive.
|
||||
|
||||
**Full description:**
|
||||
You go to the grocery store. You buy the same things you always buy. But lately, the cereal box feels lighter. The chips bag seems smaller. The detergent bottle doesn't fill up like it used to.
|
||||
|
||||
You're not imagining it. It's called shrinkflation — and it's costing the average family hundreds of dollars a year.
|
||||
|
||||
CartSnitch helps you catch it.
|
||||
|
||||
**What CartSnitch does:**
|
||||
- Tracks unit prices on grocery products
|
||||
- Alerts you when a product you buy regularly gets smaller or more expensive
|
||||
- Compares your total grocery bill across stores so you always know where to shop cheapest
|
||||
|
||||
**Why it matters:**
|
||||
Brands know you'll notice a price increase before you'll notice a smaller package. So instead of raising prices, they shrink products. The shelf price stays the same. You pay more per ounce without realizing it.
|
||||
|
||||
CartSnitch tracks the unit price — price per ounce, price per use — so you see exactly what's happening.
|
||||
|
||||
**Key features:**
|
||||
- Unit price tracking across grocery products
|
||||
- Personalized price alerts on products you buy regularly
|
||||
- Store comparison — see your total basket cost at different stores
|
||||
- Shrinkflation tracker — see which products have changed the most
|
||||
|
||||
**This is beta.** We're adding more products and stores every week.
|
||||
|
||||
---
|
||||
|
||||
## Feature Highlights (3 bullets, iOS)
|
||||
|
||||
- **Unit price tracking** — See exactly what you're paying per ounce on every product
|
||||
- **Shrinkflation alerts** — Get notified when your regular products get smaller or more expensive
|
||||
- **Store comparison** — Compare your total grocery bill across stores in seconds
|
||||
|
||||
## Feature Highlights (4 bullets, Google Play)
|
||||
|
||||
- Track unit prices on grocery products
|
||||
- Personalized alerts when products you buy change
|
||||
- Compare grocery costs across stores
|
||||
- See the biggest shrinkflation offenders
|
||||
|
||||
---
|
||||
|
||||
## Keywords (iOS — 100 character limit)
|
||||
|
||||
grocery, price tracker, savings, shrinkflation, unit price, grocery savings, price compare, food prices, grocery deals, price alert, grocery app
|
||||
|
||||
---
|
||||
|
||||
## Search Terms (Google Play)
|
||||
|
||||
grocery price tracker, grocery savings app, price comparison grocery, shrinkflation app, unit price calculator, grocery deal app, grocery savings tracker
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
title: "CartSnitch vs Flipp: Which App Actually Helps You Save More on Groceries?"
|
||||
slug: cartsnitch-vs-flipp
|
||||
status: draft
|
||||
version: 1.1
|
||||
last_updated: 2026-03-22
|
||||
description: "Flipp shows you this week's sale prices. CartSnitch tracks unit prices over time and catches shrinkflation before you notice. Here's when each tool wins."
|
||||
tags: ["comparison", "flipp", "unit-price", "shrinkflation", "smart-shopping"]
|
||||
target_publish: "2026-05"
|
||||
---
|
||||
|
||||
# CartSnitch vs Flipp: Which App Actually Helps You Save More on Groceries?
|
||||
|
||||
Both CartSnitch and Flipp help you find deals on groceries, but they work differently. Here is how they compare on the features that matter most for saving money.
|
||||
|
||||
## What Is Flipp?
|
||||
|
||||
Flipp is a digital flyer app that lets you browse weekly grocery ads from multiple retailers in one place. You can clip coupons and create a shopping list from featured deals.
|
||||
|
||||
## What Is CartSnitch?
|
||||
|
||||
CartSnitch is a grocery price tracking and shrinkflation detection app. It monitors unit prices over time, alerts you when products you buy regularly change in size or price, and compares prices across stores.
|
||||
|
||||
## Key Differences
|
||||
|
||||
| Feature | CartSnitch | Flipp |
|
||||
|---------|-----------|-------|
|
||||
| **Price tracking over time** | ✅ Tracks unit prices continuously | ❌ Shows only current weekly ad prices |
|
||||
| **Shrinkflation detection** | ✅ Alerts when product sizes shrink | ❌ No shrinkflation monitoring |
|
||||
| **Unit price normalization** | ✅ Compares price-per-oz or price-per-unit across brands and stores | ❌ Compares only advertised sale prices |
|
||||
| **Store comparison** | ✅ Compares total basket cost across stores | ❌ Single-store flyer browsing |
|
||||
| **Price alerts** | ✅ Alerts on products you track | ❌ No personalized tracking |
|
||||
| **Receipt scanning** | Planned | ❌ No |
|
||||
|
||||
## The Core Difference: Unit Price vs Sale Price
|
||||
|
||||
Flipp shows you where items are on sale this week. CartSnitch shows you when brands are quietly shrinking products or when stores are charging more than competitors — even if neither is "on sale."
|
||||
|
||||
**Example:** A cereal brand reduces its box from 18 oz to 15.5 oz. The shelf price stays the same. Flipp shows no deal. CartSnitch flags it as a 16.1% unit price increase.
|
||||
|
||||
This is shrinkflation. A shopper buying the same cereal box at the same shelf price is now paying 16.1% more per ounce — without any price tag ever changing.
|
||||
|
||||
## Which App Saves You More?
|
||||
|
||||
**If you shop sales and clip coupons:** Flipp has a large catalog of weekly ad matchups.
|
||||
|
||||
**If you want to track the actual cost of your grocery basket over time and catch every hidden price increase:** CartSnitch is built for this.
|
||||
|
||||
Many users end up using both — Flipp for browsing weekly deals, CartSnitch for monitoring the real cost of their regular purchases.
|
||||
|
||||
## Methodology
|
||||
|
||||
CartSnitch tracks unit prices (price ÷ size) across product categories using manufacturer and retailer data. Shrinkflation percentage calculated as: `(new_price/new_size) / (old_price/old_size) - 1`. Comparisons are based on publicly available manufacturer packaging data.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Price Gouging vs Shrinkflation: What's the Difference?
|
||||
|
||||
You hear both terms used when grocery prices feel unfair. But they are not the same thing — and understanding the difference helps you know what to do about each one.
|
||||
|
||||
## What Is Price Gouging?
|
||||
|
||||
Price gouging is when retailers or sellers dramatically raise prices during a crisis, shortage, or period of high demand. It is most commonly associated with:
|
||||
|
||||
- Hurricanes and natural disasters (gas, water, generators)
|
||||
- Supply chain disruptions
|
||||
- Public health emergencies
|
||||
|
||||
**Example:** A hardware store raising generator prices from $500 to $1,500 the day before a hurricane makes landfall.
|
||||
|
||||
Price gouging is **illegal in many states** during declared emergencies. Most states have consumer protection laws that prohibit excessive price increases when a state of emergency has been declared.
|
||||
|
||||
## What Is Shrinkflation?
|
||||
|
||||
Shrinkflation is when manufacturers reduce the size or quantity of a product while keeping the price the same — or raising it. The per-unit cost increases without the packaging change being obvious at first glance.
|
||||
|
||||
**Example:** A cereal brand reducing its box from 18 oz to 15.5 oz while keeping the price at $4.99. The shelf price did not change. The unit price went up 16%.
|
||||
|
||||
Shrinkflation is **legal** in the US. Manufacturers are required to disclose net weight, but they do not need to announce when a product gets smaller.
|
||||
|
||||
## Key Differences
|
||||
|
||||
| | Price Gouging | Shrinkflation |
|
||||
|---|---|---|
|
||||
| **Who does it** | Retailers and sellers | Manufacturers |
|
||||
| **When it happens** | Crises, shortages, emergencies | Continuously, as a standard practice |
|
||||
| **How it works** | Raising prices sharply | Reducing product size |
|
||||
| **Legal status** | Illegal during declared emergencies in most states | Legal year-round |
|
||||
| **Consumer response** | Report to state attorney general | Track unit prices; switch products |
|
||||
| **Detection** | Obvious price increases | Requires unit price calculation |
|
||||
|
||||
## How CartSnitch Handles Both
|
||||
|
||||
**CartSnitch tracks shrinkflation automatically.** We monitor unit prices across our tracked products and alert you when a product you buy regularly gets smaller or more expensive.
|
||||
|
||||
**Price gouging is different.** CartSnitch does not currently detect price gouging — it requires monitoring retail prices during specific time periods and comparing against pre-crisis baselines, which is outside our current scope.
|
||||
|
||||
If you encounter what you believe is price gouging:
|
||||
- **Document the prices** — take screenshots
|
||||
- **Report it** — contact your state attorney general's office
|
||||
- **Shop elsewhere** — if possible
|
||||
|
||||
## Can Both Happen at Once?
|
||||
|
||||
Yes. A product could experience shrinkflation (getting smaller over time) AND be subject to price gouging during an emergency. For example:
|
||||
|
||||
- A bottle of water that shrank from 24 oz to 16 oz over five years (shrinkflation)
|
||||
- The same product being sold for triple its normal price during a flood emergency (price gouging)
|
||||
|
||||
Both are harmful to consumers. Only one is currently illegal.
|
||||
|
||||
## The Common Ground
|
||||
|
||||
Both price gouging and shrinkflation share a common feature: they exploit the fact that most consumers don't have access to real-time price data.
|
||||
|
||||
CartSnitch was built to give that data to consumers. For shrinkflation today — and honest, transparent grocery pricing.
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
title: "Understanding Shrinkflation: A Consumer's FAQ"
|
||||
slug: shrinkflation-consumer-faq
|
||||
status: draft
|
||||
version: 1.0
|
||||
last_updated: 2026-03-22
|
||||
description: "Shrinkflation is how brands quietly raise prices by giving you less product for the same money. Here is what it is, why it is legal, and how to detect it."
|
||||
tags: ["shrinkflation", "consumer-faq", "grocery-prices", "price-transparency", "unit-price"]
|
||||
series: "The Shrinkflation Files"
|
||||
series_part: 0
|
||||
target_publish: 2026-04-01
|
||||
target_keywords: ["what is shrinkflation", "shrinkflation examples", "why did my product get smaller", "is shrinkflation legal"]
|
||||
---
|
||||
|
||||
# Understanding Shrinkflation: A Consumer's FAQ
|
||||
|
||||
You notice it at the grocery store: the cereal box looks smaller. The chip bag seems to have less air in it. The pasta salad you loved now fits less in the container. But the price is the same — or higher.
|
||||
|
||||
That is shrinkflation. Here is what you need to know.
|
||||
|
||||
---
|
||||
|
||||
## What Is Shrinkflation?
|
||||
|
||||
Shrinkflation is the practice of reducing the size or quantity of a product while keeping the price the same — or raising it. The per-unit cost increases without the packaging change being obvious at first glance.
|
||||
|
||||
It is different from inflation. Inflation raises prices for the same product. Shrinkflation keeps the price the same for a smaller product. Both cost you more per ounce, per gram, or per use.
|
||||
|
||||
---
|
||||
|
||||
## Is Shrinkflation Legal?
|
||||
|
||||
Yes. Shrinkflation is legal in the US and most markets. Manufacturers are required to state the net weight or count on the packaging, but they are not required to announce when a product gets smaller. There is no federal regulation specifically banning shrinkflation.
|
||||
|
||||
Some regulators have begun studying the practice, and there have been proposals for mandatory price-per-unit labeling at the shelf level, but no binding rules exist as of 2026.
|
||||
|
||||
---
|
||||
|
||||
## What's an Example of Shrinkflation?
|
||||
|
||||
Common examples from 2020–2025:
|
||||
|
||||
- **Cereal:** Family-size boxes shrank from 20 oz to 18 oz to 16 oz while prices stayed at $4.99–$5.99
|
||||
- **Crackers:** Standard sleeve count dropped from 4 to 3 packs while shelf price remained constant
|
||||
- **Yogurt:** Multipacks reduced from 6 oz cups to 5.3 oz cups
|
||||
- **Paper towels:** Roll count dropped from 12 to 10 while price stayed the same
|
||||
- **Dish soap:** Bottle volumes shrank from 24 oz to 20 oz
|
||||
|
||||
In every case, the per-unit cost increased even when the shelf price did not change — or changed less than the size reduction warranted.
|
||||
|
||||
---
|
||||
|
||||
## How Much Does Shrinkflation Cost the Average Family?
|
||||
|
||||
Estimates vary by shopping habits and product categories. CartSnitch analysis of manufacturer packaging data suggests the average US household spends an additional $80–$120 per year on cereals alone due to shrinkflation. Across all categories — snacks, dairy, household goods, beverages — total hidden costs per household are estimated at $300–$500 per year.
|
||||
|
||||
These figures are directional estimates based on publicly available manufacturer packaging data, not CartSnitch production data.
|
||||
|
||||
---
|
||||
|
||||
## Why Do Brands Use Shrinkflation?
|
||||
|
||||
Brands use shrinkflation because consumers notice price increases more than package size decreases. A $5 cereal box going to $5.50 is visible and may cause consumers to switch to competitors. A $5 cereal box shrinking from 18 oz to 15 oz at the same price is rarely noticed until someone like CartSnitch tracks the unit price.
|
||||
|
||||
Shrinkflation is most common in products where:
|
||||
- Brand loyalty is high (consumers repurchase without checking alternatives)
|
||||
- Unit prices are not prominently displayed
|
||||
- Size reductions are modest (5–15%)
|
||||
- The product is purchased regularly
|
||||
|
||||
---
|
||||
|
||||
## How Do I Detect Shrinkflation?
|
||||
|
||||
Three ways to catch shrinkflation before you overpay:
|
||||
|
||||
1. **Track unit prices** — Divide the shelf price by the size (oz, g, count). If the unit price goes up but the product looks the same, you are being shrunk.
|
||||
2. **Compare across brands** — A competing brand may offer more product for the same or lower price.
|
||||
3. **Use CartSnitch** — CartSnitch monitors unit prices on your tracked products and alerts you when a product you buy regularly gets smaller or more expensive.
|
||||
|
||||
---
|
||||
|
||||
## Does Shrinkflation Affect Store Brands Too?
|
||||
|
||||
Yes. Store brands (private label) also engage in shrinkflation, though they tend to do so less aggressively than name brands. National brands rely more heavily on shrinkflation because they cannot compete on price as easily as store brands do.
|
||||
|
||||
---
|
||||
|
||||
## Is There a Campaign or Movement Against Shrinkflation?
|
||||
|
||||
Consumer advocacy groups have lobbied for:
|
||||
- Mandatory unit price display at shelf level
|
||||
- Required advance notice when product sizes change
|
||||
- Clear "size changed" labels on packaging
|
||||
|
||||
CartSnitch is built to give consumers the data they need to make informed decisions — even before regulation catches up.
|
||||
|
||||
---
|
||||
|
||||
## How Is Shrinkflation Different From Price Gouging?
|
||||
|
||||
Shrinkflation is a gradual, product-level practice by manufacturers. Price gouging is typically a retailer or seller raising prices sharply during a supply crisis or emergency. Both harm consumers, but they are distinct practices.
|
||||
|
||||
Price gouging is illegal in many states during declared emergencies. Shrinkflation is legal year-round.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Shrinkflation is how brands quietly raise prices by giving you less product for the same money. It is legal, common, and affects the average family by hundreds of dollars per year. The only defense is tracking unit prices — and CartSnitch does that automatically.
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
title: "What Is Unit Price and How Do You Calculate It?"
|
||||
slug: what-is-unit-price
|
||||
status: draft
|
||||
version: 1.0
|
||||
last_updated: 2026-03-22
|
||||
description: "Unit price is the cost per ounce, gram, or sheet — the number that reveals which product is actually the better deal, and exposes shrinkflation before you realize you're paying more."
|
||||
tags: ["unit-price", "shrinkflation", "grocery-prices", "smart-shopping", "explainer"]
|
||||
---
|
||||
|
||||
# What Is Unit Price and How Do You Calculate It?
|
||||
|
||||
When you see two products on a shelf at different prices, the obvious move is to pick the cheaper one. But what if the cheaper item is actually a worse deal? Unit price is the metric that tells you the truth.
|
||||
|
||||
## What Is Unit Price?
|
||||
|
||||
Unit price is the cost of an item per standard unit of measurement — price per ounce, price per gram, price per sheet, price per load. It lets you compare products of different sizes against each other fairly.
|
||||
|
||||
Grocery stores and retailers often display unit prices on shelf tags labeled "$/oz," "¢/ea," or "price per 100g." You can also calculate it yourself.
|
||||
|
||||
## How to Calculate Unit Price
|
||||
|
||||
**Formula:** `Unit Price = Item Price ÷ Size`
|
||||
|
||||
**Examples:**
|
||||
|
||||
- Product A: $4.99 for 16 oz → $4.99 ÷ 16 = $0.31 per oz
|
||||
- Product B: $3.99 for 12 oz → $3.99 ÷ 12 = $0.33 per oz
|
||||
|
||||
Product A costs more upfront ($4.99 vs $3.99) but is actually the better value at $0.31/oz vs $0.33/oz.
|
||||
|
||||
## Unit Price vs Shelf Price
|
||||
|
||||
| Term | Definition |
|
||||
|------|------------|
|
||||
| **Shelf price** | The total price you pay at checkout |
|
||||
| **Unit price** | Price divided by size — the true cost per useable unit |
|
||||
|
||||
Shelf price misleads you when product sizes vary. Unit price reveals the actual cost regardless of packaging.
|
||||
|
||||
## Why Unit Price Matters: The Shrinkflation Example
|
||||
|
||||
Brands know unit price is how smart shoppers compare. Instead of raising shelf prices (which shoppers notice), they shrink the product. The shelf price stays the same. The unit price goes up.
|
||||
|
||||
**Real example:**
|
||||
- 2021: Cereal box — 18 oz at $4.99 → $0.277/oz
|
||||
- 2024: Same brand, same shelf price — 15.5 oz at $4.99 → $0.322/oz
|
||||
|
||||
The shelf price did not change. The unit price went up 16.1%. You are paying 16.1% more per ounce for the same product without realizing it.
|
||||
|
||||
This is shrinkflation, and it is happening across cereals, snacks, dairy, household products, and more.
|
||||
|
||||
## How to Use Unit Price at the Grocery Store
|
||||
|
||||
1. **Look for the small print** — Most stores label unit price on the shelf tag. Find the "$/oz" or "¢/load" number.
|
||||
2. **Calculate yourself** — Divide shelf price by size (oz, g, sheets, loads). Write it down or use a phone calculator.
|
||||
3. **Compare across brands** — The brand with the lower shelf price is not always the lower unit price.
|
||||
4. **Track it over time** — If you buy the same products regularly, unit price changes reveal shrinkflation before the brand announces it.
|
||||
|
||||
## Unit Price and CartSnitch
|
||||
|
||||
CartSnitch automatically calculates unit prices for the products you track. When a brand shrinks a product, CartSnitch flags the unit price increase so you see exactly how much more you are paying per ounce — even if the shelf price never changed.
|
||||
|
||||
## Summary
|
||||
|
||||
Unit price is the most honest way to compare products of different sizes. It reveals shrinkflation, exposes hidden price increases, and helps you make truly informed purchasing decisions. The formula is simple: divide the price by the size.
|
||||
|
||||
**Quick reference:**
|
||||
- Shelf price: What you pay
|
||||
- Unit price: What you pay per ounce/gram/unit — the real measure of value
|
||||
@@ -0,0 +1,83 @@
|
||||
# How CartSnitch Works
|
||||
|
||||
## The Core Idea
|
||||
|
||||
Every product at the grocery store has two prices:
|
||||
- **Shelf price** — what you pay at checkout
|
||||
- **Unit price** — what you pay per ounce, per gram, per sheet, per load
|
||||
|
||||
Most people compare shelf prices. Smart shoppers compare unit prices.
|
||||
|
||||
CartSnitch tracks unit prices automatically — so you don't have to do the math yourself.
|
||||
|
||||
---
|
||||
|
||||
## How We Track Prices
|
||||
|
||||
CartSnitch pulls pricing data from:
|
||||
- **Store loyalty portals** — Meijer, Kroger, and Target — when you connect your account, CartSnitch uses an automated scraper to pull your purchase history from the store loyalty portal
|
||||
- **Public manufacturer data** — packaging changes, suggested retail prices
|
||||
- **USDA FoodData Central** — reference data for package sizing baselines (used for historical size comparison only — not part of our live tracking system)
|
||||
|
||||
We calculate unit price for every product we track:
|
||||
|
||||
`Unit Price = Shelf Price ÷ Package Size`
|
||||
|
||||
When a brand reduces package size — or a store changes its price — we catch it.
|
||||
|
||||
---
|
||||
|
||||
## What Is Shrinkflation Detection?
|
||||
|
||||
Shrinkflation happens when a brand reduces the size of a product without lowering the price. The shelf price stays the same. The unit price goes up.
|
||||
|
||||
**Example:**
|
||||
- 2021: Cereal at $4.99 for 18 oz → $0.277 per oz
|
||||
- 2024: Same cereal at $4.99 for 15.5 oz → $0.322 per oz
|
||||
|
||||
Same price. 16% more per ounce. That's shrinkflation.
|
||||
|
||||
CartSnitch monitors unit prices over time. When we detect a statistically significant unit price increase — whether from a size reduction, a price increase, or both — we flag it.
|
||||
|
||||
---
|
||||
|
||||
## How Price Alerts Work
|
||||
|
||||
1. **You add a product** — Search for any product you buy regularly and add it to your tracked list.
|
||||
2. **We monitor unit prices** — Every time we detect a price or size change, we recalculate the unit price.
|
||||
3. **You get an alert** — If the unit price increases beyond a threshold, we notify you — so you can decide whether to switch products, switch stores, or just be aware.
|
||||
|
||||
You choose what counts as significant. Some users set alerts for any change. Others only want to know about large unit price jumps.
|
||||
|
||||
---
|
||||
|
||||
## Store Comparison
|
||||
|
||||
CartSnitch compares your total grocery basket across stores.
|
||||
|
||||
When you connect your store accounts, we can see what you bought and where. We calculate the total cost of your typical basket at each store we support — so you know where you're getting the best overall deal.
|
||||
|
||||
This is different from just comparing the price of one item. Some stores are cheaper on produce, others on pantry staples. CartSnitch shows you the full picture.
|
||||
|
||||
---
|
||||
|
||||
## What We Don't Do
|
||||
|
||||
- **We don't collect receipts** — Store account connections give us enough data to track prices and compare baskets. Receipt-based tracking is being evaluated.
|
||||
- **We don't have every product** — Beta is limited to supported stores and categories. We're adding more every week.
|
||||
- **We don't affect shelf prices** — We show you the data. What you do with it is up to you.
|
||||
|
||||
---
|
||||
|
||||
## How We Protect Your Data
|
||||
|
||||
- We read price data from your connected store accounts — we never see your login credentials
|
||||
- We store only the minimum data needed to calculate unit prices and compare baskets
|
||||
- We don't sell your data to third parties
|
||||
- You can disconnect your store account at any time and delete your data
|
||||
|
||||
---
|
||||
|
||||
## Ready to Start?
|
||||
|
||||
[Sign up for beta →]
|
||||
@@ -0,0 +1,102 @@
|
||||
# How We Calculate Shrinkflation: Our Methodology
|
||||
|
||||
We believe consumers deserve to verify our work. Here's exactly how we calculate shrinkflation percentages and where our data comes from.
|
||||
|
||||
---
|
||||
|
||||
## The Core Formula
|
||||
|
||||
For every product we track, we calculate:
|
||||
|
||||
**Unit Price = Shelf Price ÷ Package Size**
|
||||
|
||||
Then we calculate the shrinkflation percentage:
|
||||
|
||||
**Shrinkflation % = (New Unit Price ÷ Old Unit Price) − 1**
|
||||
|
||||
This gives us the effective price increase — accounting for both size changes and price changes.
|
||||
|
||||
**Example:**
|
||||
- 2021: Cereal at $4.99 for 18 oz → Unit price: $4.99 ÷ 18 oz = $0.277/oz
|
||||
- 2024: Same cereal at $4.99 for 15.5 oz → Unit price: $4.99 ÷ 15.5 oz = $0.322/oz
|
||||
|
||||
Shrinkflation % = ($4.99 ÷ 15.5) ÷ ($4.99 ÷ 18) − 1 = 16.1%
|
||||
|
||||
The shelf price is the same. The unit price went up 16.1%.
|
||||
|
||||
---
|
||||
|
||||
## Data Sources
|
||||
|
||||
We use multiple data sources to build our shrinkflation rankings:
|
||||
|
||||
### 1. Manufacturer Packaging Data
|
||||
We track documented changes in product sizes as reported by manufacturers. This includes:
|
||||
- Net weight changes on packaging
|
||||
- Count-per-package changes (e.g., 4 rolls → 3 rolls)
|
||||
- Volume changes in liquid products
|
||||
|
||||
### 2. USDA FoodData Central
|
||||
The USDA FoodData Central database provides reference data on product sizes and nutrition, which we use as baselines for historical comparison.
|
||||
|
||||
**URL:** https://fdc.nal.usda.gov/
|
||||
|
||||
### 3. Public Retail Data
|
||||
When available, we cross-reference shelf prices from public retailer sources to validate price continuity.
|
||||
|
||||
---
|
||||
|
||||
## How We Rank Shrinkflation Offenders
|
||||
|
||||
Our top shrinkflation offenders rankings are based on the calculated shrinkflation percentage for each product. We rank products by:
|
||||
|
||||
1. **Highest shrinkflation percentage** — the largest effective unit price increase
|
||||
2. **Across consistent time periods** — comparing current sizes/prices to documented baselines from 2020–2024
|
||||
3. **By product category** — cereals, snacks, dairy, household goods, etc.
|
||||
|
||||
We only include products where we have documented evidence of a size or price change. We do not estimate shrinkflation for products we cannot verify.
|
||||
|
||||
---
|
||||
|
||||
## Shrinkflation vs Regular Price Increases
|
||||
|
||||
We distinguish between:
|
||||
|
||||
- **Shrinkflation** — Package size decreases while shelf price stays the same or increases. Unit price goes up.
|
||||
- **Regular price increase** — Package size stays the same, shelf price goes up. Unit price goes up.
|
||||
- **Combined shrinkflation + price increase** — Package size decreases AND shelf price increases. Unit price goes up significantly.
|
||||
|
||||
All three result in a higher unit price. Our percentages capture the total effective increase.
|
||||
|
||||
---
|
||||
|
||||
## What We Don't Do
|
||||
|
||||
- We don't estimate shrinkflation without documented evidence
|
||||
- We don't include products we cannot verify
|
||||
- We don't adjust our calculations based on brand or retailer pressure
|
||||
- We don't publish specific rankings until we can verify the underlying data
|
||||
|
||||
---
|
||||
|
||||
## Production Data vs Estimates
|
||||
|
||||
**Before launch (current):** Our shrinkflation percentages are based on publicly available manufacturer packaging data. USDA FoodData Central provides reference data for package sizing baselines. These are directional estimates — they tell you the pattern is real.
|
||||
|
||||
**After production deployment:** Once we have a live product with real transaction data, we'll be able to run the numbers against actual purchase data. This will validate and refine our estimates.
|
||||
|
||||
We will always note when statistics are directional estimates versus based on production data.
|
||||
|
||||
---
|
||||
|
||||
## Future: Publishing Our Queries
|
||||
|
||||
Once production is live, we plan to publish the SQL queries behind our shrinkflation calculations — so anyone can run them against our data and verify our work.
|
||||
|
||||
This is part of our commitment to transparency.
|
||||
|
||||
---
|
||||
|
||||
## Questions?
|
||||
|
||||
If you have questions about our methodology or believe we've made an error, email us: hello@cartsnitch.app
|
||||
@@ -0,0 +1,97 @@
|
||||
# CartSnitch Press/Media Kit
|
||||
|
||||
**Timing:** Ready by April 24, 2026 (beta launch)
|
||||
|
||||
---
|
||||
|
||||
## About CartSnitch
|
||||
|
||||
CartSnitch is a grocery price tracking and shrinkflation detection app that helps consumers see exactly how much they are paying per unit of product — and when brands shrink products without lowering prices.
|
||||
|
||||
**Founded:** 2026
|
||||
**Mission:** Help consumers understand what they are really paying for at the grocery store, and expose the practices that cost families hundreds of dollars per year.
|
||||
|
||||
---
|
||||
|
||||
## Product Description
|
||||
|
||||
CartSnitch tracks unit prices (price ÷ size) across grocery products. Users can:
|
||||
- Set alerts on products they buy regularly
|
||||
- See when a product gets smaller or more expensive
|
||||
- Compare total grocery costs across stores
|
||||
- Access data on which products have experienced the most shrinkflation
|
||||
|
||||
**Status:** Beta (April 24, 2026)
|
||||
**Availability:** Web app / PWA
|
||||
**Supported stores:** Meijer, Kroger, and Target
|
||||
|
||||
---
|
||||
|
||||
## The Problem: Shrinkflation
|
||||
|
||||
Shrinkflation is the practice of reducing product size while keeping prices the same or raising them. The average US family loses an estimated $300–$500 per year to shrinkflation across all grocery categories.
|
||||
|
||||
**Examples (2020–2025):**
|
||||
- Family cereal boxes: 20 oz → 18 oz → 16 oz, same shelf price
|
||||
- Paper towels: 12 rolls → 10 rolls, same price
|
||||
- Yogurt cups: 6 oz → 5.3 oz, same price
|
||||
- Dish soap: 24 oz → 20 oz, same price
|
||||
|
||||
Unlike price gouging, which is illegal during emergencies in many states, shrinkflation is legal year-round. The only defense is tracking unit prices.
|
||||
|
||||
---
|
||||
|
||||
## Key Messages
|
||||
|
||||
1. **Unit prices reveal the truth.** The shelf price is misleading. Price per ounce or per unit is the honest measure of value.
|
||||
|
||||
2. **Shrinkflation is real and costly.** Brands reduce product sizes while maintaining or raising prices. The average family loses $300–$500/year.
|
||||
|
||||
3. **CartSnitch tracks it automatically.** We monitor unit prices across products and alert users when their regular purchases change.
|
||||
|
||||
4. **Consumers deserve transparency.** Price-per-unit should be displayed prominently at shelf level. Until regulation catches up, CartSnitch gives consumers the data directly.
|
||||
|
||||
---
|
||||
|
||||
## Statistics (Directional — Based on CartSnitch Analysis of Manufacturer Packaging Data)
|
||||
|
||||
- Average family loses **$300–$500/year** to shrinkflation across all grocery categories
|
||||
- Cereals specifically: **$80–$120/year** per family
|
||||
- Family cereal boxes shrank an average of **12–16%** in oz between 2020–2025
|
||||
- Top shrinkflation offenders in 2021–2025: Lay's (28%), Yoplait (27.5%), Cocoa Puffs (27%), Ruffles (23.6%), Cheerios (21.5%)
|
||||
|
||||
*Note: Dollar figures are based on CartSnitch analysis of publicly available manufacturer packaging data. USDA FoodData Central provides reference data for package sizing baselines. Production data will refine these figures.*
|
||||
|
||||
---
|
||||
|
||||
## Quotes
|
||||
|
||||
**Penny Pincherton, CEO and Co-founder:**
|
||||
> "We built CartSnitch because we were tired of going to the store and getting less for the same money. Shrinkflation is a quiet tax on families who don't have time to calculate price-per-ounce on every product, every week. We do that work automatically."
|
||||
|
||||
**Savannah Savings, CMO:**
|
||||
> "The grocery industry has been shrinking products in plain sight for years because they know most shoppers won't notice. We think noticing should be easy."
|
||||
|
||||
---
|
||||
|
||||
## Leadership
|
||||
|
||||
- **Penny Pincherton** — CEO and Co-founder
|
||||
- **Savannah Savings** — CMO
|
||||
- **Chip Overstock** — CTO
|
||||
|
||||
---
|
||||
|
||||
## Media Assets
|
||||
|
||||
- **Screenshots:** Available once staging environment is live (CAR-60 in progress)
|
||||
- **Logo:** Available in brand assets folder
|
||||
- **Product demo:** TBD
|
||||
|
||||
---
|
||||
|
||||
## Contact
|
||||
|
||||
For press inquiries: press@cartsnitch.app
|
||||
For partnerships: partners@cartsnitch.app
|
||||
Website: cartsnitch.app
|
||||
@@ -0,0 +1,94 @@
|
||||
# Your Data Is Yours. Here's How We Keep It That Way.
|
||||
|
||||
We know we're asking you to connect your grocery store account. That means trusting us with your purchase history — and we take that seriously.
|
||||
|
||||
Here's exactly what we access, what we store, and what we never do.
|
||||
|
||||
---
|
||||
|
||||
## What We Access
|
||||
|
||||
When you connect your store account, CartSnitch uses an automated scraper to pull your purchase history from the store loyalty portal. This means we can see:
|
||||
|
||||
- **What you bought** — product names and quantities
|
||||
- **How much you paid** — shelf prices at time of purchase
|
||||
- **When you bought it** — purchase dates
|
||||
|
||||
We **cannot** see:
|
||||
- Your store login credentials
|
||||
- Payment method information
|
||||
- Your physical location
|
||||
|
||||
---
|
||||
|
||||
## What We Store
|
||||
|
||||
We store only the data we need to calculate unit prices and compare baskets:
|
||||
|
||||
- Product identifiers (names, sizes, categories)
|
||||
- Shelf prices and unit prices
|
||||
- Purchase frequency
|
||||
- Your tracked products and alerts
|
||||
|
||||
We **do not store**:
|
||||
- Your full purchase history indefinitely
|
||||
- Payment information
|
||||
- Personal identifying information beyond your email
|
||||
|
||||
---
|
||||
|
||||
## What We Never Do
|
||||
|
||||
- **We never sell your data.** Your data is never a product. We don't license it, share it with third parties, or use it for advertising.
|
||||
- **We never see your login credentials.** CartSnitch accesses your store loyalty portal through an automated scraper — we never have access to your store password.
|
||||
- **We never post to your social accounts or profile.**
|
||||
- **We never use your purchase data for anything other than the CartSnitch service.**
|
||||
|
||||
---
|
||||
|
||||
## How We Use Your Data
|
||||
|
||||
We use your purchase data to:
|
||||
|
||||
1. **Calculate unit prices** — so you can compare products fairly
|
||||
2. **Detect shrinkflation** — by monitoring when products you buy change in size or price
|
||||
3. **Compare store prices** — to show you where your total basket costs less
|
||||
4. **Send you alerts** — when products you track change in price or size
|
||||
|
||||
That's it.
|
||||
|
||||
---
|
||||
|
||||
## Data Retention
|
||||
|
||||
- You can delete your account and all associated data at any time
|
||||
- When you disconnect a store account, we remove the connection and stop accessing new data
|
||||
- Historical data associated with your account can be deleted on request
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
- All data is encrypted in transit and at rest
|
||||
- CartSnitch accesses store loyalty portals using an automated scraper — we never see your store password
|
||||
- Our team follows strict access controls — only the engineers who need your data to build the product can access it
|
||||
|
||||
---
|
||||
|
||||
## Want to Disconnect?
|
||||
|
||||
You can disconnect your store account at any time:
|
||||
|
||||
1. Go to Settings
|
||||
2. Select "Connected Accounts"
|
||||
3. Click "Disconnect" next to the store you want to remove
|
||||
|
||||
Disconnecting immediately stops us from accessing new data from that store.
|
||||
|
||||
---
|
||||
|
||||
## Questions?
|
||||
|
||||
We're happy to answer questions about how we handle data. Email us anytime: privacy@cartsnitch.app
|
||||
|
||||
See our full [Terms of Service →]
|
||||
@@ -0,0 +1,129 @@
|
||||
# April 24 Beta Launch Day Social Posts
|
||||
|
||||
**Publish date:** April 24, 2026
|
||||
**Platforms:** Twitter/X, Reddit (r/Frugal, r/personalfinance)
|
||||
**Goal:** Announce beta launch, drive signups, first social proof
|
||||
|
||||
---
|
||||
|
||||
## Twitter/X — Main Launch Announcement
|
||||
|
||||
**Tweet 1 (the big one):**
|
||||
🎉 CartSnitch is officially in beta.
|
||||
|
||||
We built this because you deserve to know when brands shrink their products without lowering prices.
|
||||
|
||||
Track unit prices. Catch shrinkflation. Compare stores.
|
||||
|
||||
Join us: [link]
|
||||
|
||||
**Tweet 2:**
|
||||
Grocery brands have been shrinking products in plain sight for years. Cereal boxes, chip bags, detergent bottles — all getting smaller while shelf prices stay the same.
|
||||
|
||||
We track the unit price. You see the truth.
|
||||
|
||||
[Link]
|
||||
|
||||
**Tweet 3 (CTA thread):**
|
||||
How it works:
|
||||
1️⃣ Connect your store account
|
||||
2️⃣ We track unit prices on everything you buy
|
||||
3️⃣ Get alerts when products shrink or get more expensive
|
||||
4️⃣ Compare your total basket across stores
|
||||
|
||||
Free to join: [link]
|
||||
|
||||
**Tweet 4 (shrinkflation data hook):**
|
||||
We already found the biggest shrinkflation offenders. Lay's, Yoplait, Cocoa Puffs, Ruffles, Cheerios — all cutting sizes while keeping prices flat.
|
||||
|
||||
See the full list: [link to top-10 article]
|
||||
|
||||
**Tweet 5 (proof/activation):**
|
||||
Beta is live. Free to join.
|
||||
|
||||
No commitment. No credit card. Just the data you need to stop overpaying at the grocery store.
|
||||
|
||||
👉 [link]
|
||||
|
||||
**Hashtags:** #Shrinkflation #GrocerySpending #PriceHiking #Frugal #Beta #CartSnitch
|
||||
|
||||
---
|
||||
|
||||
## Twitter/X — Reply Chain (engagement)
|
||||
|
||||
**In reply to someone asking "what is shrinkflation":**
|
||||
When a brand reduces the size of a product but keeps the price the same — or raises it. The shelf price looks fine. The unit price goes up.
|
||||
|
||||
Example: cereal at $4.99 for 18 oz → $4.99 for 15.5 oz. Same price. 16% more per ounce.
|
||||
|
||||
We track it automatically. [link]
|
||||
|
||||
**In reply to "why should I care":**
|
||||
The average family loses an estimated $300–$500/year to shrinkflation across all grocery categories. It's not dramatic. It happens slowly. But it adds up.
|
||||
|
||||
CartSnitch shows you exactly when it happens to the products you buy.
|
||||
|
||||
**In reply to "is this free":**
|
||||
Yes, beta is free. We're building the product and adding more stores every week.
|
||||
|
||||
[link]
|
||||
|
||||
---
|
||||
|
||||
## Reddit Post — r/Frugal
|
||||
|
||||
**Title:** [Launch] CartSnitch — we built a free tool to track shrinkflation and compare grocery prices across stores (beta)
|
||||
|
||||
**Body:**
|
||||
Hey r/Frugal — been working on this for a while and finally ready to share.
|
||||
|
||||
CartSnitch tracks unit prices (price ÷ size) on grocery products and alerts you when products you buy regularly get smaller or more expensive. It also compares your total grocery bill across stores.
|
||||
|
||||
**What it does:**
|
||||
- Tracks unit prices on grocery products
|
||||
- Alerts you when a product you buy shrinks or gets more expensive
|
||||
- Compares your total basket cost across Meijer, Kroger, and Target
|
||||
- Shows you the biggest shrinkflation offenders we've found
|
||||
|
||||
**Why we built it:**
|
||||
Shrinkflation costs the average family an estimated $300–$500/year. It's legal, it's common, and most people don't notice because the shelf price doesn't change.
|
||||
|
||||
We're in beta — free to join, no credit card. Looking for feedback.
|
||||
|
||||
[link]
|
||||
|
||||
*(Mods: happy to answer questions. Not selling anything, just built this because we think consumers deserve this data.)*
|
||||
|
||||
---
|
||||
|
||||
## Reddit Post — r/personalfinance
|
||||
|
||||
**Title:** [Launch] We built a free tool to track grocery shrinkflation and price changes — thinking about the data behind your grocery bill
|
||||
|
||||
**Body:**
|
||||
I've been tracking grocery prices for about a year and the numbers are wild. Brands reduce product sizes constantly while maintaining or raising shelf prices. The average family loses an estimated $300–$500/year to this.
|
||||
|
||||
We built CartSnitch to automate the tracking. It's in beta — free to join.
|
||||
|
||||
**What it tracks:**
|
||||
- Unit prices (price per oz/g/sheet/load)
|
||||
- Product size changes (shrinkflation)
|
||||
- Price changes over time
|
||||
- Total basket comparison across stores
|
||||
|
||||
We're not affiliated with any retailers. Just built this because I kept getting annoyed at the cereal aisle.
|
||||
|
||||
Happy to answer questions about the data methodology.
|
||||
|
||||
[link]
|
||||
|
||||
---
|
||||
|
||||
## Instagram / LinkedIn (if applicable)
|
||||
|
||||
**Carousel idea:**
|
||||
Slide 1: "Your cereal box is lying to you."
|
||||
Slide 2: "Same price. Less product. Here's the math." [example with unit price calculation]
|
||||
Slide 3: "This is shrinkflation — and it's costing you hundreds a year."
|
||||
Slide 4: "CartSnitch tracks it automatically." [app screenshot]
|
||||
Slide 5: "Free beta — link in bio."
|
||||
@@ -0,0 +1,93 @@
|
||||
# Stores Supported by CartSnitch
|
||||
|
||||
CartSnitch currently supports the following stores for price tracking, shrinkflation detection, and store comparison.
|
||||
|
||||
We're actively expanding coverage. If your store isn't listed, you can request it — we prioritize stores with the highest user demand.
|
||||
|
||||
---
|
||||
|
||||
## Currently Supported
|
||||
|
||||
### Meijer
|
||||
**Status:** Full coverage
|
||||
**Available data:**
|
||||
- Real-time shelf prices
|
||||
- Unit prices by product
|
||||
- Your purchase history (when connected)
|
||||
- Store-specific pricing
|
||||
|
||||
**Supported regions:** Midwest (Meijer and Meijer Express)
|
||||
|
||||
**Note:** Connect your Meijer account and CartSnitch will pull your purchase history from the Meijer loyalty portal using an automated scraper.
|
||||
|
||||
---
|
||||
|
||||
### Kroger
|
||||
**Status:** Full coverage
|
||||
**Available data:**
|
||||
- Real-time shelf prices
|
||||
- Unit prices by product
|
||||
- Your purchase history (when connected)
|
||||
- Store-specific pricing
|
||||
|
||||
**Supported regions:** Nationwide (Kroger, Kroger Marketplace, Kroger Pickup)
|
||||
|
||||
**Note:** Connect your Kroger account and CartSnitch will pull your purchase history from the Kroger loyalty portal using an automated scraper.
|
||||
|
||||
---
|
||||
|
||||
### Target
|
||||
**Status:** Full coverage
|
||||
**Available data:**
|
||||
- Real-time shelf prices
|
||||
- Unit prices by product
|
||||
- Your purchase history (when connected)
|
||||
- Store-specific pricing
|
||||
|
||||
**Supported regions:** Nationwide
|
||||
|
||||
**Note:** Connect your Target account and CartSnitch will pull your purchase history from the Target loyalty portal using an automated scraper.
|
||||
|
||||
---
|
||||
|
||||
## Evaluating Additional Stores
|
||||
|
||||
We're always evaluating new retailers based on user demand. We can't commit to specific stores or timelines yet — but if there's a retailer you'd like us to prioritize, let us know.
|
||||
|
||||
[Submit a store request →]
|
||||
|
||||
---
|
||||
|
||||
## How Store Coverage Works
|
||||
|
||||
When you connect your store account, CartSnitch reads your purchase history and current pricing data from your loyalty account — without ever seeing your login credentials. We use read-only access to your loyalty account data.
|
||||
|
||||
**What you get when your store is supported:**
|
||||
- Personalized price alerts on products you buy
|
||||
- Accurate basket cost comparison across your stores
|
||||
- Shrinkflation detection on your actual purchases
|
||||
|
||||
**What this requires:**
|
||||
- An active loyalty account with the store
|
||||
- Willingness to connect the account (you can disconnect at any time)
|
||||
|
||||
---
|
||||
|
||||
## Privacy Note
|
||||
|
||||
We never store your store login credentials. Our integration uses read-only access to your loyalty account data. We store only the minimum data needed to calculate unit prices and compare baskets.
|
||||
|
||||
See our full [privacy policy →]
|
||||
|
||||
---
|
||||
|
||||
## Don't See Your Store?
|
||||
|
||||
We're building CartSnitch's store coverage as fast as we can. The grocery market is fragmented and each integration requires technical work.
|
||||
|
||||
**How to request a store:**
|
||||
1. Sign up for beta
|
||||
2. Go to Settings > Request a Store
|
||||
3. Submit your store name and location
|
||||
|
||||
We review requests weekly and prioritize stores with the highest demand and broadest geographic coverage.
|
||||
@@ -0,0 +1,151 @@
|
||||
# CartSnitch UAT Runbook v1
|
||||
|
||||
**Version:** 1.0
|
||||
**Author:** Savannah Savings, CTO
|
||||
**Date:** 2026-03-30
|
||||
**Effective:** Immediately upon Phase 1 completion
|
||||
|
||||
---
|
||||
|
||||
## 1. Defect Severity Classification
|
||||
|
||||
Every defect discovered during UAT **must** be classified by severity and priority before triage.
|
||||
|
||||
### Severity Levels
|
||||
|
||||
| Severity | Definition | Examples |
|
||||
|----------|-----------|----------|
|
||||
| **S1 — Critical** | Blocks all users from completing a core journey. System is down, data is lost, or security is breached. | Login page crashes for all users; purchase data deleted; auth tokens exposed in response |
|
||||
| **S2 — High** | Blocks a major user flow for a significant portion of users. Core feature is broken but workarounds may exist. | Registration fails for email addresses with `+` character; price alerts never trigger; store comparison shows wrong prices |
|
||||
| **S3 — Medium** | Feature is degraded but usable. User can complete the journey with friction. | Date formatting shows raw ISO string instead of friendly date; slow page load (>5s) on product detail; search results not sorted correctly |
|
||||
| **S4 — Low** | Cosmetic issue, minor UI inconsistency, or edge case with minimal user impact. | Button text truncated on narrow screens; extra whitespace in footer; tooltip shows on hover but not on focus |
|
||||
|
||||
### Priority Levels
|
||||
|
||||
Priority determines **when** the defect must be fixed. Priority is set by the CTO based on severity, business impact, and sprint capacity.
|
||||
|
||||
| Priority | SLA | When to Use |
|
||||
|----------|-----|------------|
|
||||
| **P0 — Fix Now** | Triage within 1 hour, fix deployed within 4 hours | S1 defects, any security vulnerability, data integrity issues |
|
||||
| **P1 — Fix This Sprint** | Triage within 4 hours, fix in current sprint | S2 defects blocking upcoming release, S1 defects with viable workaround |
|
||||
| **P2 — Fix Next Sprint** | Triage within 24 hours, scheduled for next sprint | S3 defects, S2 defects with easy workarounds |
|
||||
| **P3 — Backlog** | Triage within 48 hours, prioritized against backlog | S4 defects, minor improvements, nice-to-haves |
|
||||
|
||||
### Defect Report Template
|
||||
|
||||
Every defect filed during UAT must include:
|
||||
|
||||
```
|
||||
**Title:** [Short description]
|
||||
**Severity:** S1/S2/S3/S4
|
||||
**Priority:** P0/P1/P2/P3 (set by CTO at triage)
|
||||
**Journey:** [Which user journey — J1 through J10]
|
||||
**Environment:** [Dev / Prod, deployed image tag]
|
||||
**Steps to Reproduce:**
|
||||
1. Navigate to ...
|
||||
2. Click ...
|
||||
3. Enter ...
|
||||
**Expected Result:** ...
|
||||
**Actual Result:** ...
|
||||
**Screenshots/Logs:** [Attach or link]
|
||||
**Browser/Device:** [e.g., Chromium 124, mobile viewport 390x844]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. UAT Entry Criteria
|
||||
|
||||
UAT **must not begin** until ALL of the following are satisfied. Checkout Charlie verifies these before opening the UAT gate.
|
||||
|
||||
| # | Criterion | Verified By |
|
||||
|---|-----------|------------|
|
||||
| E1 | CI pipeline passes on the merged commit (lint, type-check, unit tests, build) | GitHub Actions (automated) |
|
||||
| E2 | Docker image is built and pushed to GHCR with a CalVer tag | GitHub Actions (automated) |
|
||||
| E3 | Dev environment is deployed and accessible at `cartsnitch.dev.farh.net` | Flux reconciliation + health check |
|
||||
| E4 | All Playwright E2E tests pass in CI | GitHub Actions (automated) |
|
||||
| E5 | No open S1/S2 defects from previous UAT cycle | Checkout Charlie (manual check) |
|
||||
| E6 | PR has been reviewed and approved by QA (Checkout Charlie) and CTO (Savannah Savings) | GitHub PR approvals |
|
||||
| E7 | PR has been merged to main by CEO (Coupon Carl) | GitHub merge event |
|
||||
| E8 | Acceptance criteria for the feature/change are documented in the Paperclip issue | Checkout Charlie (manual check) |
|
||||
|
||||
**If any entry criterion is not met**, UAT is blocked. Checkout Charlie must comment on the Paperclip issue specifying which criteria failed and assign back to the responsible party.
|
||||
|
||||
---
|
||||
|
||||
## 3. UAT Exit Criteria
|
||||
|
||||
UAT is **complete** only when ALL of the following are satisfied. Rollback Rhonda verifies these before signing off.
|
||||
|
||||
| # | Criterion | Verified By |
|
||||
|---|-----------|------------|
|
||||
| X1 | All 10 critical user journeys (J1-J10) have been executed | Rollback Rhonda (full regression) |
|
||||
| X2 | Zero open S1 (Critical) defects | Defect tracker |
|
||||
| X3 | Zero open S2 (High) defects, OR CTO has granted a documented exception | Defect tracker + CTO sign-off |
|
||||
| X4 | All S3/S4 defects are logged and triaged (not necessarily fixed) | Defect tracker |
|
||||
| X5 | 100% test execution rate -- every test case was run, none skipped | Rollback Rhonda's UAT report |
|
||||
| X6 | Accessibility scan (axe-core) reports zero critical violations | Automated in E2E suite |
|
||||
| X7 | Lighthouse performance score >= 50, accessibility score >= 90 | Lighthouse CI |
|
||||
| X8 | Written sign-off from Rollback Rhonda confirming all criteria met | Paperclip comment on issue |
|
||||
|
||||
**If any exit criterion is not met**, the release is blocked. Rollback Rhonda must:
|
||||
1. File defects for all failures using the Defect Report Template above.
|
||||
2. Comment on the Paperclip issue specifying which exit criteria failed.
|
||||
3. Assign back to CTO for triage and redistribution.
|
||||
|
||||
---
|
||||
|
||||
## 4. UAT Execution Procedure
|
||||
|
||||
### 4.1 Pre-UAT (Checkout Charlie)
|
||||
|
||||
1. Verify all entry criteria (E1-E8) are met.
|
||||
2. Comment on the Paperclip issue: "UAT gate open -- all entry criteria verified."
|
||||
3. Assign to Rollback Rhonda with status todo.
|
||||
|
||||
### 4.2 UAT Execution (Rollback Rhonda)
|
||||
|
||||
1. **Full regression run** -- execute ALL 10 user journeys against cartsnitch.dev.farh.net. No partial runs. No exceptions.
|
||||
2. For each journey, verify:
|
||||
- All interactive elements respond correctly (buttons, forms, links, toggles)
|
||||
- State transitions are correct (auth state, data mutations, navigation)
|
||||
- Error states are handled gracefully (invalid input, network failures)
|
||||
- Accessibility scan passes (axe-core integrated in Playwright)
|
||||
3. Log results for each journey: PASS / FAIL with details.
|
||||
4. File defects immediately for any failures.
|
||||
5. Complete the UAT report with execution results.
|
||||
|
||||
### 4.3 Post-UAT Sign-Off
|
||||
|
||||
1. If all exit criteria (X1-X8) are met:
|
||||
- Rollback Rhonda posts sign-off comment: "UAT PASSED -- all exit criteria met."
|
||||
- Production promotion is automated via Flux on UAT pass.
|
||||
2. If any exit criterion fails:
|
||||
- Rollback Rhonda posts failure comment with specific failures.
|
||||
- CTO triages defects and redistributes to engineers.
|
||||
- After fixes are merged, UAT restarts from 4.1 (full cycle).
|
||||
|
||||
---
|
||||
|
||||
## 5. Critical User Journeys Reference
|
||||
|
||||
| ID | Journey | Key Interactions |
|
||||
|----|---------|-----------------|
|
||||
| J1 | Registration -> Login -> Dashboard | Form submission, auth state, redirect |
|
||||
| J2 | Login -> Browse Products -> View Detail -> Price Chart | Search, navigation, data visualization |
|
||||
| J3 | Login -> Purchases -> Purchase Detail -> Product Link | List navigation, detail view, cross-linking |
|
||||
| J4 | Login -> Connect Store Account -> Verify Connection | OAuth flow, external integration |
|
||||
| J5 | Login -> Create Price Alert -> View -> Delete Alert | CRUD operations, confirmation dialogs |
|
||||
| J6 | Login -> Browse Coupons -> Copy Code | Clipboard interaction, toast feedback |
|
||||
| J7 | Login -> Settings -> Toggle Preferences -> Sign Out | Checkbox toggles, theme switch, session termination |
|
||||
| J8 | Login -> Store Comparison -> Compare Prices | Data comparison, sorting, price display |
|
||||
| J9 | Forgot Password Flow | Email input, validation, redirect |
|
||||
| J10 | Unauth Access -> Redirect to Login | Route protection, redirect behavior |
|
||||
|
||||
---
|
||||
|
||||
## 6. Revision History
|
||||
|
||||
| Version | Date | Author | Changes |
|
||||
|---------|------|--------|---------|
|
||||
| 1.0 | 2026-03-30 | Savannah Savings | Initial runbook -- defect taxonomy, entry/exit criteria, execution procedure |
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { test as base, expect } from "@playwright/test";
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
|
||||
export const test = base.extend<{ axeCheck: void }>({
|
||||
axeCheck: [async ({ page }, use) => {
|
||||
await use();
|
||||
const results = await new AxeBuilder({ page }).analyze();
|
||||
expect(results.violations).toEqual([]);
|
||||
}, { auto: true }],
|
||||
});
|
||||
|
||||
export { expect } from "@playwright/test";
|
||||
@@ -0,0 +1,56 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const uniqueEmail = () => `betty+e2e-${Date.now()}@cartsnitch.test`;
|
||||
|
||||
test.describe('J1: Registration and Login', () => {
|
||||
test('can register a new account and lands on dashboard', async ({ page }) => {
|
||||
await page.goto('/register');
|
||||
await page.fill('[placeholder="Full Name"]', 'Betty Tester');
|
||||
await page.fill('[placeholder="Email"]', uniqueEmail());
|
||||
await page.fill('[placeholder="Password (min. 8 characters)"]', 'TestPass123!');
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// With VITE_MOCK_AUTH=true the app navigates to "/" on success
|
||||
await expect(page).toHaveURL('http://localhost:5173/');
|
||||
await expect(page.getByRole('heading', { name: /cart/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows validation error when registration fields are empty', async ({ page }) => {
|
||||
await page.goto('/register');
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
await expect(page.locator('.bg-red-50')).toContainText('Please fill in all fields');
|
||||
});
|
||||
|
||||
test('can navigate from register to login', async ({ page }) => {
|
||||
await page.goto('/register');
|
||||
await page.getByRole('link', { name: /sign in/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('can sign in with credentials and land on dashboard', async ({ page }) => {
|
||||
// Register first so we have a real account
|
||||
const email = uniqueEmail();
|
||||
await page.goto('/register');
|
||||
await page.fill('[placeholder="Full Name"]', 'Login Betty');
|
||||
await page.fill('[placeholder="Email"]', email);
|
||||
await page.fill('[placeholder="Password (min. 8 characters)"]', 'TestPass123!');
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL('http://localhost:5173/');
|
||||
|
||||
// Sign out by clearing the mock session (reload with no session)
|
||||
await page.goto('/');
|
||||
await page.reload();
|
||||
|
||||
// Now sign in
|
||||
await page.goto('/login');
|
||||
await page.fill('[placeholder="Email"]', email);
|
||||
await page.fill('[placeholder="Password"]', 'TestPass123!');
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
await expect(page).toHaveURL('http://localhost:5173/');
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('J8: Unauthenticated Access', () => {
|
||||
test('redirects /dashboard (/) to /login when not authenticated', async ({ page }) => {
|
||||
// No session cookie — start fresh
|
||||
await page.context().clearCookies();
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('redirects /purchases to /login when not authenticated', async ({ page }) => {
|
||||
await page.context().clearCookies();
|
||||
await page.goto('/purchases');
|
||||
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('redirects /products to /login when not authenticated', async ({ page }) => {
|
||||
await page.context().clearCookies();
|
||||
await page.goto('/products');
|
||||
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('redirects /coupons to /login when not authenticated', async ({ page }) => {
|
||||
await page.context().clearCookies();
|
||||
await page.goto('/coupons');
|
||||
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows loading spinner while auth session is pending', async ({ page }) => {
|
||||
// Intercept but don't respond — session stays pending
|
||||
await page.context().clearCookies();
|
||||
await page.request.fetch('/api/auth/session', {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
// Just navigate to a protected route — ProtectedRoute will show spinner while session is pending
|
||||
await page.goto('/purchases');
|
||||
// Spinner is visible briefly; once resolved, should redirect to login
|
||||
await expect(page).toHaveURL(/\/login/, { timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
test('app loads', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// Unauthenticated users are redirected to /login
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
await expect(page.getByRole('heading', { name: /CartSnitch/i })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"ci": {
|
||||
"collect": {
|
||||
"staticDistDir": "./dist",
|
||||
"url": ["http://localhost:4173/"],
|
||||
"numberOfRuns": 1,
|
||||
"settings": {
|
||||
"chromeFlags": ["--headless=new", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"],
|
||||
"skipAudits": ["bf-cache"],
|
||||
"disableFullPageScreenshot": true
|
||||
}
|
||||
},
|
||||
"assert": {
|
||||
"assertions": {
|
||||
"categories:performance": ["warn", { "minScore": 0.7 }],
|
||||
"categories:accessibility": ["error", { "minScore": 0.9 }],
|
||||
"categories:best-practices": ["warn", { "minScore": 0.8 }]
|
||||
}
|
||||
},
|
||||
"upload": {
|
||||
"target": "temporary-public-storage"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,12 @@ server {
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
|
||||
gzip_min_length 256;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://*.cartsnitch.com https://*.farh.net; frame-ancestors 'self'" always;
|
||||
|
||||
# Health endpoint for K8s probes
|
||||
location /health {
|
||||
access_log off;
|
||||
|
||||
Generated
+1574
-813
File diff suppressed because it is too large
Load Diff
+16
-2
@@ -9,11 +9,13 @@
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "NODE_ENV=test vitest run",
|
||||
"test:watch": "NODE_ENV=test vitest"
|
||||
"test:watch": "NODE_ENV=test vitest",
|
||||
"test:e2e": "npx playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"better-auth": "^1.2.0",
|
||||
"picomatch": "4.0.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.0.0",
|
||||
@@ -21,24 +23,36 @@
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.10.0",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^18.3.28",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"msw": "^2.12.14",
|
||||
"playwright": "^1.58.2",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^6.3.5",
|
||||
"vite-plugin-pwa": "^0.21.2",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"overrides": {
|
||||
"@rollup/pluginutils": "5.3.0",
|
||||
"flatted": "^3.4.2",
|
||||
"serialize-javascript": "7.0.5",
|
||||
"brace-expansion": ">=1.1.13",
|
||||
"lodash": ">=4.17.24",
|
||||
"minimatch": "^10.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: 'VITE_MOCK_AUTH=true npm run dev',
|
||||
url: 'http://localhost:5173',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://cartsnitch.com/sitemap.xml
|
||||
-168
@@ -1,168 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: cartsnitch/receiptwitness
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: runners-cartsnitch
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
- name: Install cartsnitch-common from GitHub
|
||||
run: pip install "cartsnitch-common @ git+https://github.com/cartsnitch/common.git@76685ed0384103228cd670b477b967e7752ebe6b"
|
||||
- run: pip install ruff
|
||||
- name: Ruff lint
|
||||
run: ruff check .
|
||||
- name: Ruff format check
|
||||
run: ruff format --check .
|
||||
|
||||
typecheck:
|
||||
runs-on: runners-cartsnitch
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
- name: Install cartsnitch-common from GitHub
|
||||
run: pip install "cartsnitch-common @ git+https://github.com/cartsnitch/common.git@76685ed0384103228cd670b477b967e7752ebe6b"
|
||||
- run: pip install -e ".[dev]" mypy
|
||||
- name: Type check
|
||||
run: mypy src/receiptwitness
|
||||
|
||||
test:
|
||||
runs-on: runners-cartsnitch
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
credentials:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
env:
|
||||
POSTGRES_USER: cartsnitch
|
||||
POSTGRES_PASSWORD: cartsnitch_test
|
||||
POSTGRES_DB: cartsnitch_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
credentials:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
env:
|
||||
DATABASE_URL: postgresql://cartsnitch:cartsnitch_test@localhost:5432/cartsnitch_test
|
||||
REDIS_URL: redis://localhost:6379/0
|
||||
ENCRYPTION_KEY: dGVzdC1lbmNyeXB0aW9uLWtleS0xMjM0NTY3ODk=
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
- name: Install cartsnitch-common from GitHub
|
||||
run: pip install "cartsnitch-common @ git+https://github.com/cartsnitch/common.git@76685ed0384103228cd670b477b967e7752ebe6b"
|
||||
- run: pip install -e ".[dev]"
|
||||
- name: Install Playwright browsers
|
||||
run: playwright install chromium --with-deps
|
||||
- name: Run tests
|
||||
run: pytest --tb=short -q
|
||||
|
||||
build-and-push:
|
||||
runs-on: runners-cartsnitch
|
||||
needs: [lint, test]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate CalVer tag
|
||||
id: calver
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
DATE_TAG=$(date -u +%Y.%m.%d)
|
||||
EXISTING=$(git tag -l "v${DATE_TAG}*" | sort -V | tail -1)
|
||||
if [ -z "$EXISTING" ]; then
|
||||
VERSION="$DATE_TAG"
|
||||
elif [ "$EXISTING" = "v${DATE_TAG}" ]; then
|
||||
VERSION="${DATE_TAG}.2"
|
||||
else
|
||||
BUILD_NUM=$(echo "$EXISTING" | sed "s/v${DATE_TAG}\.//")
|
||||
VERSION="${DATE_TAG}.$((BUILD_NUM + 1))"
|
||||
fi
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "CalVer tag: $VERSION"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=sha,prefix=sha-
|
||||
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
target: prod
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Create git tag
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
git tag "v${{ steps.calver.outputs.version }}"
|
||||
git push origin "v${{ steps.calver.outputs.version }}"
|
||||
+10
-13
@@ -3,24 +3,21 @@ FROM python:3.12-slim AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# git is required to install cartsnitch-common from GitHub; build-essential and
|
||||
# libpq-dev are needed to compile any C-extension wheels (e.g. psycopg2 fallback)
|
||||
# build-essential and libpq-dev are needed to compile any C-extension wheels
|
||||
# (e.g. psycopg2 fallback). No git needed — common/ is copied from the repo root.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
libpq-dev \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY pyproject.toml ./
|
||||
COPY src/ ./src/
|
||||
# Build context is the repo root. These paths are relative to the root.
|
||||
COPY receiptwitness/pyproject.toml ./
|
||||
COPY receiptwitness/src/ ./src/
|
||||
COPY common/ ./common/
|
||||
|
||||
# cartsnitch-common is not on PyPI — install it directly from GitHub, then
|
||||
# install the rest of the package dependencies in a single resolver pass so
|
||||
# pip can satisfy the cartsnitch-common>=0.1.0 constraint declared in
|
||||
# pyproject.toml without hitting PyPI for it.
|
||||
RUN pip install --no-cache-dir --prefix=/install \
|
||||
"cartsnitch-common @ git+https://github.com/cartsnitch/common.git@76685ed0384103228cd670b477b967e7752ebe6b" \
|
||||
.
|
||||
# Install from the local common/ (cartsnitch-common>=0.1.0 in pyproject.toml
|
||||
# will be satisfied by the local package) then install receiptwitness itself.
|
||||
RUN pip install --no-cache-dir --prefix=/install ./common/ .
|
||||
|
||||
# Stage 2: Production image with Playwright + Chromium
|
||||
FROM python:3.12-slim AS prod
|
||||
@@ -51,7 +48,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
RUN adduser --system --group --uid 1000 app
|
||||
|
||||
COPY --from=build /install /usr/local
|
||||
COPY src/ ./src/
|
||||
COPY receiptwitness/src/ ./src/
|
||||
|
||||
# Install Playwright Chromium browser (runs as root; /opt/playwright is world-readable)
|
||||
RUN PLAYWRIGHT_BROWSERS_PATH=/opt/playwright playwright install chromium
|
||||
|
||||
@@ -14,11 +14,13 @@ dependencies = [
|
||||
"cryptography>=42.0,<44.0",
|
||||
"fastapi>=0.115,<1.0",
|
||||
"uvicorn[standard]>=0.30,<1.0",
|
||||
"beautifulsoup4>=4.12,<5.0",
|
||||
"redis>=5.0,<6.0",
|
||||
"pydantic>=2.0,<3.0",
|
||||
"pydantic-settings>=2.0,<3.0",
|
||||
"sqlalchemy[asyncio]>=2.0,<3.0",
|
||||
"asyncpg>=0.29,<1.0",
|
||||
"resend>=2.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -27,6 +29,9 @@ dev = [
|
||||
"pytest-asyncio>=0.23",
|
||||
"ruff>=0.3",
|
||||
"pytest-cov>=5.0",
|
||||
"fakeredis[aioredis]>=2.20",
|
||||
"httpx>=0.27",
|
||||
"python-multipart>=0.0.9",
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
|
||||
@@ -1,9 +1,65 @@
|
||||
"""Internal API routes for triggering scrapes and checking status."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
import hashlib
|
||||
import hmac
|
||||
import re
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from receiptwitness.config import settings
|
||||
from receiptwitness.queue.email import EmailJob, enqueue_email, get_redis
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
TOKEN_PATTERN = re.compile(r"receipts\+([A-Za-z0-9_-]+)@")
|
||||
|
||||
|
||||
def verify_mailgun_signature(token: str, timestamp: str, signature: str) -> bool:
|
||||
"""Verify Mailgun webhook signature."""
|
||||
try:
|
||||
ts = int(timestamp)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
if abs(time.time() - ts) > 300: # 5 min freshness
|
||||
return False
|
||||
key = settings.mailgun_webhook_signing_key.encode()
|
||||
hmac_digest = hmac.new(key, f"{timestamp}{token}".encode(), hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(signature, hmac_digest)
|
||||
|
||||
|
||||
@router.post("/inbound/email")
|
||||
async def receive_inbound_email(request: Request):
|
||||
form = await request.form()
|
||||
# 1. Verify Mailgun signature
|
||||
token = str(form.get("token", ""))
|
||||
timestamp = str(form.get("timestamp", ""))
|
||||
signature = str(form.get("signature", ""))
|
||||
if not verify_mailgun_signature(token, timestamp, signature):
|
||||
raise HTTPException(status_code=406, detail="Invalid signature")
|
||||
# 2. Extract account token from recipient
|
||||
recipient = str(form.get("recipient", ""))
|
||||
match = TOKEN_PATTERN.search(recipient)
|
||||
if not match:
|
||||
raise HTTPException(status_code=406, detail="Invalid recipient")
|
||||
account_token = match.group(1)
|
||||
# 3. Enqueue — worker resolves token -> user_id
|
||||
body_html_val = form.get("body-html")
|
||||
body_plain_val = form.get("body-plain")
|
||||
job = EmailJob(
|
||||
user_id=account_token,
|
||||
sender=str(form.get("sender", "")),
|
||||
recipient=recipient,
|
||||
subject=str(form.get("subject", "")),
|
||||
body_html=str(body_html_val) if body_html_val is not None else None,
|
||||
body_plain=str(body_plain_val) if body_plain_val is not None else None,
|
||||
received_at=str(form.get("timestamp", "")),
|
||||
message_id=str(form.get("Message-Id", "")),
|
||||
)
|
||||
client = await get_redis()
|
||||
await enqueue_email(client, job)
|
||||
return {"status": "queued"}
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health():
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"""Service-specific configuration for ReceiptWitness."""
|
||||
|
||||
from pydantic import model_validator
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
_PLACEHOLDER_VALUES = {"change-me-in-production"}
|
||||
|
||||
|
||||
class ReceiptWitnessSettings(BaseSettings):
|
||||
model_config = {"env_prefix": "RW_"}
|
||||
|
||||
@@ -22,5 +26,42 @@ class ReceiptWitnessSettings(BaseSettings):
|
||||
headless: bool = True
|
||||
browser_timeout_ms: int = 60000
|
||||
|
||||
# Email notifications (Resend)
|
||||
resend_api_key: str = ""
|
||||
notification_email_from: str = "notifications@cartsnitch.com"
|
||||
notifications_enabled: bool = False
|
||||
|
||||
settings = ReceiptWitnessSettings()
|
||||
# Mailgun inbound email webhook
|
||||
mailgun_webhook_signing_key: str = ""
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_required_vars(self):
|
||||
errors = []
|
||||
if not self.session_encryption_key or self.session_encryption_key in _PLACEHOLDER_VALUES:
|
||||
errors.append(
|
||||
"RW_SESSION_ENCRYPTION_KEY must be set to a secure value. "
|
||||
'Generate one with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"'
|
||||
)
|
||||
if self.notifications_enabled and not self.resend_api_key:
|
||||
errors.append(
|
||||
"RW_RESEND_API_KEY must be set when RW_NOTIFICATIONS_ENABLED=true. "
|
||||
"Get an API key from https://resend.com/api-keys"
|
||||
)
|
||||
if errors:
|
||||
raise ValueError(
|
||||
"ReceiptWitness startup failed — missing required config:\n"
|
||||
+ "\n".join(f" - {e}" for e in errors)
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class _LazySettings:
|
||||
_instance: ReceiptWitnessSettings | None = None
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
if _LazySettings._instance is None:
|
||||
_LazySettings._instance = ReceiptWitnessSettings()
|
||||
return getattr(_LazySettings._instance, name)
|
||||
|
||||
|
||||
settings = _LazySettings()
|
||||
|
||||
@@ -2,12 +2,17 @@
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from cartsnitch_common.database import get_async_session_factory
|
||||
from cartsnitch_common.models.user import User
|
||||
from sqlalchemy import select
|
||||
|
||||
from receiptwitness.config import settings
|
||||
from receiptwitness.notifications.email import send_receipt_notification
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,6 +44,36 @@ async def get_redis_client() -> aioredis.Redis:
|
||||
return aioredis.Redis(connection_pool=_get_pool())
|
||||
|
||||
|
||||
async def _send_notification_for_event(payload: dict) -> None:
|
||||
"""Look up user email and send receipt notification. Silently skips on error."""
|
||||
try:
|
||||
user_uuid = uuid.UUID(payload["user_id"])
|
||||
except (ValueError, KeyError):
|
||||
logger.warning("Invalid user_id in event payload: %s", payload.get("user_id"))
|
||||
return
|
||||
|
||||
try:
|
||||
session_factory = get_async_session_factory(settings.database_url)
|
||||
async with session_factory() as session:
|
||||
result = await session.execute(select(User.email).where(User.id == user_uuid))
|
||||
row = result.scalar_one_or_none()
|
||||
if not row:
|
||||
logger.warning("User %s not found for notification", user_uuid)
|
||||
return
|
||||
user_email = row
|
||||
except Exception:
|
||||
logger.exception("Failed to look up user email for notification")
|
||||
return
|
||||
|
||||
await send_receipt_notification(
|
||||
user_email=user_email,
|
||||
store_name=payload["store_slug"],
|
||||
item_count=payload["item_count"],
|
||||
total=payload["total"],
|
||||
purchase_date=payload["purchase_date"],
|
||||
)
|
||||
|
||||
|
||||
async def publish_receipt_ingested(
|
||||
user_id: str,
|
||||
store_slug: str,
|
||||
@@ -48,18 +83,19 @@ async def publish_receipt_ingested(
|
||||
total: Decimal | float,
|
||||
) -> None:
|
||||
"""Publish a cartsnitch.receipts.ingested event after successful ingestion."""
|
||||
payload = {
|
||||
"user_id": user_id,
|
||||
"store_slug": store_slug,
|
||||
"purchase_id": purchase_id,
|
||||
"purchase_date": purchase_date,
|
||||
"item_count": item_count,
|
||||
"total": float(total) if isinstance(total, Decimal) else total,
|
||||
}
|
||||
event = {
|
||||
"event_type": CHANNEL_RECEIPTS_INGESTED,
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"service": "receiptwitness",
|
||||
"payload": {
|
||||
"user_id": user_id,
|
||||
"store_slug": store_slug,
|
||||
"purchase_id": purchase_id,
|
||||
"purchase_date": purchase_date,
|
||||
"item_count": item_count,
|
||||
"total": float(total) if isinstance(total, Decimal) else total,
|
||||
},
|
||||
"payload": payload,
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -73,3 +109,5 @@ async def publish_receipt_ingested(
|
||||
except aioredis.ConnectionError:
|
||||
logger.error("Failed to publish event — Redis/DragonflyDB connection error")
|
||||
raise
|
||||
else:
|
||||
await _send_notification_for_event(payload)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Email notifications via Resend."""
|
||||
|
||||
import asyncio
|
||||
import html
|
||||
import logging
|
||||
|
||||
import resend
|
||||
|
||||
from receiptwitness.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def send_receipt_notification(
|
||||
user_email: str,
|
||||
store_name: str,
|
||||
item_count: int,
|
||||
total: float,
|
||||
purchase_date: str,
|
||||
) -> None:
|
||||
"""Send receipt ingestion confirmation email via Resend."""
|
||||
if not settings.notifications_enabled or not settings.resend_api_key:
|
||||
logger.debug("Notifications disabled — skipping email send")
|
||||
return
|
||||
|
||||
resend.api_key = settings.resend_api_key
|
||||
store_name_safe = html.escape(store_name)
|
||||
purchase_date_safe = html.escape(purchase_date)
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
resend.Emails.send,
|
||||
{
|
||||
"from": settings.notification_email_from,
|
||||
"to": [user_email],
|
||||
"subject": f"Receipt processed: {store_name} - ${total:.2f}",
|
||||
"html": (
|
||||
f"<p>Your receipt from <strong>{store_name_safe}</strong> on "
|
||||
f"{purchase_date_safe} has been processed.</p>"
|
||||
f"<p>{item_count} items, total: ${total:.2f}</p>"
|
||||
),
|
||||
},
|
||||
)
|
||||
logger.info("Receipt notification sent to %s", user_email)
|
||||
except Exception:
|
||||
logger.exception("Failed to send receipt notification to %s", user_email)
|
||||
@@ -0,0 +1 @@
|
||||
"""Email receipt parsers for retailer email receipts."""
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Base interface for email receipt parsers."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmailReceipt:
|
||||
"""Raw email data before parsing."""
|
||||
|
||||
sender: str
|
||||
recipient: str
|
||||
subject: str
|
||||
body_html: str | None = None
|
||||
body_plain: str | None = None
|
||||
received_at: str | None = None
|
||||
raw_headers: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
class BaseEmailParser(ABC):
|
||||
"""All retailer email parsers implement this interface."""
|
||||
|
||||
@abstractmethod
|
||||
def can_parse(self, email: EmailReceipt) -> bool:
|
||||
"""Return True if this parser handles this email."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def parse(self, email: EmailReceipt) -> dict:
|
||||
"""Parse email into a dict matching PurchaseCreate schema fields.
|
||||
Must include an items list matching PurchaseItemCreate fields."""
|
||||
...
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Detect which retailer sent a receipt email."""
|
||||
|
||||
import re
|
||||
|
||||
from receiptwitness.parsers.email.base import EmailReceipt
|
||||
|
||||
RETAILER_PATTERNS: dict[str, list[str]] = {
|
||||
"meijer": [r"@meijer\.com$", r"@email\.meijer\.com$"],
|
||||
"kroger": [r"@kroger\.com$", r"@email\.kroger\.com$"],
|
||||
"target": [r"@target\.com$", r"@email\.target\.com$"],
|
||||
}
|
||||
|
||||
|
||||
def detect_retailer(email: EmailReceipt) -> str | None:
|
||||
"""Return retailer slug or None if unrecognized."""
|
||||
sender = email.sender.lower().strip()
|
||||
# Extract email from "Name <email>" format
|
||||
match = re.search(r"<([^>]+)>", sender)
|
||||
if match:
|
||||
sender = match.group(1)
|
||||
for retailer, patterns in RETAILER_PATTERNS.items():
|
||||
for pattern in patterns:
|
||||
if re.search(pattern, sender):
|
||||
return retailer
|
||||
return None
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Kroger email receipt parser."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from receiptwitness.parsers.email.base import BaseEmailParser, EmailReceipt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _to_decimal(value: str | float | int | None, default: Decimal = Decimal("0")) -> Decimal:
|
||||
"""Safely convert a value to Decimal."""
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
return Decimal(str(value).replace("$", "").replace(",", "").strip())
|
||||
except (InvalidOperation, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _extract_total(body: str) -> Decimal:
|
||||
"""Extract the transaction total from email body."""
|
||||
patterns = [
|
||||
r"Total[:\s]*\$?([0-9,]+\.[0-9]{2})",
|
||||
r"Amount[:\s]*\$?([0-9,]+\.[0-9]{2})",
|
||||
r"Grand\s+Total[:\s]*\$?([0-9,]+\.[0-9]{2})",
|
||||
]
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, body, re.IGNORECASE)
|
||||
if match:
|
||||
return _to_decimal(match.group(1))
|
||||
return Decimal("0")
|
||||
|
||||
|
||||
def _extract_receipt_id(body: str) -> str | None:
|
||||
"""Extract receipt ID / transaction ID from HTML body.
|
||||
|
||||
Strips HTML tags first so that whitespace between delimiters and values
|
||||
(e.g. from ``</strong> KR-2026-0315-4829`` -> `` KR-2026-0315-4829``)
|
||||
is normalized and the pattern can match cleanly.
|
||||
"""
|
||||
stripped = re.sub(r"<[^>]+>", "", body)
|
||||
patterns = [
|
||||
r"Receipt\s*#[:\s]*([A-Z0-9-]+)",
|
||||
r"Transaction\s*#[:\s]*([A-Z0-9-]+)",
|
||||
r"Order\s*#[:\s]*([A-Z0-9-]+)",
|
||||
r"Confirmation\s*#[:\s]*([A-Z0-9-]+)",
|
||||
]
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, stripped, re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def _extract_date(body: str) -> str:
|
||||
"""Extract purchase date from email body. Returns ISO date string or empty string."""
|
||||
patterns = [
|
||||
r"(\d{1,2}[/-]\d{1,2}[/-]\d{2,4})",
|
||||
r"([A-Z][a-z]{2}\s+\d{1,2},?\s+\d{4})",
|
||||
]
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, body)
|
||||
if match:
|
||||
raw = match.group(1)
|
||||
try:
|
||||
dt = datetime.strptime(raw.replace(",", ""), "%b %d %Y")
|
||||
return dt.strftime("%Y-%m-%d")
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
for fmt in ("%m/%d/%Y", "%m/%d/%y", "%d/%m/%Y", "%d/%m/%y"):
|
||||
try:
|
||||
dt = datetime.strptime(raw, fmt)
|
||||
return dt.strftime("%Y-%m-%d")
|
||||
except ValueError:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_items_soup(body: str) -> list[dict]:
|
||||
"""Extract line items from HTML email body using BeautifulSoup."""
|
||||
items = []
|
||||
try:
|
||||
soup = BeautifulSoup(body, "html.parser")
|
||||
text = soup.get_text(separator="\n", strip=True)
|
||||
# Strip HTML tags from raw body to normalize whitespace
|
||||
stripped = re.sub(r"<[^>]+>", " ", body)
|
||||
stripped = re.sub(r"\s+", " ", stripped)
|
||||
skip_prefixes = (
|
||||
"Subtotal",
|
||||
"Tax",
|
||||
"Total",
|
||||
"Kroger",
|
||||
"Target",
|
||||
"Date",
|
||||
"Receipt",
|
||||
"Order",
|
||||
"Transaction",
|
||||
"Confirmation",
|
||||
"Thank",
|
||||
"Questions",
|
||||
"Keep",
|
||||
"Receipt",
|
||||
)
|
||||
for line in text.split("\n"):
|
||||
line = line.strip()
|
||||
if not line or line.startswith(skip_prefixes):
|
||||
continue
|
||||
# Match lines like "Product Name $9.99"
|
||||
match = re.match(r"(.+?)\s+\$([0-9]+\.[0-9]{2})\s*$", line)
|
||||
if match:
|
||||
name = match.group(1).strip()
|
||||
price = _to_decimal(match.group(2))
|
||||
if len(name) > 2 and price > 0:
|
||||
items.append(
|
||||
{
|
||||
"product_name_raw": name,
|
||||
"quantity": Decimal("1"),
|
||||
"unit_price": price,
|
||||
"extended_price": price,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return items[:20]
|
||||
|
||||
|
||||
class KrogerEmailParser(BaseEmailParser):
|
||||
"""Parse Kroger email receipts (digital receipts via kroger.com)."""
|
||||
|
||||
KROGER_KEYWORDS = ("kroger", "kroger.com", "plus")
|
||||
|
||||
def can_parse(self, email: EmailReceipt) -> bool:
|
||||
sender = (email.sender or "").lower()
|
||||
body = (email.body_html or email.body_plain or "").lower()
|
||||
return any(kw in sender or kw in body for kw in self.KROGER_KEYWORDS)
|
||||
|
||||
def parse(self, email: EmailReceipt) -> dict:
|
||||
body = (email.body_html or email.body_plain or "").strip()
|
||||
total = _extract_total(body)
|
||||
receipt_id = _extract_receipt_id(body) or ""
|
||||
purchase_date = _extract_date(body)
|
||||
items = _extract_items_soup(body)
|
||||
|
||||
return {
|
||||
"receipt_id": receipt_id,
|
||||
"purchase_date": purchase_date,
|
||||
"total": total,
|
||||
"items": items,
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
"""Parse Meijer digital receipt emails into structured purchase data."""
|
||||
|
||||
import re
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from bs4.element import Tag
|
||||
|
||||
from receiptwitness.parsers.email.base import BaseEmailParser, EmailReceipt
|
||||
|
||||
|
||||
def _to_decimal(value, default: str = "0") -> Decimal:
|
||||
"""Safely convert a value to Decimal."""
|
||||
if value is None:
|
||||
return Decimal(default)
|
||||
try:
|
||||
return Decimal(str(value).replace("$", "").replace(",", "").strip())
|
||||
except (InvalidOperation, ValueError, TypeError):
|
||||
return Decimal(default)
|
||||
|
||||
|
||||
def _extract_receipt_id(soup: BeautifulSoup, subject: str | None) -> str | None:
|
||||
"""Extract receipt/transaction ID from subject or body."""
|
||||
if subject:
|
||||
match = re.search(r"TXN[-\s]\d{4}[-\s]\d{4}[-\s]\d+", subject)
|
||||
if match:
|
||||
return match.group(0).replace(" ", "-")
|
||||
# Fallback: look in body
|
||||
text = soup.get_text()
|
||||
match = re.search(r"TXN[-\s]\d{4}[-\s]\d{4}[-\s]\d+", text)
|
||||
if match:
|
||||
return match.group(0).replace(" ", "-")
|
||||
return None
|
||||
|
||||
|
||||
def _extract_purchase_date(soup: BeautifulSoup, subject: str | None) -> str | None:
|
||||
"""Extract purchase date from subject or body."""
|
||||
text = soup.get_text()
|
||||
|
||||
# Try ISO format first: YYYY-MM-DD
|
||||
match = re.search(r"(\d{4})-(\d{2})-(\d{2})", text)
|
||||
if match:
|
||||
return f"{match.group(1)}-{match.group(2)}-{match.group(3)}"
|
||||
|
||||
# Try written format: March 15, 2026
|
||||
match = re.search(r"([A-Za-z]+)\s+(\d{1,2}),?\s+(\d{4})", text)
|
||||
if match:
|
||||
month_str = match.group(1).lower()
|
||||
day = match.group(2)
|
||||
year = match.group(3)
|
||||
month_map = {
|
||||
"january": "01",
|
||||
"february": "02",
|
||||
"march": "03",
|
||||
"april": "04",
|
||||
"may": "05",
|
||||
"june": "06",
|
||||
"july": "07",
|
||||
"august": "08",
|
||||
"september": "09",
|
||||
"october": "10",
|
||||
"november": "11",
|
||||
"december": "12",
|
||||
}
|
||||
month = month_map.get(month_str)
|
||||
if month:
|
||||
return f"{year}-{month}-{day.zfill(2)}"
|
||||
|
||||
# MM/DD/YYYY
|
||||
match = re.search(r"(\d{1,2})/(\d{1,2})/(\d{4})", text)
|
||||
if match:
|
||||
return f"{match.group(3)}-{match.group(1).zfill(2)}-{match.group(2).zfill(2)}"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _extract_store_info(soup: BeautifulSoup) -> dict:
|
||||
"""Extract store name and number from the email body."""
|
||||
store_info: dict = {}
|
||||
|
||||
# Look for store number in header
|
||||
store_num_match = re.search(r"Meijer\s+Store\s+#?(\d+)", soup.get_text(), re.IGNORECASE)
|
||||
if store_num_match:
|
||||
store_info["store_number"] = store_num_match.group(1)
|
||||
|
||||
return store_info
|
||||
|
||||
|
||||
def _extract_items(table: Tag | None) -> list[dict]:
|
||||
"""Extract line items from the items table."""
|
||||
items: list[dict] = []
|
||||
if not table:
|
||||
return items
|
||||
|
||||
rows = table.find_all("tr")
|
||||
for row in rows:
|
||||
cells = row.find_all("td")
|
||||
if len(cells) < 3:
|
||||
continue
|
||||
|
||||
name_cell = cells[0].get_text(strip=True)
|
||||
qty_cell = cells[1].get_text(strip=True)
|
||||
price_cell = cells[2].get_text(strip=True)
|
||||
|
||||
if not name_cell or name_cell.lower() in ("item", "description"):
|
||||
continue
|
||||
|
||||
# Skip subtotal/tax/total/savings rows
|
||||
if any(
|
||||
label in name_cell.lower()
|
||||
for label in ("subtotal", "tax", "total", "savings", "grand total")
|
||||
):
|
||||
continue
|
||||
|
||||
try:
|
||||
quantity = Decimal(qty_cell)
|
||||
except (InvalidOperation, ValueError, TypeError):
|
||||
quantity = Decimal("1")
|
||||
|
||||
price_str = price_cell.replace("$", "").replace(",", "").strip()
|
||||
try:
|
||||
unit_price = Decimal(price_str)
|
||||
except (InvalidOperation, ValueError, TypeError):
|
||||
unit_price = Decimal("0")
|
||||
|
||||
extended_price = unit_price # Default to unit price; no qty column in fixture
|
||||
|
||||
items.append(
|
||||
{
|
||||
"product_name_raw": name_cell,
|
||||
"quantity": quantity,
|
||||
"unit_price": unit_price,
|
||||
"extended_price": extended_price,
|
||||
}
|
||||
)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def _extract_totals_plain(text: str) -> dict:
|
||||
"""Extract totals from plain text (no HTML)."""
|
||||
totals: dict = {
|
||||
"subtotal": None,
|
||||
"tax": None,
|
||||
"total": None,
|
||||
"savings_total": None,
|
||||
}
|
||||
|
||||
match = re.search(r"\bSubtotal\b[:\s$]*([0-9,]+\.?\d*)", text, re.IGNORECASE)
|
||||
if match:
|
||||
totals["subtotal"] = _to_decimal(match.group(1))
|
||||
|
||||
match = re.search(r"\bTax\b[:\s$]*([0-9,]+\.?\d*)", text, re.IGNORECASE)
|
||||
if match:
|
||||
totals["tax"] = _to_decimal(match.group(1))
|
||||
|
||||
grand_total_match = re.search(r"Grand\s+Total\b[:\s$]*([0-9,]+\.?\d*)", text, re.IGNORECASE)
|
||||
if grand_total_match:
|
||||
totals["total"] = _to_decimal(grand_total_match.group(1))
|
||||
|
||||
savings_match = re.search(r"\bSavings\b[:\s$\-]*([0-9,]+\.?\d*)", text, re.IGNORECASE)
|
||||
if savings_match:
|
||||
totals["savings_total"] = _to_decimal(savings_match.group(1))
|
||||
|
||||
if totals["total"] is None:
|
||||
total_match = re.search(r"\bTotal\b[:\s$]*([0-9,]+\.?\d*)", text, re.IGNORECASE)
|
||||
if total_match:
|
||||
totals["total"] = _to_decimal(total_match.group(1))
|
||||
|
||||
return totals
|
||||
|
||||
|
||||
def _extract_totals(soup: BeautifulSoup) -> dict:
|
||||
"""Extract subtotal, tax, total, and savings from the totals section."""
|
||||
text = soup.get_text()
|
||||
|
||||
totals: dict = {
|
||||
"subtotal": None,
|
||||
"tax": None,
|
||||
"total": None,
|
||||
"savings_total": None,
|
||||
}
|
||||
|
||||
# Subtotal — use word boundary to avoid matching "Subtotal" with "Total"
|
||||
match = re.search(r"\bSubtotal\b[:\s$]*([0-9,]+\.?\d*)", text, re.IGNORECASE)
|
||||
if match:
|
||||
totals["subtotal"] = _to_decimal(match.group(1))
|
||||
|
||||
# Tax
|
||||
match = re.search(r"\bTax\b[:\s$]*([0-9,]+\.?\d*)", text, re.IGNORECASE)
|
||||
if match:
|
||||
totals["tax"] = _to_decimal(match.group(1))
|
||||
|
||||
# Grand Total (before plain "Total" to avoid matching "Subtotal")
|
||||
grand_total_match = re.search(r"Grand\s+Total\b[:\s$]*([0-9,]+\.?\d*)", text, re.IGNORECASE)
|
||||
if grand_total_match:
|
||||
totals["total"] = _to_decimal(grand_total_match.group(1))
|
||||
|
||||
# Savings — allow any combination of whitespace/$- around the number
|
||||
savings_match = re.search(r"\bSavings\b[:\s$\-]*([0-9,]+\.?\d*)", text, re.IGNORECASE)
|
||||
if savings_match:
|
||||
totals["savings_total"] = _to_decimal(savings_match.group(1))
|
||||
|
||||
# Plain "Total" only if Grand Total wasn't found
|
||||
if totals["total"] is None:
|
||||
total_match = re.search(r"\bTotal\b[:\s$]*([0-9,]+\.?\d*)", text, re.IGNORECASE)
|
||||
if total_match:
|
||||
totals["total"] = _to_decimal(total_match.group(1))
|
||||
|
||||
return totals
|
||||
|
||||
|
||||
class MeijerEmailParser(BaseEmailParser):
|
||||
"""Parse Meijer digital receipt emails forwarded by users."""
|
||||
|
||||
def can_parse(self, email: EmailReceipt) -> bool:
|
||||
sender = email.sender.lower().strip()
|
||||
# Extract email from "Name <email>" format
|
||||
match = re.search(r"<([^>]+)>", sender)
|
||||
if match:
|
||||
sender = match.group(1)
|
||||
return "meijer" in sender
|
||||
|
||||
def parse(self, email: EmailReceipt) -> dict:
|
||||
body_html = email.body_html
|
||||
body_plain = email.body_plain or ""
|
||||
body = body_html or body_plain
|
||||
soup = BeautifulSoup(body, "html.parser")
|
||||
|
||||
receipt_id = _extract_receipt_id(soup, email.subject)
|
||||
purchase_date = _extract_purchase_date(soup, email.subject)
|
||||
_ = _extract_store_info(soup)
|
||||
|
||||
# Find the items table — look for one with Item/Qty/Price headers
|
||||
table = None
|
||||
for tbl in soup.find_all("table"):
|
||||
headers = tbl.find_all("th")
|
||||
header_texts = [h.get_text(strip=True).lower() for h in headers]
|
||||
if any("item" in h or "qty" in h or "price" in h for h in header_texts):
|
||||
table = tbl
|
||||
break
|
||||
|
||||
items = _extract_items(table)
|
||||
|
||||
# Extract totals from HTML; fall back to plain text if no HTML
|
||||
if body_html:
|
||||
totals = _extract_totals(soup)
|
||||
else:
|
||||
totals = _extract_totals_plain(body_plain)
|
||||
|
||||
return {
|
||||
"receipt_id": receipt_id or "",
|
||||
"purchase_date": purchase_date or "",
|
||||
"total": totals["total"] or Decimal("0"),
|
||||
"subtotal": totals["subtotal"],
|
||||
"tax": totals["tax"],
|
||||
"savings_total": totals["savings_total"],
|
||||
"items": items,
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Target email receipt parser."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from receiptwitness.parsers.email.base import BaseEmailParser, EmailReceipt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _to_decimal(value: str | float | int | None, default: Decimal = Decimal("0")) -> Decimal:
|
||||
"""Safely convert a value to Decimal."""
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
return Decimal(str(value).replace("$", "").replace(",", "").strip())
|
||||
except (InvalidOperation, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _extract_total(body: str) -> Decimal:
|
||||
"""Extract the transaction total from email body."""
|
||||
patterns = [
|
||||
r"Total[:\s]*\$?([0-9,]+\.[0-9]{2})",
|
||||
r"Amount[:\s]*\$?([0-9,]+\.[0-9]{2})",
|
||||
r"Grand\s+Total[:\s]*\$?([0-9,]+\.[0-9]{2})",
|
||||
]
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, body, re.IGNORECASE)
|
||||
if match:
|
||||
return _to_decimal(match.group(1))
|
||||
return Decimal("0")
|
||||
|
||||
|
||||
def _extract_receipt_id(body: str) -> str | None:
|
||||
"""Extract receipt ID / transaction ID from HTML body.
|
||||
|
||||
Strips HTML tags first so that whitespace between delimiters and values
|
||||
(e.g. from ``</strong> TGT-2026-0318-9124`` -> `` TGT-2026-0318-9124``)
|
||||
is normalized and the pattern can match cleanly.
|
||||
"""
|
||||
stripped = re.sub(r"<[^>]+>", "", body)
|
||||
patterns = [
|
||||
r"Receipt\s*#[:\s]*([A-Z0-9-]+)",
|
||||
r"Order\s*#[:\s]*([A-Z0-9-]+)",
|
||||
r"Confirmation\s*#[:\s]*([A-Z0-9-]+)",
|
||||
r"Target\s+Order\s*#[:\s]*([A-Z0-9-]+)",
|
||||
]
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, stripped, re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def _extract_date(body: str) -> str:
|
||||
"""Extract purchase date from email body. Returns ISO date string or empty string."""
|
||||
patterns = [
|
||||
r"(\d{1,2}[/-]\d{1,2}[/-]\d{2,4})",
|
||||
r"([A-Z][a-z]{2}\s+\d{1,2},?\s+\d{4})",
|
||||
]
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, body)
|
||||
if match:
|
||||
raw = match.group(1)
|
||||
try:
|
||||
dt = datetime.strptime(raw.replace(",", ""), "%b %d %Y")
|
||||
return dt.strftime("%Y-%m-%d")
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
for fmt in ("%m/%d/%Y", "%m/%d/%y", "%d/%m/%Y", "%d/%m/%y"):
|
||||
try:
|
||||
dt = datetime.strptime(raw, fmt)
|
||||
return dt.strftime("%Y-%m-%d")
|
||||
except ValueError:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_items_soup(body: str) -> list[dict]:
|
||||
"""Extract line items from HTML email body using BeautifulSoup."""
|
||||
items = []
|
||||
try:
|
||||
soup = BeautifulSoup(body, "html.parser")
|
||||
text = soup.get_text(separator="\n", strip=True)
|
||||
for line in text.split("\n"):
|
||||
line = line.strip()
|
||||
if not line or line.startswith(
|
||||
(
|
||||
"Subtotal",
|
||||
"Tax",
|
||||
"Total",
|
||||
"Target",
|
||||
"Kroger",
|
||||
"Date",
|
||||
"Receipt",
|
||||
"Order",
|
||||
"Transaction",
|
||||
"Confirmation",
|
||||
"Thank",
|
||||
"Questions",
|
||||
"Keep",
|
||||
"Receipt",
|
||||
"Store",
|
||||
)
|
||||
):
|
||||
continue
|
||||
# Match lines like "Product Name $9.99"
|
||||
match = re.match(r"(.+?)\s+\$([0-9]+\.[0-9]{2})\s*$", line)
|
||||
if match:
|
||||
name = match.group(1).strip()
|
||||
price = _to_decimal(match.group(2))
|
||||
if len(name) > 2 and price > 0:
|
||||
items.append(
|
||||
{
|
||||
"product_name_raw": name,
|
||||
"quantity": Decimal("1"),
|
||||
"unit_price": price,
|
||||
"extended_price": price,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return items[:20]
|
||||
|
||||
|
||||
class TargetEmailParser(BaseEmailParser):
|
||||
"""Parse Target email receipts (Circle order confirmations)."""
|
||||
|
||||
TARGET_KEYWORDS = ("target.com", "targetnow", "circle", "target")
|
||||
|
||||
def can_parse(self, email: EmailReceipt) -> bool:
|
||||
sender = (email.sender or "").lower()
|
||||
body = (email.body_html or email.body_plain or "").lower()
|
||||
return any(kw in sender or kw in body for kw in self.TARGET_KEYWORDS)
|
||||
|
||||
def parse(self, email: EmailReceipt) -> dict:
|
||||
body = (email.body_html or email.body_plain or "").strip()
|
||||
total = _extract_total(body)
|
||||
receipt_id = _extract_receipt_id(body) or ""
|
||||
purchase_date = _extract_date(body)
|
||||
items = _extract_items_soup(body)
|
||||
|
||||
return {
|
||||
"receipt_id": receipt_id,
|
||||
"purchase_date": purchase_date,
|
||||
"total": total,
|
||||
"items": items,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""DragonflyDB Streams queue for email receipt processing."""
|
||||
@@ -0,0 +1,77 @@
|
||||
"""DragonflyDB Streams queue for email receipt processing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import cast
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from receiptwitness.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STREAM_KEY = "email:receipts"
|
||||
CONSUMER_GROUP = "email-workers"
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmailJob:
|
||||
"""Payload for an email receipt processing job."""
|
||||
|
||||
user_id: str
|
||||
sender: str
|
||||
recipient: str
|
||||
subject: str
|
||||
body_html: str | None
|
||||
body_plain: str | None
|
||||
received_at: str
|
||||
message_id: str # from email provider, for dedup
|
||||
|
||||
|
||||
async def get_redis() -> aioredis.Redis:
|
||||
"""Get async Redis/DragonflyDB client."""
|
||||
return cast(aioredis.Redis, aioredis.from_url(settings.redis_url, decode_responses=True))
|
||||
|
||||
|
||||
async def ensure_consumer_group(client: aioredis.Redis) -> None:
|
||||
"""Create consumer group if it does not exist."""
|
||||
try:
|
||||
await client.xgroup_create(STREAM_KEY, CONSUMER_GROUP, id="0", mkstream=True)
|
||||
except aioredis.ResponseError as e:
|
||||
if "BUSYGROUP" not in str(e):
|
||||
raise
|
||||
|
||||
|
||||
async def enqueue_email(client: aioredis.Redis, job: EmailJob) -> str:
|
||||
"""Add email job to the stream. Returns the stream message ID."""
|
||||
payload: dict[str, str | bytes | int | float] = {"data": json.dumps(asdict(job))}
|
||||
msg_id: str = cast(str, await client.xadd(STREAM_KEY, payload)) # type: ignore[arg-type] # redis-py StreamCommands.xadd expects broader FieldT union; runtime behavior is correct
|
||||
logger.info("Enqueued email job %s for user %s", msg_id, job.user_id)
|
||||
return msg_id
|
||||
|
||||
|
||||
async def consume_emails(
|
||||
client: aioredis.Redis,
|
||||
consumer_name: str,
|
||||
count: int = 1,
|
||||
block_ms: int = 5000,
|
||||
) -> list[tuple[str, EmailJob]]:
|
||||
"""Read pending messages from the stream. Returns list of (msg_id, EmailJob)."""
|
||||
await ensure_consumer_group(client)
|
||||
messages = await client.xreadgroup(
|
||||
CONSUMER_GROUP, consumer_name, {STREAM_KEY: ">"}, count=count, block=block_ms
|
||||
)
|
||||
results = []
|
||||
for _stream, entries in messages:
|
||||
for msg_id, fields in entries:
|
||||
job = EmailJob(**json.loads(fields["data"]))
|
||||
results.append((msg_id, job))
|
||||
return results
|
||||
|
||||
|
||||
async def ack_email(client: aioredis.Redis, msg_id: str) -> None:
|
||||
"""Acknowledge a processed message."""
|
||||
await client.xack(STREAM_KEY, CONSUMER_GROUP, msg_id)
|
||||
@@ -0,0 +1 @@
|
||||
"""Async email receipt worker consuming from DragonflyDB Streams."""
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Async worker that consumes email receipt jobs from DragonflyDB Streams."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from cartsnitch_common.database import get_async_session_factory
|
||||
from cartsnitch_common.models.user import User
|
||||
from sqlalchemy import select
|
||||
|
||||
from receiptwitness.config import settings
|
||||
from receiptwitness.events import publish_receipt_ingested
|
||||
from receiptwitness.parsers.email.base import BaseEmailParser, EmailReceipt
|
||||
from receiptwitness.parsers.email.detector import detect_retailer
|
||||
from receiptwitness.parsers.email.kroger import KrogerEmailParser
|
||||
from receiptwitness.parsers.email.meijer import MeijerEmailParser
|
||||
from receiptwitness.parsers.email.target import TargetEmailParser
|
||||
from receiptwitness.queue.email import ack_email, consume_emails, get_redis
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CONSUMER_NAME = "worker-1"
|
||||
|
||||
# Registry of available email parsers
|
||||
PARSERS: dict[str, BaseEmailParser] = {
|
||||
"meijer": MeijerEmailParser(),
|
||||
"kroger": KrogerEmailParser(),
|
||||
"target": TargetEmailParser(),
|
||||
}
|
||||
|
||||
|
||||
async def resolve_user(token: str) -> str | None:
|
||||
"""Look up user_id from email_inbound_token."""
|
||||
session_factory = get_async_session_factory(settings.database_url)
|
||||
async with session_factory() as session:
|
||||
result = await session.execute(select(User.id).where(User.email_inbound_token == token))
|
||||
row = result.scalar_one_or_none()
|
||||
return str(row) if row else None
|
||||
|
||||
|
||||
async def process_job(msg_id: str, job) -> bool:
|
||||
"""Process a single email job. Returns True on success."""
|
||||
# 1. Resolve user from token
|
||||
user_id = await resolve_user(job.user_id) # user_id field holds token
|
||||
if not user_id:
|
||||
logger.warning("Unknown token %s, dropping message %s", job.user_id, msg_id)
|
||||
return True # ack to avoid infinite retry
|
||||
|
||||
# 2. Build EmailReceipt
|
||||
email = EmailReceipt(
|
||||
sender=job.sender,
|
||||
recipient=job.recipient,
|
||||
subject=job.subject,
|
||||
body_html=job.body_html,
|
||||
body_plain=job.body_plain,
|
||||
received_at=job.received_at,
|
||||
)
|
||||
|
||||
# 3. Detect retailer
|
||||
retailer = detect_retailer(email)
|
||||
if not retailer or retailer not in PARSERS:
|
||||
logger.warning(
|
||||
"Unrecognized retailer from %s, archiving msg %s",
|
||||
job.sender,
|
||||
msg_id,
|
||||
)
|
||||
return True # ack — no parser available
|
||||
|
||||
# 4. Parse
|
||||
parser = PARSERS[retailer]
|
||||
parsed = parser.parse(email)
|
||||
|
||||
# 5. Publish event
|
||||
await publish_receipt_ingested(
|
||||
user_id=user_id,
|
||||
store_slug=retailer,
|
||||
purchase_id=parsed.get("receipt_id", msg_id),
|
||||
purchase_date=parsed.get("purchase_date", ""),
|
||||
item_count=len(parsed.get("items", [])),
|
||||
total=parsed.get("total", 0),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def run_worker() -> None:
|
||||
"""Main worker loop — consume and process email jobs."""
|
||||
client = await get_redis()
|
||||
logger.info("Email worker started, consuming from email:receipts")
|
||||
while True:
|
||||
try:
|
||||
jobs = await consume_emails(client, CONSUMER_NAME, count=5, block_ms=5000)
|
||||
for msg_id, job in jobs:
|
||||
try:
|
||||
success = await process_job(msg_id, job)
|
||||
if success:
|
||||
await ack_email(client, msg_id)
|
||||
except Exception:
|
||||
logger.exception("Failed to process email job %s", msg_id)
|
||||
except Exception:
|
||||
logger.exception("Worker loop error, retrying in 5s")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_worker())
|
||||
@@ -1,12 +1,16 @@
|
||||
"""Shared test fixtures."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
FIXTURES_DIR = Path(__file__).parent / "fixtures"
|
||||
|
||||
os.environ.setdefault("RW_SESSION_ENCRYPTION_KEY", "test-secret-key-for-unit-tests-only-32bytes!")
|
||||
os.environ.setdefault("RW_MAILGUN_WEBHOOK_SIGNING_KEY", "test-mailgun-signing-key")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def meijer_receipt_data() -> dict:
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Kroger Digital Receipt</title>
|
||||
</head>
|
||||
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;">
|
||||
<div style="background-color: #0057a8; color: white; padding: 20px; text-align: center;">
|
||||
<img src="https://www.kroger.com/email-logo.png" alt="Kroger" style="height: 40px;">
|
||||
<h1 style="margin: 10px 0; font-size: 24px;">Your Digital Receipt</h1>
|
||||
<p style="margin: 0;">Kroger Plus Member</p>
|
||||
</div>
|
||||
|
||||
<div style="padding: 20px; background-color: #f5f5f5;">
|
||||
<h2 style="color: #0057a8; margin-top: 0;">Kroger #882 - Downtown</h2>
|
||||
<p style="margin: 5px 0;">123 Main Street<br>Anytown, OH 45202</p>
|
||||
<p style="margin: 5px 0;"><strong>Date:</strong> 03/15/2026</p>
|
||||
<p style="margin: 5px 0;"><strong>Receipt #:</strong> KR-2026-0315-4829</p>
|
||||
<p style="margin: 5px 0;"><strong>Transaction #:</strong> TXN-789123456</p>
|
||||
</div>
|
||||
|
||||
<div style="padding: 20px;">
|
||||
<h3>Items Purchased</h3>
|
||||
<p>Whole Milk 1 Gallon $3.99</p>
|
||||
<p>Sourdough Bread $4.49</p>
|
||||
<p>Free Range Eggs 12ct $5.99</p>
|
||||
<p>Baby Spinach 5oz $4.29</p>
|
||||
</div>
|
||||
|
||||
<div style="padding: 20px; background-color: #e8f4e8; border-left: 4px solid #0057a8;">
|
||||
<p style="margin: 5px 0;"><strong>Subtotal:</strong> $18.76</p>
|
||||
<p style="margin: 5px 0;"><strong>Tax:</strong> $1.24</p>
|
||||
<p style="margin: 5px 0; color: #0057a8; font-weight: bold; font-size: 18px;">Total: $20.00</p>
|
||||
</div>
|
||||
|
||||
<div style="padding: 15px; margin-top: 15px; background-color: #fff8e1; border-left: 4px solid #ffc107;">
|
||||
<p style="margin: 0; font-size: 14px; color: #666;">Kroger Plus Savings: <strong>$3.25</strong> saved on this order.</p>
|
||||
</div>
|
||||
|
||||
<div style="padding: 20px; text-align: center; color: #999; font-size: 12px; margin-top: 20px;">
|
||||
<p>Thank you for shopping at Kroger!</p>
|
||||
<p>Keep your receipt for returns within 90 days.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,127 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Meijer Digital Receipt</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; background: #f4f4f4; margin: 0; padding: 20px; }
|
||||
.receipt-container { background: #ffffff; max-width: 600px; margin: 0 auto; padding: 30px; border: 1px solid #dddddd; }
|
||||
.header { background: #003399; color: #ffffff; padding: 20px; text-align: center; margin: -30px -30px 20px -30px; }
|
||||
.header h1 { margin: 0; font-size: 24px; }
|
||||
.store-info { text-align: center; margin-bottom: 20px; border-bottom: 2px dashed #cccccc; padding-bottom: 15px; }
|
||||
.store-info h2 { margin: 0; font-size: 18px; color: #003399; }
|
||||
.receipt-meta { display: flex; justify-content: space-between; font-size: 14px; color: #555555; margin-bottom: 20px; }
|
||||
table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
|
||||
th { background: #f0f0f0; text-align: left; padding: 8px 10px; font-size: 13px; color: #333333; }
|
||||
td { padding: 8px 10px; border-bottom: 1px solid #eeeeee; font-size: 14px; }
|
||||
.item-name { font-weight: bold; }
|
||||
.totals { margin-left: auto; width: 250px; }
|
||||
.totals-row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 14px; }
|
||||
.totals-row.grand-total { font-weight: bold; font-size: 16px; border-top: 2px solid #333333; padding-top: 10px; margin-top: 4px; }
|
||||
.savings { color: #cc0000; }
|
||||
.footer { text-align: center; font-size: 12px; color: #888888; margin-top: 20px; padding-top: 15px; border-top: 1px solid #dddddd; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="receipt-container">
|
||||
<div class="header">
|
||||
<h1>MEIJER</h1>
|
||||
<p style="margin: 5px 0 0; font-size: 14px;">Digital Receipt</p>
|
||||
</div>
|
||||
|
||||
<div class="store-info">
|
||||
<h2>Meijer Store #42</h2>
|
||||
<p style="margin: 5px 0 0; font-size: 13px; color: #666;">1555 Lake Drive SE, Grand Rapids, MI 49506</p>
|
||||
</div>
|
||||
|
||||
<div class="receipt-meta">
|
||||
<div>
|
||||
<strong>Date:</strong> March 15, 2026<br />
|
||||
<strong>Time:</strong> 2:34 PM
|
||||
</div>
|
||||
<div style="text-align: right;">
|
||||
<strong>Transaction #</strong><br />
|
||||
TXN-2026-0315-0042
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Item</th>
|
||||
<th style="text-align: center;">Qty</th>
|
||||
<th style="text-align: right;">Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="item-name">ORGANIC BANANAS</td>
|
||||
<td style="text-align: center;">1</td>
|
||||
<td style="text-align: right;">$0.69</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="item-name">WHOLE MILK 1 GAL</td>
|
||||
<td style="text-align: center;">1</td>
|
||||
<td style="text-align: right;">$4.29</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="item-name">MEIJER WHOLE GRAIN OAT CEREAL 18OZ</td>
|
||||
<td style="text-align: center;">1</td>
|
||||
<td style="text-align: right;">$4.99</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="item-name">FRESH BROCCOLI CROWN</td>
|
||||
<td style="text-align: center;">1</td>
|
||||
<td style="text-align: right;">$2.49</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="item-name">GROUND BEEF 85/15 1LB</td>
|
||||
<td style="text-align: center;">1</td>
|
||||
<td style="text-align: right;">$6.99</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="item-name">SOURDOUGH BREAD</td>
|
||||
<td style="text-align: center;">1</td>
|
||||
<td style="text-align: right;">$3.99</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="item-name">MEIJER BABY SPINACH 5OZ</td>
|
||||
<td style="text-align: center;">1</td>
|
||||
<td style="text-align: right;">$4.49</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="item-name">LARGE EGGS DOZEN</td>
|
||||
<td style="text-align: center;">1</td>
|
||||
<td style="text-align: right;">$3.29</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="totals">
|
||||
<div class="totals-row">
|
||||
<span>Subtotal</span>
|
||||
<span>$31.22</span>
|
||||
</div>
|
||||
<div class="totals-row">
|
||||
<span>Tax</span>
|
||||
<span>$2.19</span>
|
||||
</div>
|
||||
<div class="totals-row savings">
|
||||
<span>Total Savings</span>
|
||||
<span>-$3.40</span>
|
||||
</div>
|
||||
<div class="totals-row grand-total">
|
||||
<span>Total</span>
|
||||
<span>$33.41</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>Thank you for shopping at Meijer!</p>
|
||||
<p>Keep your receipt for your records.<br />
|
||||
Questions? Call 1-800-927-8699 or visit meijer.com</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,44 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Target Order Confirmation</title>
|
||||
</head>
|
||||
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;">
|
||||
<div style="background-color: #cc0000; color: white; padding: 20px; text-align: center;">
|
||||
<img src="https://assets.target.com/email-logo.png" alt="Target" style="height: 40px;">
|
||||
<h1 style="margin: 10px 0; font-size: 24px;">Order Confirmation</h1>
|
||||
<p style="margin: 0;">Thanks for shopping Target Circle!</p>
|
||||
</div>
|
||||
|
||||
<div style="padding: 20px; background-color: #f5f5f5;">
|
||||
<h2 style="color: #cc0000; margin-top: 0;">Target Store #1247 - Riverside</h2>
|
||||
<p style="margin: 5px 0;">4500 River Road<br>Columbus, OH 43220</p>
|
||||
<p style="margin: 5px 0;"><strong>Date:</strong> 03/18/2026</p>
|
||||
<p style="margin: 5px 0;"><strong>Order #:</strong> TGT-2026-0318-9124</p>
|
||||
<p style="margin: 5px 0;"><strong>Confirmation #:</strong> CNF-44772819</p>
|
||||
</div>
|
||||
|
||||
<div style="padding: 20px;">
|
||||
<h3>Items Purchased</h3>
|
||||
<p>Good & Gather Whole Milk 1 Gal $3.89</p>
|
||||
<p>Arborio Rice 2lb bag $6.49</p>
|
||||
<p>Parmesan Wedge 8oz $7.99</p>
|
||||
</div>
|
||||
|
||||
<div style="padding: 20px; background-color: #fff8e1; border-left: 4px solid #cc0000;">
|
||||
<p style="margin: 5px 0;"><strong>Subtotal:</strong> $18.37</p>
|
||||
<p style="margin: 5px 0;"><strong>Tax:</strong> $1.45</p>
|
||||
<p style="margin: 5px 0; color: #cc0000; font-weight: bold; font-size: 18px;">Total: $19.82</p>
|
||||
</div>
|
||||
|
||||
<div style="padding: 15px; margin-top: 15px; background-color: #e8f4e8; border-left: 4px solid #4caf50;">
|
||||
<p style="margin: 0; font-size: 14px; color: #333;">Target Circle offer saved you <strong>$0.30</strong> on this order.</p>
|
||||
</div>
|
||||
|
||||
<div style="padding: 20px; text-align: center; color: #999; font-size: 12px; margin-top: 20px;">
|
||||
<p>Questions? Call Target Guest Services at 1-800-591-3869.</p>
|
||||
<p>Receipt valid for returns within 30 days.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the ReceiptWitness API routes."""
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Tests for the /inbound/email webhook endpoint."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from receiptwitness.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis():
|
||||
redis_mock = AsyncMock()
|
||||
with patch("receiptwitness.api.routes.get_redis", return_value=redis_mock):
|
||||
enqueue_patcher = patch("receiptwitness.api.routes.enqueue_email", new_callable=AsyncMock)
|
||||
with enqueue_patcher as mock_enqueue:
|
||||
yield {"redis": redis_mock, "enqueue": mock_enqueue}
|
||||
|
||||
|
||||
def make_signature(signing_key: str, token: str, timestamp: str) -> str:
|
||||
return hmac.new(
|
||||
signing_key.encode(),
|
||||
f"{timestamp}{token}".encode(),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def valid_form(signing_key: str = "test-secret"):
|
||||
ts = str(int(time.time()))
|
||||
token = "test-token"
|
||||
sig = make_signature(signing_key, token, ts)
|
||||
return {
|
||||
"token": token,
|
||||
"timestamp": ts,
|
||||
"signature": sig,
|
||||
"sender": "sender@example.com",
|
||||
"recipient": "receipts+user123@example.com",
|
||||
"subject": "Your Meijer Receipt",
|
||||
"body-html": "<p>Thank you for shopping at Meijer</p>",
|
||||
"body-plain": "Thank you for shopping at Meijer",
|
||||
"Message-Id": "<msg-001@example.com>",
|
||||
}
|
||||
|
||||
|
||||
def test_valid_webhook(client, mock_redis):
|
||||
with patch("receiptwitness.api.routes.settings") as mock_settings:
|
||||
mock_settings.mailgun_webhook_signing_key = "test-secret"
|
||||
response = client.post("/inbound/email", data=valid_form())
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "queued"}
|
||||
mock_redis["enqueue"].assert_awaited_once()
|
||||
|
||||
|
||||
def test_invalid_signature(client, mock_redis):
|
||||
with patch("receiptwitness.api.routes.settings") as mock_settings:
|
||||
mock_settings.mailgun_webhook_signing_key = "test-secret"
|
||||
form = valid_form()
|
||||
form["signature"] = "wrong-signature"
|
||||
response = client.post("/inbound/email", data=form)
|
||||
assert response.status_code == 406
|
||||
assert response.json()["detail"] == "Invalid signature"
|
||||
mock_redis["enqueue"].assert_not_awaited()
|
||||
|
||||
|
||||
def test_invalid_recipient_no_plus(client, mock_redis):
|
||||
with patch("receiptwitness.api.routes.settings") as mock_settings:
|
||||
mock_settings.mailgun_webhook_signing_key = "test-secret"
|
||||
form = valid_form()
|
||||
form["recipient"] = "receipts@example.com" # no plus-address
|
||||
response = client.post("/inbound/email", data=form)
|
||||
assert response.status_code == 406
|
||||
assert response.json()["detail"] == "Invalid recipient"
|
||||
mock_redis["enqueue"].assert_not_awaited()
|
||||
|
||||
|
||||
def test_stale_timestamp(client, mock_redis):
|
||||
with patch("receiptwitness.api.routes.settings") as mock_settings:
|
||||
mock_settings.mailgun_webhook_signing_key = "test-secret"
|
||||
ts = str(int(time.time()) - 600) # 10 min old
|
||||
token = "test-token"
|
||||
sig = make_signature("test-secret", token, ts)
|
||||
form = {
|
||||
"token": token,
|
||||
"timestamp": ts,
|
||||
"signature": sig,
|
||||
"sender": "sender@example.com",
|
||||
"recipient": "receipts+user123@example.com",
|
||||
"subject": "Receipt",
|
||||
}
|
||||
response = client.post("/inbound/email", data=form)
|
||||
assert response.status_code == 406
|
||||
assert response.json()["detail"] == "Invalid signature"
|
||||
mock_redis["enqueue"].assert_not_awaited()
|
||||
|
||||
|
||||
def test_invalid_timestamp_returns_406(client, mock_redis):
|
||||
"""Empty timestamp should return 406, not 500."""
|
||||
with patch("receiptwitness.api.routes.settings") as mock_settings:
|
||||
mock_settings.mailgun_webhook_signing_key = "test-secret"
|
||||
form = {
|
||||
"token": "test-token",
|
||||
"timestamp": "",
|
||||
"signature": "any-sig",
|
||||
"sender": "sender@example.com",
|
||||
"recipient": "receipts+user123@example.com",
|
||||
"subject": "Receipt",
|
||||
}
|
||||
response = client.post("/inbound/email", data=form)
|
||||
assert response.status_code == 406
|
||||
assert response.json()["detail"] == "Invalid signature"
|
||||
mock_redis["enqueue"].assert_not_awaited()
|
||||
|
||||
|
||||
def test_get_inbound_email_returns_405(client):
|
||||
"""GET /inbound/email is not allowed."""
|
||||
response = client.get("/inbound/email")
|
||||
assert response.status_code == 405
|
||||
@@ -0,0 +1,46 @@
|
||||
import pytest
|
||||
from receiptwitness.config import ReceiptWitnessSettings
|
||||
|
||||
|
||||
def test_valid_config():
|
||||
s = ReceiptWitnessSettings(
|
||||
session_encryption_key="7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8="
|
||||
)
|
||||
assert s.session_encryption_key
|
||||
|
||||
|
||||
def test_missing_session_encryption_key_raises():
|
||||
with pytest.raises(ValueError, match="RW_SESSION_ENCRYPTION_KEY"):
|
||||
ReceiptWitnessSettings(session_encryption_key="")
|
||||
|
||||
|
||||
def test_placeholder_session_encryption_key_raises():
|
||||
with pytest.raises(ValueError, match="RW_SESSION_ENCRYPTION_KEY"):
|
||||
ReceiptWitnessSettings(session_encryption_key="change-me-in-production")
|
||||
|
||||
|
||||
def test_notifications_enabled_without_resend_key_raises():
|
||||
with pytest.raises(ValueError, match="RW_RESEND_API_KEY"):
|
||||
ReceiptWitnessSettings(
|
||||
session_encryption_key="7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8=",
|
||||
notifications_enabled=True,
|
||||
resend_api_key="",
|
||||
)
|
||||
|
||||
|
||||
def test_notifications_disabled_without_resend_key_ok():
|
||||
s = ReceiptWitnessSettings(
|
||||
session_encryption_key="7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8=",
|
||||
notifications_enabled=False,
|
||||
resend_api_key="",
|
||||
)
|
||||
assert s.notifications_enabled is False
|
||||
|
||||
|
||||
def test_notifications_enabled_with_resend_key_ok():
|
||||
s = ReceiptWitnessSettings(
|
||||
session_encryption_key="7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8=",
|
||||
notifications_enabled=True,
|
||||
resend_api_key="re_test_1234567890",
|
||||
)
|
||||
assert s.resend_api_key == "re_test_1234567890"
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Tests for email notifications."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestSendReceiptNotification:
|
||||
@pytest.fixture
|
||||
def mock_resend(self):
|
||||
with patch("receiptwitness.notifications.email.resend") as mock:
|
||||
yield mock
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sends_email_with_correct_params(self, mock_resend):
|
||||
from receiptwitness.notifications.email import send_receipt_notification
|
||||
|
||||
with (
|
||||
patch("receiptwitness.notifications.email.settings") as mock_settings,
|
||||
patch(
|
||||
"receiptwitness.notifications.email.asyncio.to_thread",
|
||||
new=lambda fn, *args, **kwargs: fn(*args, **kwargs),
|
||||
),
|
||||
):
|
||||
mock_settings.notifications_enabled = True
|
||||
mock_settings.resend_api_key = "re_testkey_123"
|
||||
mock_settings.notification_email_from = "noreply@test.com"
|
||||
|
||||
await send_receipt_notification(
|
||||
user_email="user@example.com",
|
||||
store_name="Meijer",
|
||||
item_count=5,
|
||||
total=42.99,
|
||||
purchase_date="2026-03-28",
|
||||
)
|
||||
|
||||
mock_resend.Emails.send.assert_called_once_with(
|
||||
{
|
||||
"from": "noreply@test.com",
|
||||
"to": ["user@example.com"],
|
||||
"subject": "Receipt processed: Meijer - $42.99",
|
||||
"html": (
|
||||
"<p>Your receipt from <strong>Meijer</strong> on "
|
||||
"2026-03-28 has been processed.</p>"
|
||||
"<p>5 items, total: $42.99</p>"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_when_disabled(self, mock_resend):
|
||||
from receiptwitness.notifications.email import send_receipt_notification
|
||||
|
||||
with patch("receiptwitness.notifications.email.settings") as mock_settings:
|
||||
mock_settings.notifications_enabled = False
|
||||
mock_settings.resend_api_key = "re_testkey_123"
|
||||
|
||||
await send_receipt_notification(
|
||||
user_email="user@example.com",
|
||||
store_name="Meijer",
|
||||
item_count=5,
|
||||
total=42.99,
|
||||
purchase_date="2026-03-28",
|
||||
)
|
||||
|
||||
mock_resend.Emails.send.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_when_api_key_empty(self, mock_resend):
|
||||
from receiptwitness.notifications.email import send_receipt_notification
|
||||
|
||||
with patch("receiptwitness.notifications.email.settings") as mock_settings:
|
||||
mock_settings.notifications_enabled = True
|
||||
mock_settings.resend_api_key = ""
|
||||
|
||||
await send_receipt_notification(
|
||||
user_email="user@example.com",
|
||||
store_name="Meijer",
|
||||
item_count=5,
|
||||
total=42.99,
|
||||
purchase_date="2026-03-28",
|
||||
)
|
||||
|
||||
mock_resend.Emails.send.assert_not_called()
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Tests for retailer detector."""
|
||||
|
||||
from receiptwitness.parsers.email.base import EmailReceipt
|
||||
from receiptwitness.parsers.email.detector import detect_retailer
|
||||
|
||||
|
||||
def test_detect_meijer():
|
||||
email = EmailReceipt(
|
||||
sender="receipts@meijer.com",
|
||||
recipient="user@example.com",
|
||||
subject="Your Receipt",
|
||||
)
|
||||
assert detect_retailer(email) == "meijer"
|
||||
|
||||
|
||||
def test_detect_kroger():
|
||||
email = EmailReceipt(
|
||||
sender="noreply@email.kroger.com",
|
||||
recipient="user@example.com",
|
||||
subject="Your Receipt",
|
||||
)
|
||||
assert detect_retailer(email) == "kroger"
|
||||
|
||||
|
||||
def test_detect_target():
|
||||
email = EmailReceipt(
|
||||
sender="Target <receipts@target.com>",
|
||||
recipient="user@example.com",
|
||||
subject="Your Receipt",
|
||||
)
|
||||
assert detect_retailer(email) == "target"
|
||||
|
||||
|
||||
def test_detect_unknown():
|
||||
email = EmailReceipt(
|
||||
sender="noreply@walmart.com",
|
||||
recipient="user@example.com",
|
||||
subject="Your Receipt",
|
||||
)
|
||||
assert detect_retailer(email) is None
|
||||
|
||||
|
||||
def test_detect_case_insensitive():
|
||||
email = EmailReceipt(
|
||||
sender="Receipts@MEIJER.COM",
|
||||
recipient="user@example.com",
|
||||
subject="Your Receipt",
|
||||
)
|
||||
assert detect_retailer(email) == "meijer"
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Tests for KrogerEmailParser."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from receiptwitness.parsers.email.base import EmailReceipt
|
||||
from receiptwitness.parsers.email.kroger import KrogerEmailParser
|
||||
|
||||
FIXTURE_PATH = Path(__file__).parent.parent.parent / "fixtures" / "kroger_email_receipt.html"
|
||||
|
||||
|
||||
class TestKrogerEmailParser:
|
||||
"""Tests for KrogerEmailParser."""
|
||||
|
||||
def setup_method(self) -> None:
|
||||
self.parser = KrogerEmailParser()
|
||||
self.fixture_html = FIXTURE_PATH.read_text()
|
||||
|
||||
def test_can_parse_kroger_sender(self) -> None:
|
||||
email = EmailReceipt(
|
||||
sender="noreply@email.kroger.com",
|
||||
recipient="user@example.com",
|
||||
subject="Your Kroger Receipt",
|
||||
body_html=self.fixture_html,
|
||||
)
|
||||
assert self.parser.can_parse(email) is True
|
||||
|
||||
def test_can_parse_kroger_in_body(self) -> None:
|
||||
email = EmailReceipt(
|
||||
sender="someone@unknown.com",
|
||||
recipient="user@example.com",
|
||||
subject="Your Receipt",
|
||||
body_html="<html><body>Kroger digital receipt</body></html>",
|
||||
)
|
||||
assert self.parser.can_parse(email) is True
|
||||
|
||||
def test_cannot_parse_unrelated(self) -> None:
|
||||
email = EmailReceipt(
|
||||
sender="noreply@walmart.com",
|
||||
recipient="user@example.com",
|
||||
subject="Your Receipt",
|
||||
body_html="<html><body>Walmart receipt</body></html>",
|
||||
)
|
||||
assert self.parser.can_parse(email) is False
|
||||
|
||||
def test_parse_items(self) -> None:
|
||||
email = EmailReceipt(
|
||||
sender="noreply@kroger.com",
|
||||
recipient="user@example.com",
|
||||
subject="Your Kroger Receipt",
|
||||
body_html=self.fixture_html,
|
||||
)
|
||||
result = self.parser.parse(email)
|
||||
items = result.get("items", [])
|
||||
assert len(items) >= 3
|
||||
product_names = [item["product_name_raw"] for item in items]
|
||||
assert any("Whole Milk" in name for name in product_names)
|
||||
assert any("Sourdough" in name for name in product_names)
|
||||
for item in items:
|
||||
assert "unit_price" in item
|
||||
assert "extended_price" in item
|
||||
|
||||
def test_parse_totals(self) -> None:
|
||||
email = EmailReceipt(
|
||||
sender="noreply@kroger.com",
|
||||
recipient="user@example.com",
|
||||
subject="Your Kroger Receipt",
|
||||
body_html=self.fixture_html,
|
||||
)
|
||||
result = self.parser.parse(email)
|
||||
total = result.get("total", 0)
|
||||
assert total > 0
|
||||
|
||||
def test_parse_receipt_id(self) -> None:
|
||||
email = EmailReceipt(
|
||||
sender="noreply@kroger.com",
|
||||
recipient="user@example.com",
|
||||
subject="Your Kroger Receipt",
|
||||
body_html=self.fixture_html,
|
||||
)
|
||||
result = self.parser.parse(email)
|
||||
receipt_id = result.get("receipt_id", "")
|
||||
assert "KR-2026" in receipt_id or "TXN" in receipt_id
|
||||
|
||||
def test_parse_date(self) -> None:
|
||||
email = EmailReceipt(
|
||||
sender="noreply@kroger.com",
|
||||
recipient="user@example.com",
|
||||
subject="Your Kroger Receipt",
|
||||
body_html=self.fixture_html,
|
||||
)
|
||||
result = self.parser.parse(email)
|
||||
purchase_date = result.get("purchase_date", "")
|
||||
assert purchase_date == "2026-03-15"
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Tests for the Meijer email receipt parser."""
|
||||
|
||||
import os
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from receiptwitness.parsers.email.base import EmailReceipt
|
||||
from receiptwitness.parsers.email.meijer import MeijerEmailParser
|
||||
|
||||
FIXTURE_PATH = os.path.join(
|
||||
os.path.dirname(__file__), "..", "..", "fixtures", "meijer_email_receipt.html"
|
||||
)
|
||||
|
||||
|
||||
def load_fixture() -> str:
|
||||
with open(FIXTURE_PATH) as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def meijer_email() -> EmailReceipt:
|
||||
html = load_fixture()
|
||||
return EmailReceipt(
|
||||
sender="Meijer Receipts <receipts@email.meijer.com>",
|
||||
recipient="shopper@example.com",
|
||||
subject="Your Meijer Receipt — Transaction #TXN-2026-0315-0042",
|
||||
body_html=html,
|
||||
body_plain=None,
|
||||
received_at="2026-03-15T14:34:00Z",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kroger_email() -> EmailReceipt:
|
||||
return EmailReceipt(
|
||||
sender="Kroger <noreply@email.kroger.com>",
|
||||
recipient="shopper@example.com",
|
||||
subject="Your Kroger Receipt",
|
||||
body_html="<html><body>Kroger receipt</body></html>",
|
||||
)
|
||||
|
||||
|
||||
class TestCanParse:
|
||||
def test_can_parse_meijer(self, meijer_email: EmailReceipt):
|
||||
parser = MeijerEmailParser()
|
||||
assert parser.can_parse(meijer_email) is True
|
||||
|
||||
def test_cannot_parse_kroger(self, kroger_email: EmailReceipt):
|
||||
parser = MeijerEmailParser()
|
||||
assert parser.can_parse(kroger_email) is False
|
||||
|
||||
def test_can_parse_meijer_plain_sender(self):
|
||||
email = EmailReceipt(
|
||||
sender="receipts@meijer.com",
|
||||
recipient="shopper@example.com",
|
||||
subject="Receipt",
|
||||
body_html="<html></html>",
|
||||
)
|
||||
parser = MeijerEmailParser()
|
||||
assert parser.can_parse(email) is True
|
||||
|
||||
def test_cannot_parse_non_meijer(self):
|
||||
email = EmailReceipt(
|
||||
sender=" Target <no-reply@target.com>",
|
||||
recipient="shopper@example.com",
|
||||
subject="Target Receipt",
|
||||
body_html="<html></html>",
|
||||
)
|
||||
parser = MeijerEmailParser()
|
||||
assert parser.can_parse(email) is False
|
||||
|
||||
|
||||
class TestParseMeijerReceipt:
|
||||
def test_receipt_id_extracted(self, meijer_email: EmailReceipt):
|
||||
parser = MeijerEmailParser()
|
||||
result = parser.parse(meijer_email)
|
||||
assert result["receipt_id"] == "TXN-2026-0315-0042"
|
||||
|
||||
def test_purchase_date_extracted(self, meijer_email: EmailReceipt):
|
||||
parser = MeijerEmailParser()
|
||||
result = parser.parse(meijer_email)
|
||||
assert result["purchase_date"] == "2026-03-15"
|
||||
|
||||
def test_items_extracted(self, meijer_email: EmailReceipt):
|
||||
parser = MeijerEmailParser()
|
||||
result = parser.parse(meijer_email)
|
||||
items = result["items"]
|
||||
assert len(items) == 8
|
||||
|
||||
names = [item["product_name_raw"] for item in items]
|
||||
assert "ORGANIC BANANAS" in names
|
||||
assert "WHOLE MILK 1 GAL" in names
|
||||
assert "GROUND BEEF 85/15 1LB" in names
|
||||
|
||||
def test_item_quantities(self, meijer_email: EmailReceipt):
|
||||
parser = MeijerEmailParser()
|
||||
result = parser.parse(meijer_email)
|
||||
# Find ORGANIC BANANAS
|
||||
bananas = next(i for i in result["items"] if "BANANAS" in i["product_name_raw"])
|
||||
assert bananas["quantity"] == Decimal("1")
|
||||
|
||||
def test_item_prices(self, meijer_email: EmailReceipt):
|
||||
parser = MeijerEmailParser()
|
||||
result = parser.parse(meijer_email)
|
||||
# Find ORGANIC BANANAS
|
||||
bananas = next(i for i in result["items"] if "BANANAS" in i["product_name_raw"])
|
||||
assert bananas["unit_price"] == Decimal("0.69")
|
||||
assert bananas["extended_price"] == Decimal("0.69")
|
||||
|
||||
def test_totals(self, meijer_email: EmailReceipt):
|
||||
parser = MeijerEmailParser()
|
||||
result = parser.parse(meijer_email)
|
||||
assert result["total"] == Decimal("33.41")
|
||||
assert result["subtotal"] == Decimal("31.22")
|
||||
assert result["tax"] == Decimal("2.19")
|
||||
assert result["savings_total"] == Decimal("3.40")
|
||||
|
||||
|
||||
class TestParseHandlesMissingFields:
|
||||
def test_missing_body_html_falls_back_to_plain(self):
|
||||
email = EmailReceipt(
|
||||
sender="receipts@email.meijer.com",
|
||||
recipient="shopper@example.com",
|
||||
subject="Your Meijer Receipt",
|
||||
body_html=None,
|
||||
body_plain="TXN-1234 | March 15, 2026 | Total: $10.00",
|
||||
)
|
||||
parser = MeijerEmailParser()
|
||||
result = parser.parse(email)
|
||||
# Should not raise, returns minimal result
|
||||
assert result["receipt_id"] == ""
|
||||
assert result["purchase_date"] == "2026-03-15"
|
||||
assert result["total"] == Decimal("10.00")
|
||||
|
||||
def test_empty_email(self):
|
||||
email = EmailReceipt(
|
||||
sender="receipts@email.meijer.com",
|
||||
recipient="shopper@example.com",
|
||||
subject="Receipt",
|
||||
body_html="",
|
||||
body_plain="",
|
||||
)
|
||||
parser = MeijerEmailParser()
|
||||
result = parser.parse(email)
|
||||
assert result["receipt_id"] == ""
|
||||
assert result["purchase_date"] == ""
|
||||
assert result["total"] == Decimal("0")
|
||||
assert result["items"] == []
|
||||
|
||||
def test_missing_subject_date_from_body(self):
|
||||
html = """
|
||||
<html>
|
||||
<body>
|
||||
<p>Thank you for shopping on April 1, 2026</p>
|
||||
<p>Total: $15.00</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
email = EmailReceipt(
|
||||
sender="receipts@email.meijer.com",
|
||||
recipient="shopper@example.com",
|
||||
subject=None,
|
||||
body_html=html,
|
||||
)
|
||||
parser = MeijerEmailParser()
|
||||
result = parser.parse(email)
|
||||
assert result["purchase_date"] == "2026-04-01"
|
||||
|
||||
def test_missing_totals_defaults_to_zero(self):
|
||||
html = "<html><body><p>Just an email with no totals</p></body></html>"
|
||||
email = EmailReceipt(
|
||||
sender="receipts@email.meijer.com",
|
||||
recipient="shopper@example.com",
|
||||
subject="Receipt",
|
||||
body_html=html,
|
||||
)
|
||||
parser = MeijerEmailParser()
|
||||
result = parser.parse(email)
|
||||
assert result["total"] == Decimal("0")
|
||||
assert result["subtotal"] is None
|
||||
assert result["tax"] is None
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Tests for TargetEmailParser."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from receiptwitness.parsers.email.base import EmailReceipt
|
||||
from receiptwitness.parsers.email.target import TargetEmailParser
|
||||
|
||||
FIXTURE_PATH = Path(__file__).parent.parent.parent / "fixtures" / "target_email_receipt.html"
|
||||
|
||||
|
||||
class TestTargetEmailParser:
|
||||
"""Tests for TargetEmailParser."""
|
||||
|
||||
def setup_method(self) -> None:
|
||||
self.parser = TargetEmailParser()
|
||||
self.fixture_html = FIXTURE_PATH.read_text()
|
||||
|
||||
def test_can_parse_target_sender(self) -> None:
|
||||
email = EmailReceipt(
|
||||
sender="receipts@target.com",
|
||||
recipient="user@example.com",
|
||||
subject="Your Target Order Confirmation",
|
||||
body_html=self.fixture_html,
|
||||
)
|
||||
assert self.parser.can_parse(email) is True
|
||||
|
||||
def test_can_parse_circle_in_body(self) -> None:
|
||||
email = EmailReceipt(
|
||||
sender="someone@unknown.com",
|
||||
recipient="user@example.com",
|
||||
subject="Your Receipt",
|
||||
body_html="<html><body>Target Circle savings offer</body></html>",
|
||||
)
|
||||
assert self.parser.can_parse(email) is True
|
||||
|
||||
def test_cannot_parse_unrelated(self) -> None:
|
||||
email = EmailReceipt(
|
||||
sender="noreply@walmart.com",
|
||||
recipient="user@example.com",
|
||||
subject="Your Receipt",
|
||||
body_html="<html><body>Walmart receipt</body></html>",
|
||||
)
|
||||
assert self.parser.can_parse(email) is False
|
||||
|
||||
def test_parse_items(self) -> None:
|
||||
email = EmailReceipt(
|
||||
sender="orders@target.com",
|
||||
recipient="user@example.com",
|
||||
subject="Your Target Order",
|
||||
body_html=self.fixture_html,
|
||||
)
|
||||
result = self.parser.parse(email)
|
||||
items = result.get("items", [])
|
||||
assert len(items) >= 3
|
||||
product_names = [item["product_name_raw"] for item in items]
|
||||
assert any("Whole Milk" in name for name in product_names)
|
||||
assert any("Arborio" in name for name in product_names)
|
||||
for item in items:
|
||||
assert "unit_price" in item
|
||||
assert "extended_price" in item
|
||||
|
||||
def test_parse_totals(self) -> None:
|
||||
email = EmailReceipt(
|
||||
sender="orders@target.com",
|
||||
recipient="user@example.com",
|
||||
subject="Your Target Order",
|
||||
body_html=self.fixture_html,
|
||||
)
|
||||
result = self.parser.parse(email)
|
||||
total = result.get("total", 0)
|
||||
assert total > 0
|
||||
|
||||
def test_parse_receipt_id(self) -> None:
|
||||
email = EmailReceipt(
|
||||
sender="orders@target.com",
|
||||
recipient="user@example.com",
|
||||
subject="Your Target Order",
|
||||
body_html=self.fixture_html,
|
||||
)
|
||||
result = self.parser.parse(email)
|
||||
receipt_id = result.get("receipt_id", "")
|
||||
assert "TGT-2026" in receipt_id or "CNF" in receipt_id
|
||||
|
||||
def test_parse_date(self) -> None:
|
||||
email = EmailReceipt(
|
||||
sender="orders@target.com",
|
||||
recipient="user@example.com",
|
||||
subject="Your Target Order",
|
||||
body_html=self.fixture_html,
|
||||
)
|
||||
result = self.parser.parse(email)
|
||||
purchase_date = result.get("purchase_date", "")
|
||||
assert purchase_date == "2026-03-18"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user