rairo commited on
Commit
e459b0c
·
verified ·
1 Parent(s): 0eadc66

Update utility.py

Browse files
Files changed (1) hide show
  1. utility.py +176 -40
utility.py CHANGED
@@ -120,6 +120,65 @@ except Exception as e:
120
  logger.error(f"Error configuring Generative AI: {e}", exc_info=True)
121
  model = vision_model = llm = None
122
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  # --- START: VISION PROCESSING FUNCTIONS ---
124
 
125
  def _transpile_vision_json_to_query(vision_json: List[Dict]) -> str:
@@ -165,7 +224,6 @@ def _analyze_image_with_vision(image_bytes: bytes, caption: Optional[str]) -> Li
165
  try:
166
  image_pil = Image.open(io.BytesIO(image_bytes))
167
 
168
- # --- FIX BUG 2: Reinforce Receipt vs Product logic ---
169
  prompt = f"""
170
  You are a bookkeeping vision model. Analyze the image. Return ONLY a valid JSON array [] of transaction objects.
171
 
@@ -205,16 +263,6 @@ DETAILS KEYS
205
  - liability: creditor, amount, currency
206
  - query: query (verbatim text)
207
 
208
- ================
209
- EXAMPLES
210
- ================
211
-
212
- // 1) Photo of a dress on a hanger (User is selling it)
213
- [ {{"intent":"create","transaction_type":"sale","details":{{"item":"dress","quantity":1}},"source":"object_detection"}} ]
214
-
215
- // 2) Paper Receipt for "City Power" (User paid this)
216
- [ {{"intent":"create","transaction_type":"expense","details":{{"description":"electricity","amount":500,"currency":"R","vendor":"City Power"}},"source":"ocr"}} ]
217
-
218
  Analyze the provided image and return only the JSON list.
219
  """
220
 
@@ -339,7 +387,6 @@ class ReportEngine:
339
  num_transactions = len(target_df)
340
  item_summary = target_df.groupby('item')['quantity'].sum()
341
 
342
- # --- FIX BUG 3: Logic for ties and single items ---
343
  if item_summary.empty:
344
  best_selling_item = "N/A"
345
  worst_selling_item = "N/A"
@@ -536,14 +583,12 @@ class ReportEngine:
536
 
537
  return snapshot
538
 
539
- # --- FIX BUG 4: Accept Currency argument ---
540
  def generateResponse(prompt: str, currency: str = "R") -> str:
541
  """Generate structured JSON response from user input using Generative AI."""
542
  if not model:
543
  return '{"error": "Model not available"}'
544
 
545
- # --- FIX BUG 4: Inject User Currency into System Prompt ---
546
- # NOTE: {currency} uses single braces for variable. {{}} uses double braces for literal JSON.
547
  system_prompt = f"""
548
  Analyze the user's request for business transaction management. Your goal is to extract structured information about one or more transactions and output it as a valid JSON list.
549
 
@@ -555,30 +600,38 @@ You MUST output your response as a valid JSON list `[]` containing one or more t
555
 
556
  **2. Transaction Object Structure:**
557
  Each transaction object MUST have the following keys:
558
- - `"intent"`: The user's goal (e.g., "create", "read", "update", "delete").
559
- - `"transaction_type"`: The category of the transaction (e.g., "sale", "purchase", "inventory", "expense", "asset", "liability", "query", "service_offering").
560
  - `"details"`: An object containing key-value pairs extracted from the request.
561
 
562
  **3. Key Naming Conventions for the `details` Object:**
563
  - **For Expenses:** Use `"amount"`, `"description"`, and `"category"`.
564
  - **For Assets:** Use `"value"` for the monetary worth and `"name"` for the item's name.
565
  - **For Liabilities:** Use `"amount"` and `"creditor"`.
566
- - **For Sales/Inventory:** Use `"item"`, `"quantity"`, and `"price"`.
 
 
 
 
 
567
  - **For all financial transactions:** Always include a `"currency"` key. Use user input if available, else default to {currency}.
568
 
569
- **4. Important Rules:**
570
- - **Rule for Queries:** For "read" intents or general questions, set `transaction_type` to "query" and the `details` object MUST contain a single key `"query"` with the user's full, original question.
571
- - **Rule for Expense Normalization:** For "create" intents with `transaction_type` "expense", analyze the `description`. If it contains common keywords, normalize it to a single word (e.g., "fuel for truck" -> "fuel").
572
 
573
  **5. Examples:**
574
 
575
- **Example 1: Simple Query**
576
- - **Input:** "what are my assets?"
577
- - **Output:** [ {{"intent": "read", "transaction_type": "query", "details": {{"query": "what are my assets?"}} }} ]
 
 
 
 
578
 
579
- **Example 2: Creating a Normalized Expense**
580
- - **Input:** "I paid 250 for fuel for work" (Assuming {currency} default)
581
- - **Output:** [ {{"intent": "create", "transaction_type": "expense", "details": {{"description": "fuel", "amount": 250, "currency": "{currency}"}} }} ]
582
  """
583
  try:
584
  full_prompt = [system_prompt, prompt]
@@ -619,8 +672,6 @@ def _get_canonical_info(user_phone: str, item_name: str) -> Dict[str, Any]:
619
  inventory_ref = db.collection("users").document(user_phone).collection("inventory_and_services")
620
  name_lower = item_name.lower().strip()
621
 
622
- # --- FIX BUG 1: Singularization Edge Case ---
623
- # Don't singularize words ending in 'ss' (Dress, Glass, Pass)
624
  if name_lower.endswith('ss'):
625
  singular = name_lower
626
  else:
@@ -707,12 +758,14 @@ def create_sale(user_phone: str, transaction_data: List[Dict]) -> tuple[bool, st
707
  all_sales_docs.sort(key=lambda doc: doc.to_dict().get('timestamp', ''), reverse=True)
708
  last_sale_data = all_sales_docs[0].to_dict()
709
  last_selling_price = last_sale_data.get('details', {}).get('price')
 
710
  @firestore.transactional
711
  def process_one_sale(transaction, sale_details):
712
  is_new_item = canonical_info['doc'] is None
713
  original_trans_type = t.get('transaction_type')
714
  item_type = 'service' if original_trans_type == 'service_offering' else 'good'
715
  user_price = sale_details.get('price') or sale_details.get('unit_price')
 
716
  if user_price is not None:
717
  selling_price = user_price
718
  elif last_selling_price is not None:
@@ -723,15 +776,45 @@ def create_sale(user_phone: str, transaction_data: List[Dict]) -> tuple[bool, st
723
  else:
724
  selling_price = 0
725
  if not isinstance(selling_price, (int, float)): selling_price = 0
 
726
  sale_details['price'] = selling_price
727
  sale_details['item'] = canonical_name
728
  if 'unit_price' in sale_details: del sale_details['unit_price']
729
  if 'service_name' in sale_details: del sale_details['service_name']
 
730
  try:
731
  quantity_sold = int(sale_details.get('quantity', 1))
732
  if quantity_sold <= 0: return f"Sale failed for '{canonical_name}': Invalid quantity ({quantity_sold})."
733
  except (ValueError, TypeError):
734
  return f"Sale failed for '{canonical_name}': Invalid quantity format."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
735
  item_doc_ref = db.collection("users").document(user_phone).collection("inventory_and_services").document(canonical_name)
736
  item_snapshot = item_doc_ref.get(transaction=transaction)
737
  item_cost = 0
@@ -755,15 +838,23 @@ def create_sale(user_phone: str, transaction_data: List[Dict]) -> tuple[bool, st
755
  'last_updated': datetime.now(timezone.utc).isoformat()
756
  }
757
  transaction.set(item_doc_ref, service_record)
 
758
  sale_doc_ref = sales_ref.document()
759
  sale_record = {
760
  'details': {**sale_details, 'cost': item_cost},
761
  'timestamp': datetime.now(timezone.utc).isoformat(),
762
- 'status': 'completed',
763
  'transaction_id': sale_doc_ref.id
764
  }
765
  transaction.set(sale_doc_ref, sale_record)
766
- return f"Sale successful for {quantity_sold} x '{canonical_name}' at {sale_details.get('currency','')}{selling_price} each."
 
 
 
 
 
 
 
767
  transaction_feedback = process_one_sale(db.transaction(), details)
768
  feedback_messages.append(transaction_feedback)
769
  if "successful" in transaction_feedback:
@@ -1092,9 +1183,12 @@ def read_datalake(user_phone: str, query: str) -> str:
1092
 
1093
  def _find_document_by_details(user_phone: str, collection_name: str, details: Dict) -> Optional[Any]:
1094
  col_ref = db.collection("users").document(user_phone).collection(collection_name)
 
 
1095
  if 'transaction_id' in details and details['transaction_id']:
1096
  doc = col_ref.document(details['transaction_id']).get()
1097
  if doc.exists: return {"id": doc.id, "data": doc.to_dict()}
 
1098
  if collection_name in ['inventory_and_services', 'sales'] and ('item' in details or 'service_name' in details):
1099
  item_name = details.get('item') or details.get('service_name')
1100
  canonical_info = _get_canonical_info(user_phone, item_name)
@@ -1134,10 +1228,10 @@ def update_transaction(user_phone: str, transaction_data: List[Dict]) -> tuple[b
1134
  continue
1135
  target_doc = _find_document_by_details(user_phone, collection_name, details)
1136
  if target_doc == "multiple_matches":
1137
- feedback.append(f"Update for {trans_type} failed: Multiple records match. Please be more specific.")
1138
  continue
1139
  if not target_doc:
1140
- feedback.append(f"Update for {trans_type} failed: No record found matching your description.")
1141
  continue
1142
  doc_id = target_doc["id"]
1143
  doc_ref = db.collection("users").document(user_phone).collection(collection_name).document(doc_id)
@@ -1171,10 +1265,10 @@ def delete_transaction(user_phone: str, transaction_data: List[Dict]) -> tuple[b
1171
  continue
1172
  target_doc = _find_document_by_details(user_phone, collection_name, details)
1173
  if target_doc == "multiple_matches":
1174
- feedback.append(f"Delete for {trans_type} failed: Multiple records match.")
1175
  continue
1176
  if not target_doc:
1177
- feedback.append(f"Delete for {trans_type} failed: No record found.")
1178
  continue
1179
  doc_id = target_doc["id"]
1180
  try:
@@ -1186,6 +1280,34 @@ def delete_transaction(user_phone: str, transaction_data: List[Dict]) -> tuple[b
1186
  feedback.append(f"Delete for {trans_type} (ID: {doc_id}) failed with an error.")
1187
  return any_success, "\n".join(feedback)
1188
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1189
  def persist_temporary_transaction(transactions: List[Dict], mobile: str) -> bool:
1190
  if not transactions: return False
1191
  try:
@@ -1207,14 +1329,24 @@ def format_transaction_response(transactions: Union[List[Dict], Dict, None]) ->
1207
  title = f"{trans_type}"
1208
  if len(transactions) > 1: output_lines.append(f"--- {title} {idx + 1} ---")
1209
  else: output_lines.append(f"--- {title} ---")
1210
- key_order = ['transaction_id', 'item', 'service_name', 'name', 'creditor', 'category', 'quantity', 'units_available', 'hours', 'price', 'rate', 'amount', 'cost', 'value', 'customer', 'vendor', 'client', 'date', 'acquisition_date', 'due_date', 'description', 'type']
1211
- displayed_keys = set()
1212
  if 'transaction_id' in trans:
1213
- output_lines.append(f"• Transaction ID: {trans['transaction_id']}")
1214
- displayed_keys.add('transaction_id')
 
 
 
 
 
 
1215
  for key in key_order:
1216
  if key in details and key not in displayed_keys:
1217
- output_lines.append(f"• {key.replace('_', ' ').title()}: {details[key]}")
 
 
 
 
1218
  displayed_keys.add(key)
1219
  for key, value in details.items():
1220
  if key not in displayed_keys and key != 'currency':
@@ -1277,6 +1409,10 @@ def process_intent(parsed_trans_data: List[Dict], mobile: str) -> str:
1277
  elif intent == 'delete':
1278
  success, message = delete_transaction(mobile, transactions)
1279
  final_feedback.append(f"Deletion Results:\n{message}")
 
 
 
 
1280
  else:
1281
  final_feedback.append(f"Unknown intent '{intent}' for type '{trans_type}'.")
1282
  except Exception as e:
 
120
  logger.error(f"Error configuring Generative AI: {e}", exc_info=True)
121
  model = vision_model = llm = None
122
 
123
+ # --- START: LANGUAGE PROCESSING FUNCTIONS (UPGRADE 4) ---
124
+
125
+ def detect_and_translate_input(text: str) -> Dict[str, str]:
126
+ """
127
+ Detects the language of the input text.
128
+ If it's not English, translates it to English.
129
+ Returns: {'english_text': str, 'detected_lang': str}
130
+ """
131
+ if not model:
132
+ return {'english_text': text, 'detected_lang': 'English'}
133
+
134
+ system_prompt = """
135
+ You are a language detection and translation engine for a bookkeeping bot.
136
+ 1. Detect the language of the user's input (e.g., English, Shona, Zulu, Ndebele, Tswana, etc.).
137
+ 2. If the language is NOT English, translate the text accurately to English, preserving business context (money, items, quantities).
138
+ 3. If the language IS English, return the text as is.
139
+ 4. Return ONLY a valid JSON object.
140
+
141
+ Output Schema:
142
+ {
143
+ "detected_lang": "Name of Language",
144
+ "english_text": "The translated text or original text"
145
+ }
146
+ """
147
+ try:
148
+ response = model.generate_content([system_prompt, text])
149
+ response_text = response.text
150
+ cleaned_response = re.sub(r'^```json\s*|\s*```$', '', response_text, flags=re.MULTILINE).strip()
151
+ result = json.loads(cleaned_response)
152
+ return result
153
+ except Exception as e:
154
+ logger.error(f"Language detection failed: {e}")
155
+ # Fallback to assuming English if AI fails
156
+ return {'english_text': text, 'detected_lang': 'English'}
157
+
158
+ def translate_output(text: str, target_lang: str) -> str:
159
+ """
160
+ Translates the system's English response back to the target language.
161
+ """
162
+ if not model or target_lang.lower() == 'english':
163
+ return text
164
+
165
+ prompt = f"""
166
+ Translate the following bookkeeping response from English to {target_lang}.
167
+ Keep numbers, currency symbols (like $ or R), and proper nouns (like item names) unchanged.
168
+ Maintain a professional but helpful tone.
169
+
170
+ Text to translate:
171
+ "{text}"
172
+ """
173
+ try:
174
+ response = model.generate_content(prompt)
175
+ return response.text.strip()
176
+ except Exception as e:
177
+ logger.error(f"Output translation failed: {e}")
178
+ return text
179
+
180
+ # --- END: LANGUAGE PROCESSING FUNCTIONS ---
181
+
182
  # --- START: VISION PROCESSING FUNCTIONS ---
183
 
184
  def _transpile_vision_json_to_query(vision_json: List[Dict]) -> str:
 
224
  try:
225
  image_pil = Image.open(io.BytesIO(image_bytes))
226
 
 
227
  prompt = f"""
228
  You are a bookkeeping vision model. Analyze the image. Return ONLY a valid JSON array [] of transaction objects.
229
 
 
263
  - liability: creditor, amount, currency
264
  - query: query (verbatim text)
265
 
 
 
 
 
 
 
 
 
 
 
266
  Analyze the provided image and return only the JSON list.
267
  """
268
 
 
387
  num_transactions = len(target_df)
388
  item_summary = target_df.groupby('item')['quantity'].sum()
389
 
 
390
  if item_summary.empty:
391
  best_selling_item = "N/A"
392
  worst_selling_item = "N/A"
 
583
 
584
  return snapshot
585
 
 
586
  def generateResponse(prompt: str, currency: str = "R") -> str:
587
  """Generate structured JSON response from user input using Generative AI."""
588
  if not model:
589
  return '{"error": "Model not available"}'
590
 
591
+ # --- UPDATED: System Prompt for Upgrades 1, 2, 3, 5 ---
 
592
  system_prompt = f"""
593
  Analyze the user's request for business transaction management. Your goal is to extract structured information about one or more transactions and output it as a valid JSON list.
594
 
 
600
 
601
  **2. Transaction Object Structure:**
602
  Each transaction object MUST have the following keys:
603
+ - `"intent"`: The user's goal ("create", "read", "update", "delete", "reset_account").
604
+ - `"transaction_type"`: The category of the transaction (e.g., "sale", "purchase", "inventory", "expense", "asset", "liability", "query", "service_offering", "account").
605
  - `"details"`: An object containing key-value pairs extracted from the request.
606
 
607
  **3. Key Naming Conventions for the `details` Object:**
608
  - **For Expenses:** Use `"amount"`, `"description"`, and `"category"`.
609
  - **For Assets:** Use `"value"` for the monetary worth and `"name"` for the item's name.
610
  - **For Liabilities:** Use `"amount"` and `"creditor"`.
611
+ - **For Sales/Inventory (Updated):**
612
+ - Use `"item"`, `"quantity"`, and `"price"`.
613
+ - **Customer:** If a customer name is mentioned (e.g., "Sold to John"), extract as `"customer"`.
614
+ - **Payment:** If the user mentions how much was paid (e.g., "They gave me $10" or "paid 500"), extract as `"amount_paid"`.
615
+ - **For Updates/Deletes (Updated):**
616
+ - If a Transaction ID is mentioned (e.g., "update transaction 8f3a..."), extract it as `"transaction_id"`.
617
  - **For all financial transactions:** Always include a `"currency"` key. Use user input if available, else default to {currency}.
618
 
619
+ **4. Special Intents:**
620
+ - **Account Reset:** If the user explicitly asks to "reset my account", "wipe data", or "delete all data", use intent `"reset_account"` and transaction_type `"account"`.
 
621
 
622
  **5. Examples:**
623
 
624
+ **Example 1: Sales with Change**
625
+ - **Input:** "Sold 2 bread for $1 each, customer gave me $5"
626
+ - **Output:** [ {{"intent": "create", "transaction_type": "sale", "details": {{"item": "bread", "quantity": 2, "price": 1, "amount_paid": 5, "currency": "$"}} }} ]
627
+
628
+ **Example 2: Update with ID**
629
+ - **Input:** "Update transaction abc1234, change price to 50"
630
+ - **Output:** [ {{"intent": "update", "transaction_type": "sale", "details": {{"transaction_id": "abc1234", "price": 50}} }} ]
631
 
632
+ **Example 3: Reset Account**
633
+ - **Input:** "Reset my account completely"
634
+ - **Output:** [ {{"intent": "reset_account", "transaction_type": "account", "details": {{}} }} ]
635
  """
636
  try:
637
  full_prompt = [system_prompt, prompt]
 
672
  inventory_ref = db.collection("users").document(user_phone).collection("inventory_and_services")
673
  name_lower = item_name.lower().strip()
674
 
 
 
675
  if name_lower.endswith('ss'):
676
  singular = name_lower
677
  else:
 
758
  all_sales_docs.sort(key=lambda doc: doc.to_dict().get('timestamp', ''), reverse=True)
759
  last_sale_data = all_sales_docs[0].to_dict()
760
  last_selling_price = last_sale_data.get('details', {}).get('price')
761
+
762
  @firestore.transactional
763
  def process_one_sale(transaction, sale_details):
764
  is_new_item = canonical_info['doc'] is None
765
  original_trans_type = t.get('transaction_type')
766
  item_type = 'service' if original_trans_type == 'service_offering' else 'good'
767
  user_price = sale_details.get('price') or sale_details.get('unit_price')
768
+
769
  if user_price is not None:
770
  selling_price = user_price
771
  elif last_selling_price is not None:
 
776
  else:
777
  selling_price = 0
778
  if not isinstance(selling_price, (int, float)): selling_price = 0
779
+
780
  sale_details['price'] = selling_price
781
  sale_details['item'] = canonical_name
782
  if 'unit_price' in sale_details: del sale_details['unit_price']
783
  if 'service_name' in sale_details: del sale_details['service_name']
784
+
785
  try:
786
  quantity_sold = int(sale_details.get('quantity', 1))
787
  if quantity_sold <= 0: return f"Sale failed for '{canonical_name}': Invalid quantity ({quantity_sold})."
788
  except (ValueError, TypeError):
789
  return f"Sale failed for '{canonical_name}': Invalid quantity format."
790
+
791
+ # --- UPGRADE 5: Debt and Change Logic ---
792
+ total_due = selling_price * quantity_sold
793
+ amount_paid = sale_details.get('amount_paid')
794
+ change_due = 0
795
+ amount_outstanding = 0
796
+ payment_status = 'completed'
797
+
798
+ if amount_paid is not None:
799
+ try:
800
+ amount_paid = float(amount_paid)
801
+ if amount_paid >= total_due:
802
+ change_due = amount_paid - total_due
803
+ sale_details['change_given'] = change_due
804
+ payment_status = 'paid_in_full'
805
+ else:
806
+ amount_outstanding = total_due - amount_paid
807
+ sale_details['amount_outstanding'] = amount_outstanding
808
+ payment_status = 'partial_payment'
809
+ except ValueError:
810
+ pass # Ignore if amount_paid is not a valid number
811
+
812
+ # Check Credit/Debt if no payment info but 'customer' exists (implicitly credit?)
813
+ # For now, if no amount_paid is sent, we assume it's a cash sale (paid in full) unless otherwise specified.
814
+
815
+ sale_details['status'] = payment_status
816
+ # -------------------------------------
817
+
818
  item_doc_ref = db.collection("users").document(user_phone).collection("inventory_and_services").document(canonical_name)
819
  item_snapshot = item_doc_ref.get(transaction=transaction)
820
  item_cost = 0
 
838
  'last_updated': datetime.now(timezone.utc).isoformat()
839
  }
840
  transaction.set(item_doc_ref, service_record)
841
+
842
  sale_doc_ref = sales_ref.document()
843
  sale_record = {
844
  'details': {**sale_details, 'cost': item_cost},
845
  'timestamp': datetime.now(timezone.utc).isoformat(),
846
+ 'status': payment_status,
847
  'transaction_id': sale_doc_ref.id
848
  }
849
  transaction.set(sale_doc_ref, sale_record)
850
+
851
+ msg = f"Sale successful for {quantity_sold} x '{canonical_name}' at {sale_details.get('currency','')}{selling_price} each."
852
+ if change_due > 0:
853
+ msg += f" Change due: {sale_details.get('currency','')}{change_due:.2f}."
854
+ if amount_outstanding > 0:
855
+ msg += f" Outstanding debt: {sale_details.get('currency','')}{amount_outstanding:.2f}."
856
+ return msg
857
+
858
  transaction_feedback = process_one_sale(db.transaction(), details)
859
  feedback_messages.append(transaction_feedback)
860
  if "successful" in transaction_feedback:
 
1183
 
1184
  def _find_document_by_details(user_phone: str, collection_name: str, details: Dict) -> Optional[Any]:
1185
  col_ref = db.collection("users").document(user_phone).collection(collection_name)
1186
+
1187
+ # --- UPGRADE 1 & 2: Search by Transaction ID ---
1188
  if 'transaction_id' in details and details['transaction_id']:
1189
  doc = col_ref.document(details['transaction_id']).get()
1190
  if doc.exists: return {"id": doc.id, "data": doc.to_dict()}
1191
+
1192
  if collection_name in ['inventory_and_services', 'sales'] and ('item' in details or 'service_name' in details):
1193
  item_name = details.get('item') or details.get('service_name')
1194
  canonical_info = _get_canonical_info(user_phone, item_name)
 
1228
  continue
1229
  target_doc = _find_document_by_details(user_phone, collection_name, details)
1230
  if target_doc == "multiple_matches":
1231
+ feedback.append(f"Update for {trans_type} failed: Multiple records match. Please be more specific or use the ID.")
1232
  continue
1233
  if not target_doc:
1234
+ feedback.append(f"Update for {trans_type} failed: No record found matching your description or ID.")
1235
  continue
1236
  doc_id = target_doc["id"]
1237
  doc_ref = db.collection("users").document(user_phone).collection(collection_name).document(doc_id)
 
1265
  continue
1266
  target_doc = _find_document_by_details(user_phone, collection_name, details)
1267
  if target_doc == "multiple_matches":
1268
+ feedback.append(f"Delete for {trans_type} failed: Multiple records match. Please be more specific or use the ID.")
1269
  continue
1270
  if not target_doc:
1271
+ feedback.append(f"Delete for {trans_type} failed: No record found matching your description or ID.")
1272
  continue
1273
  doc_id = target_doc["id"]
1274
  try:
 
1280
  feedback.append(f"Delete for {trans_type} (ID: {doc_id}) failed with an error.")
1281
  return any_success, "\n".join(feedback)
1282
 
1283
+ def reset_user_account(user_phone: str) -> str:
1284
+ """Permanently deletes all transaction data for a user. (Upgrade 3)"""
1285
+ collections = ['sales', 'expenses', 'assets', 'liabilities', 'inventory_and_services', 'temp_transactions']
1286
+ user_ref = db.collection("users").document(user_phone)
1287
+
1288
+ try:
1289
+ total_deleted = 0
1290
+ batch = db.batch()
1291
+ count = 0
1292
+
1293
+ for coll_name in collections:
1294
+ docs = user_ref.collection(coll_name).list_documents()
1295
+ for doc in docs:
1296
+ batch.delete(doc)
1297
+ count += 1
1298
+ if count >= 450: # Firestore batch limit
1299
+ batch.commit()
1300
+ batch = db.batch()
1301
+ count = 0
1302
+
1303
+ if count > 0:
1304
+ batch.commit()
1305
+
1306
+ return "Your account has been reset. All transaction data has been permanently deleted."
1307
+ except Exception as e:
1308
+ logger.error(f"Account reset failed for {user_phone}: {e}", exc_info=True)
1309
+ return "An error occurred while resetting your account. Please try again later."
1310
+
1311
  def persist_temporary_transaction(transactions: List[Dict], mobile: str) -> bool:
1312
  if not transactions: return False
1313
  try:
 
1329
  title = f"{trans_type}"
1330
  if len(transactions) > 1: output_lines.append(f"--- {title} {idx + 1} ---")
1331
  else: output_lines.append(f"--- {title} ---")
1332
+
1333
+ # --- UPGRADE 1 & 2: Make Transaction ID easy to copy ---
1334
  if 'transaction_id' in trans:
1335
+ # Markdown code block for easy copying on WhatsApp
1336
+ output_lines.append(f"• Transaction ID: ```{trans['transaction_id']}```")
1337
+
1338
+ key_order = ['item', 'service_name', 'name', 'creditor', 'category', 'quantity', 'units_available', 'hours', 'price', 'rate', 'amount', 'cost', 'value', 'customer', 'amount_paid', 'change_given', 'amount_outstanding', 'vendor', 'client', 'date', 'acquisition_date', 'due_date', 'description', 'type']
1339
+
1340
+ displayed_keys = set()
1341
+ displayed_keys.add('transaction_id') # Already displayed
1342
+
1343
  for key in key_order:
1344
  if key in details and key not in displayed_keys:
1345
+ val = details[key]
1346
+ # Format money fields if generic
1347
+ if isinstance(val, (int, float)) and key in ['price', 'amount', 'cost', 'value', 'amount_paid', 'change_given', 'amount_outstanding']:
1348
+ val = f"{details.get('currency', '')}{val:.2f}"
1349
+ output_lines.append(f"• {key.replace('_', ' ').title()}: {val}")
1350
  displayed_keys.add(key)
1351
  for key, value in details.items():
1352
  if key not in displayed_keys and key != 'currency':
 
1409
  elif intent == 'delete':
1410
  success, message = delete_transaction(mobile, transactions)
1411
  final_feedback.append(f"Deletion Results:\n{message}")
1412
+ elif intent == 'reset_account':
1413
+ # --- UPGRADE 3: Reset Account Handler ---
1414
+ message = reset_user_account(mobile)
1415
+ final_feedback.append(message)
1416
  else:
1417
  final_feedback.append(f"Unknown intent '{intent}' for type '{trans_type}'.")
1418
  except Exception as e: