Selling at the Counter
Post idempotent sales with items, split payments, coupons, VAT, and FEFO deduction.
You create every counter sale with POST /sales in apps/server/src/features/sales/route.ts. The pricing and stock logic lives in apps/server/src/features/sales/creation/service.ts, with the VAT math and coupon rules in apps/server/src/features/sales/creation/pricing.ts; apps/server/src/features/sales/service.ts only re-exports both.
Log in first
Every example below needs a bearer token. Log in once, then reuse the token.
TOKEN=$(curl -s -X POST http://localhost:8000/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@demo.pharmaflow","password":"Demo@1234"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
Post a sale with an idempotency key
You must send a fresh X-Idempotency-Key per new sale. If you retry with the same key, the server replays the stored response with status 201 instead of charging twice.
curl -X POST http://localhost:8000/api/v1/sales \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "X-Idempotency-Key: sale-0001" \
-d '{
"items": [{"medicine_id":"MED-001","quantity":2,"unit_price":12.5}],
"payment_method": "cash",
"coupon_code": "DEMO10",
"customer_name": "Walk-in"
}'
You can pass coupon_code in any case, the server uppercases it before lookup. You can also pass customer_id, notes, branch_id, client_mutation_id, and device_id in the same body.
Split payments and the dominant method
You split a total with payment_lines, and the server picks the line with the largest amount as the stored payment_method. Send this when a customer pays part cash and part card.
curl -X POST http://localhost:8000/api/v1/sales \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "X-Idempotency-Key: sale-0002" \
-d '{
"items": [{"medicine_id":"MED-001","quantity":1,"unit_price":20}],
"payment_lines": [
{"method":"cash","amount":12},
{"method":"card","amount":8}
]
}'
What the server does for you
You do not compute VAT, batches, or invoice numbers yourself. Inside one transaction the server loads the org tax policy, prices each line with vatRateForCategory from apps/server/src/features/settings/tax.ts, deducts batches with FEFO via selectFefoBatches in apps/server/src/features/sales/creation/queries.ts, writes OUT stock movements and the priced slices with insertSaleItemsAndDeductStock in apps/server/src/features/sales/creation/mutations.ts, and claims the invoice number plus ICV in the same transaction. If the payment method resolves to credit, it checks the customer credit limit and writes a CHARGE row to customer_ledger. Controlled medicines also write a row to controlled_dispense_log.
List and inspect sales
You list sales with pagination and filters, and pharmacists only ever see their own rows because the route pins the user filter to the caller for that role, applied by countSales and listSales in apps/server/src/features/sales/listing/queries.ts.
curl "http://localhost:8000/api/v1/sales?page=1&page_size=20&payment_method=cash&search=INV-001" \
-H "Authorization: Bearer $TOKEN"
You can also filter with customer_id, from_date, and to_date. Fetch one sale with its priced lines, then its invoice stub:
curl http://localhost:8000/api/v1/sales/SALE_ID -H "Authorization: Bearer $TOKEN"
curl http://localhost:8000/api/v1/sales/SALE_ID/invoice -H "Authorization: Bearer $TOKEN"
Opening a sale you do not own returns 404 by design, see enforceSaleOwnership in apps/server/src/features/sales/listing/ownership.ts.
Next steps
Continue with coupons in /sales/coupons-and-discounts, returns in /sales/returns-and-credit-notes, and your shift in /sales/sessions-and-parked.