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 (
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"
|