Every endpoint here lives inside craftivus_core/api/ on a customer's own ERPNext site. All are Frappe whitelisted methods, called as POST /api/method/craftivus_core.api.<module>.<function>.
Three different callers hit this API, and each authenticates differently — this is the single most important thing to understand before writing new endpoints here.
| Caller | Auth mechanism | Header |
|---|---|---|
The till (craftivus-pos) |
Device api_key/api_secret, a real Website User |
Authorization: token {api_key}:{api_secret} |
The manager app (craftivus-manager) |
Session cookie from /api/method/login |
none — cookie-based |
Craftivus Subscription (craftivus_subscription) |
A per-customer shared secret, checked manually in code — not a Frappe User at all | X-Craftivus-Push-Key: {push_key} (deliberately not Authorization: token, which Frappe's own middleware intercepts and 401s before your code runs) |
Every till-facing endpoint starts by calling get_calling_device() (in craftivus_core/utils.py) to resolve and validate the device. Every manager-facing endpoint starts with _require_manager() (in api/manager.py). Every subscription-integration endpoint starts with its own _verify_push_key() (in api/subscription.py).
activate(activation_code, device_id, device_name=None, app_version=None) — allow_guest=True, rate-limited (10/hour). The only entry point that works before a device has any credentials. Binds a pre-registered POS Device (status Pending) to real hardware, issues a dedicated Website User + api_key/api_secret. Enforces the current plan's device cap (from Craftivus Subscription State) before activating.do_suspend, do_reactivate, do_revoke) so both this module's own whitelisted endpoints and api/manager.py's set_device_status() call the identical logic — there is exactly one place that decides what "revoked" actually does to a device (disable its linked User; for revoke, no way back — do_reactivate explicitly refuses anything except a Suspended device).provision(setup_code, device_id) — redeems a POS Cashier (status Pending) setup code, links it to the calling device. The cashier's PIN is set and verified entirely on-device (Argon2) — this endpoint never sees or stores a PIN.bootstrap() — the single call a till makes on every sync. Returns, scoped strictly to the calling device's own POS Profile:
outlet — company, warehouse, currency, price list, default customer.items — full catalog with price (validity-window filtered), stock qty (from this outlet's own Bin only), max_discount, is_stock_item, POS-sized thumbnail, barcodes.item_groups, payment_methods, cashiers (roster, Active only — this is how a promotion/disable in Desk actually reaches the till), tax_template (estimate only — the server always computes the real figure on submit), receipt (print settings), stock_settings.allow_negative_stock.subscription — {status, plan, entitlements, days_until_due, device_cap, outlet_cap}, read from the local Craftivus Subscription State singleton. Never a live call to craftivus_subscription — see Architecture.catalog_version — a change-detection token so the till can skip a full re-sync when nothing changed.complete_sale(client_transaction_id, posting_date, posting_time, cashier, items, payments, additional_discount_percentage=0, additional_discount_amount=0, discount_approved_by=None)
Design points worth understanding before touching this function:
client_transaction_id already exists on a Sales Invoice, that invoice's summary is returned as-is — no duplicate is ever created, even on a retried/duplicated network call.rate it already showed the customer. The server does not re-price from a live catalog lookup — what the customer was actually charged is what gets recorded, even if the price has since changed on the server.Suspended → an open POS Shift must exist for this POS Profile → if a whole-sale discount is requested, a Manager-role cashier must have approved it (get_active_manager()).Sales Invoice (is_pos=1, update_stock=1), never POS Invoice — see Architecture.open_shift(cashier, opening_float=0) — one open shift per POS Profile at a time, enforced both here and in POS Shift.validate() (so Desk can't bypass it either).get_current_shift() — lets a till that just restarted know whether it can sell immediately.close_shift(shift, counted_cash, closing_note=None, closed_by=None) — sums every synced, docstatus=1 Sales Invoice tagged with this shift's name. Two things fold into this total with zero special-casing needed elsewhere:
POS Correction records of type Payment Method Fix) and folded into the per-mode totals before the cash sum runs, since a correction never touches the original invoice's own payment row.cancel() sets docstatus=2, and this query only ever looks at docstatus=1.find_returnable_sale(query) — looks up a submitted, non-return Sales Invoice on this device's own outlet, by invoice number or by client_transaction_id. Returns each line with already_returned_qty/returnable_qty computed from existing return invoices against it.process_return(client_transaction_id, original_invoice, posting_date, posting_time, cashier, lines, refund_payments, approved_by, reason=None) — builds the return via ERPNext's own make_return_doc(), then trims the resulting items to exactly the requested line(s)/quantity (a true partial return). Over-returning is rejected by ERPNext's own validate_returned_items(), which fires automatically on any is_return=1 document — not reimplemented here. Refund payments are entirely independent of the original payment method(s). Every return requires manager approval, unconditionally, in this version — see Architecture.The other half of "returns, exchanges, refunds and corrections" — distinct from a customer return: nothing is coming back, the recorded facts of the sale were wrong.
void_sale(client_transaction_id, original_invoice, cashier, approved_by, reason) — cancels the entire sale via Frappe's own Document.cancel(), which reverses stock and GL automatically and keeps the cancelled document permanently in the database (never deleted). Refuses if a submitted return already exists against the invoice (cancelling it would corrupt that return's own reference).correct_payment_method(client_transaction_id, original_invoice, cashier, approved_by, wrong_mode_of_payment, correct_mode_of_payment, amount, reason=None) — books a real, submitted Journal Entry reallocating amount between the two payment methods' configured GL accounts. The original invoice's own payments table is never touched (confirmed: Sales Invoice Payment has no allow_on_submit fields, so Frappe would refuse the edit anyway). Requires both Mode of Payment records to have a default account configured for the company — a one-time Desk setup step, not something this call can invent.POS Correction doctype — append-only (no write/delete permission granted to anyone), idempotent via its own client_transaction_id. This is the "correction audit trail."receive_state(status, plan, entitlements=None, days_until_due=None, device_cap=None, outlet_cap=None) — allow_guest=True, authenticated by X-Craftivus-Push-Key matched against this site's own Craftivus Subscription State.push_key. Overwrites the local cache verbatim.revoke_all_devices(reason=None) — same auth. Loops every Active/Suspended POS Device on this site through the existing do_revoke() — the identical function a manual per-device revoke uses, so there is exactly one implementation of what "revoked" means.Session-cookie authenticated, gated by _require_manager() (checks the Craftivus Outlet Manager role — these accounts have no direct Frappe doctype permissions at all). Covers: whoami, cashier CRUD, stock receive/count, item/price CRUD, sales_summary, device CRUD, receipt settings get/save. See Installation: Manager App for the screen-level view of what each of these powers.
get_calling_device() — resolves and validates the POS Device behind the current session; throws AuthenticationError/DeviceNotActiveError as appropriate.get_active_cashier(cashier_name, pos_profile) — validates a cashier reference is Active and actually belongs to this outlet.get_active_manager(cashier_name, pos_profile) — the same, plus pos_role == "Manager". This is the single shared gate behind every manager-PIN-approval flow on the platform (whole-sale discounts, returns, voids, payment corrections) — if you're adding a new "needs a manager's sign-off" feature, use this, don't write a new inline check.Every domain error is a dedicated exception class in craftivus_core/exceptions.py, subclassing frappe.ValidationError (or frappe.AuthenticationError where genuinely an auth failure). This is what lets a frontend distinguish "no open shift" from "subscription suspended" from "invalid manager PIN approval" without parsing message text. When adding a new failure mode, add a new exception class with a docstring explaining exactly when it fires — don't reuse an existing one for a semantically different situation, and don't throw a bare frappe.ValidationError.