# Plugins

Plugins add features to an OpenKedai server without changing OpenKedai itself. Examples:
syncing sales to an accounting package, awarding loyalty points, printing a feedback QR code on
receipts, or asking for a car plate at a drive-through. A plugin runs inside the server, reads
the shop's sales as they happen, and can add its own web routes, settings, data and payment
types.

Plugins run on the server (the hub). The POS app never runs plugin code, so a sale never waits on
a plugin, and plugins can't change prices, tax, discounts or rounding. Those come from the shop's
settings, which every device applies the same way, even offline.

> Status: the plugin interface is new (`PLUGIN_API_VERSION` 1) and may still change before the
> first release.

## Installing a plugin

A plugin is a JavaScript module. To install one, put it in the server's plugins folder,
`<data folder>/plugins/` (`/data/plugins/` in Docker), and restart the server. It can be:

- a single `.js` or `.mjs` file, or
- a folder with a `package.json` whose `exports` or `main` points at the plugin, or with an
  `index.js`.

You can also install plugins from npm next to the server and list them in `OPENKEDAI_PLUGINS`.
That suits a server run from a clone of the repository. With the Docker image or the Linux
installer, use the plugins folder: it is in the data folder, so plugins stay through updates.

| Environment variable | What it does | Default |
| --- | --- | --- |
| `OPENKEDAI_PLUGINS_DIR` | Folder to load plugins from | `<data folder>/plugins` |
| `OPENKEDAI_PLUGINS` | Extra plugins: package names or file paths, comma-separated | none |
| `OPENKEDAI_PLUGINS_DISABLED` | Plugin ids not to start, comma-separated | none |
| `OPENKEDAI_PLUGIN_<ID>_<KEY>` | A plugin setting, e.g. `OPENKEDAI_PLUGIN_DAILY_TALLY_FEEDBACK_URL` | none |

A plugin that fails to load is logged and skipped. The server still starts.

Plugins run with the server's full permissions, so only install plugins you trust.

## Writing a plugin

Install the SDK, `@openkedai/plugin-sdk`. It is MIT licensed and has no dependencies. Export a
plugin definition as the module's default export:

```ts
import { definePlugin, PLUGIN_API_VERSION } from "@openkedai/plugin-sdk";

export default definePlugin({
  id: "hello",                 // 2–40 lowercase letters, digits and single hyphens
  name: "Hello",               // or { en: "Hello", ms: "Helo" }
  version: "1.0.0",
  apiVersion: PLUGIN_API_VERSION,
  setup(api) {
    api.onEvent("order.closed", (event, ctx) => {
      ctx.log.info("Order closed", { orderId: event.orderId });
    });
  },
});
```

Bundle it into one file (for example with `esbuild --bundle --format=esm --platform=node`) and
drop it in the plugins folder. [`examples/plugins/daily-tally`](https://github.com/OpenKedai/openkedai/tree/main/examples/plugins/daily-tally/)
is a complete example to copy.

### Handling events

`api.onEvent(patterns, handler)` receives events after they are stored. A pattern is an exact
type (`order.closed`), a prefix ending in `.*` (`order.*`, `ext.loyalty.*`), or `*` for
everything.

- Events arrive **in the order they were stored**, one at a time.
- Each event is delivered **at least once**. The server remembers how far each plugin got, so
  after a restart it carries on from there. After a crash it may deliver the last few events
  again, so make handlers safe to repeat: remember what you have done, keyed by event id,
  payment id or order id.
- If a handler throws, the server retries it after 1 s, 2 s, 4 s… (up to every 5 minutes). Later
  events wait, so the plugin never sees them out of order. Sales are never held up. The owner can
  see the error and, as a last resort, skip the failing event.
- Money is in **sen** (integer cents): `1290` is RM12.90.

Core event types: `order.opened`, `order.item_added`, `order.item_changed`, `order.item_voided`,
`order.discount_applied`, `order.service_charge_set`, `order.sent_to_kitchen`,
`order.fields_set`, `order.payment_recorded`, `order.closed`, `order.refunded`, `shift.opened`,
`shift.cash_movement`, `shift.closed`. Their payloads are defined in `packages/core` and
validated by `packages/events`.

### What a handler can use

The `ctx` passed to handlers, routes and jobs is scoped to one shop (tenant):

| | |
| --- | --- |
| `ctx.settings` | This shop's settings for the plugin, with defaults filled in |
| `ctx.storage` | The plugin's own SQLite database for this shop: `exec`, `run`, `all`, `get`, `transaction`, and a small `kv` store. It is a separate file, so a plugin can't damage the shop's data |
| `ctx.emit(name, payload)` | Stores one of the plugin's own events, `ext.<plugin id>.<name>`, declared first with `api.defineEvent(name, schema?)` |
| `ctx.events.get(id)`, `ctx.events.forOrder(orderId)` | Read the shop's event log |
| `ctx.log` | Structured logs, tagged with the plugin and shop |

Plugin events are part of the shop's permanent log. They never belong to an order: refer to
orders in the payload (`{ orderId: "…" }`). A payload must be a JSON object of at most 16 KB.

`api.onTenantStart(ctx => …)` runs once per shop before any handler, for example to create
tables. Make it safe to run again.

### Routes

```ts
api.route({
  method: "GET",
  path: "/orders/:orderId",    // served at /api/ext/<plugin id>/orders/:orderId
  access: "owner",             // public | device | staff | owner (default)
  body: schema,                // optional, for POST/PUT/PATCH: any Standard Schema (Zod, Valibot…)
  handler: async (request, ctx) => ({ orderId: request.params.orderId }),
});
```

Return a value to send it as JSON, `respond(status, body, headers)` for anything else, or
nothing for `204 No Content`. Throw `new PluginHttpError(404, "No such order")` to send an
error. Any other error becomes a plain `500`, and its details go to the log only.

Paired POS devices can call `device` routes with their token. `owner` routes answer an owner or
manager signed in to the back office, and `staff` routes either of those or a paired device (see
[ADR 0020](https://docs.openkedai.com/adr/0020-back-office-sign-in/)). Treat `public` routes as reachable by anyone who
can reach the server. On a hub with several shops, a `public` route picks the shop with
`?shop=<shop id>`; for the others, the device or the session picks it.

### Settings

Declare settings as fields, and the back office draws the form:

```ts
settings: [
  { key: "api_key", type: "text", label: "API key", secret: true },
  { key: "every", type: "number", label: "Every", default: 100, min: 1, integer: true },
  { key: "enabled", type: "boolean", label: "Enabled", default: true },
  { key: "mode", type: "select", label: "Mode", options: [{ value: "a", label: "A" }] },
],
```

A value comes from what the owner saved, then from `OPENKEDAI_PLUGIN_<ID>_<KEY>`, then from the
default. Secret values are never sent back to a browser.

### Scheduled jobs

```ts
api.job({ id: "nightly-export", schedule: { dailyAt: "03:30" }, run: async (ctx) => { … } });
api.job({ id: "sync", schedule: { everyMinutes: 15 }, run: async (ctx) => { … } });
```

Jobs run per shop, on the server's local time. A run that is still going when the next is due is
skipped.

### Items for the POS app

A plugin can add items that the POS app shows and caches, so they work offline:

```ts
contributes: (settings) => ({
  tenders: [{ id: "grabpay", name: "GrabPay", method: "ewallet", reference: "optional" }],
  receiptBlocks: [{ id: "survey", text: "Rate us", qr: "https://example.my/r/{receiptNumber}" }],
  orderFields: [
    { id: "plate", label: "Car plate", type: "text", channels: ["takeaway"], print: { receipt: true } },
  ],
}),
```

- **Tenders** are extra payment types. Each is counted as a built-in method (`card`, `qr`,
  `ewallet`, `other`), so reports and the cash count keep working. The receipt prints its name.
- **Receipt blocks** print text and/or a QR code under the shop's footer. Placeholders:
  `{receiptNumber}`, `{outletName}`, `{total}` (e.g. `RM12.90`), `{totalSen}` (e.g. `1290`),
  `{date}` (YYYY-MM-DD).
- **Order fields** are extra values taken with an order: `text`, `number` or `phone`, optionally
  required, and optionally printed on the receipt or kitchen ticket.

Ids are unique within the plugin. The server prefixes them with the plugin id, e.g.
`my-plugin:grabpay`. Devices fetch the merged list from `GET /api/plugins/extras`.

## Server endpoints

| Endpoint | Access | |
| --- | --- | --- |
| `GET /api/plugins` | owner | Plugins, their state per shop, failing events, jobs, and plugins that failed to load |
| `GET /api/plugins/extras` | device | The POS items of every running plugin |
| `GET /api/plugins/:id/settings` | owner | Setting fields and values (secrets shown as set or not) |
| `PUT /api/plugins/:id/settings` | owner | Save settings; `null` clears a value |
| `POST /api/plugins/:id/skip-failed-event` | owner | Skip the event a plugin keeps failing on |

## Limits

- Plugins run in the server's process. A plugin that loops forever or uses a lot of memory slows
  the whole server. The resource budget is measured without plugins.
- A plugin can't change totals, edit or delete events, or run on the POS app.
- The plugin interface follows `PLUGIN_API_VERSION`. A server only loads plugins built for its
  version.

## Licensing

OpenKedai is AGPL-3.0. The `LICENSE` file adds a plugin exception: a plugin that talks to
OpenKedai only through `@openkedai/plugin-sdk` may use any license, including a closed-source
one. The SDK itself is MIT. (The exception's wording is still to be reviewed by a lawyer.)
