{"ArticleId":null,"Name":"Writing a plugin","Content":"\u003Cp\u003EA plugin is a separate class library that GrandNode discovers at startup and that an administrator installs, configures and uninstalls from the admin panel. Payment methods, shipping rate calculators, tax providers, widgets, discount rules, external login providers, exchange-rate sources and storefront themes are all plugins. This article walks through the files every plugin needs, using the shipped \u003Ccode\u003EPayments.CashOnDelivery\u003C/code\u003E plugin as the reference.\u003C/p\u003E\n\n\u003Ch2 id=\u0022structure\u0022\u003EStructure\u003C/h2\u003E\n\u003Cpre\u003E\u003Ccode\u003Esrc/Plugins/Payments.CashOnDelivery/\n  Payments.CashOnDelivery.csproj\n  Manifest.cs                        plugin identity\n  CashOnDeliveryPaymentDefaults.cs   system name, resource keys, URLs\n  CashOnDeliveryPaymentSettings.cs   ISettings, persisted per store\n  CashOnDeliveryPaymentProvider.cs   IPaymentProvider\n  CashOnDeliveryPaymentPlugin.cs     BasePlugin: install / uninstall\n  StartupApplication.cs              DI registration\n  EndpointProvider.cs                routes, when the plugin has pages\n  logo.jpg                           shown in the plugin list\n  Controllers/                       storefront controllers\n  Areas/Admin/Controllers/           configuration screen\n  Areas/Admin/Views/\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003EName the project after its system name, using the group prefix of the plugin type: \u003Ccode\u003EPayments.X\u003C/code\u003E, \u003Ccode\u003EShipping.X\u003C/code\u003E, \u003Ccode\u003ETax.X\u003C/code\u003E, \u003Ccode\u003EWidgets.X\u003C/code\u003E, \u003Ccode\u003EDiscountRules.X\u003C/code\u003E, \u003Ccode\u003EAuthentication.X\u003C/code\u003E, \u003Ccode\u003EExchangeRate.X\u003C/code\u003E or \u003Ccode\u003ETheme.X\u003C/code\u003E. The system name is the plugin\u0027s persisted identity and must not change after release.\u003C/p\u003E\n\n\u003Ch2 id=\u0022project-file\u0022\u003EThe project file\u003C/h2\u003E\n\u003Cpre\u003E\u003Ccode\u003E\u0026lt;Project Sdk=\u0022Microsoft.NET.Sdk.Razor\u0022\u0026gt;\n  \u0026lt;Import Project=\u0022..\\..\\Build\\Grand.Common.props\u0022 /\u0026gt;\n  \u0026lt;Import Project=\u0022..\\..\\Build\\Grand.Plugin.props\u0022 /\u0026gt;\n  \u0026lt;PropertyGroup\u0026gt;\n    \u0026lt;AddRazorSupportForMvc\u0026gt;true\u0026lt;/AddRazorSupportForMvc\u0026gt;\n    \u0026lt;StaticWebAssetsEnabled\u0026gt;false\u0026lt;/StaticWebAssetsEnabled\u0026gt;\n  \u0026lt;/PropertyGroup\u0026gt;\n  \u0026lt;PropertyGroup Condition=\u0022\u0027$(Configuration)|$(Platform)\u0027==\u0027Debug|AnyCPU\u0027\u0022\u0026gt;\n    \u0026lt;OutputPath\u0026gt;..\\..\\Web\\Grand.Web\\Plugins\\Payments.Example\\\u0026lt;/OutputPath\u0026gt;\n    \u0026lt;OutDir\u0026gt;$(OutputPath)\u0026lt;/OutDir\u0026gt;\n  \u0026lt;/PropertyGroup\u0026gt;\n  \u0026lt;PropertyGroup Condition=\u0022\u0027$(Configuration)|$(Platform)\u0027==\u0027Release|AnyCPU\u0027\u0022\u0026gt;\n    \u0026lt;OutputPath\u0026gt;..\\..\\Web\\Grand.Web\\Plugins\\Payments.Example\\\u0026lt;/OutputPath\u0026gt;\n    \u0026lt;OutDir\u0026gt;$(OutputPath)\u0026lt;/OutDir\u0026gt;\n  \u0026lt;/PropertyGroup\u0026gt;\n  \u0026lt;ItemGroup\u0026gt;\n    \u0026lt;None Update=\u0022logo.jpg\u0022\u0026gt;\u0026lt;CopyToOutputDirectory\u0026gt;Always\u0026lt;/CopyToOutputDirectory\u0026gt;\u0026lt;/None\u0026gt;\n  \u0026lt;/ItemGroup\u0026gt;\n\u0026lt;/Project\u0026gt;\u003C/code\u003E\u003C/pre\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003EGrand.Plugin.props\u003C/code\u003E adds references to the core projects with \u003Ccode\u003EPrivate=false\u003C/code\u003E, so host assemblies are not copied next to the plugin. Add NuGet packages without a version: versions are managed centrally in \u003Ccode\u003EDirectory.Packages.props\u003C/code\u003E.\u003C/li\u003E\n\u003Cli\u003ESet the output path for \u003Cstrong\u003Eboth\u003C/strong\u003E Debug and Release. A missing Release path makes the plugin disappear from release builds and Docker images.\u003C/li\u003E\n\u003Cli\u003EUse \u003Ccode\u003EMicrosoft.NET.Sdk.Razor\u003C/code\u003E when the plugin has views. The views are compiled into the plugin DLL.\u003C/li\u003E\n\u003C/ul\u003E\n\n\u003Ch2 id=\u0022manifest\u0022\u003EThe manifest\u003C/h2\u003E\n\u003Cpre\u003E\u003Ccode\u003Eusing Grand.Infrastructure.Plugins;\nusing Payments.Example;\n\n[assembly: PluginInfo(\n    FriendlyName = \u0022Example payment\u0022,\n    Group = \u0022Payment methods\u0022,\n    SystemName = ExamplePaymentDefaults.ProviderSystemName,\n    Author = \u0022Your company\u0022,\n    Version = \u00221.0.0\u0022\n)]\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003E\u003Ccode\u003EGroup\u003C/code\u003E decides where the plugin is listed; use an existing group name: \u003Ccode\u003EPayment methods\u003C/code\u003E, \u003Ccode\u003EShipping rate\u003C/code\u003E, \u003Ccode\u003ETax providers\u003C/code\u003E, \u003Ccode\u003EWidgets\u003C/code\u003E, \u003Ccode\u003EExternal authentication\u003C/code\u003E, \u003Ccode\u003EDiscount rules\u003C/code\u003E, \u003Ccode\u003EExchange rate\u003C/code\u003E or \u003Ccode\u003EThemes\u003C/code\u003E. \u003Ccode\u003ESupportedVersion\u003C/code\u003E is normally left out: it is resolved from the \u003Ccode\u003EGrand.Infrastructure\u003C/code\u003E version the plugin was compiled against, and the admin refuses to install a plugin built for another major.minor version of GrandNode.\u003C/p\u003E\n\u003Cp\u003EKeep the identity in one defaults class and reference it everywhere:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode\u003Epublic static class ExamplePaymentDefaults\n{\n    public const string ProviderSystemName = \u0022Payments.Example\u0022;\n    public const string FriendlyName = \u0022Payments.Example.FriendlyName\u0022;   // a resource key\n    public const string ConfigurationUrl = \u0022/Admin/PaymentExample/Configure\u0022;\n}\u003C/code\u003E\u003C/pre\u003E\n\n\u003Ch2 id=\u0022baseplugin\u0022\u003EBasePlugin: install and uninstall\u003C/h2\u003E\n\u003Cp\u003EThe plugin class derives from \u003Ccode\u003EBasePlugin\u003C/code\u003E. \u003Ccode\u003EInstall\u003C/code\u003E seeds default settings and localization resources; \u003Ccode\u003EUninstall\u003C/code\u003E removes exactly what \u003Ccode\u003EInstall\u003C/code\u003E added. Call \u003Ccode\u003Ebase.Install()\u003C/code\u003E and \u003Ccode\u003Ebase.Uninstall()\u003C/code\u003E last: they record the plugin in \u003Ccode\u003EApp_Data/InstalledPlugins.cfg\u003C/code\u003E.\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode\u003Epublic class ExamplePaymentPlugin(\n    ISettingService settingService,\n    IPluginTranslateResource pluginTranslateResource)\n    : BasePlugin, IPlugin\n{\n    public override string ConfigurationUrl() =\u0026gt; ExamplePaymentDefaults.ConfigurationUrl;\n\n    public override async Task Install()\n    {\n        await settingService.SaveSetting(new ExamplePaymentSettings { DisplayOrder = 1 });\n        await pluginTranslateResource.AddOrUpdatePluginTranslateResource(\n            ExamplePaymentDefaults.FriendlyName, \u0022Example payment\u0022);\n        await pluginTranslateResource.AddOrUpdatePluginTranslateResource(\n            \u0022Plugins.Payments.Example.ApiKey\u0022, \u0022API key\u0022);\n        await base.Install();\n    }\n\n    public override async Task Uninstall()\n    {\n        await settingService.DeleteSetting\u0026lt;ExamplePaymentSettings\u0026gt;();\n        await pluginTranslateResource.DeletePluginTranslationResource(ExamplePaymentDefaults.FriendlyName);\n        await pluginTranslateResource.DeletePluginTranslationResource(\u0022Plugins.Payments.Example.ApiKey\u0022);\n        await base.Uninstall();\n    }\n}\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003EA settings class is a plain class implementing \u003Ccode\u003EISettings\u003C/code\u003E. GrandNode registers settings classes in dependency injection, so a provider can take \u003Ccode\u003EExamplePaymentSettings\u003C/code\u003E in its constructor and gets the values for the current store.\u003C/p\u003E\n\n\u003Ch2 id=\u0022startup\u0022\u003ERegistering services: IStartupApplication\u003C/h2\u003E\n\u003Cpre\u003E\u003Ccode\u003Epublic class StartupApplication : IStartupApplication\n{\n    public void ConfigureServices(IServiceCollection services, IConfiguration configuration)\n    {\n        services.AddScoped\u0026lt;IPaymentProvider, ExamplePaymentProvider\u0026gt;();\n    }\n\n    public int Priority =\u0026gt; 10;\n    public void Configure(WebApplication application, IWebHostEnvironment webHostEnvironment) { }\n    public bool BeforeConfigure =\u0026gt; false;\n}\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003ENothing in \u003Ccode\u003EGrand.Web\u003C/code\u003E mentions the plugin; the class is found by assembly scanning. A plugin\u0027s startup class runs only while the plugin is installed. That is why installing or uninstalling from the admin stops the application: the host (IIS, a Windows service, systemd or Docker) starts it again with the new registrations.\u003C/p\u003E\n\n\u003Ch2 id=\u0022configuration\u0022\u003EA configuration screen\u003C/h2\u003E\n\u003Cp\u003EWhen the plugin has settings, add an admin controller under \u003Ccode\u003EAreas/Admin\u003C/code\u003E. Load and save the settings for the store selected in the admin store switcher, so each store can have its own values:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode\u003E[AuthorizeAdmin]\n[Area(\u0022Admin\u0022)]\n[PermissionAuthorize(PermissionSystemName.PaymentMethods)]\npublic class PaymentExampleController(\n    ISettingService settingService, IAdminStoreService adminStoreService) : BasePaymentController\n{\n    public async Task\u0026lt;IActionResult\u0026gt; Configure()\n    {\n        var storeScope = await adminStoreService.GetActiveStore();\n        var settings = await settingService.LoadSetting\u0026lt;ExamplePaymentSettings\u0026gt;(storeScope);\n        return View(new ConfigurationModel { ApiKey = settings.ApiKey, ActiveStore = storeScope });\n    }\n\n    [HttpPost]\n    public async Task\u0026lt;IActionResult\u0026gt; Configure(ConfigurationModel model)\n    {\n        if (!ModelState.IsValid) return await Configure();\n        var storeScope = await adminStoreService.GetActiveStore();\n        var settings = await settingService.LoadSetting\u0026lt;ExamplePaymentSettings\u0026gt;(storeScope);\n        settings.ApiKey = model.ApiKey;\n        await settingService.SaveSetting(settings, storeScope);\n        await settingService.ClearCache();\n        return await Configure();\n    }\n}\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003EViews under \u003Ccode\u003EAreas/Admin/Views\u003C/code\u003E need their own \u003Ccode\u003E_ViewImports.cshtml\u003C/code\u003E (tag helpers and \u003Ccode\u003E@inject LocService Loc\u003C/code\u003E) and a \u003Ccode\u003E_ViewStart.cshtml\u003C/code\u003E with \u003Ccode\u003ELayout = \u0022\u0022\u003C/code\u003E; the admin supplies the page shell. If store managers should configure the plugin too, add the same screen under \u003Ccode\u003EAreas/Store\u003C/code\u003E, otherwise the \u003Cstrong\u003EConfigure\u003C/strong\u003E link in the store manager panel returns 404.\u003C/p\u003E\n\n\u003Ch2 id=\u0022build-and-install\u0022\u003EBuild and install\u003C/h2\u003E\n\u003Col\u003E\n\u003Cli\u003EBuild the plugin: \u003Ccode\u003Edotnet build src/Plugins/Payments.Example\u003C/code\u003E. The output lands in \u003Ccode\u003Esrc/Web/Grand.Web/Plugins/Payments.Example\u003C/code\u003E.\u003C/li\u003E\n\u003Cli\u003EStart the site and open \u003Cstrong\u003EPlugins \u2192 Local plugins\u003C/strong\u003E in the admin panel. Click \u003Cstrong\u003EReload list of plugins\u003C/strong\u003E if the site was already running.\u003C/li\u003E\n\u003Cli\u003EClick \u003Cstrong\u003EInstall\u003C/strong\u003E next to the plugin. The application restarts.\u003C/li\u003E\n\u003Cli\u003EClick \u003Cstrong\u003EConfigure\u003C/strong\u003E, then enable the provider where its type requires it (for example under \u003Cstrong\u003EConfiguration \u2192 Payment \u2192 Payment methods\u003C/strong\u003E for a payment method, or \u003Cstrong\u003EPlugins \u2192 Widgets\u003C/strong\u003E for a widget).\u003C/li\u003E\n\u003C/ol\u003E\n\u003Cfigure\u003E\u003Cimg src=\u0022/Plugins/Theme.GrandNodeCom/Content/kb/developers/admin-local-plugins.webp\u0022 alt=\u0022Local plugins list in the admin panel with Install, Configure and Uninstall actions\u0022 width=\u00221440\u0022 height=\u0022900\u0022 loading=\u0022lazy\u0022\u003E\u003Cfigcaption\u003EPlugins \u2192 Local plugins: every plugin found in the Plugins folder, grouped by its manifest Group, with its version, author and system name.\u003C/figcaption\u003E\u003C/figure\u003E\n\u003Cp\u003EOn a server without source code, a packaged plugin can be uploaded as a ZIP with the \u003Cstrong\u003EUpload\u003C/strong\u003E button (\u003Ccode\u003EPlugin.zip\u003C/code\u003E \u2192 plugin folder \u2192 plugin files), unless \u003Ccode\u003EExtensions:DisableUploadExtensions\u003C/code\u003E is set.\u003C/p\u003E\n\n\u003Ch2 id=\u0022tips\u0022\u003ETips and common mistakes\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003EThe manifest \u003Ccode\u003ESystemName\u003C/code\u003E, the provider\u0027s \u003Ccode\u003ESystemName\u003C/code\u003E and the output folder must be identical.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EFriendlyName\u003C/code\u003E should be a resource key resolved through \u003Ccode\u003EITranslationService\u003C/code\u003E, so operators can translate it.\u003C/li\u003E\n\u003Cli\u003EEvery resource and setting added in \u003Ccode\u003EInstall\u003C/code\u003E must be removed in \u003Ccode\u003EUninstall\u003C/code\u003E.\u003C/li\u003E\n\u003Cli\u003ENever put secrets in code or in the manifest; keep them in settings or configuration.\u003C/li\u003E\n\u003Cli\u003EStore data in your own collection with \u003Ccode\u003EIRepository\u0026lt;YourEntity\u0026gt;\u003C/code\u003E; your entity derives from \u003Ccode\u003EBaseEntity\u003C/code\u003E.\u003C/li\u003E\n\u003C/ul\u003E\n\n\u003Ch2 id=\u0022related\u0022\u003ERelated\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/developers-plugin-provider-types\u0022\u003EPlugin provider types\u003C/a\u003E \u2014 payment, shipping, widget and discount rule contracts.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/developers-creating-a-theme\u0022\u003ECreating a theme\u003C/a\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/developers-upgrade-migrations\u0022\u003EUpgrade migrations\u003C/a\u003E\u003C/li\u003E\n\u003C/ul\u003E","ParentCategoryId":"6abdec6d83d2816248229f65","SeName":"developers-writing-a-plugin","MetaKeywords":null,"MetaDescription":"Build an installable GrandNode plugin: project file, manifest, BasePlugin install and uninstall, IStartupApplication and a configuration screen.","MetaTitle":null,"AllowComments":false,"Captcha":{"ReCaptchaChallengeField":null,"ReCaptchaResponseField":null,"ReCaptchaResponseValue":null,"ReCaptchaResponse":null},"RelatedArticles":[],"CategoryBreadcrumb":[{"Name":"For developers","Description":null,"IsCurrent":false,"Children":null,"Parent":null,"SeName":"docs-developers","Id":"6abdec6d83d2816248229f65","UserFields":[]}],"AddNewComment":{"CommentText":null,"DisplayCaptcha":false,"Id":null,"UserFields":[]},"Comments":[],"Id":"6abdec6d83d2816248229f6b","UserFields":[]}