Plugin Development Guide

A feature plugin is the second kind of Tours add-on (the first being channel integrations — see the Add-on Development Guide). Where a channel add-on connects an external provider, a feature plugin adds a whole capability to the marketplace: its own admin pages, customer- and operator-portal pages, public pages, API endpoints, navigation entries, event hooks, and its own database tables. Announcements, Support Desk, an Operator Payout Ledger, a Departures Calendar, and a Featured-Tours home widget all ship this way.

Feature plugins live alongside channel add-ons under src/product/addons/<id>/, are declared with "feature": true in their manifest, and are activated by license entitlement + an admin toggle — there are no files to upload. This guide uses an Operator Payout Ledger (src/product/addons/payouts/) as the worked example. The plugin contract is defined in src/core/plugins/types.ts (FeaturePlugin).

Anatomy of a feature plugin

Code
src/product/addons/payouts/
  addon.json            manifest (feature: true, premium, category, templates…)
  index.ts              exports `plugin` (the FeaturePlugin object)
  schema.ts             ensureSchema — idempotent CREATE TABLE
  api.ts                API handlers (operator ledger, admin release, mark-paid)
  admin.tsx             admin page component
  operator.tsx          operator-portal page component
  notify-payout.ts      best-effort email / WhatsApp / SMS notifications
  installation-guide.md operator-facing docs shown in admin

The folder is self-contained. Core never imports from it directly; it discovers the plugin through the generated FEATURES index and renders it through catch-all routes (below).

The manifest

JSON
{
  "id": "payouts",
  "name": "Operator Payout Ledger",
  "type": "code",
  "feature": true,
  "products": ["*"],
  "version": "1.0.0",
  "premium": true,
  "vendor": "creative-cape.com",
  "category": "Finance",
  "templates": [
    {
      "slug": "payout_released",
      "channel": "EMAIL",
      "name": "Payout released",
      "subject": "Your payout is on the way — {{amount}}",
      "body": "Hi {{name}}, we've released a payout of {{amount}} for {{count}} completed bookings…"
    }
  ]
}

"feature": true marks it a plugin (versus a channel add-on). "products": ["*"] makes it universal — it works with any product built on this core; a tours-only plugin would list ["tours"]. Any templates are seeded into the notification system on activation (idempotently), so the plugin's emails / SMS / WhatsApp messages exist the moment it is switched on. category groups the plugin under a tab on the admin Integrations page.

The FeaturePlugin object

index.ts exports a plugin matching the FeaturePlugin interface:

TS
import type { FeaturePlugin } from "@/core/plugins/types";
import { ensureSchema } from "./schema";
import { ledger, statement, adminList, release, markPaid } from "./api";
import PayoutsOperator from "./operator";
import PayoutsAdmin from "./admin";

export const plugin: FeaturePlugin = {
  id: "payouts",
  name: "Operator Payout Ledger",
  category: "Finance",
  ensureSchema,
  nav: [
    { area: "admin", label: "Payouts", icon: "Wallet", slug: "", permission: "settings" },
    { area: "operator", label: "Payouts", icon: "Wallet", slug: "" },
  ],
  adminPages: { "": PayoutsAdmin },
  operatorPages: { "": PayoutsOperator },
  api: {
    "GET ledger": ledger,
    "GET statement/:id": statement,
    "GET admin/list": adminList,
    "POST admin/release": release,
    "POST admin/:id/paid": markPaid,
  },
  hooks: {
    "order.paid": async (payload) => {
      const p = payload as { bookingId?: number; hostId?: number; amount?: number };
      if (!p?.bookingId || !p?.hostId) return;
      // accrue commission-adjusted earnings to the operator's ledger
    },
  },
};

The full shape (src/core/plugins/types.ts):

Field Purpose
id, name Identity — id matches the folder and the license key
category? Grouping label on the admin Integrations page (defaults to "Features")
ensureSchema? Idempotent CREATE TABLE …, run on activation
nav? Sidebar entries (area: admin / customer / operator; slug; optional permission)
adminPages? sub-slug → component ("" is the plugin root); rendered by the admin catch-all
customerPages? sub-slug → component; the customer-portal page surface
operatorPages? sub-slug → component; the operator-portal page surface
publicPages? pattern → component (e.g. "verify/:code"); rendered by the site catch-all
api? "METHOD pattern" → handler (e.g. "GET statement/:id")
hooks? Handlers for core domain events
slots? Components injected into named core slots (e.g. home.sections, operator.dashboard.cards)
settings? Per-plugin settings fields (text / secret / select / switch) edited on its admin page
loginButtons? Buttons rendered on login / register (e.g. SSO)

Schema

ensureSchema follows the project-wide ensure-schema pattern — a guarded, idempotent function that creates the plugin's tables. It runs automatically when the plugin is activated, so there are no migration files to manage. Note the domain-vs-DB rule: an operator is a hosts row, so the ledger references host_id.

TS
import sql from "@/lib/db";

export async function ensureSchema(): Promise<void> {
  await sql`CREATE TABLE IF NOT EXISTS payout_ledger (
    id          SERIAL PRIMARY KEY,
    host_id     INT NOT NULL,           -- the operator (hosts row)
    booking_id  INT NOT NULL,
    amount      INT NOT NULL,           -- minor units (cents)
    commission  INT NOT NULL DEFAULT 0, -- marketplace fee withheld
    status      VARCHAR(20) DEFAULT 'pending',
    created_at  TIMESTAMPTZ DEFAULT NOW()
  )`;
  await sql`CREATE TABLE IF NOT EXISTS payout_batch (
    id          SERIAL PRIMARY KEY,
    host_id     INT NOT NULL,
    total       INT NOT NULL,
    released_at TIMESTAMPTZ DEFAULT NOW()
  )`;
}

Extend existing tables the same way core does — ALTER TABLE … ADD COLUMN IF NOT EXISTS — so re-activating is always safe.

Hooks on core events

Core emits domain events at key moments; a plugin subscribes by adding a hooks handler. The neutral, marketplace-wide events every plugin can rely on are:

Event Fires when Typical payload
order.paid A booking is paid in full (or deposit taken) { bookingId, hostId, customerId, amount }
order.refunded A booking is refunded { bookingId, amount }
user.registered A traveler or operator signs up { userId, role }
user.login A user signs in { userId, role }
admin.action An admin performs an audited action { actorId, action, target }

Domain-specific Tours events (e.g. booking.created, departure.completed, review.submitted, payout.released) are emitted the same way, so a plugin can react to the exact stage it cares about. The Operator Payout Ledger reacts to a completed payment to accrue the operator's earnings:

TS
export const plugin: FeaturePlugin = {
  // …
  hooks: {
    "order.paid": async (payload) => {
      const p = payload as { bookingId?: number; hostId?: number; amount?: number };
      if (!p?.bookingId || !p?.hostId) return;
      const commission = Math.round((p.amount ?? 0) * 0.15);
      await sql`INSERT INTO payout_ledger (host_id, booking_id, amount, commission)
                VALUES (${p.hostId}, ${p.bookingId}, ${p.amount ?? 0}, ${commission})`;
      // …then send a follow-up, record analytics, etc.
    },
  },
};

Hooks are best-effort and must never throw into the core flow — wrap risky work and fail quietly, since a booking must still succeed even if the plugin's side-effect does not.

API handlers

Each API entry maps "METHOD pattern" to a PluginApiHandler(req, ctx), where ctx.params carries the pattern matches and ctx.query is the search params. Handlers reuse the same auth helpers as core — requirePermission() for admin endpoints, getOperatorId() for operator endpoints, getCustomerId() for customer endpoints. Auth guards first; queries are scoped to the owner (no IDOR):

TS
import sql from "@/lib/db";
import { getOperatorId } from "@/lib/operator-auth";
import { requirePermission } from "@/lib/rbac";
import type { PluginApiHandler } from "@/core/plugins/types";

// Operator sees only their own ledger
export const ledger: PluginApiHandler = async () => {
  const me = await getOperatorId();
  if (!me) return Response.json({ error: "unauthorized" }, { status: 401 });
  const rows = await sql`
    SELECT * FROM payout_ledger WHERE host_id = ${me} ORDER BY created_at DESC`;
  return Response.json(rows);
};

// Admin releases a batch — recompute the total server-side, never trust the client
export const release: PluginApiHandler = async (req) => {
  const denied = await requirePermission("write", "settings");
  if (denied) return denied;
  const { hostId } = await req.json();
  const [{ total }] = await sql`
    SELECT COALESCE(SUM(amount - commission), 0) AS total
    FROM payout_ledger WHERE host_id = ${Number(hostId)} AND status = 'pending'`;
  // …insert a payout_batch row, flip ledger rows to 'released', notify the operator…
  return Response.json({ ok: true, total });
};

How plugins are rendered (catch-all routes)

Plugins don't define their own Next.js route files. Catch-all routes plus one API dispatcher resolve active plugins at runtime:

Surface Route file URL
Admin pages src/app/admin/(panel)/x/[...slug]/page.tsx /admin/x/<id>/<slug>
Public pages src/app/(site)/x/[...slug]/page.tsx /x/<id>/<slug>
API src/app/api/v1/ext/[addon]/[...path]/route.ts /api/v1/ext/<id>/<path…>

Plugin nav for every audience is served by GET /api/v1/plugins/nav?area=<admin|customer|operator>, which each portal's layout merges into its sidebar. The API dispatcher looks up the active plugin, walks its api map, matches the "METHOD pattern" key against the request path, and calls the handler:

TS
const feature = (await getActiveFeatures()).find((f) => f.id === addon);
for (const [key, handler] of Object.entries(feature.api)) {
  const [m, pattern = ""] = key.trim().split(/\s+/);
  if (m.toUpperCase() !== method) continue;
  const params = matchPattern(pattern, parts);
  if (params) return handler(req, { params, query });
}

So an operator opening a statement hits /api/v1/ext/payouts/statement/42 and matches "GET statement/:id".

Slots: injecting UI into core surfaces

A plugin can render into named slots that core exposes without adding a whole page. A Featured-Tours widget, for instance, registers a component into the home.sections slot so it appears on the marketplace home page between core sections:

TS
import FeaturedTours from "./FeaturedTours";

export const plugin: FeaturePlugin = {
  id: "featured-tours",
  name: "Featured Tours",
  slots: { "home.sections": FeaturedTours },
  // …ensureSchema, api to pick which listings are featured, admin page to curate…
};

Other slots include operator.dashboard.cards and customer.dashboard.cards. Slot components receive an optional ctx and must be resilient — if the plugin is deactivated, its slot simply disappears.

Activation & licensing

A plugin is usable only when it is installed (present in the generated FEATURES list, at build time) and active (a row in the plugin_state table). Activation is handled by setFeatureActive() (src/core/plugins/state.ts):

TS
export async function setFeatureActive(id: string, active: boolean): Promise<void> {
  const f = getFeature(id);
  if (!f) throw new Error("Unknown feature plugin.");
  if (active && f.ensureSchema) await f.ensureSchema();            // create tables
  if (active && ADDON_TEMPLATES[id]) await seedAddonTemplates(id); // seed templates
  await sql`INSERT INTO plugin_state (id, active, activated_at) VALUES (${id}, ${active}, NOW())
            ON CONFLICT (id) DO UPDATE SET active = ${active}`;
}

On activation it runs ensureSchema, seeds declared templates, and flips the plugin_state row. getActiveFeatures() is the single resolver every catch-all and the event bus use, so deactivating a plugin instantly hides its nav, pages, and hooks (its data is kept). Settings are stored per-plugin in the same plugin_state.settings JSON column via saveFeatureSettings().

License gate

Premium plugins are gated by featureBlock(headers, featureId) (src/lib/premium.ts), mirroring the channel logic. A premium plugin activates only when the license entitlements carry the feature id (or "*" / "all"), or a per-add-on purchase code has been activated for the domain. The free/premium split lives in src/core/channels/tiers.ts (FREE_FEATURES is empty by default — every feature is premium); development / localhost hosts unlock everything.

Admins manage activation from Settings → Add-ons (/admin/settings/integrations) — toggling a feature on (which calls setFeatureActive) and, if needed, entering a purchase code to satisfy the entitlement.

Build checklist

  • addon.json has "feature": true, a stable id (folder name), category, premium, and vendor.
  • index.ts exports a plugin implementing FeaturePlugin; nav area values are admin / customer / operator.
  • ensureSchema is idempotent; SQL uses the real table/column names (hosts / host_id, listings / listing_id, units / unit_id).
  • Every API handler guards auth first and scopes queries to the owner; money is recomputed server-side.
  • Hooks are wrapped so they never break the core flow.
  • npm run addons:bundle re-run so the plugin lands in FEATURES; npx tsc --noEmit clean.
  • Activated and tested end-to-end on localhost across every portal it touches.

© CreativeCape Solutions · creative-cape.com · support@creative-cape.com