Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
STRIPE_ENDPOINT_SECRET=whsec_test_xxx
VERCEL_TOKEN=token_xxx
VERCEL_PROJECT_ID=prj_xxx
VERCEL_ORG_ID=team_xxx
TRYONYOU_DB_PATH=/tmp/tryonyou_leads.sqlite
LOG_LEVEL=INFO
FLASK_DEBUG=0
PORT=8000
LAFAYETTE_VERIFY_BASE_URL=https://example.com/verify/
JULES_CRON_TOKEN=cron_token_xxx
GEMINI_API_KEY=gemini_xxx
GMAIL_CLIENT_ID=gmail_client_id_xxx
GMAIL_CLIENT_SECRET=gmail_client_secret_xxx
GMAIL_REFRESH_TOKEN=gmail_refresh_token_xxx
GOOGLE_SERVICE_ACCOUNT_JSON={"type":"service_account"}
PENNYLANE_API_KEY=pennylane_xxx
SPREADSHEET_ID=spreadsheet_xxx
PAU_TTS_ENDPOINT=https://example.com/tts
PAU_TTS_FALLBACK_AUDIO_URL=https://example.com/audio.mp3
MAX_ENVIOS_DIARIOS=50
VITE_OAUTH_PORTAL_URL=https://example.com/oauth
VITE_APP_ID=app_xxx
VITE_FRONTEND_FORGE_API_KEY=forge_xxx
VITE_FRONTEND_FORGE_API_URL=https://example.com/forge
VITE_ANALYTICS_ENDPOINT=https://analytics.example.com
VITE_ANALYTICS_WEBSITE_ID=website_xxx
TRYONYOU_VERCEL=1
BUILT_IN_FORGE_API_URL=
BUILT_IN_FORGE_API_KEY=
LINEAR_API_KEY=
LINEAR_PROJECT_ID=
LINEAR_TEAM_ID=
LINEAR_API_TOKEN=
LINEAR_PAGE_SIZE=50
LINEAR_MAX_PAGES=10
LINEAR_SYNC_CRON_MS=
GOOGLE_SHEETS_ID=
GOOGLE_SHEETS_ACCESS_TOKEN=
GOOGLE_SHEETS_RANGE=Linear Tasks!A1
JULES_AUDIT_LOG_PATH=
JULES_MAIL_MAX_RESULTS=10
TMPDIR=/tmp
VERCEL_DEPLOYMENT_ID=
6 changes: 5 additions & 1 deletion .idx/dev.nix
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@
# (never commit actual secrets here)
env = {
# ── Backend — Flask API ──────────────────────────────────────────────────
STRIPE_ENDPOINT_SECRET = "";
# Placeholder values only; replace them in IDX settings or `.env.example`.
STRIPE_ENDPOINT_SECRET = "whsec_test_xxx";
VERCEL_TOKEN = "token_xxx";
VERCEL_PROJECT_ID = "prj_xxx";
VERCEL_ORG_ID = "team_xxx";
GEMINI_API_KEY = "";
GMAIL_CLIENT_ID = "";
GMAIL_CLIENT_SECRET = "";
Expand Down
78 changes: 69 additions & 9 deletions api/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

import csv
import json
import logging
import os
import sqlite3
import sys
Expand All @@ -42,14 +43,21 @@

app = Flask(__name__)

LOG_LEVEL = (os.environ.get("LOG_LEVEL") or "INFO").upper()
logging.basicConfig(level=getattr(logging, LOG_LEVEL, logging.INFO))
logger = logging.getLogger(__name__)

DB_PATH = os.environ.get("TRYONYOU_DB_PATH", "/tmp/tryonyou_leads.sqlite")
SIREN = "943 610 196"
PATENT = "PCT/EP2025/067317"
ENDPOINT_SECRET = os.environ.get("STRIPE_ENDPOINT_SECRET", "").strip()
ENDPOINT_SECRET = os.environ.get("STRIPE_ENDPOINT_SECRET")
LAFAYETTE_VERIFY_BASE_URL = os.environ.get(
"LAFAYETTE_VERIFY_BASE_URL", "https://tryonyou.lafayette.demo/verify/"
)

if not ENDPOINT_SECRET:
logger.warning("STRIPE_ENDPOINT_SECRET is not configured; Stripe webhook verification will fail")

_RATE: dict[str, list[float]] = {}
RATE_WINDOW_S = 60.0
RATE_MAX = 6
Expand Down Expand Up @@ -316,18 +324,66 @@ def run_jules_mail() -> Response:
@app.route("/api/webhook", methods=["POST"])
def webhook() -> tuple[Response, int]:
payload = request.get_data(cache=False, as_text=False)
sig_header = request.headers.get("Stripe-Signature", "")
sig_header = (request.headers.get("Stripe-Signature") or "").strip()

if not payload:
logger.warning("Rejected Stripe webhook with empty payload")
return jsonify({"status": "invalid payload"}), 400

if not sig_header:
logger.warning("Rejected Stripe webhook without Stripe-Signature header")
return jsonify({"status": "invalid signature"}), 400

if not ENDPOINT_SECRET:
logger.error("STRIPE_ENDPOINT_SECRET is not configured")
return jsonify({"status": "server misconfigured"}), 500

try:
event = stripe.Webhook.construct_event(payload, sig_header, ENDPOINT_SECRET)
except ValueError:
except ValueError as exc:
logger.warning("Rejected Stripe webhook with invalid payload: %s", exc)
return jsonify({"status": "invalid payload"}), 400
except stripe.error.SignatureVerificationError:
except stripe.error.SignatureVerificationError as exc:
logger.warning("Rejected Stripe webhook with invalid signature: %s", exc)
return jsonify({"status": "invalid signature"}), 400

event_type = event.get("type")
event_type = event.get("type", "unknown")
event_id = event.get("id", "unknown")
event_object = event.get("data", {}).get("object", {})

if event_type == "payment_intent.succeeded":
pass
logger.info(
"Stripe event %s processed: payment_intent.succeeded id=%s amount=%s currency=%s",
event_id,
event_object.get("id"),
event_object.get("amount_received"),
event_object.get("currency"),
)
elif event_type == "payment_intent.payment_failed":
logger.info(
"Stripe event %s processed: payment_intent.payment_failed id=%s last_error=%s",
event_id,
event_object.get("id"),
(event_object.get("last_payment_error") or {}).get("message"),
)
elif event_type == "checkout.session.completed":
logger.info(
"Stripe event %s processed: checkout.session.completed id=%s mode=%s customer=%s",
event_id,
event_object.get("id"),
event_object.get("mode"),
event_object.get("customer"),
)
elif event_type == "charge.refunded":
logger.info(
"Stripe event %s processed: charge.refunded id=%s amount_refunded=%s currency=%s",
event_id,
event_object.get("id"),
event_object.get("amount_refunded"),
event_object.get("currency"),
)
else:
logger.info("Stripe event %s received with unhandled type=%s", event_id, event_type)

return jsonify({"status": "success"}), 200

Expand Down Expand Up @@ -376,12 +432,12 @@ def pau_habla_endpoint() -> Response:
try:
from manoli import manoli_blueprint
except ModuleNotFoundError as e:
print(f"[tryonyou] manoli blueprint module not found: {e}", file=sys.stderr)
logger.warning("manoli blueprint module not found: %s", e)
else:
try:
app.register_blueprint(manoli_blueprint)
except (AssertionError, ValueError) as e:
print(f"[tryonyou] manoli blueprint not registered: {e}", file=sys.stderr)
logger.warning("manoli blueprint not registered: %s", e)


# ─── Lafayette VIP — Efecto Paloma ────────────────────────────────────────────
Expand Down Expand Up @@ -612,4 +668,8 @@ def registrar_metricas() -> Response:
# it as a HTTP handler instead of forwarding to the Flask app.

if __name__ == "__main__": # local dev
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 8000)), debug=True)
app.run(
host="0.0.0.0",
port=int(os.environ.get("PORT", 8000)),
debug=os.environ.get("FLASK_DEBUG") == "1",
)
2 changes: 2 additions & 0 deletions api/requirements.example.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Flask
stripe
31 changes: 20 additions & 11 deletions scripts/deploy_vercel.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
then creates a v13/deployments record targeted at production. No GitHub push.

Env / constants:
- VERCEL_TOKEN : auth token (Bearer)
- VERCEL_TEAM_ID : team_SDhj8kxLVE7oJ3S5KPbwG9uC
- VERCEL_PROJECT : prj_vDPvZ4U1MD4t3CmKxfusBB7md2Fh (id) / tryonyou-app (name)
- VERCEL_TOKEN : auth token (Bearer)
- VERCEL_ORG_ID : Vercel team/org id
- VERCEL_PROJECT_ID : Vercel project id
- VERCEL_PROJECT_NAME (optional): project display name

Bundles:
- dist/public/** → site static (deployed as `*` files, root)
Expand All @@ -27,14 +28,22 @@
from urllib import error as urlerror

ROOT = Path(__file__).resolve().parent.parent
TEAM_ID = "team_SDhj8kxLVE7oJ3S5KPbwG9uC"
PROJECT_ID = "prj_vDPvZ4U1MD4t3CmKxfusBB7md2Fh"
PROJECT_NAME = "tryonyou-app"
TOKEN = os.environ.get("VERCEL_TOKEN") or sys.argv[1] if len(sys.argv) > 1 else os.environ.get("VERCEL_TOKEN", "")

if not TOKEN:
print("ERROR: pass token as $VERCEL_TOKEN or first CLI arg")
sys.exit(2)
VERCEL_TOKEN = os.environ.get("VERCEL_TOKEN")
VERCEL_PROJECT_ID = os.environ.get("VERCEL_PROJECT_ID")
VERCEL_ORG_ID = os.environ.get("VERCEL_ORG_ID")
PROJECT_NAME = (os.environ.get("VERCEL_PROJECT_NAME") or "tryonyou-app").strip()


def _required_env(name: str, value: str | None) -> str:
result = (value or "").strip()
if not result:
raise SystemExit(f"ERROR: set {name} in the environment")
return result


TOKEN = _required_env("VERCEL_TOKEN", VERCEL_TOKEN)
TEAM_ID = _required_env("VERCEL_ORG_ID", VERCEL_ORG_ID)
PROJECT_ID = _required_env("VERCEL_PROJECT_ID", VERCEL_PROJECT_ID)

API = "https://api.vercel.com"
HEADERS_BASE = {"Authorization": f"Bearer {TOKEN}"}
Expand Down
25 changes: 19 additions & 6 deletions scripts/fetch_logs.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,25 @@
import urllib.request
import urllib.parse

TOKEN = os.environ.get("VERCEL_TOKEN", "")
if not TOKEN:
sys.exit("Error: VERCEL_TOKEN environment variable is required. Set it with: export VERCEL_TOKEN=<your_token>")
TEAM = "team_SDhj8kxLVE7oJ3S5KPbwG9uC"
PROJECT = "prj_vDPvZ4U1MD4t3CmKxfusBB7md2Fh"
DEPLOYMENT = sys.argv[1] if len(sys.argv) > 1 else "dpl_6afqyDxd6vgiCT4k4geG5SFDjvQg"
VERCEL_TOKEN = os.environ.get("VERCEL_TOKEN")
VERCEL_PROJECT_ID = os.environ.get("VERCEL_PROJECT_ID")
VERCEL_ORG_ID = os.environ.get("VERCEL_ORG_ID")
VERCEL_DEPLOYMENT_ID = os.environ.get("VERCEL_DEPLOYMENT_ID")


def _required_env(name: str, value: str | None) -> str:
result = (value or "").strip()
if not result:
sys.exit(f"Error: {name} environment variable is required.")
return result


TOKEN = _required_env("VERCEL_TOKEN", VERCEL_TOKEN)
TEAM = _required_env("VERCEL_ORG_ID", VERCEL_ORG_ID)
PROJECT = _required_env("VERCEL_PROJECT_ID", VERCEL_PROJECT_ID)
DEPLOYMENT = sys.argv[1] if len(sys.argv) > 1 else (VERCEL_DEPLOYMENT_ID or "").strip()
if not DEPLOYMENT:
sys.exit("Error: provide the deployment id as the first CLI argument or VERCEL_DEPLOYMENT_ID.")


def call(url):
Expand Down
87 changes: 87 additions & 0 deletions tests/test_stripe_webhook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import json
import time
import unittest
from unittest.mock import patch

import stripe

from api.index import app


def _stripe_signature(secret: str, payload: str, timestamp: int) -> str:
signed_payload = f"{timestamp}.{payload}"
signature = stripe.WebhookSignature._compute_signature(signed_payload, secret)
return f"t={timestamp},v1={signature}"
Comment on lines +1 to +14


class TestStripeWebhook(unittest.TestCase):
def setUp(self):
self.client = app.test_client()
self.secret = "whsec_test_xxx"

def test_rejects_missing_signature_header(self):
with patch("api.index.ENDPOINT_SECRET", self.secret):
response = self.client.post("/api/webhook", data=b"{}")

self.assertEqual(response.status_code, 400)
self.assertEqual(response.get_json(), {"status": "invalid signature"})

def test_rejects_invalid_payload(self):
payload = "not-json"
timestamp = int(time.time())
header = _stripe_signature(self.secret, payload, timestamp)

with patch("api.index.ENDPOINT_SECRET", self.secret):
response = self.client.post(
"/api/webhook",
data=payload.encode("utf-8"),
headers={"Stripe-Signature": header},
)

self.assertEqual(response.status_code, 400)
self.assertEqual(response.get_json(), {"status": "invalid payload"})

def test_accepts_supported_stripe_events(self):
event_types = (
"payment_intent.succeeded",
"payment_intent.payment_failed",
"checkout.session.completed",
"charge.refunded",
)

for event_type in event_types:
with self.subTest(event_type=event_type):
payload = json.dumps(
{
"id": "evt_test_123",
"object": "event",
"type": event_type,
"data": {
"object": {
"id": "obj_test_123",
"amount_received": 1000,
"amount_refunded": 500,
"currency": "eur",
"customer": "cus_test_123",
"last_payment_error": {"message": "Card declined"},
"mode": "payment",
}
},
}
)
timestamp = int(time.time())
header = _stripe_signature(self.secret, payload, timestamp)

with patch("api.index.ENDPOINT_SECRET", self.secret):
response = self.client.post(
"/api/webhook",
data=payload.encode("utf-8"),
headers={"Stripe-Signature": header},
)

self.assertEqual(response.status_code, 200)
self.assertEqual(response.get_json(), {"status": "success"})


if __name__ == "__main__":
unittest.main()
4 changes: 2 additions & 2 deletions todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@

THE PROJECT (single source of truth):
- Local: `/home/ubuntu/tryonyou-app/`
- Vercel: `prj_vDPvZ4U1MD4t3CmKxfusBB7md2Fh` (project name: `tryonyou-app`)
- Vercel: `VERCEL_PROJECT_ID` (project name: `tryonyou-app`)
- Domain: `tryonyou.app` (+ alias `tryonme.app`)

## Phase 1 — Inventaire
Expand All @@ -105,7 +105,7 @@ THE PROJECT (single source of truth):

## Phase 4 — Deploy
- [ ] Build prod + copie médias + strip analytics
- [ ] Deploy sur `prj_vDPvZ4U1MD4t3CmKxfusBB7md2Fh`
- [ ] Deploy sur `VERCEL_PROJECT_ID`

## Phase 5 — Vérif
- [ ] HTTP 200 sur toutes les routes
Expand Down