PromoEngine is a .NET 10 pricing and promotions engine built as a small Clean Architecture solution. It exposes minimal API endpoints for promotion CRUD, quote generation, single-strategy dry-run simulation, and multi-strategy simulation comparison, with SQL Server persistence for promotions, quote audits, redemptions, and budget consumption.
- Targets
net10.0with SDK pinned inglobal.jsonto10.0.200 - Clean split across Domain, Application, Infrastructure, and API
- All pricing logic lives in Domain with no EF Core or ASP.NET dependencies
- Explainability is returned inline for both applied and rejected promotions
/quotespersists side effects;/simulateand/simulate/compareare dry-run only- Swagger UI and OpenAPI documents are available in
Development
PercentDiscountFixedAmountDiscountCartDiscountQuantityDealBundleCoupon
CustomerBestPriceMarginFirstFundedPromotionPreferredInventoryReductionCampaignPriority
- Channels:
Store,Online,MobileApp,ClickAndCollect - Customer segments:
NewCustomer,ExistingCustomer,Loyalty,PriceSensitive,B2B,B2C - Promotion eligibility can be restricted by channel and segment; null means all
- Non-combinable promotions cannot stack with any already applied promotion
- Combinable promotions may stack only when their affected line set does not overlap
- Total budget cap, daily budget cap, and per-customer budget cap are enforced
- Daily and per-customer budget usage is tracked per promotion and per UTC day
- Funding split is modeled as manufacturer share plus retailer share
minimumCartValuerejects a promotion before application when the cart is too smallmaximumDiscountrejects a promotion when the calculated discount exceeds the cap- Bundle matching is satisfied when each bundle SKU is present, and bundles can apply multiple times based on minimum bundle SKU quantity
- Fixed amount discounts are capped per line so net line totals never go below zero
budgetUsageis the total customer discountmanufacturerFundingAmountandretailerFundingAmountshow how that discount is splitmarginDeltareflects the retailer-funded portion onlyrevenueDeltareflects the full customer discount
GET /promotionsGET /promotions/{id}POST /promotionsPUT /promotions/{id}DELETE /promotions/{id}POST /quotesPOST /simulatePOST /simulate/compareGET /health/liveGET /health/readyGET /ping
/health/live: process liveness only/health/ready: readiness including the EF Core SQL Server check/ping: lightweight health probe returningstatusandutcNow
Swagger UI is available only in Development.
- Swagger UI:
/swagger - Swagger JSON:
/swagger/v1/swagger.json - OpenAPI JSON:
/openapi/v1.json
The API uses camelCase JSON.
Contract detail:
- Request payloads use integer enum values
- Response payloads serialize enum values as strings
- This applies to request enum fields such as
type,discountValueType,strategy,channel,segment, andstrategies[] - See Swagger Schemas for numeric mappings
The promotion upsert request includes the MVP fields plus these vNext fields:
channelsegmentisCombinablebudgetDailyCapbudgetPerCustomerCapminimumCartValuemaximumDiscountfundingManufacturerRatefundingRetailerRate
isFunded is still accepted for backward compatibility. If funding rates are omitted and isFunded is true, the API derives a 0.5 / 0.5 manufacturer/retailer split.
Example:
{
"code": "SPRING_COLA10",
"name": "Spring Cola 10",
"description": "10 percent off COLA_05 for online existing customers",
"campaignKey": "SPRING-2026",
"type": 0,
"isActive": true,
"startsAtUtc": "2026-03-14T00:00:00Z",
"endsAtUtc": "2026-06-30T23:59:59Z",
"priority": 100,
"isFunded": true,
"budgetCap": 5000,
"budgetConsumed": 0,
"value": 10,
"discountValueType": 1,
"thresholdAmount": 0,
"requiredQuantity": 0,
"chargedQuantity": 0,
"bundlePrice": 0,
"minimumMarginRate": 0.1,
"couponCode": null,
"targetSkus": ["COLA_05"],
"bundleSkus": [],
"channel": 1,
"segment": 1,
"isCombinable": true,
"budgetDailyCap": 750,
"budgetPerCustomerCap": 50,
"minimumCartValue": 25,
"maximumDiscount": 20,
"fundingManufacturerRate": 0.5,
"fundingRetailerRate": 0.5
}See docs/examples/promotion-create.json.
Quote requests include:
customerIdcurrencycouponCodestrategyminimumMarginRatechannelsegmentitems
If channel or segment are omitted, the service defaults to Online and ExistingCustomer.
Example:
{
"customerId": "retail-customer-42",
"currency": "EUR",
"couponCode": "SAVE20",
"strategy": 0,
"minimumMarginRate": 0.1,
"channel": 1,
"segment": 1,
"items": [
{
"sku": "COLA_05",
"quantity": 3,
"unitPrice": 1.59,
"unitCost": 0.72,
"stockLevel": 120
},
{
"sku": "WATER_15",
"quantity": 2,
"unitPrice": 0.89,
"unitCost": 0.31,
"stockLevel": 240
}
]
}See docs/examples/quote-request.json.
/simulate uses the same request schema as /quotes and runs the same pricing pipeline, but it does not persist quote audits, redemptions, or budget consumption.
/simulate/compare is a dry-run comparison endpoint. It replaces strategy with strategies, which is an array of integer enum values.
Example:
{
"customerId": "retail-customer-42",
"currency": "EUR",
"couponCode": null,
"strategies": [0, 1, 2],
"minimumMarginRate": 0.1,
"channel": 1,
"segment": 1,
"items": [
{
"sku": "COLA_05",
"quantity": 3,
"unitPrice": 1.59,
"unitCost": 0.72,
"stockLevel": 120
},
{
"sku": "WATER_15",
"quantity": 2,
"unitPrice": 0.89,
"unitCost": 0.31,
"stockLevel": 240
}
]
}See docs/examples/simulate-compare-request.json.
Every evaluation returns one decision record per promotion with:
statusreasonCodeaffectedItemsdiscountAmountbudgetImpactkpiEffect
Important reason codes you will commonly see:
ChannelMismatchSegmentMismatchNonCombinableWithAppliedPromotionOverlappingItemsNotAllowedBudgetTotalExceededBudgetDailyExceededBudgetPerCustomerExceededMinCartValueNotMetMaxDiscountExceededMarginGuardRejected
POST /quotespersists quote audit, promotion redemptions, and budget consumption updatesPOST /simulateis dry-run only and does not persist anythingPOST /simulate/compareis dry-run only and does not persist anything- Budget buckets are tracked by promotion and UTC date; per-customer buckets are also scoped to the same UTC date
$env:ConnectionStrings__SqlServer = "Server=localhost,14333;Database=PromoEngine;User Id=sa;Password=Your_strong_Password123;TrustServerCertificate=True;Encrypt=False"
dotnet run --project .\src\PromoEngine.Api\PromoEngine.Api.csprojIn Development, open Swagger at the URL reported by dotnet run.
$env:MSSQL_SA_PASSWORD = "Your_strong_Password123"
docker compose up --buildCompose uses MSSQL_SA_PASSWORD consistently and resolves sqlcmd from /opt/mssql-tools*/bin/sqlcmd for the SQL health check.
dotnet build PromoEngine.sln -c Release
dotnet test PromoEngine.sln -c ReleaseNotes:
- Unit tests cover channel/segment filtering, combinability, budget caps, funding split, fixed-amount capping, bundle matching, and min/max discount rules
- Integration tests cover CRUD, mismatch reasons, dry-run behavior, budget persistence behavior, and
/simulate/compare - Docker Desktop must be running for the integration suite
Primary runtime override:
ConnectionStrings__SqlServer
Docker and CI use MSSQL_SA_PASSWORD for the SQL Server container.
src/PromoEngine.Api- minimal API endpoints, validation, Swagger, health checkssrc/PromoEngine.Application- orchestration, DTOs, portssrc/PromoEngine.Domain- pricing logic, explainability, conflict handlingsrc/PromoEngine.Infrastructure- EF Core DbContext, repositories, migrationstests/PromoEngine.Domain.UnitTests- engine behaviortests/PromoEngine.Application.UnitTests- orchestration and persistence behaviortests/PromoEngine.IntegrationTests- end-to-end API coverage
- ADRs:
docs/adr/0001-conflict-strategy.md,docs/adr/0002-budgeting-model.md,docs/adr/0003-persistence-model.md,docs/adr/0004-explainability-format.md - Examples:
docs/examples/promotion-create.json,docs/examples/quote-request.json,docs/examples/simulate-compare-request.json - C4 container view:
docs/c4-container.md - Load test:
docs/loadtest/quotes-loadtest.js,docs/loadtest/report.md