| ← All Docs Feature Specifications

Feature Specifications

Verified Against source code 7 core features documented Last updated: September 2026

Billing (Core)

Billing is the primary feature of AquaDealers. Every bill creates a cascade of data changes across multiple tables in a single atomic transaction.

Atomic Save: create_bill_v2 RPC

A single PostgreSQL function that performs all of the following in one transaction. If any step fails, the entire transaction is rolled back — no partial state.

#OperationTable
1Insert bill header (number, date, farmer, totals, branch)bills
2Insert line items with quantities, rates, discountsbill_items
3Deduct stock from oldest FIFO lots per iteminventory_lots
4Record inventory movements (reference_type = 'bill')inventory_movements
5Insert payment record (if amount_paid > 0)payments
6Allocate payment to this billpayment_allocations
7Create cash book entry (if cash payment)cash_book
8Update farmer due balance (via trigger)farmers
9Record transaction event for undo capabilitytransaction_events

Offline Sync

When the device is offline, bills are saved locally and synced when connectivity returns. The sync mechanism uses create_bill_offline_sync RPC with client_ref idempotency.

Idempotency

Each offline bill gets a client_ref — a UUID generated on the client when it is queued for offline sync. There is a unique index on (dealer_id, client_ref) on the bills table, and create_bill_offline_sync checks it before inserting, so re-running the sync queue never double-creates a bill.

client_ref alone does not cover every duplicate path: if an online create_bill_v2 call actually commits but its response is lost (e.g. on a flaky connection), the client cannot tell the difference from a request that never reached the server, and falls back to the offline queue with a new client_ref — which would slip past the client_ref check. To close this, every checkout also sends an idempotency_key, which survives that fallback unchanged (same payload object, same key). The key belongs to the cart content, not to the click: a repeated or retried save of the same cart reuses it, and it is cleared only after a successful save or when the cart changes (useCheckout.ts). The checkout handler also holds a synchronous lock for the whole save, because the Save button only disabled once the request started, after the session and duplicate checks had already awaited — three quick taps used to create two bills (QA audit 2026-10-15, BUG-01). create_bill_v2 itself checks (dealer_id, idempotency_key) first and returns the existing bill instead of inserting a duplicate — so the direct path and the offline-fallback path are both covered, no matter which client_ref the retry ends up using. A partial unique index on (dealer_id, idempotency_key) (ignoring nulls) backs this the same way.

create_bill_offline_sync propagates create_bill_v2's dedupe result into its own already_synced flag, not just its own client_ref check — otherwise a bill deduped via idempotency_key (new client_ref, pre-existing bill) would look "freshly synced" to the client and re-trigger the send-bill-whatsapp notification for a bill the farmer was already sent.

Draft Tabs

Up to 5 concurrent drafts stored in sessionStorage. Each draft preserves:

Drafts survive page refreshes within the same browser tab but not across tabs or after closing the browser.

Invoice Templates

5 invoice template designs available:

TemplateStyle
TemplateOneClean minimal — product, qty, rate, amount
TemplateTwoDetailed — includes HSN, MRP, discount columns
TemplateThreeGST-focused — full GST breakdown (CGST + SGST)
TemplateFourCompact — smaller font, more items per page
TemplateFiveModern — styled header, signature area prominent

Configurable Columns

Each template supports toggling these columns on/off via Settings:

Templates are branch-specific — different branches can use different templates.

PDF Generation

Bill Numbering

Format: AD-{YYYYMMDD}-{sequence}

The sequence is a branch-specific auto-incrementing counter. Example: AD-20260909-042 is the 42nd bill from this branch on September 9, 2026.

Estimates

Estimates (quotations) use the same billing flow but with no side effects.

How Estimates Differ from Bills

AspectBillEstimate
Database flagis_estimate = falseis_estimate = true
Stock impactDeducts from FIFO lotsNone
PaymentRecords payment, updates cash bookNone
Farmer dueIncreases outstanding balanceNo change
WhatsApp notificationSent (if configured)Not sent
Listed in/bill-history/estimates
Conversion—Can be converted to a real bill

When an estimate is converted to a bill, the system creates a new bill using the estimate's items and runs the full create_bill_v2 transaction. The estimate record is updated with a reference to the new bill.

Returns

Returns handle the reversal of sold goods. There are two entry points that both restore stock and adjust balances, but differ in scope:

Database Tables

Both bill_return_items.bill_item_id and bill_return_allocations.bill_item_id are what get_farmer_items_v1/get_farmer_item_bills_v1 sum up to net returned quantity/value out of the farmer's purchase totals (see below) — a return only ever reduces a bill_item's effective quantity for reporting, it never edits the original bill_items row.

Stock Restoration

Bill-first returns restock the same lots the sale consumed, walked in the same order they were consumed (earliest-consumed lot first — i.e. FIFO order, not LIFO), via bill_item_lot_allocations. If a lot record is gone, restocking falls back to the inventory row's quantity_in_stock directly.

Farmer-first returns aren't tied to specific bill_item_lot_allocations rows (a farmer-first return spans a date range, not one bill), so they can't restock the original lot(s). Instead the returned quantity goes into one synthetic inventory_lots row per product per branch, batch_number = 'CUSTOMER-RETURN', shared across every farmer-first return of that product — a later return tops up the same lot's quantities rather than creating a new one. See Inventory Logic: the CUSTOMER-RETURN lot.

Fixed 2026-09-17: the 2026-08-17 rate-limit-bypass migration for create_farmer_return_v1 was rewritten from an older copy of the function and lost the shared-lot reuse above — undo_transaction_v1 still assumed it (decrementing the lot rather than deleting it), but every new farmer-first return on a product with a prior return created a second CUSTOMER-RETURN lot instead of topping up the first. That also meant the lot's mrp/selling_price were never set (only ever true for this batch — the fields aren't populated on insert), so Billing's batch picker showed the returned stock's quantity split across duplicate rows and its price as ₹0. Separately, and unrelated to this regression: ProductSelector's "Add to Bill" was assuming getLotsWithStock() returns lots newest-first and read it back-to-front to find the oldest one with room — but it actually returns oldest-first (FIFO), so the reversed read picked the newest lot instead, most visible once a product had a recent, differently-priced CUSTOMER-RETURN lot in the mix. Both are fixed: the RPC restores the shared-lot reuse and seeds pricing from the inventory row (existing null-priced lots were backfilled; existing duplicate lots from the affected window were left alone rather than merged, to avoid corrupting their undo history), and lot selection now walks getLotsWithStock() in its actual order via a small, unit-tested pickLotWithCapacity() helper in src/features/inventory/utils/pricing.ts.

Excess Returns and Credit

How the excess (return value beyond what's owed) is handled differs by flow:

Audit Trail

Every return creates inventory movement records (reference_type = 'bill_return' for bill-first, 'farmer_return' for farmer-first) pointing to the return, plus a transaction_events row so it shows up in and can be undone from /transactions.

RPCs

RPCPurpose
create_bill_returnBill-first: execute a return against one bill's items — restock lots, reduce that bill's balance, refresh farmer due
get_returns_for_billList returns (either flow) that touched a given bill, for the Bill Details page
preview_farmer_return_v1Farmer-first: preview what will happen — shows affected bills, amounts, credit calculation — before committing
create_farmer_return_v1Farmer-first: execute the return — restore stock, adjust farmer balance, create audit entries
replace_farmer_return_v1Farmer-first: replace (edit) an existing return — undoes the previous and applies the new
Fixed 2026-09-16: get_farmer_items_v1 and get_farmer_item_bills_v1 (the farmer detail page's Items tab and its per-item bill drilldown) used to sum bill_items directly with no idea returns existed, so a farmer's purchased quantity/value never dropped after a return — stock correctly went back up, but the farmer-facing numbers were stuck at pre-return values indefinitely. Both RPCs now subtract bill_return_items + bill_return_allocations per bill_item before aggregating. Bills, statements, the ledger, and farmer due totals were never miscalculated by this bug — bills.balance_due/farmers.total_due are updated directly by the return RPCs, independent of these two aggregation functions.
Fixed 2026-09-17: even though the numbers above were always correct, returns were effectively invisible everywhere they should show up. The Bill History list (/bills) and the farmer's own Bills tab (/farmers/:id) showed no indicator at all — no badge, no column — for any bill with a return, in either the desktop table or the mobile card. Bill Details (/bills/:id) only showed a return in a card at the very bottom of the page with no indicator above the fold, and its per-item "N sold − M returned" note only counted bill_return_allocations, silently missing bill-first returns recorded in bill_return_items on the same line. A farmer's Ledger tab return entries were not clickable at all — no way to see what was actually returned without leaving the page. The Balance Statement PDF (getFarmerStatement() + farmerStatementPdf.ts) fetched only the bill_returns header with no line items from either return table, and its template only ever printed an amount for bill/payment rows — a return row showed -/N/A in both amount columns despite the running balance visibly dropping. Separately, getFarmerReturnDetail() (used by the Returns page's own detail modal) only ever queried farmer_return_lines, so opening a bill-first return there silently showed zero items.

Fixed: Bill History and the farmer's Bills tab now show an orange badge on any row touched by either return flow (bill_returns.bill_id for bill-first, bill_return_allocations.bill_id for farmer-first) — Bill History reads "Return Filed" (updated 2026-09-17 to match Bill Details' wording, since a plain "Return" badge next to the branch badge read as if the row itself were a return rather than a bill with a return against it), while the farmer's Bills tab still reads "Return". Bill Details shows the same "Return Filed" badge that jumps to the returns section and counts both return tables per item. A farmer's Ledger tab return entries now open a read-only item/amount breakdown on click. The statement lists each return's items and prints its amount (in amber) in the PAID(-) column instead of a blank placeholder. getFarmerReturnDetail() now merges items from both farmer_return_lines and bill_return_items.

Apply Expiry to Older Stock (add-on)

Add-on lot_expiry_sync, turned on per dealer from Admin → Add-ons → Apply Expiry to Older Stock or the dealer's Subscription tab. On New Purchase, a medicine line with an expiry date shows a tick-box Also set this expiry on older stock of this product. When ticked, record_stock_purchase_v2 (payload apply_expiry_to_existing_lots) writes that expiry to the product's older lots in the same branch that still have stock and are not already past expiry. Sold-out lots, processed-expired lots and the old purchase records are not changed. Staff who can add stock can use it. The RPC rejects the flag if the add-on is off, or if the new expiry is in the past (a mistyped past date would otherwise expire and write off every older lot); the tick-box only shows for an expiry of today or later.

Account Statement

Every dealer has it. A read-only history of one farmer: all bills, payments and returns in a period. Nothing in it changes any money. Renamed 2026-10-25 (was View Statement / Balance Statement; the code still uses BalanceStatementModal).

Payment Requests (add-on)

Add-on farmer_statements, added 2026-10-25, turned on per dealer from Admin → Add-ons → Farmer Payment Requests or the dealer's Subscription tab (dealers.custom_features, or a plan whose plan_definitions.features includes it). Shown in the app as Payment Request; the code keeps the statement names (table farmer_statements, RPCs *_farmer_statement_v1, routes /farmers/:id/statements/..., staff key farmerStatements). Not the same as the Account Statement (read-only history, PDF and /f/:token link), which every dealer has, and not a settlement discount. Money rules are in Financial Logic.

Supplier Returns (Purchase Returns)

Sends stock back to the supplier it was bought from — expired medicine, damaged feed, a wrong item. Added 2026-10-15. The money side is in Financial Logic.

Stock Transfers

Transfer stock between branches while preserving FIFO lot data. Each transfer creates movement records at both ends.

How It Works

1
Select source branch and product — shows available lots at that branch
2
Select destination branch — must be a different branch owned by the same dealer
3
Enter quantity — cannot exceed available stock at source
4
Execute via create_stock_transfer RPC

What the RPC Does

FIFO Preservation: If transferring 100 units and the source has Lot A (80 units, Jan expiry) and Lot B (50 units, Mar expiry), the transfer creates two lots at the destination: 80 from Lot A and 20 from Lot B, preserving the original cost and expiry dates.

Bill Editing

RPC: edit_bill_v1

Editing quantities and prices needs the edit_bills feature (Pro and Pro+ plans). Adding, removing and changing products is a separate add-on, bill_item_edit: an admin turns it on per dealer from Admin → Add-ons → Bill Item Editing or the dealer's Subscription tab, which writes dealers.custom_features. A plan whose plan_definitions.features includes it also counts. The RPC refuses removed_item_ids / added_items without it, and the edit screen hides those controls. Staff also need the Transactions permission. One call can change, remove and add lines together, and it either applies fully or not at all. Payload keys: edits (bill_item_id, quantity, unit_price), removed_item_ids, added_items (inventory_id, quantity, unit_price, discount_percentage, gst_rate), excess_mode, idempotency_key and bill_date. A call that only sends edits still works.

What Can Be Edited

Locked Cases

Stock Adjustment Logic

Money

Retries

Each edit session sends an idempotency_key. A retried save with the same key returns the first result and changes nothing.

Audit Trail

Every edit creates a record in bill_audit_logs.changes_jsonb with:

Each record also stores user_id and a timestamp. The bill is flagged is_edited = true, and the Edit History panel on Bill Details shows every entry.

Bill Cancellation

RPC: cancel_bill_v1

Constraints

ConstraintRuleReason
TimingSame-day onlyPrevents backdated financial manipulation
Paymentamount_paid must be 0Bills with payments must be handled via returns or editing
StatusMust not already be cancelledIdempotency — returns 'already_cancelled' without error

What Cancellation Does

  1. Sets bill status = 'cancelled'
  2. Restores exactly what the sale consumed, per bill_item_lot_allocations row: the lot's remaining_quantity and inventory.quantity_in_stock both go back up, then the allocation is deleted. Estimates and other non-stock bills have no allocations, so cancelling them changes no stock.
  3. Sets the bill's balance_due to 0 and reduces farmers.total_due by the old balance (floored at 0)
  4. Creates one inventory movement per allocation with reference_type = 'bill_cancellation' and its lot_id
  5. Stamps cancelled_at and cancelled_reason on the bill
Fixed 2026-10-15 (20261015000000_cancel_bill_restores_lots.sql): this RPC used to add each item's quantity to inventory only. The consumed lots stayed depleted, so the stock was invisible to the next FIFO sale, and cancelling an estimate added stock that had never left. The app has no button that calls cancel_bill_v1 (cancelling in the UI is Undo on /transactions, which already restored lots), and no live bill was ever cancelled through it, so no data needed correcting.
Idempotent: Calling cancel_bill_v1 on an already-cancelled bill returns 'already_cancelled' status without error or side effects. Safe to retry.

Transaction Undo

A universal undo system that covers all financial transactions in the application.

Supported Transaction Types

TypeUndo Behavior
BillsCancels the bill, restores stock, reverses farmer due
PurchasesRemoves inventory lots, reverses supplier balance
Payments (farmer)Reverses payment allocation, restores farmer due
Supplier PaymentsReverses payment, restores supplier balance
ReturnsRe-deducts returned stock, reverses farmer credit
Supplier ReturnsPuts each line's units back into the lot it was taken from, restores the supplier due (reduce-due returns) or removes the cash refund row
Stock TransfersReverses transfer at both branches
ExpensesRemoves expense record and cash book entry
Other/Uncategorized Cash EntriesRemoves the entry from cash book — the catch-all category for any cash_book row whose source doesn't match a recognized type. There's no feature to actually create one; it exists only as an undo fallback.

How It Works

20-Day Window: The undo window is enforced server-side in the RPC. The UI hides the undo button after 20 days, but even if called directly, the RPC rejects undo attempts outside the window.

AI Assistant (add-on)

Add-on ai_connector, added 2026-10-25, turned on per dealer from Admin → Add-ons → AI Assistant (ChatGPT / Claude). The dealer connects their own ChatGPT or Claude account; AquaDealers runs no AI itself and pays no AI cost.