Custom Plugin Guide
Learn how to build custom plugins to extend billing behavior.
Basic Structure
Every plugin implements the Plugin interface, plus one or more hook interfaces:
package myplugin
import (
"context"
"github.com/contract-to-cash/core/plugin"
)
type MyPlugin struct {
priority int
}
func (p *MyPlugin) Name() string { return "my-plugin" }
func (p *MyPlugin) Version() string { return "1.0.0" }
func (p *MyPlugin) Priority() int { return p.priority }
func (p *MyPlugin) Initialize(_ context.Context, config plugin.Config) error {
if v, ok := config["priority"]; ok {
if n, ok := v.(int); ok {
p.priority = n
}
}
return nil
}
func (p *MyPlugin) Shutdown(_ context.Context) error { return nil }
Example: Loyalty Discount Plugin
A plugin that gives a 5% discount to all invoices:
type LoyaltyDiscountPlugin struct {
priority int
rate *big.Rat
}
func NewLoyaltyDiscountPlugin(discountPercent int) *LoyaltyDiscountPlugin {
return &LoyaltyDiscountPlugin{
rate: new(big.Rat).SetFrac64(int64(discountPercent), 100),
}
}
func (p *LoyaltyDiscountPlugin) Name() string { return "loyalty-discount" }
func (p *LoyaltyDiscountPlugin) Version() string { return "1.0.0" }
func (p *LoyaltyDiscountPlugin) Priority() int { return p.priority }
func (p *LoyaltyDiscountPlugin) Initialize(_ context.Context, config plugin.Config) error {
if v, ok := config["priority"]; ok {
if n, ok := v.(int); ok {
p.priority = n
}
}
return nil
}
func (p *LoyaltyDiscountPlugin) Shutdown(_ context.Context) error { return nil }
// Implement DiscountHook
func (p *LoyaltyDiscountPlugin) CalculateDiscount(ctx *plugin.CalculationContext) (shared.Money, error) {
discount := ctx.Subtotal().Multiply(p.rate)
ctx.RecordDiscount(plugin.AppliedDiscount{
PluginName: "loyalty-discount",
Code: "LOYALTY",
Amount: discount,
})
return discount, nil
}
Example: Audit Log Plugin
A plugin that logs the invoice calculation lifecycle:
type AuditLogPlugin struct {
priority int
logger *slog.Logger
}
// Implement InvoiceLifecycleHook
func (p *AuditLogPlugin) BeforeCalculation(ctx *plugin.CalculationContext) error {
p.logger.Info("invoice calculation started",
"contract_id", ctx.ContractID(),
"subtotal", ctx.Subtotal().Amount().RatString(),
)
return nil
}
func (p *AuditLogPlugin) AfterCalculation(ctx *plugin.CalculationContext, inv *invoice.Invoice) error {
p.logger.Info("invoice calculation completed",
"contract_id", ctx.ContractID(),
"total", inv.Total().Amount().RatString(),
"discounts_applied", len(ctx.AppliedDiscounts()),
)
return nil
}
Example: Hosting Provisioning Plugin
A plugin that provisions/deprovisions hosting resources on contract lifecycle events:
type HostingPlugin struct {
priority int
provisioningClient ProvisioningClient
}
// Implement OnContractActivateHook
func (p *HostingPlugin) OnContractActivate(ctx *plugin.Context, c *contract.ContractAggregate) error {
return p.provisioningClient.CreateServer(ctx.Context(), c.ContractID(), c.PriceID())
}
// Implement OnContractSuspendHook
func (p *HostingPlugin) OnContractSuspend(ctx *plugin.Context, c *contract.ContractAggregate) error {
return p.provisioningClient.StopServer(ctx.Context(), c.ContractID())
}
// Implement OnContractResumeHook
func (p *HostingPlugin) OnContractResume(ctx *plugin.Context, c *contract.ContractAggregate) error {
return p.provisioningClient.StartServer(ctx.Context(), c.ContractID())
}
// Implement OnContractCancelHook
func (p *HostingPlugin) OnContractCancel(ctx *plugin.Context, c *contract.ContractAggregate) error {
return p.provisioningClient.DeleteServer(ctx.Context(), c.ContractID())
}
The OnContractResume hook cannot distinguish between initial provisioning (first payment) and re-activation (payment after suspension). Consider tracking provisioning state externally and checking it in OnContractResume:
func (p *HostingPlugin) OnContractResume(ctx *plugin.Context, c *contract.ContractAggregate) error {
if p.provisioningClient.Exists(ctx.Context(), c.ContractID()) {
return p.provisioningClient.StartServer(ctx.Context(), c.ContractID())
}
return p.provisioningClient.CreateServer(ctx.Context(), c.ContractID(), c.PriceID())
}
Registering Custom Plugins
registry := plugin.NewRegistry()
// The registry auto-classifies plugins by their interfaces
registry.Register(&LoyaltyDiscountPlugin{}) // → DiscountHook
registry.Register(&AuditLogPlugin{}) // → InvoiceLifecycleHook
registry.Register(&HostingPlugin{}) // → Multiple contract hooks
configs := map[string]plugin.Config{
"loyalty-discount": {"priority": plugin.PriorityNormal},
"audit-log": {"priority": plugin.PriorityHighest},
"hosting": {"priority": plugin.PriorityHigh},
}
registry.InitializeAll(ctx, configs)
Multi-Hook Plugins
A single plugin can implement multiple hook interfaces:
type MetricsPlugin struct {
priority int
metrics MetricsCollector
}
// Implements OnContractChangeHook + OnInvoiceIssuedHook + OnPaymentProcessedHook
func (p *MetricsPlugin) OnContractChange(ctx *plugin.Context, event plugin.ContractChangeEvent) error {
p.metrics.RecordContractChange(event.ChangeType, event.MRRChange)
return nil
}
func (p *MetricsPlugin) OnInvoiceIssued(ctx *plugin.Context, inv *invoice.Invoice) error {
p.metrics.RecordRevenue(inv.Total())
return nil
}
// OnPaymentProcessed receives a *PaymentContext (like the payment hooks), so the
// payment can be attributed to a contract/account via ctx.ContractID()/ctx.AccountID().
func (p *MetricsPlugin) OnPaymentProcessed(ctx *plugin.PaymentContext) error {
pay := ctx.Payment()
p.metrics.RecordPayment(pay.Status(), pay.Amount())
return nil
}
Plugin Execution Order
The core guarantees the invoice generation pipeline order structurally (Discount → Tax → Total). Priority only controls execution within the same hook type.
For the complete pipeline diagram, see Plugin System — Invoice Generation Pipeline.