Sagar Patel commited on
Commit
aef5cd6
·
1 Parent(s): 28b2a53

Add seller depth and action polish

Browse files
README.md CHANGED
@@ -18,18 +18,21 @@ VoiceLedger is a voice-first bookkeeping app for informal sellers, street vendor
18
  The submission version is feature-frozen around the complete demo loop:
19
 
20
  - Record a transaction with your microphone and transcribe it with faster-whisper.
 
21
  - Type or paste a transaction note.
22
  - Bulk import multiple pasted notes for review and editing.
23
  - Parse it with Modal-hosted NVIDIA Nemotron when configured, with local rules as a deterministic fallback.
 
24
  - Review a human-friendly transaction card with warning badges before saving.
25
  - Save the structured transaction to SQLite.
26
  - See a “Saved just now” receipt with bookkeeping side effects.
27
  - View the ledger, customer credit book, and inventory in a Gradio interface.
28
- - Monitor business insights and a daily sales/expense timeline in a dashboard.
29
  - Use a mobile-first, business-style Gradio interface with custom styling.
30
  - Run a Daily Closeout that prepares PDF, WhatsApp summary, and CSV exports together.
31
  - Download a Daily Summary PDF report.
32
  - Generate a WhatsApp-ready daily business summary.
 
33
  - Offload speech transcription and LLM parsing to optional Modal endpoints.
34
  - Edit or delete saved transactions while keeping customer credit and inventory balances consistent.
35
  - Export the ledger as CSV for spreadsheet sharing.
@@ -58,6 +61,8 @@ Sold 12 mangoes, 20 each
58
  Paid 500 for supplies
59
  Amit owes 100
60
  Bought 50 mangoes
 
 
61
  ```
62
 
63
  The same examples are available in `sample_data/demo_transactions.txt`.
@@ -73,24 +78,29 @@ The same examples are available in `sample_data/demo_transactions.txt`.
73
  | `Bought 50 mangoes` | Inventory purchase, quantity `50`, item `mangoes` |
74
  | `Amit owes 100` | Customer credit, customer `Amit`, amount `100` |
75
  | `Amit paid 50` | Customer payment, customer `Amit`, amount `50` |
 
 
 
 
76
 
77
  ## Demo Script
78
 
79
- 1. Use the `Hackathon Demo Launchpad` on the first screen to seed demo transactions.
80
- 2. Open `Record Text & Voice`, speak or type `Sold 12 mangoes, 20 each`, parse it, and review the human-readable transaction card.
81
- 3. Show the parse status and warning badges: Modal/NVIDIA Nemotron when available, local fallback when needed.
82
- 4. Save the transaction and show the receipt with stock, customer, or amount side effects.
83
- 5. Open `Dashboard`, `Customer Credit`, and `Inventory` to show timeline charts, customer detail, inventory detail, and automatic bookkeeping updates.
84
- 6. Open `Ledger`, load a transaction by id, update or delete it, then show refreshed balances.
85
- 7. Run Daily Closeout from `Reports & PDF`, then download CSV/PDF and generate the WhatsApp summary.
86
- 8. Click `Check Demo Health` from the launchpad, or open `Demo Health`, to show Modal health, deployed backend version, Nemotron status, database, PDF, and endpoint checks.
 
87
 
88
  ## Screenshot and GIF Moments
89
 
90
  Capture these three moments for the Space README, demo video, or social post:
91
 
92
  - `Record Text & Voice`: Today’s Work quick actions, review card, warning badges, and save receipt.
93
- - `Dashboard`: metrics, sales/expense timeline, top-selling item, low-stock table, and outstanding credit.
94
  - `Reports & PDF` plus `Ledger`: Daily Closeout, PDF/WhatsApp export, CSV download, and ledger correction.
95
  - `Submission Story`: AI pipeline strip and “Why small models fit” card for the Modal/Nemotron story.
96
 
@@ -163,6 +173,9 @@ curl -X POST https://sagarpat3199--voiceledger-api.modal.run/parse \
163
  - The dashboard shows daily sales, expenses, profit, outstanding credit, top sellers, and low-stock alerts from saved data.
164
  - Customer and inventory detail views show transaction history for one customer or item.
165
  - Smart review warnings flag low confidence, missing fields, duplicate risk, and negative stock before save.
 
 
 
166
  - WhatsApp summaries provide a short copyable daily recap for sharing.
167
  - Bulk import splits pasted notes by line, parses each line, supports review edits, and saves all reviewed transactions.
168
  - Modal integration lives in `backend/`; if endpoint URLs are not configured, local fallback stays active.
 
18
  The submission version is feature-frozen around the complete demo loop:
19
 
20
  - Record a transaction with your microphone and transcribe it with faster-whisper.
21
+ - Configure seller context: business name, currency label, low-stock threshold, and language style.
22
  - Type or paste a transaction note.
23
  - Bulk import multiple pasted notes for review and editing.
24
  - Parse it with Modal-hosted NVIDIA Nemotron when configured, with local rules as a deterministic fallback.
25
+ - Parse common English, Hinglish, Hindi-lite, and Gujarati-lite seller phrases with deterministic fallback rules.
26
  - Review a human-friendly transaction card with warning badges before saving.
27
  - Save the structured transaction to SQLite.
28
  - See a “Saved just now” receipt with bookkeeping side effects.
29
  - View the ledger, customer credit book, and inventory in a Gradio interface.
30
+ - Monitor business insights, a Command Center, a seller-day activity timeline, and a daily sales/expense chart.
31
  - Use a mobile-first, business-style Gradio interface with custom styling.
32
  - Run a Daily Closeout that prepares PDF, WhatsApp summary, and CSV exports together.
33
  - Download a Daily Summary PDF report.
34
  - Generate a WhatsApp-ready daily business summary.
35
+ - Generate customer follow-up reminders and an inventory reorder list.
36
  - Offload speech transcription and LLM parsing to optional Modal endpoints.
37
  - Edit or delete saved transactions while keeping customer credit and inventory balances consistent.
38
  - Export the ledger as CSV for spreadsheet sharing.
 
61
  Paid 500 for supplies
62
  Amit owes 100
63
  Bought 50 mangoes
64
+ Amit ne 100 dene hai
65
+ 50 mango kharida
66
  ```
67
 
68
  The same examples are available in `sample_data/demo_transactions.txt`.
 
78
  | `Bought 50 mangoes` | Inventory purchase, quantity `50`, item `mangoes` |
79
  | `Amit owes 100` | Customer credit, customer `Amit`, amount `100` |
80
  | `Amit paid 50` | Customer payment, customer `Amit`, amount `50` |
81
+ | `Amit ne 100 dene hai` | Customer credit, customer `Amit`, amount `100` |
82
+ | `Amit ne 50 diya` | Customer payment, customer `Amit`, amount `50` |
83
+ | `50 mango kharida` | Inventory purchase, quantity `50`, item `mango` |
84
+ | `50 mango lidha` | Inventory purchase, quantity `50`, item `mango` |
85
 
86
  ## Demo Script
87
 
88
+ 1. Use `Seller Setup` to show business name, currency, low-stock threshold, and language style.
89
+ 2. Use the `Hackathon Demo Launchpad` on the first screen to seed demo transactions.
90
+ 3. Open `Record Text & Voice`, speak or type `Sold 12 mangoes, 20 each`, parse it, and review the human-readable transaction card.
91
+ 4. Show the parse status and warning badges: Modal/NVIDIA Nemotron when available, local fallback when needed.
92
+ 5. Save the transaction and show the Command Center plus the receipt with stock, customer, or amount side effects.
93
+ 6. Open `Dashboard`, `Customer Credit`, and `Inventory` to show timeline charts, customer detail, follow-up message, inventory detail, reorder list, and automatic bookkeeping updates.
94
+ 7. Open `Ledger`, load a transaction by id, update or delete it, then show refreshed balances.
95
+ 8. Run Daily Closeout from `Reports & PDF`, then download CSV/PDF and generate the WhatsApp summary.
96
+ 9. Click `Check Demo Health` from the launchpad, or open `Demo Health`, to show Modal health, deployed backend version, Nemotron status, database, PDF, and endpoint checks.
97
 
98
  ## Screenshot and GIF Moments
99
 
100
  Capture these three moments for the Space README, demo video, or social post:
101
 
102
  - `Record Text & Voice`: Today’s Work quick actions, review card, warning badges, and save receipt.
103
+ - `Dashboard`: Command Center, metrics, seller-day activity timeline, sales/expense timeline, top-selling item, low-stock table, and outstanding credit.
104
  - `Reports & PDF` plus `Ledger`: Daily Closeout, PDF/WhatsApp export, CSV download, and ledger correction.
105
  - `Submission Story`: AI pipeline strip and “Why small models fit” card for the Modal/Nemotron story.
106
 
 
173
  - The dashboard shows daily sales, expenses, profit, outstanding credit, top sellers, and low-stock alerts from saved data.
174
  - Customer and inventory detail views show transaction history for one customer or item.
175
  - Smart review warnings flag low confidence, missing fields, duplicate risk, and negative stock before save.
176
+ - Seller setup persists business name, currency label, low-stock threshold, and language style in SQLite.
177
+ - Local fallback rules include common English, Hinglish, Hindi-lite, and Gujarati-lite seller notes.
178
+ - Customer follow-up and inventory reorder helpers generate WhatsApp-ready action messages.
179
  - WhatsApp summaries provide a short copyable daily recap for sharing.
180
  - Bulk import splits pasted notes by line, parses each line, supports review edits, and saves all reviewed transactions.
181
  - Modal integration lives in `backend/`; if endpoint URLs are not configured, local fallback stays active.
docs/demo-video-script.md CHANGED
@@ -16,6 +16,8 @@ On the first screen, show the `Judge Demo Flow` panel:
16
 
17
  Click `Seed Demo Transactions`.
18
 
 
 
19
  Open `Submission Story` and show the AI pipeline strip plus the “Why small models fit” card.
20
 
21
  Click `Check Demo Health` from the launchpad, or open `Demo Health`, and show:
@@ -45,6 +47,7 @@ Click parse. Show:
45
  - Human-friendly transaction review card
46
  - Warning badges for low confidence, missing fields, duplicate risk, or negative stock when present
47
  - Parse source/status
 
48
  - “Saved just now” receipt with stock, customer, or amount side effects
49
 
50
  ## 0:45-1:05 — Bookkeeping Updates
@@ -52,8 +55,8 @@ Click parse. Show:
52
  Use the `Sections` navigation to open:
53
 
54
  - `Dashboard` to show sales/profit/credit and the sales/expense timeline
55
- - `Customer Credit` to show dues and a selected customer detail view
56
- - `Inventory` to show stock and a selected item detail view
57
  - `Ledger` to show saved transactions
58
 
59
  Say: “The model handles messy input, but the app owns the accounting state.”
@@ -69,6 +72,7 @@ Open `Reports & PDF`, run `Daily Closeout`, then generate the PDF, WhatsApp summ
69
  Suggested screenshot/GIF moments:
70
 
71
  - Record flow: Today’s Work actions, review card, parse source, warning badges, save receipt.
 
72
  - Submission Story: AI pipeline and small-model fit card.
73
  - Dashboard: sales, expenses, profit, credit, timeline, top item, low-stock inventory.
74
  - Reports/Ledger: Daily Closeout, PDF download, WhatsApp summary, CSV export.
 
16
 
17
  Click `Seed Demo Transactions`.
18
 
19
+ Show `Seller Setup` and mention that business name, currency, low-stock threshold, and language style are saved in SQLite.
20
+
21
  Open `Submission Story` and show the AI pipeline strip plus the “Why small models fit” card.
22
 
23
  Click `Check Demo Health` from the launchpad, or open `Demo Health`, and show:
 
47
  - Human-friendly transaction review card
48
  - Warning badges for low confidence, missing fields, duplicate risk, or negative stock when present
49
  - Parse source/status
50
+ - Command Center update
51
  - “Saved just now” receipt with stock, customer, or amount side effects
52
 
53
  ## 0:45-1:05 — Bookkeeping Updates
 
55
  Use the `Sections` navigation to open:
56
 
57
  - `Dashboard` to show sales/profit/credit and the sales/expense timeline
58
+ - `Customer Credit` to show dues, a selected customer detail view, and a follow-up message
59
+ - `Inventory` to show stock, a selected item detail view, and the reorder list
60
  - `Ledger` to show saved transactions
61
 
62
  Say: “The model handles messy input, but the app owns the accounting state.”
 
72
  Suggested screenshot/GIF moments:
73
 
74
  - Record flow: Today’s Work actions, review card, parse source, warning badges, save receipt.
75
+ - Seller setup: currency/threshold/language style and Command Center.
76
  - Submission Story: AI pipeline and small-model fit card.
77
  - Dashboard: sales, expenses, profit, credit, timeline, top item, low-stock inventory.
78
  - Reports/Ledger: Daily Closeout, PDF download, WhatsApp summary, CSV export.
docs/field-notes.md CHANGED
@@ -47,8 +47,10 @@ The hackathon version is intentionally frozen around the complete bookkeeping lo
47
 
48
  - Voice and text capture.
49
  - Modal/Nemotron parsing with local rule fallback.
 
 
50
  - Human-friendly review cards, warning badges, receipts, and save feedback.
51
- - Dashboard timeline, customer detail, inventory detail, ledger correction, Daily Closeout, PDF, CSV, and WhatsApp exports.
52
  - Demo Health for backend observability.
53
  - Submission Story for the AI pipeline and small-model fit rationale.
54
 
 
47
 
48
  - Voice and text capture.
49
  - Modal/Nemotron parsing with local rule fallback.
50
+ - Seller setup for business name, currency, low-stock threshold, and language style.
51
+ - Hinglish/Hindi-lite/Gujarati-lite fallback examples for common seller notes.
52
  - Human-friendly review cards, warning badges, receipts, and save feedback.
53
+ - Command Center, dashboard timeline, seller-day timeline, customer follow-up, inventory reorder list, ledger correction, Daily Closeout, PDF, CSV, and WhatsApp exports.
54
  - Demo Health for backend observability.
55
  - Submission Story for the AI pipeline and small-model fit rationale.
56
 
docs/social-post.md CHANGED
@@ -18,11 +18,14 @@ VoiceLedger turns those notes into a working ledger with:
18
  - local rule fallback for reliability
19
  - human-friendly review cards and smart warning badges
20
  - save receipts that explain stock, credit, and amount side effects
 
 
21
  - SQLite ledger
22
  - customer credit tracking
23
  - inventory tracking
24
  - dashboard insights with a daily timeline
25
  - customer and inventory detail drilldowns
 
26
  - PDF, CSV, and WhatsApp summaries
27
  - one-click Daily Closeout
28
  - a visible AI pipeline and small-model fit explanation for judges
 
18
  - local rule fallback for reliability
19
  - human-friendly review cards and smart warning badges
20
  - save receipts that explain stock, credit, and amount side effects
21
+ - seller setup for currency, stock threshold, and language style
22
+ - English/Hinglish/Hindi-lite/Gujarati-lite local fallback examples
23
  - SQLite ledger
24
  - customer credit tracking
25
  - inventory tracking
26
  - dashboard insights with a daily timeline
27
  - customer and inventory detail drilldowns
28
+ - customer follow-up and inventory reorder messages
29
  - PDF, CSV, and WhatsApp summaries
30
  - one-click Daily Closeout
31
  - a visible AI pipeline and small-model fit explanation for judges
docs/submission-checklist.md CHANGED
@@ -16,17 +16,23 @@
16
  - `Submission Story` shows the “Why small models fit” card.
17
  - `Demo Health` shows Modal reachable and backend version.
18
  - `Demo Health` shows NVIDIA Nemotron parser status.
 
19
  - Text parse works with `Sold 12 mangoes, 20 each`.
 
20
  - Voice parse works and transcript appears.
21
  - Review card shows type, item, quantity, price, amount, customer, source, and confidence.
22
  - Smart warnings appear for low confidence, missing fields, duplicate risk, and negative stock.
23
  - Save shows a receipt with transaction type, amount, and side effects.
 
24
  - Save updates Dashboard and Ledger.
25
  - Dashboard timeline loads from saved transactions.
 
26
  - Customer Credit section reflects `Amit owes 100` and `Amit paid 40`.
27
  - Customer detail lookup shows customer transaction history and status.
 
28
  - Inventory reflects purchases and sales.
29
  - Inventory detail lookup shows bought, sold, current stock, and low-stock status.
 
30
  - Ledger edit/delete works and derived balances rebuild.
31
  - CSV export downloads from Ledger.
32
  - Daily Closeout generates PDF, CSV, WhatsApp summary, and a status line.
 
16
  - `Submission Story` shows the “Why small models fit” card.
17
  - `Demo Health` shows Modal reachable and backend version.
18
  - `Demo Health` shows NVIDIA Nemotron parser status.
19
+ - Seller Setup saves business name, currency label, low-stock threshold, and language style.
20
  - Text parse works with `Sold 12 mangoes, 20 each`.
21
+ - Local fallback parses `Amit ne 100 dene hai`, `Amit ne 50 diya`, and `50 mango kharida`.
22
  - Voice parse works and transcript appears.
23
  - Review card shows type, item, quantity, price, amount, customer, source, and confidence.
24
  - Smart warnings appear for low confidence, missing fields, duplicate risk, and negative stock.
25
  - Save shows a receipt with transaction type, amount, and side effects.
26
+ - Command Center updates after save and seed.
27
  - Save updates Dashboard and Ledger.
28
  - Dashboard timeline loads from saved transactions.
29
+ - Seller-day timeline shows recent saved transactions.
30
  - Customer Credit section reflects `Amit owes 100` and `Amit paid 40`.
31
  - Customer detail lookup shows customer transaction history and status.
32
+ - Customer follow-up generates a WhatsApp reminder.
33
  - Inventory reflects purchases and sales.
34
  - Inventory detail lookup shows bought, sold, current stock, and low-stock status.
35
+ - Inventory reorder list uses the configured threshold.
36
  - Ledger edit/delete works and derived balances rebuild.
37
  - CSV export downloads from Ledger.
38
  - Daily Closeout generates PDF, CSV, WhatsApp summary, and a status line.
tests/test_parser.py CHANGED
@@ -22,6 +22,16 @@ def test_parse_shorthand_sale() -> None:
22
  assert transaction.amount == 240
23
 
24
 
 
 
 
 
 
 
 
 
 
 
25
  def test_parse_expense() -> None:
26
  transaction = parse_transaction("Paid 500 for supplies")
27
 
@@ -39,6 +49,14 @@ def test_parse_shorthand_expense() -> None:
39
  assert transaction.amount == 300
40
 
41
 
 
 
 
 
 
 
 
 
42
  def test_parse_inventory_purchase() -> None:
43
  transaction = parse_transaction("Bought 50 mangoes")
44
 
@@ -48,6 +66,22 @@ def test_parse_inventory_purchase() -> None:
48
  assert transaction.payment_status == "paid"
49
 
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  def test_parse_sale_without_unit_price_for_inventory() -> None:
52
  transaction = parse_transaction("Sold 12 mangoes")
53
 
@@ -66,6 +100,14 @@ def test_parse_customer_credit() -> None:
66
  assert transaction.payment_status == "credit"
67
 
68
 
 
 
 
 
 
 
 
 
69
  def test_parse_customer_payment() -> None:
70
  transaction = parse_transaction("Amit paid 50")
71
 
@@ -75,6 +117,14 @@ def test_parse_customer_payment() -> None:
75
  assert transaction.payment_status == "paid"
76
 
77
 
 
 
 
 
 
 
 
 
78
  def test_unknown_note_is_preserved() -> None:
79
  transaction = parse_transaction("Need to check yesterday")
80
 
 
22
  assert transaction.amount == 240
23
 
24
 
25
+ def test_parse_quantity_first_sale() -> None:
26
+ transaction = parse_transaction("12 mango 20 each")
27
+
28
+ assert transaction.transaction_type == "sale"
29
+ assert transaction.item == "mango"
30
+ assert transaction.quantity == 12
31
+ assert transaction.unit_price == 20
32
+ assert transaction.amount == 240
33
+
34
+
35
  def test_parse_expense() -> None:
36
  transaction = parse_transaction("Paid 500 for supplies")
37
 
 
49
  assert transaction.amount == 300
50
 
51
 
52
+ def test_parse_hinglish_expense() -> None:
53
+ transaction = parse_transaction("rent 300 diya")
54
+
55
+ assert transaction.transaction_type == "expense"
56
+ assert transaction.item == "rent"
57
+ assert transaction.amount == 300
58
+
59
+
60
  def test_parse_inventory_purchase() -> None:
61
  transaction = parse_transaction("Bought 50 mangoes")
62
 
 
66
  assert transaction.payment_status == "paid"
67
 
68
 
69
+ def test_parse_quantity_first_inventory_purchase() -> None:
70
+ transaction = parse_transaction("50 mango kharida")
71
+
72
+ assert transaction.transaction_type == "inventory_purchase"
73
+ assert transaction.quantity == 50
74
+ assert transaction.item == "mango"
75
+
76
+
77
+ def test_parse_gujarati_lite_inventory_purchase() -> None:
78
+ transaction = parse_transaction("50 mango lidha")
79
+
80
+ assert transaction.transaction_type == "inventory_purchase"
81
+ assert transaction.quantity == 50
82
+ assert transaction.item == "mango"
83
+
84
+
85
  def test_parse_sale_without_unit_price_for_inventory() -> None:
86
  transaction = parse_transaction("Sold 12 mangoes")
87
 
 
100
  assert transaction.payment_status == "credit"
101
 
102
 
103
+ def test_parse_hinglish_customer_credit() -> None:
104
+ transaction = parse_transaction("Amit ne 100 dene hai")
105
+
106
+ assert transaction.transaction_type == "customer_credit"
107
+ assert transaction.customer == "Amit"
108
+ assert transaction.amount == 100
109
+
110
+
111
  def test_parse_customer_payment() -> None:
112
  transaction = parse_transaction("Amit paid 50")
113
 
 
117
  assert transaction.payment_status == "paid"
118
 
119
 
120
+ def test_parse_hinglish_customer_payment() -> None:
121
+ transaction = parse_transaction("Amit ne 50 diya")
122
+
123
+ assert transaction.transaction_type == "customer_payment"
124
+ assert transaction.customer == "Amit"
125
+ assert transaction.amount == 50
126
+
127
+
128
  def test_unknown_note_is_preserved() -> None:
129
  transaction = parse_transaction("Need to check yesterday")
130
 
tests/test_settings.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ from voiceledger.ledger.settings import get_business_settings, get_low_stock_threshold, update_business_settings
4
+
5
+
6
+ def test_business_settings_defaults_load_when_empty(tmp_path: Path) -> None:
7
+ db_path = tmp_path / "voiceledger.sqlite3"
8
+
9
+ settings = get_business_settings(db_path)
10
+
11
+ assert settings["business_name"] == "VoiceLedger Seller"
12
+ assert settings["currency_symbol"] == "₹"
13
+ assert get_low_stock_threshold(db_path) == 5
14
+
15
+
16
+ def test_business_settings_update_values(tmp_path: Path) -> None:
17
+ db_path = tmp_path / "voiceledger.sqlite3"
18
+
19
+ settings = update_business_settings(
20
+ business_name="Mango Cart",
21
+ currency_symbol="Rs ",
22
+ low_stock_threshold=8,
23
+ language_style="English + Hinglish",
24
+ db_path=db_path,
25
+ )
26
+
27
+ assert settings["business_name"] == "Mango Cart"
28
+ assert settings["currency_symbol"] == "Rs"
29
+ assert settings["low_stock_threshold"] == "8.0"
30
+ assert get_low_stock_threshold(db_path) == 8
tests/test_ui_audio_flow.py CHANGED
@@ -70,7 +70,7 @@ def test_parse_note_surfaces_modal_source(monkeypatch) -> None:
70
  assert structured["transaction_type"] == "expense"
71
  assert state == structured
72
  assert "NVIDIA Nemotron" in status
73
- assert "Ready to save" in review_card
74
 
75
 
76
  def test_high_contrast_demo_panels_are_rendered() -> None:
@@ -133,6 +133,16 @@ def test_review_warnings_flag_missing_and_low_confidence() -> None:
133
  assert "Low confidence" in warnings
134
 
135
 
 
 
 
 
 
 
 
 
 
 
136
  def test_receipt_card_summarizes_saved_sale(tmp_path) -> None:
137
  transaction = gradio_app.local_parse_transaction("Sold 12 mangoes, 20 each")
138
 
@@ -152,5 +162,37 @@ def test_daily_closeout_returns_exports(tmp_path) -> None:
152
  assert "Daily Closeout Ready" in summary
153
  assert pdf_path is not None
154
  assert csv_path is not None
155
- assert "VoiceLedger Daily Summary" in whatsapp
156
  assert "Daily closeout complete" in status
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  assert structured["transaction_type"] == "expense"
71
  assert state == structured
72
  assert "NVIDIA Nemotron" in status
73
+ assert "Safe to save" in review_card
74
 
75
 
76
  def test_high_contrast_demo_panels_are_rendered() -> None:
 
133
  assert "Low confidence" in warnings
134
 
135
 
136
+ def test_review_card_surfaces_needs_review_for_missing_fields() -> None:
137
+ transaction = gradio_app.Transaction(transaction_type="customer_credit", confidence=0.8)
138
+
139
+ review_card = gradio_app._review_card(transaction, "Parsed locally.", gradio_app._review_warnings(transaction, None))
140
+
141
+ assert "Needs review" in review_card
142
+ assert "Missing amount" in review_card
143
+ assert "Missing customer" in review_card
144
+
145
+
146
  def test_receipt_card_summarizes_saved_sale(tmp_path) -> None:
147
  transaction = gradio_app.local_parse_transaction("Sold 12 mangoes, 20 each")
148
 
 
162
  assert "Daily Closeout Ready" in summary
163
  assert pdf_path is not None
164
  assert csv_path is not None
165
+ assert "VoiceLedger Seller Daily Summary" in whatsapp
166
  assert "Daily closeout complete" in status
167
+
168
+
169
+ def test_command_center_and_seller_setup_use_settings(tmp_path) -> None:
170
+ db_path = tmp_path / "voiceledger.sqlite3"
171
+ gradio_app.update_business_settings(
172
+ business_name="Mango Cart",
173
+ currency_symbol="Rs ",
174
+ low_stock_threshold=3,
175
+ language_style="English + Hinglish",
176
+ db_path=db_path,
177
+ )
178
+ gradio_app.add_transaction(gradio_app.local_parse_transaction("Sold 12 mangoes, 20 each"), db_path)
179
+
180
+ command_center = gradio_app._command_center(db_path)
181
+
182
+ assert "Mango Cart Command Center" in command_center
183
+ assert "Rs240" in command_center
184
+
185
+
186
+ def test_customer_followup_and_reorder_helpers(tmp_path) -> None:
187
+ db_path = tmp_path / "voiceledger.sqlite3"
188
+ gradio_app.add_transaction(gradio_app.local_parse_transaction("Amit owes 100"), db_path)
189
+ gradio_app.add_transaction(gradio_app.local_parse_transaction("Amit paid 40"), db_path)
190
+ gradio_app.add_transaction(gradio_app.local_parse_transaction("Bought 3 onions"), db_path)
191
+
192
+ followup = gradio_app.generate_customer_followup("Amit", db_path)
193
+ reorder, message = gradio_app.generate_reorder_list(db_path)
194
+
195
+ assert "Amit" in followup
196
+ assert "₹60" in followup
197
+ assert "onions" in set(reorder["item"])
198
+ assert "Onions" in message
voiceledger/ledger/__init__.py CHANGED
@@ -11,6 +11,7 @@ from voiceledger.ledger.analytics import (
11
  from voiceledger.ledger.customers import add_credit, get_customer_balances, record_payment
12
  from voiceledger.ledger.database import add_transaction, get_transactions, initialize_database
13
  from voiceledger.ledger.inventory import add_stock, get_inventory, remove_stock
 
14
 
15
  __all__ = [
16
  "initialize_database",
@@ -22,6 +23,9 @@ __all__ = [
22
  "add_stock",
23
  "remove_stock",
24
  "get_inventory",
 
 
 
25
  "calculate_daily_sales",
26
  "calculate_daily_expenses",
27
  "calculate_net_profit",
 
11
  from voiceledger.ledger.customers import add_credit, get_customer_balances, record_payment
12
  from voiceledger.ledger.database import add_transaction, get_transactions, initialize_database
13
  from voiceledger.ledger.inventory import add_stock, get_inventory, remove_stock
14
+ from voiceledger.ledger.settings import get_business_settings, get_low_stock_threshold, update_business_settings
15
 
16
  __all__ = [
17
  "initialize_database",
 
23
  "add_stock",
24
  "remove_stock",
25
  "get_inventory",
26
+ "get_business_settings",
27
+ "update_business_settings",
28
+ "get_low_stock_threshold",
29
  "calculate_daily_sales",
30
  "calculate_daily_expenses",
31
  "calculate_net_profit",
voiceledger/ledger/database.py CHANGED
@@ -13,6 +13,7 @@ import pandas as pd
13
  from voiceledger.config import get_database_path
14
  from voiceledger.ledger.customers import add_credit, initialize_customers_table, record_payment
15
  from voiceledger.ledger.inventory import add_stock, initialize_inventory_table, remove_stock
 
16
  from voiceledger.parser.schema import Transaction
17
 
18
 
@@ -58,6 +59,7 @@ def initialize_database(db_path: str | Path | None = None) -> Path:
58
 
59
  initialize_customers_table(path)
60
  initialize_inventory_table(path)
 
61
  return path
62
 
63
 
 
13
  from voiceledger.config import get_database_path
14
  from voiceledger.ledger.customers import add_credit, initialize_customers_table, record_payment
15
  from voiceledger.ledger.inventory import add_stock, initialize_inventory_table, remove_stock
16
+ from voiceledger.ledger.settings import initialize_business_settings_table
17
  from voiceledger.parser.schema import Transaction
18
 
19
 
 
59
 
60
  initialize_customers_table(path)
61
  initialize_inventory_table(path)
62
+ initialize_business_settings_table(path)
63
  return path
64
 
65
 
voiceledger/ledger/settings.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Business settings persistence for VoiceLedger."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sqlite3
6
+ from pathlib import Path
7
+
8
+ from voiceledger.config import get_database_path
9
+
10
+
11
+ SETTINGS_SCHEMA_SQL = """
12
+ CREATE TABLE IF NOT EXISTS business_settings (
13
+ key TEXT PRIMARY KEY,
14
+ value TEXT NOT NULL
15
+ );
16
+ """
17
+
18
+ DEFAULT_BUSINESS_SETTINGS = {
19
+ "business_name": "VoiceLedger Seller",
20
+ "currency_symbol": "₹",
21
+ "low_stock_threshold": "5",
22
+ "language_style": "English + Hinglish",
23
+ "field_test_who": "A local informal seller who tracks sales, stock, customer dues, and profit from short notes.",
24
+ "field_test_tried": "Voice notes, typed notes, customer credit, inventory updates, dashboard review, and exports.",
25
+ "field_test_changed": "Added review warnings, save receipts, edit/delete, CSV export, and clearer demo health.",
26
+ }
27
+
28
+
29
+ def initialize_business_settings_table(db_path: str | Path | None = None) -> Path:
30
+ """Create the business settings table and seed default values."""
31
+ path = _resolve_db_path(db_path)
32
+ path.parent.mkdir(parents=True, exist_ok=True)
33
+
34
+ with sqlite3.connect(path) as connection:
35
+ connection.execute(SETTINGS_SCHEMA_SQL)
36
+ connection.executemany(
37
+ """
38
+ INSERT OR IGNORE INTO business_settings (key, value)
39
+ VALUES (?, ?)
40
+ """,
41
+ DEFAULT_BUSINESS_SETTINGS.items(),
42
+ )
43
+ connection.commit()
44
+
45
+ return path
46
+
47
+
48
+ def get_business_settings(db_path: str | Path | None = None) -> dict[str, str]:
49
+ """Return business settings merged with defaults."""
50
+ path = initialize_business_settings_table(db_path)
51
+ with sqlite3.connect(path) as connection:
52
+ rows = connection.execute("SELECT key, value FROM business_settings").fetchall()
53
+
54
+ settings = dict(DEFAULT_BUSINESS_SETTINGS)
55
+ settings.update({str(key): str(value) for key, value in rows})
56
+ return settings
57
+
58
+
59
+ def update_business_settings(
60
+ business_name: str | None = None,
61
+ currency_symbol: str | None = None,
62
+ low_stock_threshold: float | int | str | None = None,
63
+ language_style: str | None = None,
64
+ db_path: str | Path | None = None,
65
+ **extra_settings: str | None,
66
+ ) -> dict[str, str]:
67
+ """Update business settings and return the merged settings."""
68
+ path = initialize_business_settings_table(db_path)
69
+ updates: dict[str, str] = {}
70
+ if business_name is not None:
71
+ updates["business_name"] = _clean_text(business_name, DEFAULT_BUSINESS_SETTINGS["business_name"])
72
+ if currency_symbol is not None:
73
+ updates["currency_symbol"] = _clean_text(currency_symbol, DEFAULT_BUSINESS_SETTINGS["currency_symbol"])
74
+ if low_stock_threshold is not None:
75
+ updates["low_stock_threshold"] = str(_coerce_threshold(low_stock_threshold))
76
+ if language_style is not None:
77
+ updates["language_style"] = _clean_text(language_style, DEFAULT_BUSINESS_SETTINGS["language_style"])
78
+
79
+ for key, value in extra_settings.items():
80
+ if key in DEFAULT_BUSINESS_SETTINGS and value is not None:
81
+ updates[key] = _clean_text(value, DEFAULT_BUSINESS_SETTINGS[key])
82
+
83
+ if updates:
84
+ with sqlite3.connect(path) as connection:
85
+ connection.executemany(
86
+ """
87
+ INSERT INTO business_settings (key, value)
88
+ VALUES (?, ?)
89
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
90
+ """,
91
+ updates.items(),
92
+ )
93
+ connection.commit()
94
+
95
+ return get_business_settings(path)
96
+
97
+
98
+ def get_low_stock_threshold(db_path: str | Path | None = None) -> float:
99
+ """Return the configured low-stock threshold as a float."""
100
+ settings = get_business_settings(db_path)
101
+ return _coerce_threshold(settings.get("low_stock_threshold", DEFAULT_BUSINESS_SETTINGS["low_stock_threshold"]))
102
+
103
+
104
+ def _coerce_threshold(value: float | int | str) -> float:
105
+ """Return a non-negative low-stock threshold."""
106
+ try:
107
+ threshold = float(value)
108
+ except (TypeError, ValueError):
109
+ threshold = float(DEFAULT_BUSINESS_SETTINGS["low_stock_threshold"])
110
+ return max(threshold, 0.0)
111
+
112
+
113
+ def _clean_text(value: str, default: str) -> str:
114
+ """Normalize a setting string."""
115
+ cleaned = " ".join(str(value or "").split()).strip()
116
+ return cleaned or default
117
+
118
+
119
+ def _resolve_db_path(db_path: str | Path | None) -> Path:
120
+ """Resolve an explicit or configured database path."""
121
+ if db_path is None:
122
+ return get_database_path()
123
+ return Path(db_path).expanduser()
voiceledger/parser/rules.py CHANGED
@@ -32,11 +32,23 @@ SHORTHAND_SALE_PATTERN = re.compile(
32
  rf"(?P<unit_price>\d+(?:\.\d+)?)\s*$",
33
  re.IGNORECASE,
34
  )
 
 
 
 
 
 
35
  SHORTHAND_EXPENSE_PATTERN = re.compile(
36
  rf"^\s*(?P<item>[a-zA-Z][a-zA-Z\s-]*?)\s+"
37
  rf"(?P<amount>\d+(?:\.\d+)?)\s*$",
38
  re.IGNORECASE,
39
  )
 
 
 
 
 
 
40
  INVENTORY_PURCHASE_PATTERN = re.compile(
41
  rf"\b(?:bought|purchased|buy|stocked|restocked)\s+"
42
  rf"(?P<quantity>\d+(?:\.\d+)?)\s+"
@@ -44,18 +56,36 @@ INVENTORY_PURCHASE_PATTERN = re.compile(
44
  rf"(?:\s+(?:for|at)\s+(?P<amount>\d+(?:\.\d+)?))?\b",
45
  re.IGNORECASE,
46
  )
 
 
 
 
 
 
47
  CREDIT_PATTERN = re.compile(
48
  rf"^\s*(?P<customer>[a-zA-Z][a-zA-Z\s-]*?)\s+"
49
  rf"(?:owes|owe|will\s+pay|to\s+pay)\s+"
50
  rf"(?P<amount>\d+(?:\.\d+)?)\b",
51
  re.IGNORECASE,
52
  )
 
 
 
 
 
 
53
  CUSTOMER_PAYMENT_PATTERN = re.compile(
54
  rf"^\s*(?P<customer>[a-zA-Z][a-zA-Z\s-]*?)\s+"
55
  rf"(?:paid|pays|settled|repaid)\s+"
56
  rf"(?P<amount>\d+(?:\.\d+)?)\b",
57
  re.IGNORECASE,
58
  )
 
 
 
 
 
 
59
 
60
 
61
  class RuleParser(Parser):
@@ -96,10 +126,18 @@ def parse_transaction(note: str) -> Transaction:
96
  if shorthand_sale:
97
  return shorthand_sale
98
 
 
 
 
 
99
  expense = _parse_expense(cleaned_note)
100
  if expense:
101
  return expense
102
 
 
 
 
 
103
  shorthand_expense = _parse_shorthand_expense(cleaned_note)
104
  if shorthand_expense:
105
  return shorthand_expense
@@ -171,6 +209,26 @@ def _parse_shorthand_sale(note: str) -> Transaction | None:
171
  )
172
 
173
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  def _parse_shorthand_expense(note: str) -> Transaction | None:
175
  """Parse notes like 'rent 300'."""
176
  match = SHORTHAND_EXPENSE_PATTERN.search(note)
@@ -187,9 +245,25 @@ def _parse_shorthand_expense(note: str) -> Transaction | None:
187
  )
188
 
189
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  def _parse_inventory_purchase(note: str) -> Transaction | None:
191
  """Parse notes like 'Bought 50 mangoes'."""
192
- match = INVENTORY_PURCHASE_PATTERN.search(note)
193
  if not match:
194
  return None
195
 
@@ -197,7 +271,7 @@ def _parse_inventory_purchase(note: str) -> Transaction | None:
197
  transaction_type="inventory_purchase",
198
  item=_clean_item(match.group("item")),
199
  quantity=_to_float(match.group("quantity")),
200
- amount=_to_float(match.group("amount")),
201
  payment_status="paid",
202
  notes=note,
203
  confidence=0.86,
@@ -206,7 +280,7 @@ def _parse_inventory_purchase(note: str) -> Transaction | None:
206
 
207
  def _parse_customer_credit(note: str) -> Transaction | None:
208
  """Parse notes like 'Amit owes 100'."""
209
- match = CREDIT_PATTERN.search(note)
210
  if not match:
211
  return None
212
 
@@ -222,7 +296,7 @@ def _parse_customer_credit(note: str) -> Transaction | None:
222
 
223
  def _parse_customer_payment(note: str) -> Transaction | None:
224
  """Parse notes like 'Amit paid 50'."""
225
- match = CUSTOMER_PAYMENT_PATTERN.search(note)
226
  if not match:
227
  return None
228
 
 
32
  rf"(?P<unit_price>\d+(?:\.\d+)?)\s*$",
33
  re.IGNORECASE,
34
  )
35
+ QUANTITY_FIRST_SALE_PATTERN = re.compile(
36
+ rf"^\s*(?P<quantity>\d+(?:\.\d+)?)\s+"
37
+ rf"(?P<item>[a-zA-Z][a-zA-Z\s-]*?)\s+"
38
+ rf"(?P<unit_price>\d+(?:\.\d+)?)\s*(?:each|per\s+\w+)?\s*$",
39
+ re.IGNORECASE,
40
+ )
41
  SHORTHAND_EXPENSE_PATTERN = re.compile(
42
  rf"^\s*(?P<item>[a-zA-Z][a-zA-Z\s-]*?)\s+"
43
  rf"(?P<amount>\d+(?:\.\d+)?)\s*$",
44
  re.IGNORECASE,
45
  )
46
+ HINGLISH_EXPENSE_PATTERN = re.compile(
47
+ rf"^\s*(?P<item>[a-zA-Z][a-zA-Z\s-]*?)\s+"
48
+ rf"(?P<amount>\d+(?:\.\d+)?)\s+"
49
+ rf"(?:diya|dia|paid|pay|chukaya)\s*$",
50
+ re.IGNORECASE,
51
+ )
52
  INVENTORY_PURCHASE_PATTERN = re.compile(
53
  rf"\b(?:bought|purchased|buy|stocked|restocked)\s+"
54
  rf"(?P<quantity>\d+(?:\.\d+)?)\s+"
 
56
  rf"(?:\s+(?:for|at)\s+(?P<amount>\d+(?:\.\d+)?))?\b",
57
  re.IGNORECASE,
58
  )
59
+ QUANTITY_FIRST_INVENTORY_PATTERN = re.compile(
60
+ rf"^\s*(?P<quantity>\d+(?:\.\d+)?)\s+"
61
+ rf"(?P<item>[a-zA-Z][a-zA-Z\s-]*?)\s+"
62
+ rf"(?:kharida|kharidi|khareeda|lidha|liya|li|bought|purchased)\s*$",
63
+ re.IGNORECASE,
64
+ )
65
  CREDIT_PATTERN = re.compile(
66
  rf"^\s*(?P<customer>[a-zA-Z][a-zA-Z\s-]*?)\s+"
67
  rf"(?:owes|owe|will\s+pay|to\s+pay)\s+"
68
  rf"(?P<amount>\d+(?:\.\d+)?)\b",
69
  re.IGNORECASE,
70
  )
71
+ HINGLISH_CREDIT_PATTERN = re.compile(
72
+ rf"^\s*(?P<customer>[a-zA-Z][a-zA-Z\s-]*?)\s+"
73
+ rf"(?:ne\s+)?(?P<amount>\d+(?:\.\d+)?)\s+"
74
+ rf"(?:dene\s+hai|dena\s+hai|aapva\s+che|apvana\s+che|owes)\b",
75
+ re.IGNORECASE,
76
+ )
77
  CUSTOMER_PAYMENT_PATTERN = re.compile(
78
  rf"^\s*(?P<customer>[a-zA-Z][a-zA-Z\s-]*?)\s+"
79
  rf"(?:paid|pays|settled|repaid)\s+"
80
  rf"(?P<amount>\d+(?:\.\d+)?)\b",
81
  re.IGNORECASE,
82
  )
83
+ HINGLISH_PAYMENT_PATTERN = re.compile(
84
+ rf"^\s*(?P<customer>[a-zA-Z][a-zA-Z\s-]*?)\s+"
85
+ rf"ne\s+(?P<amount>\d+(?:\.\d+)?)\s+"
86
+ rf"(?:diya|dia|aapya|apya|paid|chukaya)\b",
87
+ re.IGNORECASE,
88
+ )
89
 
90
 
91
  class RuleParser(Parser):
 
126
  if shorthand_sale:
127
  return shorthand_sale
128
 
129
+ quantity_first_sale = _parse_quantity_first_sale(cleaned_note)
130
+ if quantity_first_sale:
131
+ return quantity_first_sale
132
+
133
  expense = _parse_expense(cleaned_note)
134
  if expense:
135
  return expense
136
 
137
+ hinglish_expense = _parse_hinglish_expense(cleaned_note)
138
+ if hinglish_expense:
139
+ return hinglish_expense
140
+
141
  shorthand_expense = _parse_shorthand_expense(cleaned_note)
142
  if shorthand_expense:
143
  return shorthand_expense
 
209
  )
210
 
211
 
212
+ def _parse_quantity_first_sale(note: str) -> Transaction | None:
213
+ """Parse notes like '12 mango 20 each'."""
214
+ match = QUANTITY_FIRST_SALE_PATTERN.search(note)
215
+ if not match:
216
+ return None
217
+
218
+ quantity = _to_float(match.group("quantity"))
219
+ unit_price = _to_float(match.group("unit_price"))
220
+ return Transaction(
221
+ transaction_type="sale",
222
+ item=_clean_item(match.group("item")),
223
+ quantity=quantity,
224
+ unit_price=unit_price,
225
+ amount=round(quantity * unit_price, 2) if quantity is not None and unit_price is not None else None,
226
+ payment_status="paid",
227
+ notes=note,
228
+ confidence=0.8,
229
+ )
230
+
231
+
232
  def _parse_shorthand_expense(note: str) -> Transaction | None:
233
  """Parse notes like 'rent 300'."""
234
  match = SHORTHAND_EXPENSE_PATTERN.search(note)
 
245
  )
246
 
247
 
248
+ def _parse_hinglish_expense(note: str) -> Transaction | None:
249
+ """Parse notes like 'rent 300 diya'."""
250
+ match = HINGLISH_EXPENSE_PATTERN.search(note)
251
+ if not match:
252
+ return None
253
+
254
+ return Transaction(
255
+ transaction_type="expense",
256
+ item=_clean_item(match.group("item")),
257
+ amount=_to_float(match.group("amount")),
258
+ payment_status="paid",
259
+ notes=note,
260
+ confidence=0.76,
261
+ )
262
+
263
+
264
  def _parse_inventory_purchase(note: str) -> Transaction | None:
265
  """Parse notes like 'Bought 50 mangoes'."""
266
+ match = INVENTORY_PURCHASE_PATTERN.search(note) or QUANTITY_FIRST_INVENTORY_PATTERN.search(note)
267
  if not match:
268
  return None
269
 
 
271
  transaction_type="inventory_purchase",
272
  item=_clean_item(match.group("item")),
273
  quantity=_to_float(match.group("quantity")),
274
+ amount=_to_float(match.groupdict().get("amount")),
275
  payment_status="paid",
276
  notes=note,
277
  confidence=0.86,
 
280
 
281
  def _parse_customer_credit(note: str) -> Transaction | None:
282
  """Parse notes like 'Amit owes 100'."""
283
+ match = CREDIT_PATTERN.search(note) or HINGLISH_CREDIT_PATTERN.search(note)
284
  if not match:
285
  return None
286
 
 
296
 
297
  def _parse_customer_payment(note: str) -> Transaction | None:
298
  """Parse notes like 'Amit paid 50'."""
299
+ match = CUSTOMER_PAYMENT_PATTERN.search(note) or HINGLISH_PAYMENT_PATTERN.search(note)
300
  if not match:
301
  return None
302
 
voiceledger/reports/__init__.py CHANGED
@@ -1,5 +1,6 @@
1
  """Reporting helpers for VoiceLedger."""
2
 
 
3
  from voiceledger.reports.pdf_report import build_daily_summary, generate_daily_summary_pdf
4
  from voiceledger.reports.summary import summarize_transactions
5
  from voiceledger.reports.whatsapp_summary import generate_whatsapp_summary
@@ -9,4 +10,6 @@ __all__ = [
9
  "build_daily_summary",
10
  "generate_daily_summary_pdf",
11
  "generate_whatsapp_summary",
 
 
12
  ]
 
1
  """Reporting helpers for VoiceLedger."""
2
 
3
+ from voiceledger.reports.actions import generate_customer_followup, generate_reorder_list
4
  from voiceledger.reports.pdf_report import build_daily_summary, generate_daily_summary_pdf
5
  from voiceledger.reports.summary import summarize_transactions
6
  from voiceledger.reports.whatsapp_summary import generate_whatsapp_summary
 
10
  "build_daily_summary",
11
  "generate_daily_summary_pdf",
12
  "generate_whatsapp_summary",
13
+ "generate_customer_followup",
14
+ "generate_reorder_list",
15
  ]
voiceledger/reports/actions.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Action-oriented seller messages for follow-up and restocking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import pandas as pd
8
+
9
+ from voiceledger.ledger.customers import get_customer_balances
10
+ from voiceledger.ledger.inventory import get_inventory
11
+ from voiceledger.ledger.settings import get_business_settings, get_low_stock_threshold
12
+
13
+
14
+ REORDER_COLUMNS = ["item", "current_stock", "suggested_action"]
15
+
16
+
17
+ def generate_customer_followup(customer_name: str, db_path: str | Path | None = None) -> str:
18
+ """Generate a short WhatsApp reminder for one customer's outstanding balance."""
19
+ name = " ".join((customer_name or "").split()).strip()
20
+ if not name:
21
+ return "Select a customer to generate a follow-up message."
22
+
23
+ settings = get_business_settings(db_path)
24
+ currency = settings["currency_symbol"]
25
+ balances = get_customer_balances(db_path)
26
+ if balances.empty:
27
+ return f"No outstanding balance found for {name}."
28
+
29
+ matches = balances[balances["customer"].astype(str).str.lower() == name.lower()]
30
+ if matches.empty:
31
+ return f"No outstanding balance found for {name}."
32
+
33
+ balance = float(matches.iloc[0]["outstanding_balance"])
34
+ if balance <= 0:
35
+ return f"Hi {matches.iloc[0]['customer']}, your balance is clear. Thank you."
36
+
37
+ return f"Hi {matches.iloc[0]['customer']}, your balance is {_format_money(balance, currency)}. Please pay when possible."
38
+
39
+
40
+ def generate_reorder_list(db_path: str | Path | None = None) -> tuple[pd.DataFrame, str]:
41
+ """Return low-stock rows and a WhatsApp-ready restock message."""
42
+ settings = get_business_settings(db_path)
43
+ threshold = get_low_stock_threshold(db_path)
44
+ inventory = get_inventory(db_path)
45
+ if inventory.empty:
46
+ return pd.DataFrame(columns=REORDER_COLUMNS), "No inventory recorded yet."
47
+
48
+ stock = inventory.copy()
49
+ stock["current_stock"] = pd.to_numeric(stock["current_stock"], errors="coerce").fillna(0)
50
+ low_stock = stock[stock["current_stock"] < threshold].sort_values(["current_stock", "item"]).copy()
51
+ if low_stock.empty:
52
+ return pd.DataFrame(columns=REORDER_COLUMNS), f"All stock is above the low-stock threshold of {_format_quantity(threshold)}."
53
+
54
+ low_stock["suggested_action"] = low_stock.apply(
55
+ lambda row: f"Restock {row['item']} soon; current stock is {_format_quantity(row['current_stock'])}.",
56
+ axis=1,
57
+ )
58
+ message_lines = [
59
+ f"{settings['business_name']} Reorder List",
60
+ f"Low-stock threshold: {_format_quantity(threshold)}",
61
+ "",
62
+ ]
63
+ message_lines.extend(
64
+ f"- {row['item'].title()}: {_format_quantity(row['current_stock'])} left"
65
+ for _, row in low_stock.head(8).iterrows()
66
+ )
67
+ return low_stock[REORDER_COLUMNS].reset_index(drop=True), "\n".join(message_lines)
68
+
69
+
70
+ def _format_quantity(value: object) -> str:
71
+ """Format quantity without unnecessary decimals."""
72
+ quantity = float(value)
73
+ if quantity.is_integer():
74
+ return str(int(quantity))
75
+ return f"{quantity:.2f}"
76
+
77
+
78
+ def _format_money(value: float, currency_symbol: str) -> str:
79
+ """Format money with the configured currency symbol."""
80
+ amount = float(value)
81
+ if amount.is_integer():
82
+ return f"{currency_symbol}{int(amount)}"
83
+ return f"{currency_symbol}{amount:,.2f}"
voiceledger/reports/pdf_report.py CHANGED
@@ -51,6 +51,8 @@ def generate_daily_summary_pdf(
51
  db_path: str | Path | None = None,
52
  output_dir: str | Path | None = None,
53
  report_date: date | None = None,
 
 
54
  ) -> Path:
55
  """Generate a Daily Summary PDF and return its filesystem path."""
56
  try:
@@ -65,8 +67,8 @@ def generate_daily_summary_pdf(
65
  pdf.set_auto_page_break(auto=True, margin=15)
66
  pdf.add_page()
67
 
68
- _add_title(pdf, summary.report_date)
69
- _add_totals(pdf, summary)
70
  _add_inventory_summary(pdf, summary.inventory)
71
 
72
  pdf.output(str(output_path))
@@ -106,31 +108,31 @@ def _build_output_path(output_dir: str | Path | None, report_date: date) -> Path
106
  return directory / f"voiceledger-daily-summary-{report_date.isoformat()}-{timestamp}.pdf"
107
 
108
 
109
- def _add_title(pdf: object, report_date: date) -> None:
110
  """Add report title and date."""
111
  pdf.set_font("Helvetica", "B", 18)
112
- pdf.cell(0, 12, "VoiceLedger Daily Summary", new_x="LMARGIN", new_y="NEXT")
113
  pdf.set_font("Helvetica", "", 11)
114
  pdf.cell(0, 8, f"Date: {report_date.isoformat()}", new_x="LMARGIN", new_y="NEXT")
115
  pdf.ln(4)
116
 
117
 
118
- def _add_totals(pdf: object, summary: DailySummary) -> None:
119
  """Add the financial summary section."""
120
  pdf.set_font("Helvetica", "B", 13)
121
  pdf.cell(0, 9, "Financial Summary", new_x="LMARGIN", new_y="NEXT")
122
  pdf.set_font("Helvetica", "", 11)
123
- _add_metric_row(pdf, "Total Sales", summary.total_sales)
124
- _add_metric_row(pdf, "Total Expenses", summary.total_expenses)
125
- _add_metric_row(pdf, "Net Profit", summary.net_profit)
126
- _add_metric_row(pdf, "Customer Credit Outstanding", summary.customer_credit_outstanding)
127
  pdf.ln(5)
128
 
129
 
130
- def _add_metric_row(pdf: object, label: str, value: float) -> None:
131
  """Add one label/value row to the PDF."""
132
  pdf.cell(85, 8, label)
133
- pdf.cell(0, 8, _format_money(value), new_x="LMARGIN", new_y="NEXT")
134
 
135
 
136
  def _add_inventory_summary(pdf: object, inventory: pd.DataFrame) -> None:
@@ -151,9 +153,10 @@ def _add_inventory_summary(pdf: object, inventory: pd.DataFrame) -> None:
151
  pdf.cell(40, 8, _format_quantity(row["current_stock"]), border=1, new_x="LMARGIN", new_y="NEXT")
152
 
153
 
154
- def _format_money(value: float) -> str:
155
  """Format a monetary amount for PDF output."""
156
- return f"{value:,.2f}"
 
157
 
158
 
159
  def _format_quantity(value: object) -> str:
@@ -162,3 +165,8 @@ def _format_quantity(value: object) -> str:
162
  if quantity.is_integer():
163
  return str(int(quantity))
164
  return f"{quantity:,.2f}"
 
 
 
 
 
 
51
  db_path: str | Path | None = None,
52
  output_dir: str | Path | None = None,
53
  report_date: date | None = None,
54
+ business_name: str = "VoiceLedger",
55
+ currency_symbol: str = "",
56
  ) -> Path:
57
  """Generate a Daily Summary PDF and return its filesystem path."""
58
  try:
 
67
  pdf.set_auto_page_break(auto=True, margin=15)
68
  pdf.add_page()
69
 
70
+ _add_title(pdf, summary.report_date, business_name)
71
+ _add_totals(pdf, summary, currency_symbol)
72
  _add_inventory_summary(pdf, summary.inventory)
73
 
74
  pdf.output(str(output_path))
 
108
  return directory / f"voiceledger-daily-summary-{report_date.isoformat()}-{timestamp}.pdf"
109
 
110
 
111
+ def _add_title(pdf: object, report_date: date, business_name: str) -> None:
112
  """Add report title and date."""
113
  pdf.set_font("Helvetica", "B", 18)
114
+ pdf.cell(0, 12, f"{_pdf_safe_text(business_name)} Daily Summary", new_x="LMARGIN", new_y="NEXT")
115
  pdf.set_font("Helvetica", "", 11)
116
  pdf.cell(0, 8, f"Date: {report_date.isoformat()}", new_x="LMARGIN", new_y="NEXT")
117
  pdf.ln(4)
118
 
119
 
120
+ def _add_totals(pdf: object, summary: DailySummary, currency_symbol: str) -> None:
121
  """Add the financial summary section."""
122
  pdf.set_font("Helvetica", "B", 13)
123
  pdf.cell(0, 9, "Financial Summary", new_x="LMARGIN", new_y="NEXT")
124
  pdf.set_font("Helvetica", "", 11)
125
+ _add_metric_row(pdf, "Total Sales", summary.total_sales, currency_symbol)
126
+ _add_metric_row(pdf, "Total Expenses", summary.total_expenses, currency_symbol)
127
+ _add_metric_row(pdf, "Net Profit", summary.net_profit, currency_symbol)
128
+ _add_metric_row(pdf, "Customer Credit Outstanding", summary.customer_credit_outstanding, currency_symbol)
129
  pdf.ln(5)
130
 
131
 
132
+ def _add_metric_row(pdf: object, label: str, value: float, currency_symbol: str) -> None:
133
  """Add one label/value row to the PDF."""
134
  pdf.cell(85, 8, label)
135
+ pdf.cell(0, 8, _format_money(value, currency_symbol), new_x="LMARGIN", new_y="NEXT")
136
 
137
 
138
  def _add_inventory_summary(pdf: object, inventory: pd.DataFrame) -> None:
 
153
  pdf.cell(40, 8, _format_quantity(row["current_stock"]), border=1, new_x="LMARGIN", new_y="NEXT")
154
 
155
 
156
+ def _format_money(value: float, currency_symbol: str) -> str:
157
  """Format a monetary amount for PDF output."""
158
+ symbol = _pdf_safe_text(currency_symbol)
159
+ return f"{symbol}{value:,.2f}" if symbol else f"{value:,.2f}"
160
 
161
 
162
  def _format_quantity(value: object) -> str:
 
165
  if quantity.is_integer():
166
  return str(int(quantity))
167
  return f"{quantity:,.2f}"
168
+
169
+
170
+ def _pdf_safe_text(value: str) -> str:
171
+ """Return text safe for the default fpdf Helvetica font."""
172
+ return str(value or "").replace("₹", "Rs").encode("latin-1", "ignore").decode("latin-1")
voiceledger/reports/whatsapp_summary.py CHANGED
@@ -21,6 +21,8 @@ def generate_whatsapp_summary(
21
  db_path: str | Path | None = None,
22
  report_date: date | None = None,
23
  low_stock_threshold: float = 5,
 
 
24
  ) -> str:
25
  """Generate a concise daily summary suitable for WhatsApp sharing."""
26
  sales = calculate_daily_sales(db_path=db_path, report_date=report_date)
@@ -32,13 +34,13 @@ def generate_whatsapp_summary(
32
 
33
  return "\n".join(
34
  [
35
- "VoiceLedger Daily Summary",
36
  "",
37
- f"Sales: {_format_money(sales)}",
38
- f"Expenses: {_format_money(expenses)}",
39
- f"Profit: {_format_money(profit)}",
40
  "",
41
- f"Outstanding Credit: {_format_money(credit)}",
42
  "",
43
  f"Top Product: {top_product}",
44
  f"Low Stock: {low_stock}",
@@ -60,9 +62,9 @@ def _low_stock_names(low_stock: pd.DataFrame) -> str:
60
  return ", ".join(str(item).title() for item in low_stock["item"].head(3))
61
 
62
 
63
- def _format_money(value: float) -> str:
64
  """Format an amount using a rupee symbol and no decimals for whole values."""
65
  amount = float(value)
66
  if amount.is_integer():
67
- return f"{int(amount)}"
68
- return f"{amount:,.2f}"
 
21
  db_path: str | Path | None = None,
22
  report_date: date | None = None,
23
  low_stock_threshold: float = 5,
24
+ business_name: str = "VoiceLedger",
25
+ currency_symbol: str = "₹",
26
  ) -> str:
27
  """Generate a concise daily summary suitable for WhatsApp sharing."""
28
  sales = calculate_daily_sales(db_path=db_path, report_date=report_date)
 
34
 
35
  return "\n".join(
36
  [
37
+ f"{business_name} Daily Summary",
38
  "",
39
+ f"Sales: {_format_money(sales, currency_symbol)}",
40
+ f"Expenses: {_format_money(expenses, currency_symbol)}",
41
+ f"Profit: {_format_money(profit, currency_symbol)}",
42
  "",
43
+ f"Outstanding Credit: {_format_money(credit, currency_symbol)}",
44
  "",
45
  f"Top Product: {top_product}",
46
  f"Low Stock: {low_stock}",
 
62
  return ", ".join(str(item).title() for item in low_stock["item"].head(3))
63
 
64
 
65
+ def _format_money(value: float, currency_symbol: str) -> str:
66
  """Format an amount using a rupee symbol and no decimals for whole values."""
67
  amount = float(value)
68
  if amount.is_integer():
69
+ return f"{currency_symbol}{int(amount)}"
70
+ return f"{currency_symbol}{amount:,.2f}"
voiceledger/ui/gradio_app.py CHANGED
@@ -38,9 +38,11 @@ from voiceledger.ledger.database import (
38
  update_transaction,
39
  )
40
  from voiceledger.ledger.inventory import get_inventory
 
41
  from voiceledger.parser.bulk import REVIEW_COLUMNS, parse_bulk_notes, review_table_to_transactions
42
  from voiceledger.parser.rules import parse_transaction as local_parse_transaction
43
  from voiceledger.parser.schema import Transaction
 
44
  from voiceledger.reports.pdf_report import generate_daily_summary_pdf
45
  from voiceledger.reports.whatsapp_summary import generate_whatsapp_summary
46
  from voiceledger.speech.transcribe import TranscriptionError, transcribe_audio as local_transcribe_audio
@@ -83,6 +85,7 @@ def create_app(db_path: str | Path | None = None) -> gr.Blocks:
83
  )
84
 
85
  parsed_state = gr.State(value=None)
 
86
 
87
  with gr.Group(elem_classes="vl-app-nav"):
88
  gr.HTML("<strong>Sections</strong>")
@@ -105,6 +108,35 @@ def create_app(db_path: str | Path | None = None) -> gr.Blocks:
105
  "Use the Sections buttons above to open each workflow page. VoiceLedger is locked for the hackathon demo: core bookkeeping is frozen, with Modal/Nemotron active and local fallback ready.",
106
  )
107
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  gr.HTML(_judge_demo_panel())
109
  gr.HTML(_today_work_panel())
110
  with gr.Row():
@@ -234,6 +266,7 @@ def create_app(db_path: str | Path | None = None) -> gr.Blocks:
234
  wrap=True,
235
  elem_classes="vl-panel",
236
  )
 
237
  refresh_dashboard_button.click(
238
  fn=lambda: _get_dashboard_data(db_path),
239
  inputs=None,
@@ -246,6 +279,7 @@ def create_app(db_path: str | Path | None = None) -> gr.Blocks:
246
  timeline_output,
247
  top_items_output,
248
  low_stock_output,
 
249
  ],
250
  )
251
  demo.load(
@@ -260,6 +294,7 @@ def create_app(db_path: str | Path | None = None) -> gr.Blocks:
260
  timeline_output,
261
  top_items_output,
262
  low_stock_output,
 
263
  ],
264
  )
265
 
@@ -300,6 +335,28 @@ def create_app(db_path: str | Path | None = None) -> gr.Blocks:
300
  gr.HTML(_submission_story_panel())
301
  gr.HTML(_ai_pipeline_strip())
302
  gr.HTML(_small_model_fit_card())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
  seed_demo_button = gr.Button("Seed Demo Transactions", variant="primary")
304
  seed_demo_status = gr.Markdown(elem_classes="vl-status")
305
 
@@ -382,6 +439,20 @@ def create_app(db_path: str | Path | None = None) -> gr.Blocks:
382
  inputs=customer_detail_name,
383
  outputs=[customer_detail_summary, customer_detail_output],
384
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
385
 
386
  with gr.Column(visible=False, elem_classes="vl-page-section") as inventory_page:
387
  gr.HTML('<div id="vl-page-inventory" class="vl-page-anchor"></div>')
@@ -426,6 +497,26 @@ def create_app(db_path: str | Path | None = None) -> gr.Blocks:
426
  inputs=inventory_detail_item,
427
  outputs=[inventory_detail_summary, inventory_detail_output],
428
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
429
 
430
  with gr.Column(visible=False, elem_classes="vl-page-section") as reports_page:
431
  gr.HTML('<div id="vl-page-reports" class="vl-page-anchor"></div>')
@@ -480,10 +571,7 @@ def create_app(db_path: str | Path | None = None) -> gr.Blocks:
480
  """
481
  )
482
  generate_whatsapp_button.click(
483
- fn=lambda: generate_whatsapp_summary(
484
- db_path=db_path,
485
- low_stock_threshold=LOW_STOCK_THRESHOLD,
486
- ),
487
  inputs=None,
488
  outputs=whatsapp_summary_output,
489
  )
@@ -586,6 +674,7 @@ def create_app(db_path: str | Path | None = None) -> gr.Blocks:
586
  outputs=[
587
  status_output,
588
  receipt_output,
 
589
  total_sales_output,
590
  total_expenses_output,
591
  net_profit_output,
@@ -594,6 +683,7 @@ def create_app(db_path: str | Path | None = None) -> gr.Blocks:
594
  timeline_output,
595
  top_items_output,
596
  low_stock_output,
 
597
  ledger_output,
598
  customer_balances_output,
599
  inventory_output,
@@ -627,6 +717,7 @@ def create_app(db_path: str | Path | None = None) -> gr.Blocks:
627
  ],
628
  outputs=[
629
  edit_status_output,
 
630
  total_sales_output,
631
  total_expenses_output,
632
  net_profit_output,
@@ -635,6 +726,7 @@ def create_app(db_path: str | Path | None = None) -> gr.Blocks:
635
  timeline_output,
636
  top_items_output,
637
  low_stock_output,
 
638
  ledger_output,
639
  customer_balances_output,
640
  inventory_output,
@@ -645,6 +737,7 @@ def create_app(db_path: str | Path | None = None) -> gr.Blocks:
645
  inputs=edit_transaction_id,
646
  outputs=[
647
  edit_status_output,
 
648
  total_sales_output,
649
  total_expenses_output,
650
  net_profit_output,
@@ -653,6 +746,7 @@ def create_app(db_path: str | Path | None = None) -> gr.Blocks:
653
  timeline_output,
654
  top_items_output,
655
  low_stock_output,
 
656
  ledger_output,
657
  customer_balances_output,
658
  inventory_output,
@@ -663,6 +757,7 @@ def create_app(db_path: str | Path | None = None) -> gr.Blocks:
663
  inputs=None,
664
  outputs=[
665
  seed_demo_status,
 
666
  total_sales_output,
667
  total_expenses_output,
668
  net_profit_output,
@@ -671,6 +766,7 @@ def create_app(db_path: str | Path | None = None) -> gr.Blocks:
671
  timeline_output,
672
  top_items_output,
673
  low_stock_output,
 
674
  ledger_output,
675
  customer_balances_output,
676
  inventory_output,
@@ -681,6 +777,7 @@ def create_app(db_path: str | Path | None = None) -> gr.Blocks:
681
  inputs=None,
682
  outputs=[
683
  record_demo_status,
 
684
  total_sales_output,
685
  total_expenses_output,
686
  net_profit_output,
@@ -689,11 +786,41 @@ def create_app(db_path: str | Path | None = None) -> gr.Blocks:
689
  timeline_output,
690
  top_items_output,
691
  low_stock_output,
 
692
  ledger_output,
693
  customer_balances_output,
694
  inventory_output,
695
  ],
696
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
697
  record_health_button.click(
698
  fn=lambda: _get_system_check(db_path),
699
  inputs=None,
@@ -940,16 +1067,16 @@ def _save_transaction(transaction_payload: dict[str, Any] | None, db_path: str |
940
  def _save_transaction_and_refresh(
941
  transaction_payload: dict[str, Any] | None,
942
  db_path: str | Path | None,
943
- ) -> tuple[str, str, str, str, str, str, str, pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.io.formats.style.Styler]:
944
  """Save a transaction and refresh the demo-critical data views."""
945
  if not transaction_payload:
946
- return ("Parse a transaction before saving.", _empty_receipt_card(), *_refresh_core_views(db_path))
947
 
948
  transaction = Transaction(**transaction_payload)
949
  transaction_id = add_transaction(transaction, db_path)
950
  status = f"Saved transaction #{transaction_id}: {_transaction_summary(transaction)}."
951
  receipt = _receipt_card(transaction, transaction_id, db_path)
952
- return (status, receipt, *_refresh_core_views(db_path))
953
 
954
 
955
  def _load_transaction_for_edit(
@@ -1013,11 +1140,11 @@ def _update_transaction_and_refresh(
1013
  notes: str | None,
1014
  confidence: float | None,
1015
  db_path: str | Path | None,
1016
- ) -> tuple[str, str, str, str, str, str, pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.io.formats.style.Styler]:
1017
  """Update a transaction and refresh all dependent views."""
1018
  parsed_id = _coerce_transaction_id(transaction_id)
1019
  if parsed_id is None:
1020
- return ("Enter a transaction id before updating.", *_refresh_core_views(db_path))
1021
 
1022
  try:
1023
  transaction = _transaction_from_edit_fields(
@@ -1032,27 +1159,27 @@ def _update_transaction_and_refresh(
1032
  confidence=confidence,
1033
  )
1034
  except Exception as exc:
1035
- return (f"Could not update transaction: {exc}", *_refresh_core_views(db_path))
1036
 
1037
  updated = update_transaction(parsed_id, transaction, db_path)
1038
  if not updated:
1039
- return (f"Transaction #{parsed_id} was not found.", *_refresh_core_views(db_path))
1040
- return (f"Updated transaction #{parsed_id}: {_transaction_summary(transaction)}.", *_refresh_core_views(db_path))
1041
 
1042
 
1043
  def _delete_transaction_and_refresh(
1044
  transaction_id: float | int | None,
1045
  db_path: str | Path | None,
1046
- ) -> tuple[str, str, str, str, str, str, pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.io.formats.style.Styler]:
1047
  """Delete a transaction and refresh all dependent views."""
1048
  parsed_id = _coerce_transaction_id(transaction_id)
1049
  if parsed_id is None:
1050
- return ("Enter a transaction id before deleting.", *_refresh_core_views(db_path))
1051
 
1052
  deleted = delete_transaction(parsed_id, db_path)
1053
  if not deleted:
1054
- return (f"Transaction #{parsed_id} was not found.", *_refresh_core_views(db_path))
1055
- return (f"Deleted transaction #{parsed_id} and refreshed balances.", *_refresh_core_views(db_path))
1056
 
1057
 
1058
  def _export_ledger_csv(db_path: str | Path | None) -> tuple[str, str]:
@@ -1063,18 +1190,19 @@ def _export_ledger_csv(db_path: str | Path | None) -> tuple[str, str]:
1063
 
1064
  def _seed_demo_transactions_and_refresh(
1065
  db_path: str | Path | None,
1066
- ) -> tuple[str, str, str, str, str, str, pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.io.formats.style.Styler]:
1067
  """Seed realistic demo transactions and refresh all dependent views."""
1068
  saved_ids = [add_transaction(local_parse_transaction(note), db_path) for note in DEMO_NOTES]
1069
  return (
1070
  f"Seeded {len(saved_ids)} demo transactions. Last transaction id: #{saved_ids[-1]}.",
 
1071
  *_refresh_core_views(db_path),
1072
  )
1073
 
1074
 
1075
  def _refresh_core_views(
1076
  db_path: str | Path | None,
1077
- ) -> tuple[str, str, str, str, str, pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.io.formats.style.Styler]:
1078
  """Return dashboard, ledger, customer, and inventory refresh values."""
1079
  return (
1080
  *_get_dashboard_data(db_path),
@@ -1109,34 +1237,158 @@ def _save_bulk_transactions(review_table: Any, db_path: str | Path | None) -> st
1109
  return f"Saved {len(saved_ids)} transactions. Last transaction id: #{saved_ids[-1]}."
1110
 
1111
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1112
  def _get_dashboard_data(
1113
  db_path: str | Path | None,
1114
- ) -> tuple[str, str, str, str, str, pd.DataFrame, pd.DataFrame, pd.DataFrame]:
1115
  """Return business insight values for the Dashboard section."""
 
 
 
1116
  sales = calculate_daily_sales(db_path)
1117
  expenses = calculate_daily_expenses(db_path)
1118
  profit = calculate_net_profit(db_path)
1119
  credit = outstanding_credit(db_path)
1120
  top_items = top_selling_items(db_path)
1121
- low_stock = low_stock_items(db_path, threshold=LOW_STOCK_THRESHOLD)
1122
  timeline = _daily_timeline(db_path)
 
1123
  top_item = "No sales recorded today"
1124
  if not top_items.empty:
1125
  first_item = top_items.iloc[0]
1126
  top_item = f"{first_item['item']} ({_format_quantity(first_item['quantity_sold'])} sold)"
1127
 
1128
  return (
1129
- _metric_card("Total Sales Today", _format_money(sales), "Recorded sales"),
1130
- _metric_card("Total Expenses Today", _format_money(expenses), "Purchases and costs"),
1131
- _metric_card("Net Profit", _format_money(profit), "Sales minus expenses", profit=profit),
1132
- _metric_card("Outstanding Credit", _format_money(credit), "Customer dues"),
1133
  top_item,
1134
  timeline,
1135
  top_items,
1136
  low_stock,
 
1137
  )
1138
 
1139
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1140
  def _daily_timeline(db_path: str | Path | None) -> pd.DataFrame:
1141
  """Return chart-ready daily sales and expense rows."""
1142
  transactions = get_transactions(db_path)
@@ -1165,7 +1417,8 @@ def _daily_timeline(db_path: str | Path | None) -> pd.DataFrame:
1165
  def _get_inventory_display(db_path: str | Path | None) -> pd.io.formats.style.Styler:
1166
  """Return inventory with low-stock rows highlighted for Gradio display."""
1167
  inventory = get_inventory(db_path)
1168
- return inventory.style.apply(_highlight_low_stock, axis=1)
 
1169
 
1170
 
1171
  def _get_customer_detail(customer_name: str | None, db_path: str | Path | None) -> tuple[str, pd.DataFrame]:
@@ -1191,10 +1444,11 @@ def _get_customer_detail(customer_name: str | None, db_path: str | Path | None)
1191
  status = "owes"
1192
  elif balance < 0:
1193
  status = "overpaid"
 
1194
  summary = f"""
1195
  <section class="vl-detail-card">
1196
  <h2>{escape(name)} · {status}</h2>
1197
- <p>Outstanding balance: <strong>{_format_money(balance)}</strong>. Credit: {_format_money(float(credit))}. Payments: {_format_money(float(paid))}.</p>
1198
  </section>
1199
  """
1200
  return summary, matches[columns].reset_index(drop=True)
@@ -1218,11 +1472,12 @@ def _get_inventory_detail(item: str | None, db_path: str | Path | None) -> tuple
1218
  bought = pd.to_numeric(matches.loc[matches["transaction_type"] == "inventory_purchase", "quantity"], errors="coerce").fillna(0).sum()
1219
  sold = pd.to_numeric(matches.loc[matches["transaction_type"] == "sale", "quantity"], errors="coerce").fillna(0).sum()
1220
  stock = round(float(bought - sold), 2)
1221
- status = "low stock" if stock < LOW_STOCK_THRESHOLD else "in stock"
 
1222
  summary = f"""
1223
  <section class="vl-detail-card">
1224
  <h2>{escape(item_name)} · {status}</h2>
1225
- <p>Bought: {_format_quantity(bought)}. Sold: {_format_quantity(sold)}. Current stock: <strong>{_format_quantity(stock)}</strong>.</p>
1226
  </section>
1227
  """
1228
  return summary, matches[columns].reset_index(drop=True)
@@ -1230,8 +1485,13 @@ def _get_inventory_detail(item: str | None, db_path: str | Path | None) -> tuple
1230
 
1231
  def _generate_daily_summary_report(db_path: str | Path | None) -> tuple[str | None, str]:
1232
  """Generate the Daily Summary PDF for download in Gradio."""
 
1233
  try:
1234
- report_path = generate_daily_summary_pdf(db_path=db_path)
 
 
 
 
1235
  except Exception as exc:
1236
  return None, f"Could not generate report: {exc}"
1237
  return str(report_path), "Daily Summary PDF is ready."
@@ -1239,16 +1499,25 @@ def _generate_daily_summary_report(db_path: str | Path | None) -> tuple[str | No
1239
 
1240
  def _run_daily_closeout(db_path: str | Path | None) -> tuple[str, str | None, str | None, str, str]:
1241
  """Generate the daily closeout summary and exports."""
 
 
 
1242
  sales = calculate_daily_sales(db_path)
1243
  expenses = calculate_daily_expenses(db_path)
1244
  profit = calculate_net_profit(db_path)
1245
  credit = outstanding_credit(db_path)
1246
- low_stock = low_stock_items(db_path, threshold=LOW_STOCK_THRESHOLD)
1247
 
1248
  pdf_path: str | None
1249
  csv_path: str | None
1250
  try:
1251
- pdf_path = str(generate_daily_summary_pdf(db_path=db_path))
 
 
 
 
 
 
1252
  except Exception:
1253
  pdf_path = None
1254
  try:
@@ -1256,15 +1525,20 @@ def _run_daily_closeout(db_path: str | Path | None) -> tuple[str, str | None, st
1256
  except Exception:
1257
  csv_path = None
1258
 
1259
- whatsapp = generate_whatsapp_summary(db_path=db_path, low_stock_threshold=LOW_STOCK_THRESHOLD)
 
 
 
 
 
1260
  summary = f"""
1261
  <section class="vl-closeout-card">
1262
  <h2>Daily Closeout Ready</h2>
1263
  <div class="vl-closeout-grid">
1264
- <span><strong>Sales</strong>{_format_money(sales)}</span>
1265
- <span><strong>Expenses</strong>{_format_money(expenses)}</span>
1266
- <span><strong>Profit</strong>{_format_money(profit)}</span>
1267
- <span><strong>Credit</strong>{_format_money(credit)}</span>
1268
  </div>
1269
  <p>{len(low_stock)} low-stock item(s). PDF, WhatsApp summary, and CSV are prepared below.</p>
1270
  </section>
@@ -1356,18 +1630,30 @@ def _metric_card(label: str, value: str, note: str, profit: float | None = None)
1356
  """
1357
 
1358
 
1359
- def _format_money(value: float) -> str:
 
 
 
 
 
 
 
 
 
 
 
1360
  """Format money for dashboard cards."""
 
1361
  amount = float(value)
1362
  if amount.is_integer():
1363
- return f"{int(amount):,}"
1364
- return f"{amount:,.2f}"
1365
 
1366
 
1367
- def _highlight_low_stock(row: pd.Series) -> list[str]:
1368
  """Highlight rows where stock is below the configured threshold."""
1369
  current_stock = row.get("current_stock")
1370
- if current_stock is not None and float(current_stock) < LOW_STOCK_THRESHOLD:
1371
  return ["background-color: #fff3cd; color: #5f370e"] * len(row)
1372
  return [""] * len(row)
1373
 
@@ -1414,9 +1700,12 @@ def _empty_detail_card(title: str, body: str = "Select an item to see details.")
1414
 
1415
  def _review_card(transaction: Transaction, source_message: str, warnings: list[str]) -> str:
1416
  """Render a human-friendly transaction review card."""
1417
- warning_markup = "".join(f'<span class="vl-warning-badge">{escape(warning)}</span>' for warning in warnings)
1418
- if not warning_markup:
1419
- warning_markup = '<span class="vl-success-badge">Ready to save</span>'
 
 
 
1420
 
1421
  fields = [
1422
  ("Type", transaction.transaction_type),
@@ -1448,6 +1737,8 @@ def _review_warnings(transaction: Transaction, db_path: str | Path | None) -> li
1448
  warnings: list[str] = []
1449
  if transaction.transaction_type == "unknown":
1450
  warnings.append("Unknown type")
 
 
1451
  if transaction.amount is None and transaction.transaction_type in {"sale", "expense", "customer_credit", "customer_payment"}:
1452
  warnings.append("Missing amount")
1453
  if transaction.transaction_type in {"customer_credit", "customer_payment"} and not transaction.customer:
@@ -1461,6 +1752,23 @@ def _review_warnings(transaction: Transaction, db_path: str | Path | None) -> li
1461
  return warnings
1462
 
1463
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1464
  def _would_make_stock_negative(transaction: Transaction, db_path: str | Path | None) -> bool:
1465
  """Return whether saving this sale would take inventory below zero."""
1466
  if transaction.transaction_type != "sale" or not transaction.item or transaction.quantity is None:
@@ -1532,6 +1840,7 @@ def _status_message(
1532
  ) -> str:
1533
  """Return a human-readable parsing status."""
1534
  parts = []
 
1535
  if prefix:
1536
  parts.append(prefix)
1537
  parts.append(source_message)
@@ -1547,6 +1856,15 @@ def _status_message(
1547
  return " ".join(parts)
1548
 
1549
 
 
 
 
 
 
 
 
 
 
1550
  def _transcription_status(result: modal_api.TranscriptionResult) -> str:
1551
  """Return a concise transcription source status."""
1552
  status = result.message
 
38
  update_transaction,
39
  )
40
  from voiceledger.ledger.inventory import get_inventory
41
+ from voiceledger.ledger.settings import get_business_settings, get_low_stock_threshold, update_business_settings
42
  from voiceledger.parser.bulk import REVIEW_COLUMNS, parse_bulk_notes, review_table_to_transactions
43
  from voiceledger.parser.rules import parse_transaction as local_parse_transaction
44
  from voiceledger.parser.schema import Transaction
45
+ from voiceledger.reports.actions import generate_customer_followup, generate_reorder_list
46
  from voiceledger.reports.pdf_report import generate_daily_summary_pdf
47
  from voiceledger.reports.whatsapp_summary import generate_whatsapp_summary
48
  from voiceledger.speech.transcribe import TranscriptionError, transcribe_audio as local_transcribe_audio
 
85
  )
86
 
87
  parsed_state = gr.State(value=None)
88
+ initial_settings = get_business_settings(db_path)
89
 
90
  with gr.Group(elem_classes="vl-app-nav"):
91
  gr.HTML("<strong>Sections</strong>")
 
108
  "Use the Sections buttons above to open each workflow page. VoiceLedger is locked for the hackathon demo: core bookkeeping is frozen, with Modal/Nemotron active and local fallback ready.",
109
  )
110
  )
111
+ command_center_output = gr.HTML(_command_center(db_path))
112
+ gr.HTML(_section_heading("Seller Setup"))
113
+ with gr.Row():
114
+ business_name_input = gr.Textbox(
115
+ label="Business name",
116
+ value=initial_settings["business_name"],
117
+ elem_classes="vl-panel",
118
+ )
119
+ currency_symbol_input = gr.Textbox(
120
+ label="Currency label",
121
+ value=initial_settings["currency_symbol"],
122
+ elem_classes="vl-panel",
123
+ )
124
+ with gr.Row():
125
+ low_stock_threshold_input = gr.Number(
126
+ label="Low-stock threshold",
127
+ value=float(initial_settings["low_stock_threshold"]),
128
+ elem_classes="vl-panel",
129
+ )
130
+ language_style_input = gr.Dropdown(
131
+ choices=["English", "English + Hinglish", "English + Gujarati-lite"],
132
+ label="Primary language style",
133
+ value=initial_settings["language_style"],
134
+ )
135
+ save_settings_button = gr.Button("Save Seller Setup")
136
+ settings_status_output = gr.Markdown(
137
+ value=_seller_setup_status(initial_settings),
138
+ elem_classes="vl-status",
139
+ )
140
  gr.HTML(_judge_demo_panel())
141
  gr.HTML(_today_work_panel())
142
  with gr.Row():
 
266
  wrap=True,
267
  elem_classes="vl-panel",
268
  )
269
+ seller_day_output = gr.HTML(_seller_day_timeline(db_path))
270
  refresh_dashboard_button.click(
271
  fn=lambda: _get_dashboard_data(db_path),
272
  inputs=None,
 
279
  timeline_output,
280
  top_items_output,
281
  low_stock_output,
282
+ seller_day_output,
283
  ],
284
  )
285
  demo.load(
 
294
  timeline_output,
295
  top_items_output,
296
  low_stock_output,
297
+ seller_day_output,
298
  ],
299
  )
300
 
 
335
  gr.HTML(_submission_story_panel())
336
  gr.HTML(_ai_pipeline_strip())
337
  gr.HTML(_small_model_fit_card())
338
+ gr.HTML(_section_heading("Field Test Notes"))
339
+ with gr.Row():
340
+ field_who_input = gr.Textbox(
341
+ label="Who this is for",
342
+ value=initial_settings["field_test_who"],
343
+ lines=3,
344
+ elem_classes="vl-panel",
345
+ )
346
+ field_tried_input = gr.Textbox(
347
+ label="What they tried",
348
+ value=initial_settings["field_test_tried"],
349
+ lines=3,
350
+ elem_classes="vl-panel",
351
+ )
352
+ field_changed_input = gr.Textbox(
353
+ label="What changed after feedback",
354
+ value=initial_settings["field_test_changed"],
355
+ lines=3,
356
+ elem_classes="vl-panel",
357
+ )
358
+ save_field_notes_button = gr.Button("Save Field Test Notes")
359
+ field_notes_status_output = gr.Markdown(elem_classes="vl-status")
360
  seed_demo_button = gr.Button("Seed Demo Transactions", variant="primary")
361
  seed_demo_status = gr.Markdown(elem_classes="vl-status")
362
 
 
439
  inputs=customer_detail_name,
440
  outputs=[customer_detail_summary, customer_detail_output],
441
  )
442
+ gr.HTML(_section_heading("Customer Follow-up"))
443
+ followup_customer_name = gr.Textbox(label="Customer for WhatsApp reminder", placeholder="Amit")
444
+ followup_button = gr.Button("Generate Follow-up Message")
445
+ followup_output = gr.Textbox(
446
+ label="WhatsApp follow-up",
447
+ lines=4,
448
+ interactive=False,
449
+ elem_classes="vl-panel",
450
+ )
451
+ followup_button.click(
452
+ fn=lambda customer_name: generate_customer_followup(customer_name, db_path),
453
+ inputs=followup_customer_name,
454
+ outputs=followup_output,
455
+ )
456
 
457
  with gr.Column(visible=False, elem_classes="vl-page-section") as inventory_page:
458
  gr.HTML('<div id="vl-page-inventory" class="vl-page-anchor"></div>')
 
497
  inputs=inventory_detail_item,
498
  outputs=[inventory_detail_summary, inventory_detail_output],
499
  )
500
+ gr.HTML(_section_heading("Inventory Reorder List"))
501
+ reorder_button = gr.Button("Generate Reorder List")
502
+ reorder_output = gr.Dataframe(
503
+ headers=["item", "current_stock", "suggested_action"],
504
+ label="Low-stock reorder list",
505
+ interactive=False,
506
+ wrap=True,
507
+ elem_classes="vl-panel",
508
+ )
509
+ reorder_message_output = gr.Textbox(
510
+ label="WhatsApp restock message",
511
+ lines=8,
512
+ interactive=False,
513
+ elem_classes="vl-panel",
514
+ )
515
+ reorder_button.click(
516
+ fn=lambda: generate_reorder_list(db_path),
517
+ inputs=None,
518
+ outputs=[reorder_output, reorder_message_output],
519
+ )
520
 
521
  with gr.Column(visible=False, elem_classes="vl-page-section") as reports_page:
522
  gr.HTML('<div id="vl-page-reports" class="vl-page-anchor"></div>')
 
571
  """
572
  )
573
  generate_whatsapp_button.click(
574
+ fn=lambda: _generate_whatsapp_summary(db_path),
 
 
 
575
  inputs=None,
576
  outputs=whatsapp_summary_output,
577
  )
 
674
  outputs=[
675
  status_output,
676
  receipt_output,
677
+ command_center_output,
678
  total_sales_output,
679
  total_expenses_output,
680
  net_profit_output,
 
683
  timeline_output,
684
  top_items_output,
685
  low_stock_output,
686
+ seller_day_output,
687
  ledger_output,
688
  customer_balances_output,
689
  inventory_output,
 
717
  ],
718
  outputs=[
719
  edit_status_output,
720
+ command_center_output,
721
  total_sales_output,
722
  total_expenses_output,
723
  net_profit_output,
 
726
  timeline_output,
727
  top_items_output,
728
  low_stock_output,
729
+ seller_day_output,
730
  ledger_output,
731
  customer_balances_output,
732
  inventory_output,
 
737
  inputs=edit_transaction_id,
738
  outputs=[
739
  edit_status_output,
740
+ command_center_output,
741
  total_sales_output,
742
  total_expenses_output,
743
  net_profit_output,
 
746
  timeline_output,
747
  top_items_output,
748
  low_stock_output,
749
+ seller_day_output,
750
  ledger_output,
751
  customer_balances_output,
752
  inventory_output,
 
757
  inputs=None,
758
  outputs=[
759
  seed_demo_status,
760
+ command_center_output,
761
  total_sales_output,
762
  total_expenses_output,
763
  net_profit_output,
 
766
  timeline_output,
767
  top_items_output,
768
  low_stock_output,
769
+ seller_day_output,
770
  ledger_output,
771
  customer_balances_output,
772
  inventory_output,
 
777
  inputs=None,
778
  outputs=[
779
  record_demo_status,
780
+ command_center_output,
781
  total_sales_output,
782
  total_expenses_output,
783
  net_profit_output,
 
786
  timeline_output,
787
  top_items_output,
788
  low_stock_output,
789
+ seller_day_output,
790
  ledger_output,
791
  customer_balances_output,
792
  inventory_output,
793
  ],
794
  )
795
+ save_settings_button.click(
796
+ fn=lambda business_name, currency_symbol, low_stock_threshold, language_style: _save_seller_setup_and_refresh(
797
+ business_name,
798
+ currency_symbol,
799
+ low_stock_threshold,
800
+ language_style,
801
+ db_path,
802
+ ),
803
+ inputs=[business_name_input, currency_symbol_input, low_stock_threshold_input, language_style_input],
804
+ outputs=[
805
+ settings_status_output,
806
+ command_center_output,
807
+ total_sales_output,
808
+ total_expenses_output,
809
+ net_profit_output,
810
+ outstanding_credit_output,
811
+ top_selling_item_output,
812
+ timeline_output,
813
+ top_items_output,
814
+ low_stock_output,
815
+ seller_day_output,
816
+ inventory_output,
817
+ ],
818
+ )
819
+ save_field_notes_button.click(
820
+ fn=lambda who, tried, changed: _save_field_notes(who, tried, changed, db_path),
821
+ inputs=[field_who_input, field_tried_input, field_changed_input],
822
+ outputs=field_notes_status_output,
823
+ )
824
  record_health_button.click(
825
  fn=lambda: _get_system_check(db_path),
826
  inputs=None,
 
1067
  def _save_transaction_and_refresh(
1068
  transaction_payload: dict[str, Any] | None,
1069
  db_path: str | Path | None,
1070
+ ) -> tuple[str, str, str, str, str, str, str, str, pd.DataFrame, pd.DataFrame, pd.DataFrame, str, pd.DataFrame, pd.DataFrame, pd.io.formats.style.Styler]:
1071
  """Save a transaction and refresh the demo-critical data views."""
1072
  if not transaction_payload:
1073
+ return ("Parse a transaction before saving.", _empty_receipt_card(), _command_center(db_path), *_refresh_core_views(db_path))
1074
 
1075
  transaction = Transaction(**transaction_payload)
1076
  transaction_id = add_transaction(transaction, db_path)
1077
  status = f"Saved transaction #{transaction_id}: {_transaction_summary(transaction)}."
1078
  receipt = _receipt_card(transaction, transaction_id, db_path)
1079
+ return (status, receipt, _command_center(db_path), *_refresh_core_views(db_path))
1080
 
1081
 
1082
  def _load_transaction_for_edit(
 
1140
  notes: str | None,
1141
  confidence: float | None,
1142
  db_path: str | Path | None,
1143
+ ) -> tuple[str, str, str, str, str, str, str, pd.DataFrame, pd.DataFrame, pd.DataFrame, str, pd.DataFrame, pd.DataFrame, pd.io.formats.style.Styler]:
1144
  """Update a transaction and refresh all dependent views."""
1145
  parsed_id = _coerce_transaction_id(transaction_id)
1146
  if parsed_id is None:
1147
+ return ("Enter a transaction id before updating.", _command_center(db_path), *_refresh_core_views(db_path))
1148
 
1149
  try:
1150
  transaction = _transaction_from_edit_fields(
 
1159
  confidence=confidence,
1160
  )
1161
  except Exception as exc:
1162
+ return (f"Could not update transaction: {exc}", _command_center(db_path), *_refresh_core_views(db_path))
1163
 
1164
  updated = update_transaction(parsed_id, transaction, db_path)
1165
  if not updated:
1166
+ return (f"Transaction #{parsed_id} was not found.", _command_center(db_path), *_refresh_core_views(db_path))
1167
+ return (f"Updated transaction #{parsed_id}: {_transaction_summary(transaction)}.", _command_center(db_path), *_refresh_core_views(db_path))
1168
 
1169
 
1170
  def _delete_transaction_and_refresh(
1171
  transaction_id: float | int | None,
1172
  db_path: str | Path | None,
1173
+ ) -> tuple[str, str, str, str, str, str, str, pd.DataFrame, pd.DataFrame, pd.DataFrame, str, pd.DataFrame, pd.DataFrame, pd.io.formats.style.Styler]:
1174
  """Delete a transaction and refresh all dependent views."""
1175
  parsed_id = _coerce_transaction_id(transaction_id)
1176
  if parsed_id is None:
1177
+ return ("Enter a transaction id before deleting.", _command_center(db_path), *_refresh_core_views(db_path))
1178
 
1179
  deleted = delete_transaction(parsed_id, db_path)
1180
  if not deleted:
1181
+ return (f"Transaction #{parsed_id} was not found.", _command_center(db_path), *_refresh_core_views(db_path))
1182
+ return (f"Deleted transaction #{parsed_id} and refreshed balances.", _command_center(db_path), *_refresh_core_views(db_path))
1183
 
1184
 
1185
  def _export_ledger_csv(db_path: str | Path | None) -> tuple[str, str]:
 
1190
 
1191
  def _seed_demo_transactions_and_refresh(
1192
  db_path: str | Path | None,
1193
+ ) -> tuple[str, str, str, str, str, str, str, pd.DataFrame, pd.DataFrame, pd.DataFrame, str, pd.DataFrame, pd.DataFrame, pd.io.formats.style.Styler]:
1194
  """Seed realistic demo transactions and refresh all dependent views."""
1195
  saved_ids = [add_transaction(local_parse_transaction(note), db_path) for note in DEMO_NOTES]
1196
  return (
1197
  f"Seeded {len(saved_ids)} demo transactions. Last transaction id: #{saved_ids[-1]}.",
1198
+ _command_center(db_path),
1199
  *_refresh_core_views(db_path),
1200
  )
1201
 
1202
 
1203
  def _refresh_core_views(
1204
  db_path: str | Path | None,
1205
+ ) -> tuple[str, str, str, str, str, pd.DataFrame, pd.DataFrame, pd.DataFrame, str, pd.DataFrame, pd.DataFrame, pd.io.formats.style.Styler]:
1206
  """Return dashboard, ledger, customer, and inventory refresh values."""
1207
  return (
1208
  *_get_dashboard_data(db_path),
 
1237
  return f"Saved {len(saved_ids)} transactions. Last transaction id: #{saved_ids[-1]}."
1238
 
1239
 
1240
+ def _save_seller_setup_and_refresh(
1241
+ business_name: str,
1242
+ currency_symbol: str,
1243
+ low_stock_threshold: float | None,
1244
+ language_style: str,
1245
+ db_path: str | Path | None,
1246
+ ) -> tuple[str, str, str, str, str, str, str, pd.DataFrame, pd.DataFrame, pd.DataFrame, str, pd.io.formats.style.Styler]:
1247
+ """Save seller setup and refresh context-sensitive dashboard views."""
1248
+ settings = update_business_settings(
1249
+ business_name=business_name,
1250
+ currency_symbol=currency_symbol,
1251
+ low_stock_threshold=low_stock_threshold,
1252
+ language_style=language_style,
1253
+ db_path=db_path,
1254
+ )
1255
+ return (
1256
+ _seller_setup_status(settings),
1257
+ _command_center(db_path),
1258
+ *_get_dashboard_data(db_path),
1259
+ _get_inventory_display(db_path),
1260
+ )
1261
+
1262
+
1263
+ def _save_field_notes(who: str, tried: str, changed: str, db_path: str | Path | None) -> str:
1264
+ """Persist anonymized field-test notes."""
1265
+ update_business_settings(
1266
+ db_path=db_path,
1267
+ field_test_who=who,
1268
+ field_test_tried=tried,
1269
+ field_test_changed=changed,
1270
+ )
1271
+ return "Saved anonymized field-test notes."
1272
+
1273
+
1274
  def _get_dashboard_data(
1275
  db_path: str | Path | None,
1276
+ ) -> tuple[str, str, str, str, str, pd.DataFrame, pd.DataFrame, pd.DataFrame, str]:
1277
  """Return business insight values for the Dashboard section."""
1278
+ settings = get_business_settings(db_path)
1279
+ currency_symbol = settings["currency_symbol"]
1280
+ threshold = get_low_stock_threshold(db_path)
1281
  sales = calculate_daily_sales(db_path)
1282
  expenses = calculate_daily_expenses(db_path)
1283
  profit = calculate_net_profit(db_path)
1284
  credit = outstanding_credit(db_path)
1285
  top_items = top_selling_items(db_path)
1286
+ low_stock = low_stock_items(db_path, threshold=threshold)
1287
  timeline = _daily_timeline(db_path)
1288
+ seller_day = _seller_day_timeline(db_path)
1289
  top_item = "No sales recorded today"
1290
  if not top_items.empty:
1291
  first_item = top_items.iloc[0]
1292
  top_item = f"{first_item['item']} ({_format_quantity(first_item['quantity_sold'])} sold)"
1293
 
1294
  return (
1295
+ _metric_card("Total Sales Today", _format_money(sales, currency_symbol), "Recorded sales"),
1296
+ _metric_card("Total Expenses Today", _format_money(expenses, currency_symbol), "Purchases and costs"),
1297
+ _metric_card("Net Profit", _format_money(profit, currency_symbol), "Sales minus expenses", profit=profit),
1298
+ _metric_card("Outstanding Credit", _format_money(credit, currency_symbol), "Customer dues"),
1299
  top_item,
1300
  timeline,
1301
  top_items,
1302
  low_stock,
1303
+ seller_day,
1304
  )
1305
 
1306
 
1307
+ def _command_center(db_path: str | Path | None) -> str:
1308
+ """Return the top-of-record-page seller command center."""
1309
+ settings = get_business_settings(db_path)
1310
+ currency = settings["currency_symbol"]
1311
+ threshold = get_low_stock_threshold(db_path)
1312
+ sales = calculate_daily_sales(db_path)
1313
+ credit = outstanding_credit(db_path)
1314
+ low_stock_count = len(low_stock_items(db_path, threshold=threshold))
1315
+ last_transaction = _last_transaction_label(db_path)
1316
+ return f"""
1317
+ <section class="vl-command-center">
1318
+ <h2>{escape(settings['business_name'])} Command Center</h2>
1319
+ <div>
1320
+ <span><strong>Sales today</strong>{escape(_format_money(sales, currency))}</span>
1321
+ <span><strong>Outstanding credit</strong>{escape(_format_money(credit, currency))}</span>
1322
+ <span><strong>Low-stock items</strong>{low_stock_count}</span>
1323
+ <span><strong>Last saved</strong>{escape(last_transaction)}</span>
1324
+ </div>
1325
+ </section>
1326
+ """
1327
+
1328
+
1329
+ def _seller_setup_status(settings: dict[str, str]) -> str:
1330
+ """Return a concise seller setup status line."""
1331
+ return (
1332
+ f"Seller setup: {settings['business_name']} · currency {settings['currency_symbol']} · "
1333
+ f"low stock below {settings['low_stock_threshold']} · {settings['language_style']}."
1334
+ )
1335
+
1336
+
1337
+ def _last_transaction_label(db_path: str | Path | None) -> str:
1338
+ """Return a concise label for the newest transaction."""
1339
+ ledger = get_transactions(db_path)
1340
+ if ledger.empty:
1341
+ return "None yet"
1342
+ row = ledger.iloc[0]
1343
+ amount = row.get("amount")
1344
+ item_or_customer = row.get("item") or row.get("customer") or "transaction"
1345
+ if pd.notna(amount):
1346
+ return f"{row['transaction_type']} · {item_or_customer} · {_format_money(float(amount), get_business_settings(db_path)['currency_symbol'])}"
1347
+ return f"{row['transaction_type']} · {item_or_customer}"
1348
+
1349
+
1350
+ def _seller_day_timeline(db_path: str | Path | None) -> str:
1351
+ """Return a visual timeline of recent seller activity."""
1352
+ ledger = get_transactions(db_path)
1353
+ if ledger.empty:
1354
+ return _empty_detail_card("Seller day timeline", "No saved transactions yet.")
1355
+
1356
+ rows = []
1357
+ currency = get_business_settings(db_path)["currency_symbol"]
1358
+ for _, row in ledger.head(8).iterrows():
1359
+ label = _transaction_type_label(str(row["transaction_type"]))
1360
+ subject = row.get("item") or row.get("customer") or "ledger entry"
1361
+ amount = row.get("amount")
1362
+ amount_text = ""
1363
+ if pd.notna(amount):
1364
+ amount_text = f" · {_format_money(float(amount), currency)}"
1365
+ rows.append(
1366
+ f"""
1367
+ <li>
1368
+ <strong>{escape(label)}</strong>
1369
+ <span>{escape(str(subject))}{escape(amount_text)}</span>
1370
+ </li>
1371
+ """
1372
+ )
1373
+ return f"""
1374
+ <section class="vl-seller-timeline">
1375
+ <h2>Seller day timeline</h2>
1376
+ <ol>{''.join(rows)}</ol>
1377
+ </section>
1378
+ """
1379
+
1380
+
1381
+ def _transaction_type_label(transaction_type: str) -> str:
1382
+ """Return a compact human label for a transaction type."""
1383
+ return {
1384
+ "sale": "Sale",
1385
+ "expense": "Expense",
1386
+ "inventory_purchase": "Stock purchase",
1387
+ "customer_credit": "Customer owes",
1388
+ "customer_payment": "Customer paid",
1389
+ }.get(transaction_type, "Needs review")
1390
+
1391
+
1392
  def _daily_timeline(db_path: str | Path | None) -> pd.DataFrame:
1393
  """Return chart-ready daily sales and expense rows."""
1394
  transactions = get_transactions(db_path)
 
1417
  def _get_inventory_display(db_path: str | Path | None) -> pd.io.formats.style.Styler:
1418
  """Return inventory with low-stock rows highlighted for Gradio display."""
1419
  inventory = get_inventory(db_path)
1420
+ threshold = get_low_stock_threshold(db_path)
1421
+ return inventory.style.apply(lambda row: _highlight_low_stock(row, threshold), axis=1)
1422
 
1423
 
1424
  def _get_customer_detail(customer_name: str | None, db_path: str | Path | None) -> tuple[str, pd.DataFrame]:
 
1444
  status = "owes"
1445
  elif balance < 0:
1446
  status = "overpaid"
1447
+ currency = get_business_settings(db_path)["currency_symbol"]
1448
  summary = f"""
1449
  <section class="vl-detail-card">
1450
  <h2>{escape(name)} · {status}</h2>
1451
+ <p>Outstanding balance: <strong>{_format_money(balance, currency)}</strong>. Credit: {_format_money(float(credit), currency)}. Payments: {_format_money(float(paid), currency)}.</p>
1452
  </section>
1453
  """
1454
  return summary, matches[columns].reset_index(drop=True)
 
1472
  bought = pd.to_numeric(matches.loc[matches["transaction_type"] == "inventory_purchase", "quantity"], errors="coerce").fillna(0).sum()
1473
  sold = pd.to_numeric(matches.loc[matches["transaction_type"] == "sale", "quantity"], errors="coerce").fillna(0).sum()
1474
  stock = round(float(bought - sold), 2)
1475
+ threshold = get_low_stock_threshold(db_path)
1476
+ status = "low stock" if stock < threshold else "in stock"
1477
  summary = f"""
1478
  <section class="vl-detail-card">
1479
  <h2>{escape(item_name)} · {status}</h2>
1480
+ <p>Bought: {_format_quantity(bought)}. Sold: {_format_quantity(sold)}. Current stock: <strong>{_format_quantity(stock)}</strong>. Low-stock threshold: {_format_quantity(threshold)}.</p>
1481
  </section>
1482
  """
1483
  return summary, matches[columns].reset_index(drop=True)
 
1485
 
1486
  def _generate_daily_summary_report(db_path: str | Path | None) -> tuple[str | None, str]:
1487
  """Generate the Daily Summary PDF for download in Gradio."""
1488
+ settings = get_business_settings(db_path)
1489
  try:
1490
+ report_path = generate_daily_summary_pdf(
1491
+ db_path=db_path,
1492
+ business_name=settings["business_name"],
1493
+ currency_symbol=settings["currency_symbol"],
1494
+ )
1495
  except Exception as exc:
1496
  return None, f"Could not generate report: {exc}"
1497
  return str(report_path), "Daily Summary PDF is ready."
 
1499
 
1500
  def _run_daily_closeout(db_path: str | Path | None) -> tuple[str, str | None, str | None, str, str]:
1501
  """Generate the daily closeout summary and exports."""
1502
+ settings = get_business_settings(db_path)
1503
+ currency = settings["currency_symbol"]
1504
+ threshold = get_low_stock_threshold(db_path)
1505
  sales = calculate_daily_sales(db_path)
1506
  expenses = calculate_daily_expenses(db_path)
1507
  profit = calculate_net_profit(db_path)
1508
  credit = outstanding_credit(db_path)
1509
+ low_stock = low_stock_items(db_path, threshold=threshold)
1510
 
1511
  pdf_path: str | None
1512
  csv_path: str | None
1513
  try:
1514
+ pdf_path = str(
1515
+ generate_daily_summary_pdf(
1516
+ db_path=db_path,
1517
+ business_name=settings["business_name"],
1518
+ currency_symbol=currency,
1519
+ )
1520
+ )
1521
  except Exception:
1522
  pdf_path = None
1523
  try:
 
1525
  except Exception:
1526
  csv_path = None
1527
 
1528
+ whatsapp = generate_whatsapp_summary(
1529
+ db_path=db_path,
1530
+ low_stock_threshold=threshold,
1531
+ business_name=settings["business_name"],
1532
+ currency_symbol=currency,
1533
+ )
1534
  summary = f"""
1535
  <section class="vl-closeout-card">
1536
  <h2>Daily Closeout Ready</h2>
1537
  <div class="vl-closeout-grid">
1538
+ <span><strong>Sales</strong>{_format_money(sales, currency)}</span>
1539
+ <span><strong>Expenses</strong>{_format_money(expenses, currency)}</span>
1540
+ <span><strong>Profit</strong>{_format_money(profit, currency)}</span>
1541
+ <span><strong>Credit</strong>{_format_money(credit, currency)}</span>
1542
  </div>
1543
  <p>{len(low_stock)} low-stock item(s). PDF, WhatsApp summary, and CSV are prepared below.</p>
1544
  </section>
 
1630
  """
1631
 
1632
 
1633
+ def _generate_whatsapp_summary(db_path: str | Path | None) -> str:
1634
+ """Generate a WhatsApp summary using seller settings."""
1635
+ settings = get_business_settings(db_path)
1636
+ return generate_whatsapp_summary(
1637
+ db_path=db_path,
1638
+ low_stock_threshold=get_low_stock_threshold(db_path),
1639
+ business_name=settings["business_name"],
1640
+ currency_symbol=settings["currency_symbol"],
1641
+ )
1642
+
1643
+
1644
+ def _format_money(value: float, currency_symbol: str | None = None) -> str:
1645
  """Format money for dashboard cards."""
1646
+ symbol = currency_symbol if currency_symbol is not None else get_business_settings()["currency_symbol"]
1647
  amount = float(value)
1648
  if amount.is_integer():
1649
+ return f"{symbol}{int(amount):,}"
1650
+ return f"{symbol}{amount:,.2f}"
1651
 
1652
 
1653
+ def _highlight_low_stock(row: pd.Series, threshold: float = LOW_STOCK_THRESHOLD) -> list[str]:
1654
  """Highlight rows where stock is below the configured threshold."""
1655
  current_stock = row.get("current_stock")
1656
+ if current_stock is not None and float(current_stock) < threshold:
1657
  return ["background-color: #fff3cd; color: #5f370e"] * len(row)
1658
  return [""] * len(row)
1659
 
 
1700
 
1701
  def _review_card(transaction: Transaction, source_message: str, warnings: list[str]) -> str:
1702
  """Render a human-friendly transaction review card."""
1703
+ readiness = _review_readiness(transaction, warnings)
1704
+ warning_markup = "".join(
1705
+ f'<span class="{_warning_badge_class(warning)}">{escape(warning)}</span>'
1706
+ for warning in warnings
1707
+ )
1708
+ warning_markup = f"{readiness}{warning_markup}"
1709
 
1710
  fields = [
1711
  ("Type", transaction.transaction_type),
 
1737
  warnings: list[str] = []
1738
  if transaction.transaction_type == "unknown":
1739
  warnings.append("Unknown type")
1740
+ if transaction.transaction_type in {"sale", "inventory_purchase"} and not transaction.item:
1741
+ warnings.append("Missing item")
1742
  if transaction.amount is None and transaction.transaction_type in {"sale", "expense", "customer_credit", "customer_payment"}:
1743
  warnings.append("Missing amount")
1744
  if transaction.transaction_type in {"customer_credit", "customer_payment"} and not transaction.customer:
 
1752
  return warnings
1753
 
1754
 
1755
+ def _review_readiness(transaction: Transaction, warnings: list[str]) -> str:
1756
+ """Return the primary trust cue for a parsed transaction."""
1757
+ blocking_warnings = {"Unknown type", "Missing item", "Missing amount", "Missing customer", "Inventory would go negative"}
1758
+ if transaction.transaction_type == "inventory_purchase" and transaction.quantity is not None:
1759
+ blocking_warnings.discard("Missing amount")
1760
+ if any(warning in blocking_warnings for warning in warnings):
1761
+ return '<span class="vl-warning-badge vl-warning-strong">Needs review</span>'
1762
+ return '<span class="vl-success-badge">Safe to save</span>'
1763
+
1764
+
1765
+ def _warning_badge_class(warning: str) -> str:
1766
+ """Return a severity-aware warning badge class."""
1767
+ if warning in {"Inventory would go negative", "Possible duplicate"}:
1768
+ return "vl-warning-badge vl-warning-strong"
1769
+ return "vl-warning-badge"
1770
+
1771
+
1772
  def _would_make_stock_negative(transaction: Transaction, db_path: str | Path | None) -> bool:
1773
  """Return whether saving this sale would take inventory below zero."""
1774
  if transaction.transaction_type != "sale" or not transaction.item or transaction.quantity is None:
 
1840
  ) -> str:
1841
  """Return a human-readable parsing status."""
1842
  parts = []
1843
+ parts.append(_source_chip(source_message, fallback_reason))
1844
  if prefix:
1845
  parts.append(prefix)
1846
  parts.append(source_message)
 
1856
  return " ".join(parts)
1857
 
1858
 
1859
+ def _source_chip(source_message: str, fallback_reason: str | None = None) -> str:
1860
+ """Return a visual parse-source chip for Markdown/HTML status output."""
1861
+ if fallback_reason or "local" in source_message.lower() or "fallback" in source_message.lower():
1862
+ return '<span class="vl-status-chip vl-status-chip-fallback">Local fallback</span>'
1863
+ if "modal" in source_message.lower() or "nemotron" in source_message.lower():
1864
+ return '<span class="vl-status-chip vl-status-chip-cloud">Cloud AI</span>'
1865
+ return '<span class="vl-status-chip">Parsed</span>'
1866
+
1867
+
1868
  def _transcription_status(result: modal_api.TranscriptionResult) -> str:
1869
  """Return a concise transcription source status."""
1870
  status = result.message
voiceledger/ui/theme.py CHANGED
@@ -315,7 +315,9 @@ body,
315
  .vl-detail-card,
316
  .vl-closeout-card,
317
  .vl-pipeline-strip,
318
- .vl-small-model-card {
 
 
319
  background: #ffffff !important;
320
  border: 1px solid var(--vl-border) !important;
321
  border-radius: 14px !important;
@@ -330,7 +332,9 @@ body,
330
  .vl-detail-card *,
331
  .vl-closeout-card *,
332
  .vl-pipeline-strip *,
333
- .vl-small-model-card * {
 
 
334
  color: var(--vl-text) !important;
335
  opacity: 1 !important;
336
  }
@@ -341,7 +345,9 @@ body,
341
  .vl-detail-card h2,
342
  .vl-closeout-card h2,
343
  .vl-pipeline-strip h2,
344
- .vl-small-model-card h2 {
 
 
345
  font-size: 18px !important;
346
  font-weight: 800 !important;
347
  margin: 0 0 10px !important;
@@ -350,7 +356,8 @@ body,
350
  .vl-today-panel div,
351
  .vl-review-grid,
352
  .vl-closeout-grid,
353
- .vl-small-model-card div {
 
354
  display: grid !important;
355
  gap: 10px !important;
356
  grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)) !important;
@@ -359,7 +366,8 @@ body,
359
  .vl-today-panel span,
360
  .vl-review-grid div,
361
  .vl-closeout-grid span,
362
- .vl-small-model-card span {
 
363
  background: #f6f7f2 !important;
364
  border: 1px solid var(--vl-border) !important;
365
  border-radius: 10px !important;
@@ -370,7 +378,8 @@ body,
370
  .vl-today-panel strong,
371
  .vl-review-grid span,
372
  .vl-closeout-grid strong,
373
- .vl-small-model-card strong {
 
374
  color: #475247 !important;
375
  display: block !important;
376
  font-size: 12px !important;
@@ -418,6 +427,12 @@ body,
418
  color: #5f370e !important;
419
  }
420
 
 
 
 
 
 
 
421
  .vl-success-badge {
422
  background: #e7f6ed !important;
423
  border: 1px solid #9bd2ad !important;
@@ -472,6 +487,58 @@ body,
472
  margin-top: 4px !important;
473
  }
474
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
475
  .vl-chip {
476
  background: #2c2926 !important;
477
  border-radius: 6px !important;
 
315
  .vl-detail-card,
316
  .vl-closeout-card,
317
  .vl-pipeline-strip,
318
+ .vl-small-model-card,
319
+ .vl-command-center,
320
+ .vl-seller-timeline {
321
  background: #ffffff !important;
322
  border: 1px solid var(--vl-border) !important;
323
  border-radius: 14px !important;
 
332
  .vl-detail-card *,
333
  .vl-closeout-card *,
334
  .vl-pipeline-strip *,
335
+ .vl-small-model-card *,
336
+ .vl-command-center *,
337
+ .vl-seller-timeline * {
338
  color: var(--vl-text) !important;
339
  opacity: 1 !important;
340
  }
 
345
  .vl-detail-card h2,
346
  .vl-closeout-card h2,
347
  .vl-pipeline-strip h2,
348
+ .vl-small-model-card h2,
349
+ .vl-command-center h2,
350
+ .vl-seller-timeline h2 {
351
  font-size: 18px !important;
352
  font-weight: 800 !important;
353
  margin: 0 0 10px !important;
 
356
  .vl-today-panel div,
357
  .vl-review-grid,
358
  .vl-closeout-grid,
359
+ .vl-small-model-card div,
360
+ .vl-command-center div {
361
  display: grid !important;
362
  gap: 10px !important;
363
  grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)) !important;
 
366
  .vl-today-panel span,
367
  .vl-review-grid div,
368
  .vl-closeout-grid span,
369
+ .vl-small-model-card span,
370
+ .vl-command-center span {
371
  background: #f6f7f2 !important;
372
  border: 1px solid var(--vl-border) !important;
373
  border-radius: 10px !important;
 
378
  .vl-today-panel strong,
379
  .vl-review-grid span,
380
  .vl-closeout-grid strong,
381
+ .vl-small-model-card strong,
382
+ .vl-command-center strong {
383
  color: #475247 !important;
384
  display: block !important;
385
  font-size: 12px !important;
 
427
  color: #5f370e !important;
428
  }
429
 
430
+ .vl-warning-strong {
431
+ background: #f8d7da !important;
432
+ border-color: #d98b91 !important;
433
+ color: #651922 !important;
434
+ }
435
+
436
  .vl-success-badge {
437
  background: #e7f6ed !important;
438
  border: 1px solid #9bd2ad !important;
 
487
  margin-top: 4px !important;
488
  }
489
 
490
+ .vl-seller-timeline ol {
491
+ display: grid !important;
492
+ gap: 8px !important;
493
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)) !important;
494
+ list-style: none !important;
495
+ margin: 0 !important;
496
+ padding: 0 !important;
497
+ }
498
+
499
+ .vl-seller-timeline li {
500
+ background: #f6f7f2 !important;
501
+ border: 1px solid var(--vl-border) !important;
502
+ border-left: 5px solid #4aa357 !important;
503
+ border-radius: 10px !important;
504
+ padding: 10px !important;
505
+ }
506
+
507
+ .vl-seller-timeline li strong,
508
+ .vl-seller-timeline li span {
509
+ display: block !important;
510
+ }
511
+
512
+ .vl-seller-timeline li span {
513
+ color: #475247 !important;
514
+ font-size: 13px !important;
515
+ margin-top: 4px !important;
516
+ }
517
+
518
+ .vl-status-chip {
519
+ background: #eef6ed !important;
520
+ border: 1px solid #b9d4b4 !important;
521
+ border-radius: 999px !important;
522
+ color: #183525 !important;
523
+ display: inline-block !important;
524
+ font-size: 12px !important;
525
+ font-weight: 900 !important;
526
+ margin-right: 6px !important;
527
+ padding: 4px 9px !important;
528
+ }
529
+
530
+ .vl-status-chip-cloud {
531
+ background: #e8f0ff !important;
532
+ border-color: #9eb8eb !important;
533
+ color: #173c73 !important;
534
+ }
535
+
536
+ .vl-status-chip-fallback {
537
+ background: #fff3cd !important;
538
+ border-color: #e3bc5b !important;
539
+ color: #5f370e !important;
540
+ }
541
+
542
  .vl-chip {
543
  background: #2c2926 !important;
544
  border-radius: 6px !important;