diff --git a/.cursorignore b/.cursorignore index 6e0ec98..1761c01 100644 --- a/.cursorignore +++ b/.cursorignore @@ -1,2 +1 @@ -/.envrc -**/.envrc \ No newline at end of file +.envrc \ No newline at end of file diff --git a/compose.yaml b/compose.yaml index 7a745f7..ca12dc9 100644 --- a/compose.yaml +++ b/compose.yaml @@ -10,6 +10,7 @@ services: - /app/node_modules environment: - NODE_ENV=development + - NEXT_PUBLIC_STRIPE_KEY=${NEXT_PUBLIC_STRIPE_KEY} depends_on: - backend @@ -21,6 +22,7 @@ services: - LOCALMART_UBER_DIRECT_CUSTOMER_ID=${LOCALMART_UBER_DIRECT_CUSTOMER_ID} - LOCALMART_UBER_DIRECT_CLIENT_ID=${LOCALMART_UBER_DIRECT_CLIENT_ID} - LOCALMART_UBER_DIRECT_CLIENT_SECRET=${LOCALMART_UBER_DIRECT_CLIENT_SECRET} + - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY} - PYTHONPATH=/app ports: - "8000:8000" @@ -30,6 +32,15 @@ services: depends_on: - pocketbase + stripe-cli: + image: stripe/stripe-cli + container_name: stripe-cli + command: "listen --api-key ${STRIPE_SECRET_KEY} --forward-to backend:8000/api/v0/webhooks/stripe" + environment: + - STRIPE_API_KEY=${STRIPE_SECRET_KEY} + depends_on: + - backend + pocketbase: image: ghcr.io/muchobien/pocketbase:latest ports: @@ -68,4 +79,4 @@ services: - meilisearch volumes: - pocketbase_data: + pocketbase_data: \ No newline at end of file diff --git a/db/pb_migrations/1739301952_created_payment_methods.js b/db/pb_migrations/1739301952_created_payment_methods.js new file mode 100644 index 0000000..a5d16fe --- /dev/null +++ b/db/pb_migrations/1739301952_created_payment_methods.js @@ -0,0 +1,115 @@ +/// +migrate((app) => { + const collection = new Collection({ + "createRule": "@request.auth.id != ''", + "deleteRule": "@request.auth.id = user.id", + "fields": [ + { + "id": "user_relation", + "name": "user", + "type": "relation", + "required": true, + "presentable": false, + "system": false, + "cascadeDelete": true, + "collectionId": "_pb_users_auth_", + "maxSelect": 1, + "minSelect": 1 + }, + { + "id": "stripe_payment_method", + "name": "stripe_payment_method_id", + "type": "text", + "required": true, + "presentable": false, + "system": false, + "hidden": false + }, + { + "id": "last4", + "name": "last4", + "type": "text", + "required": true, + "presentable": false, + "system": false, + "hidden": false + }, + { + "id": "brand", + "name": "brand", + "type": "text", + "required": true, + "presentable": false, + "system": false, + "hidden": false + }, + { + "id": "exp_month", + "name": "exp_month", + "type": "number", + "required": true, + "presentable": false, + "system": false, + "hidden": false, + "min": 1, + "max": 12, + "onlyInt": true + }, + { + "id": "exp_year", + "name": "exp_year", + "type": "number", + "required": true, + "presentable": false, + "system": false, + "hidden": false, + "min": 2024, + "onlyInt": true + }, + { + "id": "is_default", + "name": "is_default", + "type": "bool", + "required": true, + "presentable": false, + "system": false, + "hidden": false + }, + { + "id": "created", + "name": "created", + "type": "autodate", + "required": false, + "presentable": false, + "system": false, + "hidden": false, + "onCreate": true, + "onUpdate": false + }, + { + "id": "updated", + "name": "updated", + "type": "autodate", + "required": false, + "presentable": false, + "system": false, + "hidden": false, + "onCreate": true, + "onUpdate": true + } + ], + "id": "payment_methods", + "indexes": ["CREATE UNIQUE INDEX idx_unique_payment_method ON payment_methods (user, stripe_payment_method_id)"], + "listRule": "@request.auth.id = user.id", + "name": "payment_methods", + "system": false, + "type": "base", + "updateRule": "@request.auth.id = user.id", + "viewRule": "@request.auth.id = user.id" + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("payment_methods"); + return app.delete(collection); +}) \ No newline at end of file diff --git a/db/pb_migrations/1739302173_updated_orders.js b/db/pb_migrations/1739302173_updated_orders.js new file mode 100644 index 0000000..9826e95 --- /dev/null +++ b/db/pb_migrations/1739302173_updated_orders.js @@ -0,0 +1,59 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_3527180448"); + + // add payment intent field + collection.fields.addAt(collection.fields.length, new Field({ + "id": "stripe_payment_intent", + "name": "stripe_payment_intent_id", + "type": "text", + "required": false, + "presentable": false, + "system": false, + "hidden": false, + "autogeneratePattern": "", + "max": 0, + "min": 0, + "pattern": "", + "primaryKey": false + })); + + // add payment method relation + collection.fields.addAt(collection.fields.length, new Field({ + "id": "stripe_payment_method", + "name": "payment_method", + "type": "relation", + "required": false, + "presentable": false, + "system": false, + "hidden": false, + "collectionId": "payment_methods", + "cascadeDelete": false, + "maxSelect": 1, + "minSelect": 0 + })); + + // add payment status field + collection.fields.addAt(collection.fields.length, new Field({ + "id": "stripe_payment_status", + "name": "payment_status", + "type": "select", + "required": true, + "presentable": false, + "system": false, + "hidden": false, + "values": ["pending", "processing", "succeeded", "failed", "refunded"], + "maxSelect": 1 + })); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_3527180448"); + + // remove fields + collection.fields.removeById("stripe_payment_intent"); + collection.fields.removeById("stripe_payment_method"); + collection.fields.removeById("stripe_payment_status"); + + return app.save(collection); +}) \ No newline at end of file diff --git a/db/pb_migrations/1739302174_created_stripe_customers.js b/db/pb_migrations/1739302174_created_stripe_customers.js new file mode 100644 index 0000000..f50d6f4 --- /dev/null +++ b/db/pb_migrations/1739302174_created_stripe_customers.js @@ -0,0 +1,65 @@ +/// +migrate((app) => { + const collection = new Collection({ + "createRule": "@request.auth.id != ''", + "deleteRule": "@request.auth.id = user.id", + "fields": [ + { + "id": "user_relation", + "name": "user", + "type": "relation", + "required": true, + "presentable": false, + "system": false, + "cascadeDelete": true, + "collectionId": "_pb_users_auth_", + "maxSelect": 1, + "minSelect": 1 + }, + { + "id": "stripe_customer", + "name": "stripe_customer_id", + "type": "text", + "required": true, + "presentable": false, + "system": false, + "hidden": false + }, + { + "id": "created", + "name": "created", + "type": "autodate", + "required": false, + "presentable": false, + "system": false, + "hidden": false, + "onCreate": true, + "onUpdate": false + }, + { + "id": "updated", + "name": "updated", + "type": "autodate", + "required": false, + "presentable": false, + "system": false, + "hidden": false, + "onCreate": true, + "onUpdate": true + } + ], + "id": "stripe_customers", + "indexes": ["CREATE UNIQUE INDEX idx_unique_customer ON stripe_customers (user, stripe_customer_id)"], + "listRule": "@request.auth.id = user.id", + "name": "stripe_customers", + "system": false, + "type": "base", + "updateRule": "@request.auth.id = user.id", + "viewRule": "@request.auth.id = user.id" + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("stripe_customers"); + return app.delete(collection); +}) \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0163611..3fe5599 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,8 @@ "dependencies": { "@headlessui/react": "^2.2.0", "@heroicons/react": "^2.2.0", + "@stripe/react-stripe-js": "^3.1.1", + "@stripe/stripe-js": "^5.6.0", "next": "15.1.5", "pocketbase": "^0.25.1", "react": "^19.0.0", @@ -1063,6 +1065,29 @@ "dev": true, "license": "MIT" }, + "node_modules/@stripe/react-stripe-js": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-3.1.1.tgz", + "integrity": "sha512-+JzYFgUivVD7koqYV7LmLlt9edDMAwKH7XhZAHFQMo7NeRC+6D2JmQGzp9tygWerzwttwFLlExGp4rAOvD6l9g==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "@stripe/stripe-js": "^1.44.1 || ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", + "react": ">=16.8.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@stripe/stripe-js": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-5.6.0.tgz", + "integrity": "sha512-w8CEY73X/7tw2KKlL3iOk679V9bWseE4GzNz3zlaYxcTjmcmWOathRb0emgo/QQ3eoNzmq68+2Y2gxluAv3xGw==", + "license": "MIT", + "engines": { + "node": ">=12.16" + } + }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -3898,7 +3923,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -4055,7 +4079,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -4278,7 +4301,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4756,7 +4778,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -4846,7 +4867,6 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, "node_modules/read-cache": { diff --git a/frontend/package.json b/frontend/package.json index fcda669..2118c43 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,8 @@ "dependencies": { "@headlessui/react": "^2.2.0", "@heroicons/react": "^2.2.0", + "@stripe/react-stripe-js": "^3.1.1", + "@stripe/stripe-js": "^5.6.0", "next": "15.1.5", "pocketbase": "^0.25.1", "react": "^19.0.0", diff --git a/frontend/src/app/orders/page.tsx b/frontend/src/app/orders/page.tsx index fc2991c..c861881 100644 --- a/frontend/src/app/orders/page.tsx +++ b/frontend/src/app/orders/page.tsx @@ -20,6 +20,7 @@ interface Order { id: string; created: string; status: string; + payment_status: string; delivery_fee: number; total_amount: number; customer_name: string; @@ -56,6 +57,22 @@ const statusLabels = { cancelled: 'Cancelled', }; +const paymentStatusColors = { + pending: 'bg-yellow-100 text-yellow-800', + processing: 'bg-blue-100 text-blue-800', + succeeded: 'bg-green-100 text-green-800', + failed: 'bg-red-100 text-red-800', + refunded: 'bg-gray-100 text-gray-800', +}; + +const paymentStatusLabels = { + pending: 'Pending', + processing: 'Processing', + succeeded: 'Succeeded', + failed: 'Failed', + refunded: 'Refunded', +}; + const formatDateTime = (isoString: string) => { // Parse the UTC time string and create a Date object const utcDate = new Date(isoString + 'Z'); // Ensure UTC interpretation by appending Z @@ -189,6 +206,9 @@ export default function OrdersDashboard() { Status + + Payment + Items @@ -242,6 +262,11 @@ export default function OrdersDashboard() { + + + {paymentStatusLabels[order.payment_status as keyof typeof paymentStatusLabels]} + +
{order.stores.map((store) => ( diff --git a/frontend/src/app/profile/page.tsx b/frontend/src/app/profile/page.tsx index 846453e..4f1bc07 100644 --- a/frontend/src/app/profile/page.tsx +++ b/frontend/src/app/profile/page.tsx @@ -4,6 +4,11 @@ import { useState, useEffect } from 'react'; import { useAuth } from '@/app/contexts/auth'; import { toast } from 'react-hot-toast'; import { config } from '@/config'; +import { loadStripe } from '@stripe/stripe-js'; +import { Elements, useStripe, useElements, CardElement } from '@stripe/react-stripe-js'; + +// Initialize Stripe +const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_KEY!); interface UserProfile { first_name: string; @@ -16,10 +21,117 @@ interface UserProfile { zip: string; } +interface SavedCard { + id: string; + last4: string; + brand: string; + exp_month: number; + exp_year: number; + isDefault: boolean; +} + +// Card form component +function CardForm({ onSuccess }: { onSuccess: () => void }) { + const stripe = useStripe(); + const elements = useElements(); + const { user } = useAuth(); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!stripe || !elements || !user?.token) return; + + setLoading(true); + try { + // Get setup intent from backend + const setupResponse = await fetch(`${config.apiUrl}/api/v0/payment/setup-intent`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${user.token}`, + } + }); + + if (!setupResponse.ok) { + throw new Error('Failed to create setup intent'); + } + + const { clientSecret } = await setupResponse.json(); + + // Confirm card setup + const result = await stripe.confirmCardSetup(clientSecret, { + payment_method: { + card: elements.getElement(CardElement)!, + billing_details: { + name: user.name, + }, + }, + }); + + if (result.error) { + throw new Error(result.error.message); + } + + // Notify backend of successful setup + const attachResponse = await fetch(`${config.apiUrl}/api/v0/payment/cards`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${user.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + payment_method_id: result.setupIntent!.payment_method, + }), + }); + + if (!attachResponse.ok) { + throw new Error('Failed to save card'); + } + + toast.success('Card added successfully'); + onSuccess(); + } catch (error) { + console.error('Error adding card:', error); + toast.error(error instanceof Error ? error.message : 'Failed to add card'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ +
+ +
+ ); +} + export default function ProfilePage() { const { user } = useAuth(); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); + const [showCardForm, setShowCardForm] = useState(false); + const [savedCards, setSavedCards] = useState([]); const [profile, setProfile] = useState({ first_name: '', last_name: '', @@ -87,6 +199,42 @@ export default function ProfilePage() { fetchProfile(); }, [user]); + const fetchCards = async () => { + if (!user?.token) return; + + try { + const response = await fetch(`${config.apiUrl}/api/v0/payment/cards`, { + headers: { + 'Authorization': `Bearer ${user.token}` + } + }); + + if (response.status === 404) { + // No cards is a valid state + setSavedCards([]); + return; + } + + if (!response.ok) { + console.error('Error fetching cards:', response.status, response.statusText); + const errorData = await response.json().catch(() => ({})); + console.error('Error details:', errorData); + throw new Error('Failed to fetch cards'); + } + + const data = await response.json(); + setSavedCards(Array.isArray(data) ? data : []); + } catch (error) { + console.error('Error fetching cards:', error); + // Don't show error toast for no cards + setSavedCards([]); + } + }; + + useEffect(() => { + fetchCards(); + }, [user]); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!user?.token) return; @@ -115,6 +263,29 @@ export default function ProfilePage() { } }; + const handleRemoveCard = async (cardId: string) => { + if (!user?.token) return; + + try { + const response = await fetch(`${config.apiUrl}/api/v0/payment/cards/${cardId}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${user.token}`, + }, + }); + + if (!response.ok) { + throw new Error('Failed to remove card'); + } + + toast.success('Card removed successfully'); + setSavedCards(cards => cards.filter(card => card.id !== cardId)); + } catch (error) { + console.error('Error removing card:', error); + toast.error('Failed to remove card'); + } + }; + if (loading) { return (
@@ -298,6 +469,61 @@ export default function ProfilePage() {
+ + {/* Payment Methods Section - Outside the profile form */} +
+

+ + + + + Payment Methods +

+ + {/* Saved Cards */} +
+ {savedCards.map((card) => ( +
+
+
+

+ {card.brand.charAt(0).toUpperCase() + card.brand.slice(1)} •••• {card.last4} +

+

+ Expires {card.exp_month.toString().padStart(2, '0')}/{card.exp_year} +

+
+
+ +
+ ))} +
+ + {/* Add Card Section */} + {showCardForm ? ( + + { + setShowCardForm(false); + fetchCards(); + }} + /> + + ) : ( + + )} +
diff --git a/frontend/src/components/CartModal.tsx b/frontend/src/components/CartModal.tsx index 31f6641..d298338 100644 --- a/frontend/src/components/CartModal.tsx +++ b/frontend/src/components/CartModal.tsx @@ -18,11 +18,22 @@ interface Store { name: string; } +interface SavedCard { + id: string; + last4: string; + brand: string; + exp_month: number; + exp_year: number; + isDefault: boolean; +} + export default function CartModal({ isOpen, onClose }: CartModalProps) { const { items, updateQuantity, removeItem, totalPrice, clearCart } = useCart(); const { user } = useAuth(); const [store, setStore] = useState(null); const [isCheckingOut, setIsCheckingOut] = useState(false); + const [savedCards, setSavedCards] = useState([]); + const [selectedCardId, setSelectedCardId] = useState(''); // Fetch store details when items change useEffect(() => { @@ -49,6 +60,49 @@ export default function CartModal({ isOpen, onClose }: CartModalProps) { fetchStore(); }, [items]); + // Fetch saved cards when modal opens + useEffect(() => { + const fetchCards = async () => { + if (!user?.token) return; + + try { + const response = await fetch(`${config.apiUrl}/api/v0/payment/cards`, { + headers: { + 'Authorization': `Bearer ${user.token}` + } + }); + + if (response.status === 404) { + // No cards found is a valid state + setSavedCards([]); + return; + } + + if (!response.ok) { + throw new Error('Failed to fetch cards'); + } + + const cards = await response.json(); + setSavedCards(Array.isArray(cards) ? cards : []); + // Set the default card if available + const defaultCard = cards.find((card: SavedCard) => card.isDefault); + if (defaultCard) { + setSelectedCardId(defaultCard.id); + } else if (cards.length > 0) { + setSelectedCardId(cards[0].id); + } + } catch (error) { + console.error('Error fetching cards:', error); + // Don't show error toast for no cards + setSavedCards([]); + } + }; + + if (isOpen && user) { + fetchCards(); + } + }, [isOpen, user]); + const handleCheckout = async () => { if (!user) { toast.error('Please log in to checkout'); @@ -60,6 +114,11 @@ export default function CartModal({ isOpen, onClose }: CartModalProps) { return; } + if (!selectedCardId) { + toast.error('Please select a payment method'); + return; + } + setIsCheckingOut(true); try { @@ -83,6 +142,7 @@ export default function CartModal({ isOpen, onClose }: CartModalProps) { tax_amount: taxAmount, delivery_fee: deliveryFee, total_amount: totalAmount, + payment_method_id: selectedCardId, delivery_address: { street_address: ["123 Main St"], // TODO: Get from user input city: "New York", @@ -224,6 +284,35 @@ export default function CartModal({ isOpen, onClose }: CartModalProps) {
+ {/* Payment Method Selection */} + {savedCards.length > 0 && ( +
+ + +
+ )} + + {savedCards.length === 0 && ( +
+

+ Please add a payment method in your profile before checking out. +

+
+ )} +

Subtotal

${totalPrice.toFixed(2)}

@@ -234,14 +323,10 @@ export default function CartModal({ isOpen, onClose }: CartModalProps) {
diff --git a/frontend/src/components/OrderHistoryModal.tsx b/frontend/src/components/OrderHistoryModal.tsx index 9b07117..b0d6e0c 100644 --- a/frontend/src/components/OrderHistoryModal.tsx +++ b/frontend/src/components/OrderHistoryModal.tsx @@ -36,6 +36,7 @@ interface Order { id: string; created: string; status: string; + payment_status: string; delivery_fee: number; total_amount: number; tax_amount: number; @@ -77,6 +78,34 @@ export default function OrderHistoryModal({ isOpen, onClose, orders }: OrderHist return status.charAt(0).toUpperCase() + status.slice(1).replace('_', ' ') } + const getPaymentStatusColor = (status: string) => { + switch (status) { + case 'pending': + return 'bg-yellow-100 text-yellow-800' + case 'processing': + return 'bg-blue-100 text-blue-800' + case 'succeeded': + return 'bg-green-100 text-green-800' + case 'failed': + return 'bg-red-100 text-red-800' + case 'refunded': + return 'bg-gray-100 text-gray-800' + default: + return 'bg-gray-100 text-gray-800' + } + } + + const getPaymentStatusLabel = (status: string) => { + const labels: { [key: string]: string } = { + pending: 'Payment Pending', + processing: 'Processing Payment', + succeeded: 'Payment Successful', + failed: 'Payment Failed', + refunded: 'Payment Refunded' + } + return labels[status] || status.charAt(0).toUpperCase() + status.slice(1) + } + return ( @@ -127,6 +156,11 @@ export default function OrderHistoryModal({ isOpen, onClose, orders }: OrderHist > {getStatusLabel(order.status)} + + {getPaymentStatusLabel(order.payment_status)} +
diff --git a/python-backend/localmart_backend/main.py b/python-backend/localmart_backend/main.py index 1507253..7568a0e 100644 --- a/python-backend/localmart_backend/main.py +++ b/python-backend/localmart_backend/main.py @@ -4,12 +4,14 @@ import json import logging import os -from typing import List, Dict, Set +from typing import List, Dict, Set, Optional from contextlib import contextmanager -from fastapi import FastAPI, HTTPException, Response, BackgroundTasks, Request +from fastapi import FastAPI, HTTPException, Response, BackgroundTasks, Request, Header, Depends from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel +import stripe +from pocketbase import Client from .uber_direct import UberDirectClient from .pocketbase_service import PocketBaseService @@ -34,6 +36,12 @@ # Track active deliveries active_deliveries: Set[str] = set() +# Initialize Stripe +stripe.api_key = os.getenv('STRIPE_SECRET_KEY') + +# Initialize Stripe webhook secret +STRIPE_WEBHOOK_SECRET = os.getenv('STRIPE_WEBHOOK_SECRET') + @contextmanager def user_auth_context(token: str): """Context manager to handle user authentication state""" @@ -227,18 +235,45 @@ async def get_delivery_quote(request: DeliveryQuoteRequest): async def create_order(request: Dict): """Create a new order with basic status tracking""" try: + # Get the payment method + payment_method = pb_service.get_one('payment_methods', request['payment_method_id']) + if not payment_method: + raise HTTPException(status_code=400, detail="Invalid payment method") + + # Get the customer + customers = pb_service.get_list( + 'stripe_customers', + query_params={"filter": f'user = "{request["user_id"]}"'} + ) + if not customers.items: + raise HTTPException(status_code=400, detail="No Stripe customer found") + + stripe_customer_id = customers.items[0].stripe_customer_id + + # Create a payment intent + payment_intent = stripe.PaymentIntent.create( + amount=int(request['total_amount'] * 100), # Convert to cents + currency='usd', + customer=stripe_customer_id, + payment_method=payment_method.stripe_payment_method_id, + off_session=True, + confirm=True, + ) + # Create the order in PocketBase with simplified fields order = pb_service.create('orders', { 'user': request['user_id'], 'store': request['store_id'], 'status': 'pending', # Initial status + 'payment_status': 'pending', # Initial payment status + 'payment_method': payment_method.id, # Link to payment method + 'stripe_payment_intent_id': payment_intent.id, 'subtotal_amount': request['subtotal_amount'], 'tax_amount': request['tax_amount'], 'delivery_fee': request['delivery_fee'], 'total_amount': request['total_amount'], 'delivery_address': request['delivery_address'], 'customer_notes': request.get('customer_notes', ''), - 'created_at': datetime.datetime.now(datetime.timezone.utc).isoformat(), }) # Create order items @@ -254,9 +289,13 @@ async def create_order(request: Dict): return { 'order_id': order.id, 'status': 'pending', + 'payment_intent_client_secret': payment_intent.client_secret, 'message': 'Order created successfully' } + except stripe.error.StripeError as e: + logger.error(f"Stripe error creating order: {str(e)}") + raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"Error creating order: {str(e)}") raise HTTPException( @@ -335,6 +374,7 @@ async def get_user_orders(request: Request): 'id': order.id, 'created': order.created, 'status': order.status, + 'payment_status': order.payment_status, 'delivery_fee': order.delivery_fee, 'total_amount': order.total_amount, 'tax_amount': order.tax_amount, @@ -552,3 +592,240 @@ def serialize_store_item(item) -> Dict: "name": item.name, "price": item.price } + +@app.post("/api/v0/payment/setup-intent") +async def create_setup_intent(authorization: str = Header(None)): + if not authorization: + raise HTTPException(status_code=401, detail="No authorization token") + + try: + # Verify the user's token + token = authorization.split(' ')[1] if authorization.startswith('Bearer ') else authorization + user = pb_service.get_user_from_token(token) + if not user: + raise HTTPException(status_code=401, detail="Invalid token") + + # Create a SetupIntent + setup_intent = stripe.SetupIntent.create( + payment_method_types=['card'], + usage='off_session', # Allow using this payment method for future payments + ) + + return {"clientSecret": setup_intent.client_secret} + + except Exception as e: + print(f"Error creating setup intent: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to create setup intent") + +@app.post("/api/v0/payment/cards") +async def save_payment_method(payment_data: dict, authorization: str = Header(None)): + if not authorization: + raise HTTPException(status_code=401, detail="No authorization token") + + try: + # Verify the user's token + token = authorization.split(' ')[1] if authorization.startswith('Bearer ') else authorization + user = pb_service.get_user_from_token(token) + if not user: + raise HTTPException(status_code=401, detail="Invalid token") + + payment_method_id = payment_data.get('payment_method_id') + if not payment_method_id: + raise HTTPException(status_code=400, detail="Payment method ID is required") + + # Get or create Stripe Customer + customers = pb_service.get_list( + 'stripe_customers', + query_params={"filter": f'user = "{user.id}"'} + ) + + if customers.items: + stripe_customer_id = customers.items[0].stripe_customer_id + else: + # Create new Stripe customer + stripe_customer = stripe.Customer.create( + email=user.email, + name=f"{user.first_name} {user.last_name}".strip(), + metadata={"user_id": user.id} + ) + stripe_customer_id = stripe_customer.id + + # Save customer ID to PocketBase + pb_service.create('stripe_customers', { + "user": user.id, + "stripe_customer_id": stripe_customer_id + }) + + # Attach payment method to customer + stripe.PaymentMethod.attach( + payment_method_id, + customer=stripe_customer_id, + ) + + # Get payment method details from Stripe + payment_method = stripe.PaymentMethod.retrieve(payment_method_id) + + # Create a record in PocketBase + card_data = { + "user": user.id, + "stripe_payment_method_id": payment_method_id, + "last4": payment_method.card.last4, + "brand": payment_method.card.brand, + "exp_month": payment_method.card.exp_month, + "exp_year": payment_method.card.exp_year, + "is_default": True # First card added will be default + } + + # If this is the default card, update other cards to not be default + if card_data["is_default"]: + existing_cards = pb_service.pb.collection('payment_methods').get_list( + 1, 50, + {"filter": f'user = "{user.id}" && is_default = true'} + ) + for card in existing_cards.items: + pb_service.pb.collection('payment_methods').update(card.id, {"is_default": False}) + + # Save the card to PocketBase + pb_service.pb.collection('payment_methods').create(card_data) + + return {"status": "success"} + + except stripe.error.StripeError as e: + print(f"Stripe error: {str(e)}") + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + print(f"Error saving payment method: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to save payment method") + +@app.get("/api/v0/payment/cards") +async def get_payment_methods(authorization: str = Header(None)): + if not authorization: + raise HTTPException(status_code=401, detail="No authorization token") + + try: + # Verify the user's token + token = authorization.split(' ')[1] if authorization.startswith('Bearer ') else authorization + user = pb_service.get_user_from_token(token) + if not user: + raise HTTPException(status_code=401, detail="Invalid token") + + # Get cards from PocketBase + cards = pb_service.pb.collection('payment_methods').get_list( + 1, 50, + {"filter": f'user = "{user.id}"'} + ) + + return cards.items + + except Exception as e: + print(f"Error fetching payment methods: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to fetch payment methods") + +@app.delete("/api/v0/payment/cards/{card_id}") +async def delete_payment_method(card_id: str, authorization: str = Header(None)): + if not authorization: + raise HTTPException(status_code=401, detail="No authorization token") + + try: + # Verify the user's token + token = authorization.split(' ')[1] if authorization.startswith('Bearer ') else authorization + user = pb_service.get_user_from_token(token) + if not user: + raise HTTPException(status_code=401, detail="Invalid token") + + # Get the card from PocketBase + card = pb_service.pb.collection('payment_methods').get_one(card_id) + if not card or card.user != user.id: + raise HTTPException(status_code=404, detail="Card not found") + + # Delete the payment method from Stripe + stripe.PaymentMethod.detach(card.stripe_payment_method_id) + + # Delete the card from PocketBase + pb_service.pb.collection('payment_methods').delete(card_id) + + return {"status": "success"} + + except stripe.error.StripeError as e: + print(f"Stripe error: {str(e)}") + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + print(f"Error deleting payment method: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to delete payment method") + +@app.post("/api/v0/webhooks/stripe") +async def stripe_webhook(request: Request): + # Get the webhook payload + payload = await request.body() + + # In development (when using stripe-cli), we skip signature verification + # In production, we verify the signature + if STRIPE_WEBHOOK_SECRET: + # Get the Stripe signature from headers + sig_header = request.headers.get('stripe-signature') + try: + # Verify the webhook signature + event = stripe.Webhook.construct_event( + payload, sig_header, STRIPE_WEBHOOK_SECRET + ) + except ValueError as e: + raise HTTPException(status_code=400, detail="Invalid payload") + except stripe.error.SignatureVerificationError as e: + raise HTTPException(status_code=400, detail="Invalid signature") + else: + # Development mode - parse the payload without verification + try: + event = json.loads(payload) + except json.JSONDecodeError: + raise HTTPException(status_code=400, detail="Invalid JSON payload") + + # Handle specific event types + if event['type'] == 'payment_intent.succeeded': + payment_intent = event['data']['object'] + # Update order payment status to succeeded + try: + logger.info(f"Processing payment_intent.succeeded for intent {payment_intent['id']}") + orders = pb_service.get_list( + 'orders', + query_params={ + "filter": f'stripe_payment_intent_id = "{payment_intent["id"]}"' + } + ) + if orders.items: + order = orders.items[0] + pb_service.update('orders', order.id, { + 'payment_status': 'succeeded' + }) + logger.info(f"Updated order {order.id} payment status to succeeded") + else: + logger.warning(f"No order found for payment intent {payment_intent['id']}") + except Exception as e: + logger.error(f"Error updating order payment status: {str(e)}") + logger.error("Full payment intent object:") + logger.error(json.dumps(payment_intent, indent=2)) + + elif event['type'] == 'payment_intent.payment_failed': + payment_intent = event['data']['object'] + # Update order payment status to failed + try: + logger.info(f"Processing payment_intent.payment_failed for intent {payment_intent['id']}") + orders = pb_service.get_list( + 'orders', + query_params={ + "filter": f'stripe_payment_intent_id = "{payment_intent["id"]}"' + } + ) + if orders.items: + order = orders.items[0] + pb_service.update('orders', order.id, { + 'payment_status': 'failed' + }) + logger.info(f"Updated order {order.id} payment status to failed") + else: + logger.warning(f"No order found for payment intent {payment_intent['id']}") + except Exception as e: + logger.error(f"Error updating order payment status: {str(e)}") + logger.error("Full payment intent object:") + logger.error(json.dumps(payment_intent, indent=2)) + + return {"status": "success"} diff --git a/python-backend/localmart_backend/pocketbase_service.py b/python-backend/localmart_backend/pocketbase_service.py index a4e5a2e..9767743 100644 --- a/python-backend/localmart_backend/pocketbase_service.py +++ b/python-backend/localmart_backend/pocketbase_service.py @@ -1,4 +1,6 @@ import logging +import base64 +import json from typing import Dict, List, Any, Optional from pocketbase import PocketBase @@ -8,6 +10,7 @@ class PocketBaseService: def __init__(self, url: str = 'http://pocketbase:8090'): self.url = url self.client = PocketBase(url) + self.pb = self.client # Alias for compatibility def set_token(self, token: str) -> None: """Set the auth token for subsequent requests""" @@ -121,4 +124,44 @@ def auth_with_password( return result except Exception as e: logger.error(f"Error authenticating user {email}: {str(e)}") - raise \ No newline at end of file + raise + + def get_user_from_token(self, token: str) -> Optional[Dict[str, Any]]: + """Get user information from a JWT token""" + try: + # Set the token for this request + self.set_token(token) + + # Get the decoded token data + parts = token.split('.') + if len(parts) != 3: + logger.error("Invalid JWT token format") + return None + + # Decode the payload (second part) + try: + # Add padding if needed + padding = len(parts[1]) % 4 + if padding: + parts[1] += '=' * (4 - padding) + + payload = base64.b64decode(parts[1]) + token_data = json.loads(payload) + except Exception as e: + logger.error(f"Error decoding token: {str(e)}") + return None + + # Get user ID from token + user_id = token_data.get('id') + if not user_id: + logger.error("No user ID in token") + return None + + # Get user from database + user = self.get_one('users', user_id) + return user + + except Exception as e: + logger.error(f"Error getting user from token: {str(e)}") + self.clear_token() # Only clear token on error + return None \ No newline at end of file diff --git a/python-backend/poetry.lock b/python-backend/poetry.lock index 83b5603..61e8439 100644 --- a/python-backend/poetry.lock +++ b/python-backend/poetry.lock @@ -59,6 +59,107 @@ files = [ {file = "certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db"}, ] +[[package]] +name = "charset-normalizer" +version = "3.4.1" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7" +files = [ + {file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-win32.whl", hash = "sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-win32.whl", hash = "sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765"}, + {file = "charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85"}, + {file = "charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3"}, +] + [[package]] name = "click" version = "8.1.8" @@ -717,6 +818,27 @@ files = [ {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, ] +[[package]] +name = "requests" +version = "2.32.3" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.8" +files = [ + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + [[package]] name = "sniffio" version = "1.3.1" @@ -765,6 +887,21 @@ typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\"" [package.extras] full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"] +[[package]] +name = "stripe" +version = "11.5.0" +description = "Python bindings for the Stripe API" +optional = false +python-versions = ">=3.6" +files = [ + {file = "stripe-11.5.0-py2.py3-none-any.whl", hash = "sha256:3b2cd47ed3002328249bff5cacaee38d5e756c3899ab425d3bd07acdaf32534a"}, + {file = "stripe-11.5.0.tar.gz", hash = "sha256:bc3e0358ffc23d5ecfa8aafec1fa4f048ee8107c3237bcb00003e68c8c96fa02"}, +] + +[package.dependencies] +requests = {version = ">=2.20", markers = "python_version >= \"3.0\""} +typing-extensions = {version = ">=4.5.0", markers = "python_version >= \"3.7\""} + [[package]] name = "tomli" version = "2.2.1" @@ -832,6 +969,23 @@ files = [ {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] +[[package]] +name = "urllib3" +version = "2.3.0" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.9" +files = [ + {file = "urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df"}, + {file = "urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + [[package]] name = "uvicorn" version = "0.34.0" @@ -1084,4 +1238,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "52aa8d230846a2b9bafc8c6dafacccbb072195ba93bff4170735e11453355fd5" +content-hash = "22e2bf10c27bf61ef21d79001ff804b6e71eef980af9b7bba44f5748756e2079" diff --git a/python-backend/pyproject.toml b/python-backend/pyproject.toml index 6068bd3..459bdc2 100644 --- a/python-backend/pyproject.toml +++ b/python-backend/pyproject.toml @@ -11,6 +11,7 @@ fastapi = "^0.115.6" uvicorn = {extras = ["standard"], version = "^0.34.0"} pocketbase = "^0.14.0" ipdb = "^0.13.13" +stripe = "^11.5.0" [tool.poetry.group.dev.dependencies] pytest = "^8.3.4"