Skip to main content

Plugin Registration Factories

Unchained ships a set of registerX(...) factory functions — the recommended way to add a custom payment provider, pricing rule, discount, delivery method, worker, filter, file backend, quotation or enrollment adapter. Each factory takes a small typed options object, builds the underlying IPlugin for you, and registers it with the plugin registry in a single call.

All factories are exported from @unchainedshop/core:

import { registerPaymentProvider, registerProductPricing } from '@unchainedshop/core';
Three ways to register a plugin
  1. Presets — registerBasePlugins() / registerAllPlugins() for the built-in plugins (see Plugin Presets).
  2. registerX(...) factories — recommended for your own custom adapters (this page).
  3. Hand-built IPlugin + pluginRegistry.register() — the low-level escape hatch (see When not to use a factory).

Call factories before startPlatform(), in your boot code.

Two things every factory does for you​

  • Keys are auto-namespaced. Most factories take an adapterId (a short, stable string) and derive a plugin/adapter key using the namespace shown below. The worker factory uses type; filter factories can generate an id if omitted. Reusing the same factory and id is dedupe-safe (the registry ignores a duplicate key and warns). Pick a stable id when you want repeat calls to refer to the same plugin.
  • The version is fixed at 1.0.0. If you need to control the key, version, attach HTTP routes, a module, or onRegister/onShutdown lifecycle hooks, build an IPlugin by hand instead — see below.
Parameter naming varies per factory

Because each director exposes a different adapter contract, the option names differ between factories (e.g. payment uses isActive, delivery uses active). The tables below are authoritative per factory.

Many behavior options accept either a literal value or a function — e.g. charge, send, stock. Pass a constant for static behavior, or a (configuration, context) => Promise<...> callback for dynamic behavior.


Payment​

registerPaymentProvider​

A generic payment provider for any gateway.

OptionTypeRequiredNotes
adapterIdstring✅key shop.unchained.payment.<adapterId>
chargefalse | (config, context) => Promise<PaymentChargeActionResult | false>✅false/return-false = not yet paid; return { transactionId } = paid; throw = abort checkout
typePaymentProviderTypeGENERIC (default) or INVOICE; card gateways use GENERIC
sign(config, context) => Promise<string | null>sign a client-side payment intent
validate(config, context) => Promise<boolean>validate a stored credential
isActivebooleandefault true
isPayLaterAllowedbooleandefault false
configurationErrorPaymentError | nullsurface a misconfiguration
cancel / confirm(config, context) => Promise<boolean>refund / capture
import { registerPaymentProvider } from '@unchainedshop/core';

registerPaymentProvider({
adapterId: 'acme-gateway',
isActive: true,
charge: async (configuration, context) => {
const receipt = await acme.charge(context.order, context.transactionContext);
return { transactionId: receipt.id };
},
});

registerInvoicePayment​

A pay-by-invoice provider (PaymentProviderType.INVOICE).

OptionTypeRequiredNotes
adapterIdstring✅key shop.unchained.payment.invoice-<adapterId>
chargefalse | (config, context) => Promise<…>✅usually false (collected out of band)
activebooleandefault true
payLaterAllowedbooleandefault false

See also: Payment plugins


Delivery​

registerDeliveryProvider​

A generic delivery provider; choose the type.

OptionTypeRequiredNotes
adapterIdstring✅key shop.unchained.delivery.<adapterId>
sendboolean | (config, context) => Promise<boolean | Work>✅trigger fulfilment; any truthy result (incl. a Work item) marks the delivery DELIVERED
typeDeliveryProviderTypeSHIPPING (default), PICKUP
activebooleandefault true
autoReleaseAllowedbooleandefault true
estimatedDeliveryThroughput(warehousingThroughputTime, context) => Promise<number | null>ms estimate
import { registerDeliveryProvider } from '@unchainedshop/core';

registerDeliveryProvider({
adapterId: 'acme-express',
send: async (configuration, context) => {
await acme.createShipment(context.order);
return true;
},
});

registerShippingDelivery / registerPickUpDelivery​

Convenience specializations of the above for SHIPPING and PICKUP. registerPickUpDelivery additionally takes:

OptionTypeRequiredNotes
locationsDeliveryLocation[] | (context) => Promise<DeliveryLocation[]>✅available pickup points

See also: Delivery plugins


Warehousing​

registerPhysicalWarehousing​

Stock and timing for physical goods.

OptionTypeRequiredNotes
adapterIdstring✅key shop.unchained.warehousing.physical.<adapterId>
stocknumber | (referenceDate, config, context) => Promise<number>available quantity
productionTimenumber | (qty, config, context) => Promise<number>ms (made-to-order)
commissioningTimenumber | (qty, config, context) => Promise<number>ms to prepare
orderIndexnumberdefault 0

registerVirtualWarehousing​

Tokenized / NFT products. tokenize is required.

OptionTypeRequiredNotes
adapterIdstring✅key shop.unchained.warehousing.virtual.<adapterId>
tokenize(config, context) => Promise<Omit<TokenSurrogate, 'userId' | 'productId' | 'orderPositionId'>[]>✅mint tokens for a checked-out position
stocknumber | fn
tokenMetadata(serial, date, config, context) => Promise<Metadata>ERC metadata
isInvalidateable(serial, date, config, context) => Promise<boolean>
orderIndexnumberdefault 0

See also: Warehousing plugins


Pricing​

The four pricing factories share the same signature: { adapterId, orderIndex?, isActivatedFor?, calculate }.

OptionTypeRequiredNotes
adapterIdstring✅key shop.unchained.pricing.<domain>-<adapterId>
calculate(sheet, context) => Promise<void>✅push rows onto sheet; the factory continues the chain for you
isActivatedFor(context) => booleandefault true
orderIndexnumberdefault 0; the chain runs in ascending order, ties keep registration order
Do not call the base calculate() yourself

The factory wraps your calculate and continues the pricing chain automatically. Just push rows onto the sheet — do not call super.calculate() / pricingAdapter.calculate() (that was the old class-based API).

  • registerProductPricing — per-product price (sheet.addItem({ amount, isTaxable, isNetPrice, meta }))
  • registerOrderPricing — order totals (sheet.addItems(...))
  • registerPaymentPricing — payment fees (sheet.addFee(...))
  • registerDeliveryPricing — delivery fees (sheet.addFee(...))
import { registerProductPricing } from '@unchainedshop/core';

registerProductPricing({
adapterId: 'member-surcharge',
isActivatedFor: (context) => Boolean(context.product?.tags?.includes('exclusive')),
calculate: async (sheet, context) => {
sheet.addItem({ amount: 500, isTaxable: true, isNetPrice: true, meta: { adapter: 'member-surcharge' } });
},
});

See also: Product pricing, Delivery pricing, Payment pricing


Discounts​

registerProductDiscount / registerOrderDiscount​

discountForPricingAdapterKey is the core hook (return a discount configuration, or null to not discount).

OptionTypeRequiredNotes
adapterIdstring✅key shop.unchained.discount.<product|order>-<adapterId>
discountForPricingAdapterKey(params, context?) => DiscountConfiguration | null✅maps a discount to a pricing row
isValidForSystemTriggering() => Promise<boolean>auto-apply without a code
isValidForCodeTriggering(code/context) => Promise<boolean>apply for a coupon code
reserve / releasefnreserve/return coupon capacity
orderIndex (product only)number
isManualAdditionAllowed / isManualRemovalAllowed (product only)fnmanual coupon entry

The callback arguments differ between the two factories:

CallbackProduct discountOrder discount
isValidForSystemTriggering()(context)
isValidForCodeTriggering({ code })(code, context)
discountForPricingAdapterKey({ pricingAdapterKey, calculationSheet })({ pricingAdapterKey, calculationSheet }, context)
reserve({ code })(code, context)
release()(context)
import { registerOrderDiscount } from '@unchainedshop/core';

registerOrderDiscount({
adapterId: 'automatic-promo10',
isValidForSystemTriggering: async () => true,
discountForPricingAdapterKey: ({ pricingAdapterKey }) =>
pricingAdapterKey === 'shop.unchained.pricing.order-discount' ? { rate: 0.1 } : null,
});

registerOrderDiscount inherits isManualAdditionAllowed and isManualRemovalAllowed as false. For a customer-entered coupon, use the full adapter example to expose the correct manual permissions.

See also: Order discounts


FactoryRequired callbackNotes
registerProductSearchFiltersearch(params) => Promise<string[]>external product search (e.g. Algolia); adapterId optional
registerAssortmentSearchFiltersearch(params) => Promise<string[]>external assortment search; adapterId optional
registerProductDiscoverabilityFilter—hides products tagged hiddenTagValue (default 'hidden') from search

search receives SearchQuery & { queryString, locale } and returns matching ids. All three accept an optional adapterId (auto-generated if omitted) and orderIndex.

import { registerProductSearchFilter } from '@unchainedshop/core';

registerProductSearchFilter({
adapterId: 'algolia',
search: async ({ queryString, locale }) => algolia.search(queryString, locale.baseName),
});

See also: Filters


Workers​

registerWorker​

A background job type. Keyed by type — there is no adapterId.

OptionTypeRequiredNotes
typestring✅work type; key shop.unchained.worker.<type lower-cased>
process(input, workId) => Promise<Result>your job logic; a thrown error becomes { success: false }
externalbooleandefault false
maxParallelAllocationsnumberconcurrency cap
import { registerWorker } from '@unchainedshop/core';

registerWorker<{ email: string }, { messageId: string }>({
type: 'SEND_WELCOME',
process: async (input) => ({ messageId: await mailer.send(input.email) }),
});

See also: Work Queue


Files​

registerFileAdapter​

A storage backend (S3, etc.). createSignedURL and uploadFileFromStream are required.

OptionTypeRequired
adapterIdstring✅
createSignedURL(directoryName, fileName, api) => Promise<{ putURL, … } | null>✅
uploadFileFromStream(directoryName, rawFile, api, options?) => Promise<UploadFileData>✅
createDownloadURL(file, expiry?) => Promise<string | null>
removeFiles(files, api) => Promise<void>
uploadFileFromURL(directoryName, fileInput, api) => Promise<UploadFileData>

The active file backend is the first registered file adapter — register exactly one (the base preset registers GridFS). See File plugins.


Quotations​

registerQuotation​

All callbacks are optional (the base adapter provides working defaults); only adapterId is required.

OptionTypeNotes
adapterIdstringrequired
quote(context) => Promise<QuotationProposal>produce the offer
transformItemConfiguration(params, context) => Promise<QuotationItemConfiguration | null>map the requested config to an order item
isManualProposalRequired / isManualRequestVerificationRequiredboolean
submitRequest / verifyRequest / rejectRequest(context) => Promise<boolean>lifecycle hooks

See also: Quotation


Enrollments​

registerEnrollment​

Recurring/subscription plans. configurationForOrder is required.

OptionTypeNotes
adapterIdstringrequired
configurationForOrder(params, context) => Promise<{ orderPositionTemplates, orderContext? } | null>builds the recurring order
isActivatedFor(productPlan?) => booleangate by plan; default true
transformOrderItem(orderPosition, api) => Promise<EnrollmentPlan>
nextPeriod(context) => Promise<EnrollmentPeriod | null>next billing window
isOverdue / isValidForActivation(context) => Promise<boolean>default false; supply isValidForActivation to grant access

See also: Enrollment


When not to use a factory​

Reach for a hand-built IPlugin + pluginRegistry.register() when you need:

  • a custom key or version (factories fix the namespace and 1.0.0);
  • HTTP routes (a webhook), a DB-backed module, or onRegister/onShutdown lifecycle hooks;
  • more than one adapter in a single plugin;
  • behavior a factory doesn't expose.
import { pluginRegistry, type IPlugin } from '@unchainedshop/core';

const MyPlugin: IPlugin = {
key: 'com.acme.payment.gateway',
label: 'Acme Gateway',
version: '2.1.0',
adapters: [AcmeAdapter],
routes: [{ path: '/payment/acme/webhook', method: 'POST', handler: acmeWebhook }],
onRegister: () => {
if (!process.env.ACME_SECRET) throw new Error('ACME_SECRET not set');
},
};

pluginRegistry.register(MyPlugin);