# 0018. Plugins and extension points

- Status: Accepted
- Date: 2026-10-27

## Context

Shops and integrators want OpenKedai to do things the core project will never ship: sync to an
accounting package, award loyalty points, print a survey QR on receipts, ask for a car plate at a
drive-through. If the only way to add these is to change the core codebase, every shop with a
need forks, and forks drift. The human asked for the server to be as flexible and extensible as
possible, with plugins that add features without touching the core (in the spirit of
[ADR 0014](https://docs.openkedai.com/adr/0014-configurable-business-rules/)). A future hosted fork
([ADR 0013](https://docs.openkedai.com/adr/0013-public-repository/)) needs the same seams to stay thin.

Three constraints shape what a plugin may do:

- **Offline-first** ([ADR 0002](https://docs.openkedai.com/adr/0002-offline-first/)): taking, paying and printing an order
  never wait on the network, so nothing on the server can sit on that path.
- **Devices compute totals** ([ADR 0007](https://docs.openkedai.com/adr/0007-authority/)): a device calculates totals with
  `packages/core` while offline. A server-side plugin that changed a price, tax, discount or
  rounding would disagree with what the till already printed.
- **App stores** do not allow an app to download and run new code, and the POS must work offline,
  so plugin code cannot run on devices.

## Decision

**Two layers, one mechanism.**

1. **In-process server plugins** for self-hosters. The operator installs a plugin by putting it
   in the plugins folder (or naming a package in an environment variable). Through
   `@openkedai/plugin-sdk` a plugin can:
   - handle events after they are stored, including its own;
   - add HTTP routes under `/api/ext/<plugin id>/`, each with an access level;
   - declare settings as a list of simple fields, so any front end can render the form;
   - keep data in its own SQLite file per tenant;
   - define its own event types, `ext.<plugin id>.<name>`, validated by a schema it supplies;
   - run scheduled jobs;
   - contribute **declarative items** that the POS app shows (below).

   Plugins are trusted code running with the server's permissions, like WordPress or Strapi
   plugins. The operator decides what to install; a hosted version only allows plugins it has
   vetted.
2. **Outgoing webhooks and a token-scoped API** for everyone else: integrations in any
   language, hosted or self-hosted. These are designed for now and built in a later milestone.
   They are off by default and only send data when the owner switches them on.

**Event-log consumers.** Each plugin's event handlers, and later each webhook, read the tenant's
event log from a saved position (cursor). A handler that throws is retried with backoff and
never blocks storing or syncing sales; the plugin is marked as failing until it recovers. After a
restart or a fixed bug, the plugin catches up from where it stopped. Delivery is at least once, in
storage order, so handlers must tolerate seeing an event twice. Delivery is woken by new events,
never by polling.

**What plugins cannot do.** They cannot change prices, tax, discounts or rounding, or edit or
delete events. Anything that changes money goes through settings and rule data
([ADR 0006](https://docs.openkedai.com/adr/0006-country-rules-are-data/), [ADR 0014](https://docs.openkedai.com/adr/0014-configurable-business-rules/)),
which every device applies the same way offline. Plugin events never belong to an order
(`orderId` stays empty; they refer to orders in their payload), so order and shift projections
never see them.

**Declarative items for the POS app.** A plugin can contribute data the app knows how to use, and
the app caches it so it works offline:

- **tenders**: extra payment types (for example "GrabPay", "Touch 'n Go"), each mapped to a
  built-in method, optionally asking for a reference;
- **receipt blocks**: text or a QR code at the foot of the receipt, with a few placeholders such
  as the receipt number;
- **order fields**: extra fields taken with an order (for example a car plate or a pager number),
  optionally printed on the receipt or kitchen ticket.

The same shapes can later be defined by the owner directly, without a plugin.

**The hub's own features use the same API.** Sync, pairing and discovery (M6b), and later backup,
e-invoicing and payment providers, are built as modules on the plugin kernel where practical, so
the API stays honest and a hosted fork becomes the core plus private plugins.

**Licensing.** `@openkedai/plugin-sdk` is MIT, with no runtime dependencies. `LICENSE` adds a
section 7 additional permission (a plugin exception) so plugins that use only the plugin
interface may be distributed under any license. Like the CLA, the exception's wording must be
reviewed by a lawyer before the repository is published.

**Compatibility.** The SDK exports `PLUGIN_API_VERSION`. A plugin declares the version it was
built for. The server loads a plugin only when the major versions match, and reports any plugin
it skips.

## Consequences

- The server grows a plugin kernel: a loader, consumers, a route mount, settings, storage, jobs,
  and the contributions endpoint. M6 is split: M6a builds the kernel, M6b builds the hub on it.
- The event store accepts extension events alongside the core events. The core order and shift
  types stay closed, so exhaustive checks still catch missing core cases.
- A broken plugin can still harm the server: it can use memory, block the event loop or read
  files. That is the price of in-process plugins. The resource budget is measured without
  plugins.
- The plugin API is a public contract. Breaking it needs a new major `PLUGIN_API_VERSION` and a
  note in the changelog.
- Payment payloads, orders and receipts grow optional fields for tenders, order fields and receipt
  blocks. Older events stay valid.
