rairo commited on
Commit
fcd33dc
·
verified ·
1 Parent(s): 9ed495c

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +420 -656
main.py CHANGED
@@ -1,7 +1,10 @@
 
 
1
  import os
2
  import io
3
  import json
4
  import hashlib
 
5
  from datetime import datetime, time, timedelta
6
  from PIL import Image
7
  import pytz
@@ -11,738 +14,499 @@ import google.generativeai as genai
11
  import firebase_admin
12
  from firebase_admin import credentials, db, storage, auth
13
  import pandas as pd
 
14
  import requests
15
  from urllib.parse import urlparse, unquote
16
- import numpy as np
17
 
18
  app = Flask(__name__)
19
  CORS(app)
20
 
21
- # Firebase initialization
22
- Firebase_DB = os.getenv("Firebase_DB")
23
  Firebase_Storage = os.getenv("Firebase_Storage")
24
 
25
  try:
26
- # Retrieve the JSON content from the secret
27
- credentials_json_string = os.environ.get("FIREBASE")
28
-
29
- if credentials_json_string:
30
- # Parse the JSON string into a Python dictionary
31
- credentials_json = json.loads(credentials_json_string)
32
-
33
- # Initialize Firebase Admin SDK
34
- cred = credentials.Certificate(credentials_json)
35
  firebase_admin.initialize_app(cred, {
36
- 'databaseURL': f'{Firebase_DB}',
37
- 'storageBucket': f'{Firebase_Storage}'
38
- })
39
-
40
- print("Firebase Admin SDK initialized successfully.")
41
  else:
42
  print("FIREBASE secret not set.")
43
-
44
  except Exception as e:
45
- print(f"Error initializing Firebase: {e}")
46
-
47
 
48
  bucket = storage.bucket()
49
 
50
-
51
- # Helper functions
52
  def configure_gemini():
53
- genai.configure(api_key=api_key)
54
  return genai.GenerativeModel('gemini-2.0-flash-thinking-exp')
55
 
56
  def verify_token(token):
57
  try:
58
- decoded_token = auth.verify_id_token(token)
59
- return decoded_token['uid']
60
- except Exception as e:
61
  return None
62
 
63
- def check_daily_reset(user_ref):
64
- try:
65
- user_data = user_ref.get()
66
- now = datetime.now(pytz.UTC)
67
- last_reset = datetime.fromisoformat(user_data.get('last_reset', '2000-01-01T00:00:00+00:00'))
68
-
69
- if now.time() >= time(8,0) and last_reset.date() < now.date():
70
- user_ref.update({
71
- 'remaining_cash': user_data['daily_cash'],
72
- 'last_reset': now.isoformat()
73
- })
74
- return True
75
- return False
76
- except Exception as e:
77
- print(f"Reset error: {str(e)}")
78
- return False
79
-
80
- # Process receipt image
81
- def process_receipt(model, image):
82
- prompt = """Analyze this image and determine if it's a receipt. If it is a receipt, extract:
83
- - Total amount (as float)
84
- - List of items purchased (array of strings)
85
- - Date of transaction (DD/MM/YYYY format)
86
- - Receipt number (as string)
87
- Return JSON format with keys: is_receipt (boolean), total, items, date, receipt_number.
88
- If not a receipt, return {"is_receipt": false}"""
89
-
90
- try:
91
- response = model.generate_content([prompt, image])
92
- return response.text
93
- except Exception as e:
94
- print(f"Gemini error: {str(e)}")
95
- return "{}"
96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
 
98
- # Write report
99
- @app.route('/api/write-report', methods=['POST'])
100
- def generate_report():
101
- prompt = """You are the TrueSpend AI analyst, analyze this transaction data
102
- and write an insightful business report on the spending habits of the employees.
103
- Make sure the report is in plain text"""
104
- model = configure_gemini()
 
 
105
  try:
106
- transaction_data = request.get_json()
107
- transaction_json_string = json.dumps(transaction_data['transactions'])
108
- response = model.generate_content([prompt, transaction_json_string])
109
- report = response.text
110
- return jsonify({"report": report})
 
 
 
111
  except Exception as e:
112
- return jsonify({"error": str(e)}), 500
113
 
114
  # ========================================
115
- # Authentication Endpoints
116
  # ========================================
117
- # (Any existing authentication endpoints remain unchanged)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
 
119
  # ========================================
120
- # Image Receipt Processing Endpoint
121
  # ========================================
122
- @app.route('/api/process-receipt', methods=['POST'])
123
- def process_receipt_endpoint():
124
- try:
125
- auth_header = request.headers.get('Authorization')
126
- token = auth_header.split('Bearer ')[1] if auth_header else None
127
- uid = verify_token(token) if token else None
128
-
129
- if not uid:
130
- return jsonify({'error': 'Invalid token'}), 401
131
-
132
- user_ref = db.reference(f'users/{uid}')
133
- user_data = user_ref.get()
134
-
135
- # If the confirmation flag is set, then use the form fields (and hidden file_hash/image_url)
136
- # to create the transaction. (Note: image_bytes is None because the image was already uploaded.)
137
- if request.form.get('confirmed') == 'true':
138
- data = {
139
- 'total': float(request.form.get('total')),
140
- 'items': [item.strip() for item in request.form.get('items', '').split(',')],
141
- 'date': request.form.get('date'),
142
- 'receipt_number': request.form.get('receipt_number'),
143
- 'is_receipt': True,
144
- 'image_url': request.form.get('image_url') # provided by the first submission
145
- }
146
- file_hash = request.form.get('file_hash', '')
147
- # In this confirmation branch, we pass image_bytes as None.
148
- return validate_and_save_transaction(
149
- uid=uid,
150
- user_data=user_data,
151
- data=data,
152
- file_hash=file_hash,
153
- image_bytes=None,
154
- manual=False
155
- )
156
-
157
- # Handle image processing (first submission)
158
- file = request.files.get('receipt')
159
- if not file:
160
- return jsonify({'error': 'No file uploaded'}), 400
161
-
162
- image_bytes = file.read()
163
- file_hash = hashlib.md5(image_bytes).hexdigest()
164
-
165
- transactions_ref = db.reference('transactions')
166
- existing = transactions_ref.order_by_child('hash').equal_to(file_hash).get()
167
- if existing:
168
- return jsonify({'error': 'Receipt already processed'}), 400
169
-
170
- # Immediately upload the image so that it is stored regardless of review outcome.
171
- timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
172
- blob = bucket.blob(f'receipts/{uid}/{timestamp}_{file_hash}.jpg')
173
- blob.upload_from_string(image_bytes, content_type='image/jpeg')
174
- image_url = blob.public_url
175
-
176
- # Process the image with Gemini
177
- image = Image.open(io.BytesIO(image_bytes))
178
- # --- Image Compression ---
179
- compressed_buffer = io.BytesIO()
180
- image.save(compressed_buffer, format="JPEG", optimize=True, quality=90) # Adjust quality as needed
181
- compressed_image_bytes = compressed_buffer.getvalue()
182
- compressed_image = Image.open(io.BytesIO(compressed_image_bytes))
183
- model = configure_gemini()
184
- result_text = process_receipt(model, compressed_image)
185
-
186
- try:
187
- json_str = result_text[result_text.find('{'):result_text.rfind('}')+1]
188
- data = json.loads(json_str)
189
- except json.JSONDecodeError:
190
- return jsonify({'error': 'Failed to parse receipt data', 'raw_response': result_text}), 400
191
-
192
- if not data.get('is_receipt', False):
193
- return jsonify({'error': 'Not a valid receipt'}), 400
194
-
195
- # Instead of saving immediately, return the extracted data along with the image info for review.
196
- data['file_hash'] = file_hash
197
- data['image_url'] = image_url
198
- return jsonify({
199
- 'success': True,
200
- 'extracted': True,
201
- 'data': data,
202
- 'message': 'Review extracted information before confirming.'
203
  })
204
-
205
- except Exception as e:
206
- print(e)
207
- return jsonify({'error': str(e)}), 500
208
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
 
210
  # ========================================
211
- # Manual Entry Endpoint
212
  # ========================================
213
- @app.route('/api/manual-entry', methods=['POST'])
214
- def manual_entry_endpoint():
215
  try:
216
- auth_header = request.headers.get('Authorization')
217
- token = auth_header.split('Bearer ')[1] if auth_header else None
218
- uid = verify_token(token) if token else None
219
-
220
- if not uid:
221
- return jsonify({'error': 'Invalid token'}), 401
222
-
223
- user_ref = db.reference(f'users/{uid}')
224
- user_data = user_ref.get()
225
 
226
- return handle_manual_entry(uid, user_ref, user_data)
227
- except Exception as e:
228
- return jsonify({'error': str(e)}), 500
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
 
 
 
230
 
231
- def handle_manual_entry(uid, user_ref, user_data):
232
- try:
233
- data = {
234
- 'total': float(request.form.get('total')),
235
- 'items': [item.strip() for item in request.form.get('items', '').split(',')],
236
- 'date': request.form.get('date'),
237
- 'receipt_number': request.form.get('receipt_number'),
238
- 'is_receipt': True
239
- }
 
 
 
 
 
 
 
 
 
 
 
240
 
241
- return validate_and_save_transaction(
242
- uid=uid,
243
- user_data=user_data,
244
- data=data,
245
- file_hash=hashlib.md5(str(datetime.now()).encode()).hexdigest(),
246
- image_bytes=None,
247
- manual=True
248
- )
249
- except Exception as e:
250
- return jsonify({'error': str(e)}), 400
251
-
252
-
253
- def validate_and_save_transaction(uid, user_data, data, file_hash, image_bytes, manual=False):
254
- transactions_ref = db.reference('transactions')
255
- receipt_number = data.get('receipt_number')
256
- items = data.get('items', [])
257
-
258
- # Check for duplicate transactions based on receipt number and items
259
- existing_transactions_with_receipt = transactions_ref.order_by_child('receipt_number').equal_to(receipt_number).get()
260
-
261
- if existing_transactions_with_receipt:
262
- for transaction_id, existing_transaction_data in existing_transactions_with_receipt.items():
263
- existing_items = sorted(existing_transaction_data.get('items', []))
264
- current_items = sorted(items)
265
- if existing_items == current_items:
266
- return jsonify({'error': f"Transaction with Receipt #{receipt_number} and identical items already exists"}), 400
267
-
268
- total = float(data.get('total', 0))
269
-
270
- # Initialize image_url from data (could be None)
271
- image_url = data.get('image_url')
272
-
273
- # Upload image if image_bytes are provided and not manual
274
- if image_bytes and not manual:
275
- timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
276
- blob = bucket.blob(f'receipts/{uid}/{timestamp}_{file_hash}.jpg')
277
- blob.upload_from_string(image_bytes, content_type='image/jpeg')
278
- image_url = blob.public_url
279
-
280
- # Update user cash - allowing negative values
281
- new_remaining = user_data['remaining_cash'] - total
282
- db.reference(f'users/{uid}').update({'remaining_cash': new_remaining})
283
-
284
- # Build transaction data without image_url initially
285
- transaction_data = {
286
- 'uid': uid,
287
- 'total': total,
288
- 'items': items,
289
- 'date': data.get('date'),
290
- 'receipt_number': receipt_number,
291
  'timestamp': datetime.now(pytz.UTC).isoformat(),
292
- 'hash': file_hash,
293
- 'manual_entry': manual
294
  }
 
 
 
 
 
295
 
296
- # Only add image_url if it is not None
297
- if image_url is not None:
298
- transaction_data['image_url'] = image_url
299
-
300
- # Save the transaction
301
- new_transaction_ref = transactions_ref.push(transaction_data)
302
- return jsonify({
303
- 'success': True,
304
- 'transaction': {**transaction_data, 'id': new_transaction_ref.key},
305
- 'remaining_cash': new_remaining
306
- })
307
 
308
  # ========================================
309
- # Data Endpoints for Visualizations
310
  # ========================================
311
-
312
  @app.route('/api/user/spending-overview', methods=['GET'])
313
- def get_spending_overview():
314
- try:
315
- # Extract the token and verify it.
316
- auth_header = request.headers.get('Authorization', '')
317
- token = auth_header.split(' ')[1] if len(auth_header.split(' ')) > 1 else ''
318
- uid = verify_token(token)
319
-
320
- # Get transactions for the user.
321
- transactions_ref = db.reference('transactions')
322
- transactions = transactions_ref.order_by_child('uid').equal_to(uid).get() or {}
323
-
324
- # Convert to list for easier processing
325
- transactions_list = []
326
- for tx_id, tx_data in transactions.items():
327
- # Add ID to the transaction data
328
- tx_item = tx_data.copy()
329
- tx_item['id'] = tx_id
330
- transactions_list.append(tx_item)
331
-
332
- # Create a DataFrame
333
- df = pd.DataFrame(transactions_list) if transactions_list else pd.DataFrame()
334
-
335
- if df.empty:
336
- return jsonify({
337
- 'daily_spending': [],
338
- 'recent_transactions': []
339
- })
340
-
341
- # Handle date parsing without dropping invalid dates
342
- if 'date' in df.columns:
343
- # Create a temporary column for parsed dates
344
- df['parsed_date'] = pd.to_datetime(df['date'], errors='coerce')
345
-
346
- # For rows with invalid dates, set a default date
347
- default_date = pd.Timestamp('2000-01-01')
348
- mask = df['parsed_date'].isna()
349
-
350
- if mask.any():
351
- print(f"Found {mask.sum()} transactions with invalid dates")
352
- # Keep the original string in 'date' column, but use default for calculations
353
- df.loc[mask, 'parsed_date'] = default_date
354
-
355
- # Create ISO format dates for valid dates
356
- df['date_iso'] = df['parsed_date'].apply(lambda d: d.isoformat() if pd.notnull(d) else '2000-01-01T00:00:00')
357
-
358
- # Extract date part for daily spending calculations
359
- df['date_only'] = df['date_iso'].apply(lambda d: d.split('T')[0])
360
- else:
361
- # If no date column exists, create default values
362
- df['date_only'] = '2000-01-01'
363
- df['date_iso'] = '2000-01-01T00:00:00'
364
-
365
- # For daily spending, group by date
366
- daily_spending = df.groupby('date_only')['total'].sum().reset_index()
367
- daily_spending.rename(columns={'date_only': 'date'}, inplace=True)
368
-
369
- # Sort transactions by timestamp if available
370
- if 'timestamp' in df.columns:
371
- df = df.sort_values(by='timestamp', ascending=False)
372
-
373
- # Select the most recent transactions
374
- recent_transactions = df.head(10)
375
-
376
- # Replace NaN with None for JSON serialization
377
- daily_spending = daily_spending.replace({np.nan: None})
378
- recent_transactions = recent_transactions.replace({np.nan: None})
379
-
380
- # Ensure proper column selections for the response
381
- recent_tx_dict = recent_transactions.to_dict(orient='records')
382
-
383
- # Make sure each transaction has the original date string
384
- for tx in recent_tx_dict:
385
- # Remove temporary columns
386
- if 'parsed_date' in tx:
387
- del tx['parsed_date']
388
- if 'date_only' in tx:
389
- del tx['date_only']
390
- if 'date_iso' in tx and 'date' not in tx:
391
- tx['date'] = tx['date_iso']
392
- del tx['date_iso']
393
- elif 'date_iso' in tx:
394
- del tx['date_iso']
395
-
396
- return jsonify({
397
- 'daily_spending': daily_spending.to_dict(orient='records'),
398
- 'recent_transactions': recent_tx_dict
399
- })
400
- except Exception as e:
401
- print(f"Error in spending overview: {e}")
402
- return jsonify({'error': str(e)}), 500
403
 
 
 
 
 
 
 
 
 
 
 
 
 
404
 
405
  # ========================================
406
- # Modified verify_admin function (now checks database is_admin flag)
407
  # ========================================
408
- def verify_admin(auth_header):
409
- if not auth_header or not auth_header.startswith('Bearer '):
410
- raise ValueError('Invalid token')
411
-
412
- token = auth_header.split(' ')[1]
413
- uid = verify_token(token)
414
- if not uid:
415
- raise PermissionError('Invalid user')
416
-
417
- user_ref = db.reference(f'users/{uid}')
418
- user_data = user_ref.get()
419
- if not user_data or not user_data.get('is_admin', False):
420
- raise PermissionError('Admin access required')
421
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
422
  try:
423
- auth.set_custom_user_claims(uid, {"admin": True})
424
- print(f"Custom admin claim set for user {uid}")
 
 
 
 
 
425
  except Exception as e:
426
- print(f"Error setting custom admin claim: {e}")
427
- raise PermissionError('Error setting admin claim, but admin verified')
428
-
429
- return uid
430
 
431
  # ========================================
432
- # Existing Admin Endpoints
433
  # ========================================
434
  @app.route('/api/admin/overview', methods=['GET'])
435
  def get_admin_overview():
436
  try:
437
- verify_admin(request.headers.get('Authorization', ''))
438
-
439
- users_ref = db.reference('users')
440
- all_users = users_ref.get() or {}
441
- users_list = []
442
- for uid, user_data in all_users.items():
443
- try:
444
- auth_user = auth.get_user(uid)
445
- email = auth_user.email
446
- except:
447
- email = "Deleted User"
448
- users_list.append({
449
- 'uid': uid,
450
- 'email': email,
451
- 'daily_cash': user_data.get('daily_cash', 0),
452
- 'remaining_cash': user_data.get('remaining_cash', 0),
453
- 'last_reset': user_data.get('last_reset'),
454
- 'is_admin': user_data.get('is_admin', False)
455
- })
456
- transactions_ref = db.reference('transactions')
457
- all_transactions = transactions_ref.get() or {}
458
- transactions_list = [{'id': tid, **data} for tid, data in all_transactions.items()]
459
- return jsonify({
460
- 'users': users_list,
461
- 'transactions': transactions_list,
462
- 'analytics': {
463
- 'total_users': len(users_list),
464
- 'total_transactions': len(transactions_list),
465
- 'total_spent': sum(t['total'] for t in transactions_list)
466
- }
467
- })
468
- except Exception as e:
469
- return jsonify({'error': str(e)}), 500
470
-
471
- @app.route('/api/admin/users', methods=['POST'])
472
- def create_user():
473
- try:
474
- verify_admin(request.headers.get('Authorization', ''))
475
- data = request.get_json()
476
-
477
- user = auth.create_user(
478
- email=data['email'],
479
- password=data['password']
480
  )
481
-
482
- user_ref = db.reference(f'users/{user.uid}')
483
- user_data = {
484
- 'daily_cash': data.get('daily_cash', 0),
485
- 'remaining_cash': data.get('daily_cash', 0),
486
- 'last_reset': '2025-01-01T00:00:00+00:00',
487
- 'is_admin': data.get('is_admin', False)
488
- }
489
- user_ref.set(user_data)
490
-
491
- return jsonify({
492
- 'success': True,
493
- 'user': {
494
- 'uid': user.uid,
495
- 'email': user.email,
496
- **user_data
497
- }
498
- }), 201
499
  except Exception as e:
500
- return jsonify({'error': str(e)}), 400
501
 
502
- @app.route('/api/admin/users/<string:uid>/limit', methods=['PUT'])
503
- def update_user_limit(uid):
504
  try:
505
- verify_admin(request.headers.get('Authorization', ''))
506
- data = request.get_json()
507
- new_limit = float(data['daily_cash'])
508
-
509
- user_ref = db.reference(f'users/{uid}')
510
- user_data = user_ref.get()
511
-
512
- if not user_data:
513
- return jsonify({'error': 'User not found'}), 404
514
-
515
- updates = {'daily_cash': new_limit}
516
- current_remaining = user_data.get('remaining_cash', new_limit)
517
- if current_remaining > new_limit:
518
- updates['remaining_cash'] = new_limit
519
-
520
- user_ref.update(updates)
521
-
522
- return jsonify({
523
- 'success': True,
524
- 'new_daily_cash': new_limit,
525
- 'updated_remaining': updates.get('remaining_cash', current_remaining)
526
  })
 
527
  except Exception as e:
528
- return jsonify({'error': str(e)}), 400
529
-
530
- # ========================================
531
- # New Admin Endpoints for Updating Remaining Cash, Setting Cash Limits, and Resetting Password
532
- # ========================================
533
-
534
- @app.route('/api/admin/users/<string:uid>/remaining-cash', methods=['PUT'])
535
- def update_remaining_cash(uid):
536
- try:
537
- verify_admin(request.headers.get('Authorization', ''))
538
- data = request.get_json()
539
- if 'remaining_cash' not in data:
540
- return jsonify({'error': 'remaining_cash is required'}), 400
541
- new_remaining_cash = float(data['remaining_cash'])
542
- user_ref = db.reference(f'users/{uid}')
543
- user_data = user_ref.get()
544
- if not user_data:
545
- return jsonify({'error': 'User not found'}), 404
546
-
547
- user_ref.update({'remaining_cash': new_remaining_cash})
548
- return jsonify({'success': True, 'remaining_cash': new_remaining_cash})
549
- except Exception as e:
550
- return jsonify({'error': str(e)}), 400
551
-
552
 
553
- @app.route('/api/admin/users/<string:uid>/reset-password', methods=['PUT'])
554
  def admin_reset_password(uid):
555
  try:
556
- verify_admin(request.headers.get('Authorization', ''))
557
- data = request.get_json()
558
- new_password = data.get('new_password')
559
- if not new_password:
560
- return jsonify({'error': 'new_password is required'}), 400
561
- auth.update_user(uid, password=new_password)
562
- return jsonify({'success': True, 'message': 'Password reset successfully'})
563
- except Exception as e:
564
- return jsonify({'error': str(e)}), 400
565
-
566
-
567
- @app.route('/api/admin/transactions/<string:transaction_id>', methods=['DELETE'])
568
- def delete_transaction(transaction_id):
569
- """Allow admin users to delete transactions."""
570
- try:
571
- verify_admin(request.headers.get('Authorization', ''))
572
- transactions_ref = db.reference(f'transactions/{transaction_id}')
573
- transaction_data = transactions_ref.get()
574
-
575
- if not transaction_data:
576
- return jsonify({'error': 'Transaction not found'}), 404
577
-
578
- transactions_ref.delete()
579
- return jsonify({'success': True, 'message': 'Transaction deleted successfully'})
580
-
581
  except Exception as e:
582
- return jsonify({'error': str(e)}), 500
583
-
584
- @app.route('/api/admin/transactions/<string:transaction_id>', methods=['PUT'])
585
- def edit_transaction(transaction_id):
586
- """Allow admin users to edit transactions."""
587
- try:
588
- verify_admin(request.headers.get('Authorization', ''))
589
- transactions_ref = db.reference(f'transactions/{transaction_id}')
590
- transaction_data = transactions_ref.get()
591
-
592
- if not transaction_data:
593
- return jsonify({'error': 'Transaction not found'}), 404
594
-
595
- update_data = request.get_json()
596
- transactions_ref.update(update_data)
597
-
598
- return jsonify({'success': True, 'updated_transaction': update_data})
599
 
600
- except Exception as e:
601
- return jsonify({'error': str(e)}), 500
602
-
603
- # ========================================
604
- # User management endpoint for profile
605
- # ========================================
606
-
607
- @app.route('/api/user/profile', methods=['GET'])
608
- def get_user_profile():
609
- try:
610
- uid = verify_token(request.headers.get('Authorization', '').split(' ')[1])
611
- user = auth.get_user(uid)
612
- user_data = db.reference(f'users/{uid}').get()
613
-
614
- return jsonify({
615
- 'uid': uid,
616
- 'email': user.email,
617
- 'daily_cash': user_data.get('daily_cash', 0),
618
- 'remaining_cash': user_data.get('remaining_cash', 0),
619
- 'last_reset': user_data.get('last_reset'),
620
- 'is_admin': user_data.get('is_admin', False)
621
- })
622
- except Exception as e:
623
- uid = verify_token(request.headers.get('Authorization', '').split(' ')[1])
624
- user = auth.get_user(uid)
625
- user_data = db.reference(f'users/{uid}').get()
626
- return jsonify({'error': str(e)+ f'user data: {user_data}'}), 500
627
-
628
- # ========================================
629
- # Receipt media endpoints
630
- # ========================================
631
-
632
- def get_blob_from_image_url(image_url):
633
- parsed = urlparse(image_url)
634
- if parsed.netloc == "storage.googleapis.com":
635
- parts = parsed.path.strip("/").split("/", 1)
636
- if len(parts) == 2:
637
- bucket_name, blob_path = parts
638
- return blob_path
639
- prefix = f"/v0/b/{bucket.name}/o/"
640
- if parsed.path.startswith(prefix):
641
- encoded_blob_path = parsed.path[len(prefix):]
642
- blob_path = unquote(encoded_blob_path)
643
- return blob_path
644
- return None
645
-
646
- @app.route('/api/admin/receipt/<string:transaction_id>/view', methods=['GET'])
647
- def view_receipt(transaction_id):
648
- try:
649
- verify_admin(request.headers.get('Authorization', ''))
650
- transaction_ref = db.reference(f'transactions/{transaction_id}')
651
- transaction_data = transaction_ref.get()
652
- if not transaction_data:
653
- return jsonify({'error': 'Transaction not found'}), 404
654
-
655
- image_url = transaction_data.get('image_url')
656
- if not image_url:
657
- return jsonify({'error': 'No receipt image found for this transaction'}), 404
658
-
659
- blob_path = get_blob_from_image_url(image_url)
660
- if not blob_path:
661
- return jsonify({'error': 'Could not determine blob path from URL'}), 500
662
-
663
- print(f"Blob path for view: {blob_path}")
664
- blob = bucket.blob(blob_path)
665
- if not blob.exists():
666
- print("Blob does not exist at path:", blob_path)
667
- return jsonify({'error': 'Blob not found'}), 404
668
-
669
- signed_url = blob.generate_signed_url(expiration=timedelta(minutes=10))
670
- r = requests.get(signed_url)
671
- if r.status_code != 200:
672
- return jsonify({'error': 'Unable to fetch image from storage'}), 500
673
-
674
- return send_file(io.BytesIO(r.content), mimetype='image/jpeg')
675
- except Exception as e:
676
- print(f"View receipt error: {str(e)}")
677
- return jsonify({'error': str(e)}), 500
678
-
679
- @app.route('/api/admin/receipt/<string:transaction_id>/download', methods=['GET'])
680
- def download_receipt(transaction_id):
681
- try:
682
- verify_admin(request.headers.get('Authorization', ''))
683
- transaction_ref = db.reference(f'transactions/{transaction_id}')
684
- transaction_data = transaction_ref.get()
685
- if not transaction_data:
686
- return jsonify({'error': 'Transaction not found'}), 404
687
-
688
- image_url = transaction_data.get('image_url')
689
- if not image_url:
690
- return jsonify({'error': 'No receipt image found for this transaction'}), 404
691
-
692
- blob_path = get_blob_from_image_url(image_url)
693
- if not blob_path:
694
- return jsonify({'error': 'Could not determine blob path from URL'}), 500
695
-
696
- print(f"Blob path for download: {blob_path}")
697
- blob = bucket.blob(blob_path)
698
- if not blob.exists():
699
- print("Blob does not exist at path:", blob_path)
700
- return jsonify({'error': 'Blob not found'}), 404
701
-
702
- signed_url = blob.generate_signed_url(expiration=timedelta(minutes=10))
703
- r = requests.get(signed_url)
704
- if r.status_code != 200:
705
- return jsonify({'error': 'Unable to fetch image from storage'}), 500
706
-
707
- return send_file(
708
- io.BytesIO(r.content),
709
- mimetype='image/jpeg',
710
- as_attachment=True,
711
- attachment_filename='receipt.jpg'
712
- )
713
- except Exception as e:
714
- print(f"Download receipt error: {str(e)}")
715
- return jsonify({'error': str(e)}), 500
716
-
717
- # ========================================
718
- # Delete user endpoint
719
- # ========================================
720
-
721
- @app.route('/api/admin/users/<string:uid>', methods=['DELETE'])
722
  def delete_user(uid):
723
  try:
724
- verify_admin(request.headers.get('Authorization', ''))
725
-
726
- try:
727
- user = auth.get_user(uid)
728
- except auth.UserNotFoundError:
729
- return jsonify({'error': 'User not found'}), 404
730
-
731
  auth.delete_user(uid)
732
  db.reference(f'users/{uid}').delete()
733
-
734
- transactions_ref = db.reference('transactions')
735
- user_transactions = transactions_ref.order_by_child('uid').equal_to(uid).get()
736
- if user_transactions:
737
- for transaction_id in user_transactions.keys():
738
- transactions_ref.child(transaction_id).delete()
739
-
740
- return jsonify({
741
- 'success': True,
742
- 'message': f'User {uid} and all associated data deleted successfully'
743
- })
744
  except Exception as e:
745
- return jsonify({'error': str(e)}), 500
746
 
 
 
 
747
  if __name__ == '__main__':
748
- app.run(debug=True, host="0.0.0.0", port=7860)
 
1
+ Here’s the fully cleaned-up app.py. I’ve removed duplicates, fixed any mismatched verifications (verify_global_admin vs. verify_org_manager), consolidated helper imports, and ensured every route is unique and correctly wired. You can copy-and-paste this directly.
2
+
3
  import os
4
  import io
5
  import json
6
  import hashlib
7
+ import uuid
8
  from datetime import datetime, time, timedelta
9
  from PIL import Image
10
  import pytz
 
14
  import firebase_admin
15
  from firebase_admin import credentials, db, storage, auth
16
  import pandas as pd
17
+ import numpy as np
18
  import requests
19
  from urllib.parse import urlparse, unquote
 
20
 
21
  app = Flask(__name__)
22
  CORS(app)
23
 
24
+ # ---------- Firebase initialization ----------
25
+ Firebase_DB = os.getenv("Firebase_DB")
26
  Firebase_Storage = os.getenv("Firebase_Storage")
27
 
28
  try:
29
+ cred_json = os.environ.get("FIREBASE")
30
+ if cred_json:
31
+ cred = credentials.Certificate(json.loads(cred_json))
 
 
 
 
 
 
32
  firebase_admin.initialize_app(cred, {
33
+ 'databaseURL': Firebase_DB,
34
+ 'storageBucket': Firebase_Storage
35
+ })
 
 
36
  else:
37
  print("FIREBASE secret not set.")
 
38
  except Exception as e:
39
+ print(f"Firebase init error: {e}")
 
40
 
41
  bucket = storage.bucket()
42
 
43
+ # ---------- Helper functions ----------
 
44
  def configure_gemini():
45
+ genai.configure(api_key=os.getenv("GEMINI_API_KEY"))
46
  return genai.GenerativeModel('gemini-2.0-flash-thinking-exp')
47
 
48
  def verify_token(token):
49
  try:
50
+ return auth.verify_id_token(token)['uid']
51
+ except:
 
52
  return None
53
 
54
+ def verify_global_admin(header):
55
+ if not header or not header.startswith('Bearer '):
56
+ raise PermissionError('Invalid token')
57
+ uid = verify_token(header.split(' ')[1])
58
+ if not uid or not db.reference(f'users/{uid}').get().get('is_admin', False):
59
+ raise PermissionError('Global admin required')
60
+ return uid
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
 
62
+ def verify_org_manager(uid, org_id):
63
+ org = db.reference(f'organizations/{org_id}').get() or {}
64
+ if uid != org.get('owner') and uid not in org.get('managers', []):
65
+ raise PermissionError('Manager access required')
66
+ return org
67
+
68
+ def get_auth_uid():
69
+ h = request.headers.get('Authorization', '')
70
+ parts = h.split(' ')
71
+ return verify_token(parts[1]) if len(parts) == 2 and parts[0]=='Bearer' else None
72
+
73
+ def get_blob_path(url):
74
+ p = urlparse(url)
75
+ if p.netloc == "storage.googleapis.com":
76
+ return '/'.join(p.path.lstrip('/').split('/')[1:])
77
+ prefix = f"/v0/b/{bucket.name}/o/"
78
+ if p.path.startswith(prefix):
79
+ return unquote(p.path[len(prefix):])
80
+ return None
81
 
82
+ # ========================================
83
+ # AUTH & REGISTRATION
84
+ # ========================================
85
+ @app.route('/api/register', methods=['POST'])
86
+ def register():
87
+ data = request.get_json() or {}
88
+ email, password = data.get('email'), data.get('password')
89
+ if not email or not password:
90
+ return jsonify(error='email and password required'), 400
91
  try:
92
+ user = auth.create_user(email=email, password=password)
93
+ db.reference(f'users/{user.uid}').set({
94
+ 'daily_cash': 0,
95
+ 'remaining_cash': 0,
96
+ 'last_reset': datetime.now(pytz.UTC).isoformat(),
97
+ 'is_admin': False
98
+ })
99
+ return jsonify(success=True, uid=user.uid, email=user.email), 201
100
  except Exception as e:
101
+ return jsonify(error=str(e)), 400
102
 
103
  # ========================================
104
+ # ORGANIZATIONS & INVITES
105
  # ========================================
106
+ @app.route('/api/orgs', methods=['POST'])
107
+ def create_org():
108
+ uid = get_auth_uid()
109
+ if not uid: return jsonify(error='Invalid token'), 401
110
+ name = (request.get_json() or {}).get('name')
111
+ if not name: return jsonify(error='Organization name required'), 400
112
+ ref = db.reference('organizations').push({
113
+ 'name': name,
114
+ 'owner': uid,
115
+ 'managers': [],
116
+ 'members': [uid],
117
+ 'created_at': datetime.now(pytz.UTC).isoformat()
118
+ })
119
+ return jsonify(success=True, org_id=ref.key), 201
120
+
121
+ @app.route('/api/orgs', methods=['GET'])
122
+ def list_orgs():
123
+ uid = get_auth_uid()
124
+ if not uid: return jsonify(error='Invalid token'), 401
125
+ all_orgs = db.reference('organizations').get() or {}
126
+ mine = [{'org_id': oid, **o} for oid,o in all_orgs.items() if uid in o.get('members',[])]
127
+ return jsonify(organizations=mine)
128
+
129
+ @app.route('/api/orgs/<org_id>/invite', methods=['POST'])
130
+ def invite_user(org_id):
131
+ uid = get_auth_uid()
132
+ try: verify_org_manager(uid, org_id)
133
+ except PermissionError as e: return jsonify(error=str(e)), 403
134
+ j = request.get_json() or {}
135
+ email, role = j.get('email'), j.get('role','member')
136
+ if role not in ('member','manager'): return jsonify(error='invalid role'), 400
137
+ invite_id = str(uuid.uuid4())
138
+ db.reference(f'invites/{invite_id}').set({
139
+ 'org_id': org_id,
140
+ 'email': email,
141
+ 'role': role,
142
+ 'sent_at': datetime.now(pytz.UTC).isoformat()
143
+ })
144
+ return jsonify(success=True, invite_id=invite_id)
145
+
146
+ @app.route('/api/orgs/invite/accept', methods=['POST'])
147
+ def accept_invite():
148
+ uid = get_auth_uid()
149
+ if not uid: return jsonify(error='Invalid token'), 401
150
+ inv = db.reference(f'invites/{(request.get_json() or {}).get("invite_id")}').get()
151
+ if not inv: return jsonify(error='Invite not found'), 404
152
+ if auth.get_user(uid).email.lower() != inv['email'].lower():
153
+ return jsonify(error='Email mismatch'), 403
154
+ org_ref = db.reference(f'organizations/{inv["org_id"]}')
155
+ org = org_ref.get() or {}
156
+ m, g = set(org.get('members',[])), set(org.get('managers',[]))
157
+ m.add(uid)
158
+ if inv['role']=='manager': g.add(uid)
159
+ org_ref.update({'members':list(m), 'managers':list(g)})
160
+ db.reference(f'invites/{inv["invite_id"]}').delete()
161
+ return jsonify(success=True, org_id=inv['org_id'])
162
 
163
  # ========================================
164
+ # PROJECTS & ALLOCATIONS
165
  # ========================================
166
+ @app.route('/api/orgs/<org_id>/projects', methods=['POST'])
167
+ def create_project(org_id):
168
+ uid = get_auth_uid()
169
+ try: verify_org_manager(uid, org_id)
170
+ except PermissionError as e: return jsonify(error=str(e)), 403
171
+ b = request.get_json() or {}
172
+ name = b.get('name'); budget = float(b.get('budget',0))
173
+ if not name or budget<=0: return jsonify(error='name and positive budget required'), 400
174
+ ref = db.reference('projects').push({
175
+ 'org_id': org_id,
176
+ 'name': name,
177
+ 'budget': budget,
178
+ 'spent': 0.0,
179
+ 'recurring': bool(b.get('recurring',False)),
180
+ 'interval': b.get('interval'),
181
+ 'due_date': b.get('due_date'),
182
+ 'allocations': {},
183
+ 'created_at': datetime.now(pytz.UTC).isoformat()
184
+ })
185
+ return jsonify(success=True, project_id=ref.key), 201
186
+
187
+ @app.route('/api/orgs/<org_id>/projects', methods=['GET'])
188
+ def list_projects(org_id):
189
+ uid = get_auth_uid()
190
+ org = db.reference(f'organizations/{org_id}').get() or {}
191
+ if uid not in org.get('members',[]): return jsonify(error='Access denied'), 403
192
+ projs = db.reference('projects').order_by_child('org_id').equal_to(org_id).get() or {}
193
+ return jsonify(projects=[{'project_id':k,**v} for k,v in projs.items()])
194
+
195
+ @app.route('/api/orgs/<org_id>/projects/<pid>', methods=['PUT','DELETE'])
196
+ def modify_project(org_id, pid):
197
+ uid = get_auth_uid()
198
+ try: verify_org_manager(uid, org_id)
199
+ except PermissionError as e: return jsonify(error=str(e)), 403
200
+ ref = db.reference(f'projects/{pid}')
201
+ if request.method=='PUT':
202
+ ref.update(request.get_json() or {})
203
+ else:
204
+ ref.delete()
205
+ return jsonify(success=True)
206
+
207
+ # Allocations
208
+ @app.route('/api/orgs/<org_id>/projects/<pid>/allocations', methods=['POST','GET'])
209
+ def allocations(pid, org_id):
210
+ uid = get_auth_uid()
211
+ org = db.reference(f'organizations/{org_id}').get() or {}
212
+ if request.method=='POST':
213
+ try: verify_org_manager(uid, org_id)
214
+ except PermissionError as e: return jsonify(error=str(e)), 403
215
+ b = request.get_json() or {}
216
+ name, amt = b.get('name'), float(b.get('budget',0))
217
+ if not name or amt<=0: return jsonify(error='name and positive budget required'), 400
218
+ aid = str(uuid.uuid4())
219
+ db.reference(f'projects/{pid}/allocations/{aid}').set({
220
+ 'name': name, 'budget': amt, 'spent': 0.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  })
222
+ return jsonify(success=True, allocation_id=aid), 201
223
+ # GET
224
+ if uid not in org.get('members',[]): return jsonify(error='Access denied'), 403
225
+ allocs = db.reference(f'projects/{pid}/allocations').get() or {}
226
+ return jsonify(allocations=[{'allocation_id':k,**v} for k,v in allocs.items()])
227
+
228
+ @app.route('/api/orgs/<org_id>/projects/<pid>/allocations/<aid>', methods=['PUT','DELETE'])
229
+ def modify_allocation(org_id, pid, aid):
230
+ uid = get_auth_uid()
231
+ try: verify_org_manager(uid, org_id)
232
+ except PermissionError as e: return jsonify(error=str(e)), 403
233
+ ref = db.reference(f'projects/{pid}/allocations/{aid}')
234
+ if request.method=='PUT':
235
+ updates = {}
236
+ j = request.get_json() or {}
237
+ if 'name' in j: updates['name'] = j['name']
238
+ if 'budget' in j: updates['budget'] = float(j['budget'])
239
+ if not updates: return jsonify(error='nothing to update'), 400
240
+ ref.update(updates)
241
+ else:
242
+ ref.delete()
243
+ return jsonify(success=True)
244
 
245
  # ========================================
246
+ # RECEIPTS, MANUAL ENTRY, AI REPORT
247
  # ========================================
248
+ def process_receipt(model, image):
249
+ prompt = """Analyze receipt... return JSON with keys: is_receipt,total,items,date,receipt_number"""
250
  try:
251
+ return model.generate_content([prompt, image]).text
252
+ except:
253
+ return "{}"
 
 
 
 
 
 
254
 
255
+ @app.route('/api/process-receipt', methods=['POST'])
256
+ def process_receipt_endpoint():
257
+ uid = get_auth_uid()
258
+ if not uid: return jsonify(error='Invalid token'), 401
259
+
260
+ # Confirmation branch
261
+ if request.form.get('confirmed')=='true':
262
+ data = {**{k:request.form[k] for k in ('date','receipt_number','image_url')},
263
+ 'total':float(request.form['total']),
264
+ 'items': [i.strip() for i in request.form['items'].split(',')],
265
+ }
266
+ return validate_and_save_transaction(uid, db.reference(f'users/{uid}').get(), data,
267
+ request.form['file_hash'], None, False)
268
+
269
+ file = request.files.get('receipt')
270
+ if not file: return jsonify(error='No file uploaded'), 400
271
+ img_bytes = file.read()
272
+ file_hash = hashlib.md5(img_bytes).hexdigest()
273
+ if db.reference('transactions').order_by_child('hash').equal_to(file_hash).get():
274
+ return jsonify(error='Receipt already processed'), 400
275
+
276
+ ts = datetime.now().strftime('%Y%m%d_%H%M%S')
277
+ blob = bucket.blob(f'receipts/{uid}/{ts}_{file_hash}.jpg')
278
+ blob.upload_from_string(img_bytes, content_type='image/jpeg')
279
+ img = Image.open(io.BytesIO(img_bytes))
280
+ buf = io.BytesIO(); img.save(buf, 'JPEG', optimize=True, quality=90)
281
+ text = process_receipt(configure_gemini(), Image.open(io.BytesIO(buf.getvalue())))
282
+ try:
283
+ js = json.loads(text[text.find('{'):text.rfind('}')+1])
284
+ except:
285
+ return jsonify(error='Parse error', raw=text), 400
286
+ if not js.get('is_receipt'): return jsonify(error='Not a valid receipt'), 400
287
 
288
+ js.update(file_hash=file_hash, image_url=blob.public_url)
289
+ return jsonify(success=True, extracted=True, data=js, message='Confirm to save')
290
 
291
+ @app.route('/api/manual-entry', methods=['POST'])
292
+ def manual_entry():
293
+ uid = get_auth_uid()
294
+ if not uid: return jsonify(error='Invalid token'), 401
295
+ user = db.reference(f'users/{uid}')
296
+ data = {
297
+ 'total': float(request.form.get('total',0)),
298
+ 'items': [i.strip() for i in request.form.get('items','').split(',')],
299
+ 'date': request.form.get('date'),
300
+ 'receipt_number': request.form.get('receipt_number')
301
+ }
302
+ return validate_and_save_transaction(uid, user.get(), data,
303
+ hashlib.md5(str(datetime.now()).encode()).hexdigest(),
304
+ None, True)
305
+
306
+ def validate_and_save_transaction(uid, user_data, data, file_hash, img_bytes, manual):
307
+ total = float(data.get('total',0))
308
+ db.reference(f'users/{uid}').update({
309
+ 'remaining_cash': user_data['remaining_cash'] - total
310
+ })
311
 
312
+ # project/allocation spend
313
+ pid = data.get('project_id'); aid = data.get('allocation_id')
314
+ if pid:
315
+ proj_ref = db.reference(f'projects/{pid}')
316
+ p = proj_ref.get() or {}
317
+ proj_ref.update({'spent': p.get('spent',0)+total})
318
+ if aid:
319
+ alloc_ref = proj_ref.child(f'allocations/{aid}')
320
+ a = alloc_ref.get() or {}
321
+ alloc_ref.update({'spent': a.get('spent',0)+total})
322
+
323
+ tx = {
324
+ 'uid': uid, 'total': total, 'items': data.get('items',[]),
325
+ 'date': data.get('date'), 'receipt_number': data.get('receipt_number'),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
  'timestamp': datetime.now(pytz.UTC).isoformat(),
327
+ 'hash': file_hash, 'manual_entry': manual,
328
+ 'project_id': pid, 'allocation_id': aid
329
  }
330
+ if img_bytes:
331
+ ts = datetime.now().strftime('%Y%m%d_%H%M%S')
332
+ blob = bucket.blob(f'receipts/{uid}/{ts}_{file_hash}.jpg')
333
+ blob.upload_from_string(img_bytes, 'image/jpeg')
334
+ tx['image_url'] = blob.public_url
335
 
336
+ new = db.reference('transactions').push(tx)
337
+ return jsonify(success=True, transaction={**tx,'id':new.key})
 
 
 
 
 
 
 
 
 
338
 
339
  # ========================================
340
+ # PERSONAL OVERVIEW & PROFILE
341
  # ========================================
 
342
  @app.route('/api/user/spending-overview', methods=['GET'])
343
+ def spending_overview():
344
+ uid = get_auth_uid()
345
+ ref = db.reference('transactions').order_by_child('uid').equal_to(uid)
346
+ items = [{**v,'id':k} for k,v in (ref.get() or {}).items()]
347
+ df = pd.DataFrame(items)
348
+ if df.empty:
349
+ return jsonify(daily_spending=[], recent_transactions=[])
350
+ df['parsed'] = pd.to_datetime(df['date'], errors='coerce').fillna(pd.Timestamp('2000-01-01'))
351
+ df['date_only'] = df['parsed'].dt.date.astype(str)
352
+ daily = df.groupby('date_only')['total'].sum().reset_index().rename(columns={'date_only':'date'})
353
+ recent = df.sort_values('timestamp',ascending=False).head(10).drop(columns=['parsed'])
354
+ return jsonify(
355
+ daily_spending=daily.to_dict(orient='records'),
356
+ recent_transactions=recent.to_dict(orient='records')
357
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
358
 
359
+ @app.route('/api/user/profile', methods=['GET'])
360
+ def user_profile():
361
+ uid = get_auth_uid()
362
+ u = auth.get_user(uid)
363
+ d = db.reference(f'users/{uid}').get() or {}
364
+ return jsonify(
365
+ uid=uid, email=u.email,
366
+ daily_cash=d.get('daily_cash',0),
367
+ remaining_cash=d.get('remaining_cash',0),
368
+ last_reset=d.get('last_reset'),
369
+ is_admin=d.get('is_admin',False)
370
+ )
371
 
372
  # ========================================
373
+ # ORG-LEVEL ADMIN
374
  # ========================================
375
+ @app.route('/api/orgs/<org_id>/admin/overview', methods=['GET'])
376
+ def org_admin_overview(org_id):
377
+ uid = get_auth_uid()
378
+ try: org = verify_org_manager(uid, org_id)
379
+ except PermissionError as e: return jsonify(error=str(e)), 403
380
+
381
+ members = []
382
+ for m in org.get('members',[]):
383
+ try: em = auth.get_user(m).email
384
+ except: em = None
385
+ role = 'owner' if m==org['owner'] else 'manager' if m in org['managers'] else 'member'
386
+ members.append(dict(uid=m,email=em,role=role))
387
+
388
+ txs = []
389
+ all_t = db.reference('transactions').get() or {}
390
+ for tid,td in all_t.items():
391
+ p = db.reference(f'projects/{td.get("project_id","")}').get() or {}
392
+ if p.get('org_id')==org_id:
393
+ txs.append({**td,'id':tid})
394
+
395
+ return jsonify(
396
+ members=members,
397
+ transactions=txs,
398
+ analytics=dict(
399
+ total_members=len(members),
400
+ total_transactions=len(txs),
401
+ total_spent=sum(t['total'] for t in txs)
402
+ )
403
+ )
404
+
405
+ @app.route('/api/orgs/<org_id>/admin/users/<mid>/role', methods=['PUT'])
406
+ def org_admin_set_role(org_id, mid):
407
+ uid = get_auth_uid()
408
+ try: verify_org_manager(uid, org_id)
409
+ except PermissionError as e: return jsonify(error=str(e)), 403
410
+ r = (request.get_json() or {}).get('role')
411
+ if r not in ('member','manager'): return jsonify(error='invalid role'),400
412
+ ref = db.reference(f'organizations/{org_id}')
413
+ org = ref.get() or {}
414
+ mng = set(org.get('managers',[]))
415
+ if r=='manager': mng.add(mid)
416
+ else: mng.discard(mid)
417
+ ref.update(managers=list(mng))
418
+ return jsonify(success=True)
419
+
420
+ @app.route('/api/orgs/<org_id>/admin/users/<mid>', methods=['DELETE'])
421
+ def org_admin_remove_user(org_id, mid):
422
+ uid = get_auth_uid()
423
+ try: verify_org_manager(uid, org_id)
424
+ except PermissionError as e: return jsonify(error=str(e)), 403
425
+ ref = db.reference(f'organizations/{org_id}')
426
+ org = ref.get() or {}
427
+ if mid==org.get('owner'): return jsonify(error='Cannot remove owner'),400
428
+ m_set, mg_set = set(org.get('members',[])), set(org.get('managers',[]))
429
+ m_set.discard(mid); mg_set.discard(mid)
430
+ ref.update(members=list(m_set), managers=list(mg_set))
431
+ return jsonify(success=True)
432
+
433
+ @app.route('/api/orgs/<org_id>transactions/<tid>', methods=['PUT','DELETE'])
434
+ def modify_transaction(tid):
435
  try:
436
+ verify_org_manager(request.headers.get('Authorization',''))
437
+ ref = db.reference(f'transactions/{tid}')
438
+ if request.method=='PUT':
439
+ ref.update(request.get_json() or {})
440
+ else:
441
+ ref.delete()
442
+ return jsonify(success=True)
443
  except Exception as e:
444
+ return jsonify(error=str(e)),500
 
 
 
445
 
446
  # ========================================
447
+ # GLOBAL ADMIN (developer)
448
  # ========================================
449
  @app.route('/api/admin/overview', methods=['GET'])
450
  def get_admin_overview():
451
  try:
452
+ verify_global_admin(request.headers.get('Authorization',''))
453
+ users = db.reference('users').get() or {}
454
+ ulist = [{'uid':u,'email':(auth.get_user(u).email if auth.get_user(u) else None),
455
+ 'is_admin':d.get('is_admin',False)} for u,d in users.items()]
456
+ txs = db.reference('transactions').get() or {}
457
+ tlist = [{**v,'id':k} for k,v in txs.items()]
458
+ return jsonify(
459
+ users=ulist, transactions=tlist,
460
+ analytics=dict(
461
+ total_users=len(ulist),
462
+ total_transactions=len(tlist),
463
+ total_spent=sum(t['total'] for t in tlist)
464
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
465
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
466
  except Exception as e:
467
+ return jsonify(error=str(e)), 500
468
 
469
+ @app.route('/api/admin/users', methods=['POST'])
470
+ def create_user_admin():
471
  try:
472
+ verify_global_admin(request.headers.get('Authorization',''))
473
+ d = request.get_json() or {}
474
+ user = auth.create_user(email=d['email'], password=d['password'])
475
+ db.reference(f'users/{user.uid}').set({
476
+ 'daily_cash': d.get('daily_cash',0),
477
+ 'remaining_cash': d.get('daily_cash',0),
478
+ 'last_reset': datetime.now(pytz.UTC).isoformat(),
479
+ 'is_admin': d.get('is_admin',False)
 
 
 
 
 
 
 
 
 
 
 
 
 
480
  })
481
+ return jsonify(success=True, user=dict(uid=user.uid,email=user.email)),201
482
  except Exception as e:
483
+ return jsonify(error=str(e)),400
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
484
 
485
+ @app.route('/api/admin/users/<uid>/reset-password', methods=['PUT'])
486
  def admin_reset_password(uid):
487
  try:
488
+ verify_global_admin(request.headers.get('Authorization',''))
489
+ npw = (request.get_json() or {}).get('new_password')
490
+ if not npw: return jsonify(error='new_password required'),400
491
+ auth.update_user(uid, password=npw)
492
+ return jsonify(success=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
493
  except Exception as e:
494
+ return jsonify(error=str(e)),400
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
495
 
496
+ @app.route('/api/admin/users/<uid>', methods=['DELETE'])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
497
  def delete_user(uid):
498
  try:
499
+ verify_global_admin(request.headers.get('Authorization',''))
 
 
 
 
 
 
500
  auth.delete_user(uid)
501
  db.reference(f'users/{uid}').delete()
502
+ txs = db.reference('transactions').order_by_child('uid').equal_to(uid).get() or {}
503
+ for k in txs: db.reference(f'transactions/{k}').delete()
504
+ return jsonify(success=True)
 
 
 
 
 
 
 
 
505
  except Exception as e:
506
+ return jsonify(error=str(e)),500
507
 
508
+
509
+
510
+ # ========================================
511
  if __name__ == '__main__':
512
+ app.run(debug=True, host="0.0.0.0", port=7860)