Skip to content

Core hooks and the registry

For developers. This page lists everything the POS core offers an extension to plug into: the Python SDK, the named hooks the core reads on an extension's behalf, and the JavaScript registry that places an extension's buttons in the POS. Anything not on this page is internal to the core and can change in any release.

The Python SDK — ffl_core.extensions

from ffl_core.extensions import extension_hook, require_enabled, is_enabled

@extension_hook("acme_layaway")                 # switched off → returns None, does nothing
def on_sales_invoice_submit(doc, method):
    ...

@frappe.whitelist(methods=["POST"])
def start_plan(customer: str, item: str):
    """Start a layaway plan.

    Args:
        customer: Customer name.
        item: Item code.

    Returns:
        dict: {"plan": name}
    """
    require_enabled("acme_layaway", roles=COUNTER_ROLES)   # must be the first statement
    ...
Function Behaviour
extension_hook(app) Decorator for doc_events and scheduler_events handlers. When the store has switched the extension off, the handler returns None without running.
require_enabled(app, *, roles=None) Raises frappe.PermissionError when the extension is off or the user has none of roles. The built-in gate audit requires it (or frappe.only_for) to be the first executable statement of every whitelisted endpoint.
is_enabled(app) For templates and branches. Never use it to bypass require_enabled.
title_of(app) The extension's title as the registry knows it.

Roles: reuse ffl_core.roles.COUNTER_ROLES, STOCK_ROLES and SYSTEM_ROLES, or your own role.

Boot: frappe.boot.gunshop_extensions[id] = {enabled, version, title} for every extension installed on the site. The registry renders from it; extension pages can ask gunstore.extensions.isEnabled(id, frappe.boot).

Named hooks the core reads (gunshop_*)

Declare these in hooks.py like any Frappe hook. Values are dotted paths unless the table says otherwise. Every provider is called inside try/except: a failure goes to Error Log and the last column says what the core assumes.

Hook Value Called with → returns When a provider fails
gunshop_counter_holds dotted path (item_code) → [{serial, source, ref, kind, tooltip, url}] — serials of that Item the extension is holding for something not yet disposed (a draft outbound consignment, a layaway). Merged into the counter's hold registry after the counter's own sources, so a serial already on a counter order keeps the counter's reason. Enforced everywhere holds are: POS picker, web stock, second consignment. contributes nothing
gunshop_pending_queue_exclusion_fields fieldname (string) A Sales Invoice Link fieldname the extension owns (its own custom field) marking invoices whose fulfilment lives on the extension's record. Such invoices stay out of the Pending Order queue, the counter-hold registry and the counter-order marker, and the core does not auto-book dealer-transfer dispositions for them. Tax, receipt and Sales Report rules still apply. A site without the column ignores the entry. — (data, not code)
gunshop_disposition_stock_handled dotted path (ffl_disposition_doc) → bool; True = the extension already moved this gun's stock, the core must not Material-Issue it on submit. treated as True (a second stock move is the worse error)
gunshop_website_user_home_page dotted path (user) → route | None for a signed-in Website User. First non-None answer wins. Answer a website route even when your feature is switched off; None hands the user the Desk, which a Website User cannot open. skipped
gunshop_extension_toggled dotted path (app, effective) exactly once each time the extension's effective state changes while it is installed: a manager flips the switch, or a core update makes it compatible or incompatible. Install and uninstall do not call it. Runs for every provider, including the extension that has stopped — do not wrap it in @extension_hook. Use it to add or remove workspace shortcuts (ffl_core.workspace_shortcuts.ensure_shortcut / remove_shortcut). logged; the change is already saved
gunshop_sidebar_items list of dicts (data, not a path) Sidebar Item fields per row (type, label, link_type, link_to, icon, child, …). The first row is normally a Section Break and its links carry child: 1. While the extension is effectively on, the core writes the rows into the Gun Shop sidebar right before Settings, and lists the same links under the section's title in the Dashboard's All records. It takes them out when the extension goes off or is uninstalled; the toggle, install, uninstall and every migrate rebuild it. Each link shows only to users who may open it. rows skipped; no section for the extension
gunshop_woo_product_meta dotted path (doctype, name) → {meta_key: value}, where doctype is "Item" (a uniform-price product) or "Serial No" (a per-serial gun). Appended to the WooCommerce product's meta_data on every full push. Keys MUST match ^_[a-z0-9_]{2,60}$; a key the core already emits wins the collision. Pair it with gunshop_woo_product_meta_keys. logged, skipped; the listing still publishes without the extension's value
gunshop_woo_product_meta_keys meta key (string) A key the extension may write through gunshop_woo_product_meta, declared so it also survives a lean update (the machine-triggered push that keeps only POS-owned fields). Emitted but not declared, the value lands on a full push and is dropped by the next lean one. — (data, not code)
gunshop_woo_push_fields dict {"Item": [fieldname, …], "Serial No": [fieldname, …]} Custom fields whose change enqueues a lean WooCommerce push of that product, as the core's own price and status fields do. Declare every field your gunshop_woo_product_meta provider reads, or a field edited on its own never reaches the store. It fires while the extension is off too; the provider then contributes nothing. — (data, not code)
gunshop_dropship_listing dotted path (item_code, distributor \| None, fields: tuple) → dict \| None: the Item's website drop-ship listing at that distributor (any house when None). The counter's quote, fulfilment and drop-ship routing read SKU and fulfilment mode through it. First non-None answer wins. Its presence also decides whether the Distributors hub shows drop-ship buttons. raises into the caller
gunshop_dropship_suggested_price dotted path (distributor_doc, catalog_row, listing) → number \| None: the counter's suggested price under the drop-ship pricing rules. First non-None wins; with no provider the core suggests MAP, else MSRP, else cost. raises into the quote
gunshop_distributor_source_settled dotted path (source_doctype, source_name) once every distributor order of a non-counter source (a web order) has settled. The layer that created the orders completes the source; enqueue after commit. Called for every provider. raises into the order save, which rolls back
gunshop_distributor_feed_stale dotted path (distributor, *, aged: bool) → bool when a distributor's quantity feed is stale. Zero what you publish from that feed only when aged; return True when your zero-stock interlock is on. raises into the watchdog run
gunshop_distributor_hub_rows dotted path (rows): the Distributors hub's rows; add your own display columns in place. logged; the column stays blank

The core also reads gunshop_extension_updates, the source behind the Extensions page's Update button. The core's own integrations app registers it (Old Steel Arsenal's update service, reached through Data Service Settings). An extension cannot declare it.

The core apps pass a few more named hooks among themselves, such as distributor_providers, distributor_listing_push, ffl_acquisition_sync_push, serial_channel_exit and channel_presale_probe. They are not extension points: the lint refuses any hook in an extension's hooks.py that is not on this page or in the developer specification §5.1.

Planned, not implemented: gunshop_pos_actions, gunshop_after_counter_sale, gunshop_customer_created, gunshop_settings_links. Do not declare them.

First-party only

These hooks pass the lint only for an extension whose manifest declares first_party (§8.1 of the developer specification). They decide what the website sells and at what price, or answer another app's endpoint path. Today osa_dropship is the one provider of the drop-ship hooks; without it every caller falls back as the last column says.

Hook Value Called with → returns Without a provider / when it fails
distributor_stock_source dotted path (item_code) → int \| None: the quantity the WooCommerce push publishes for a drop-ship listed product. First non-None wins. the store's own per-warehouse stock
dropship_source_for_item dotted path (item_code, *, lead=True) → {distributor, sku, fulfillment_mode, lead_days} \| None: who ships the product. Recorded on each web-order line at checkout. the store ships it
distributor_web_price dotted path (item_code) → float \| None: the website price of a listed product. The counter price is untouched. the Item's standard rate
map_mode_for_item dotted path (item_code, brand) → str: the brand's MAP mode for the push. Floor
distributor_listing_push_failed dotted path (item_code, site, error): a queued Woo push of a listed product failed. logged
woo_order_cancelled dotted path (woo_online_order_name): the customer cancelled a web order. logged; the order stays cancelled
override_whitelisted_methods dict {old dotted path: new dotted path} Frappe's own hook. A request to an endpoint that moved out of the core into the extension runs the new one, so existing API clients keep working. —
auth_hooks dotted path Frappe's own hook. It can authenticate a request as any user, which is why only a first-party extension may register it. —

The JavaScript registry — gunstore.extensions

An extension's bundle registers once; the core decides where things render, in what order, and hides everything while the extension is not running (switched off, or paused because it does not fit this core). Never insert nodes into the POS yourself and never override erpnext.PointOfSale.* prototypes.

// public/js/acme_layaway.bundle.js
gunstore.extensions.register('acme_layaway', {
  posActions: [{
    id: 'acme-layaway-start',
    label: __('Layaway'),
    icon: 'calendar',
    condition: (ctx) => ctx.invoice && ctx.invoice.customer && !ctx.invoice.is_return,
    onClick: (ctx) => gunstore.ext.acme_layaway.openDialog(ctx),
  }],
  serialActions:   [{ id, label, onClick({ frm }) }],   // Serial No form
  customerActions: [{ id, label, onClick({ frm }) }],   // Customer form
  settingsLinks:   [{ label: __('Layaway'), doctype: 'Acme Layaway Settings' }],
  onDeskReady() { /* once per page load, after the desk is ready */ },
});
Entry Where it appears Notes
posActions Buttons on the sale screen, after the core's own condition(ctx) must be synchronous and cheap; ctx is a read-only snapshot (current invoice, customer, selected serial, POS Profile).
serialActions Serial No form toolbar, under an Extensions group onClick({ frm }).
customerActions Customer form toolbar, under an Extensions group onClick({ frm }).
settingsLinks The Settings button on the extension's card in Settings → Extensions One entry per Settings Single you ship.
onDeskReady() Nowhere; it runs code Called once after the desk is ready, for each extension that is effectively on. Use it to mount something on every desk page (OSA AI mounts its assistant button this way). A throw is logged to the console and does not stop other extensions.

Other useful pieces: gunstore.extensions.isEnabled(id, boot), gunstore.featureOffPage(page, featureTitle, whereToTurnOn) for an extension's own Desk pages, and gunstore.renderReleaseNotes(entries).

Desk pages: an extension's pages live in its own module. To list them in the Gun Shop sidebar and the Dashboard's All records, declare gunshop_sidebar_items (above); the core keeps that section in step with the switch. To give your module's own pages the same sidebar as the core pages, call ffl_core.setup.sidebar.mirror_gun_shop_sidebar(<module>, module=<module>, extra_items=[...]) from after_install / after_migrate. Frappe lets it write a sidebar only during install, migrate, a patch or developer mode, so called from a switch handler it writes nothing until the next migrate. Declare add_to_apps_screen with a has_permission that returns False so the extension does not become a separate app in the app switcher.

Workspace shortcuts

ffl_core.workspace_shortcuts.ensure_shortcut(label, url, anchor=..., block_id=..., shortcut_type="URL", workspace="Gun Shop") and remove_shortcut(label, workspace=...) add and remove cards on the Gun Shop or Settings workspace. Call them from gunshop_extension_toggled so the cards follow whether the extension runs. The Gun Shop workspace is no longer where staff land (the Dashboard is), so put the way to your pages in gunshop_sidebar_items first.

First-party surface

Extensions published by Old Steel Arsenal (publisher_id == "osa", permission first_party, and an id on the core's first-party list) may also call a documented set of core functions that third-party extensions may not: dealer-transfer dispositions, custody flags, the consignment warehouses, settlement invoicing and the ShipStation client. They may also register the first-party-only hooks above. That list is in the developer specification under "First party" and changes only with a note in the core release notes.