Skip to main content

Turnover register

No live consumer yet

TurnoverRegister<> has infrastructure — a writer, a reader, and an AddTurnoverRegister<>() DI extension — but nothing in Kandra's reference configuration uses it, and no test exercises it. It is validated by design, not by use. The register below (SalesTurnover) is illustrative, built from the engine's source and the create-register skill's data/Turnover.cs template rather than copied from working code. Before choosing it, check whether a Balance register (if you want totals) or a document-bound Info register (if a document sets a value) fits instead.

A Turnover register is the Balance family without the balance table: it keeps only the movement log — every posted receipt/expense row — and the log is the register. Reach for it when you want period-bucketed movement totals ("units sold per item per month") and explicitly don't want the cost of maintaining a running-total table. You sum movements when you read.

Read Creating a register first for the shared concepts, and Balance register: Inventory — everything there about dimensions, resources, movements, IsExpense, LineId, and the write path applies unchanged. This page covers only what's different. Acme stands in for your configuration's root name.

How it differs from Balance

  • One row entity (Movement<,,>), one table. No Balance<,> class, no _Balance table, no Version concurrency column.
  • Nothing to overdraw. With no balance there is nothing for EnsureNonNegative() to guard at the register level — so a plain QuantityResources (sign unconstrained) is a natural fit. Each movement's resources still get EnsureNonNegative() at PostAsync time, so a non-negative resource type still rejects a negative movement value.
  • Reads sum movements, via ITurnoverReader<TMovement>.Movements(from, to, ...). There's no Balances(...) and no as-of-date balance.
  • DI is the one thing that is not generated. See section 2.

1. Dimensions, record, register

Acme.Domain/Entities/Registers/SalesTurnover.cs. Resources reuse the shipped QuantityResources, and there are no movement-scoped fields, so NoDetails:

using System.Text.Json.Serialization;
using Kandra.Attributes.Entities;
using Kandra.Attributes.Naming;
using Kandra.Domain.Registers;
using Acme.Domain.Entities.Dictionaries;

namespace Acme.Domain.Entities.Registers;

[KandraGeneratedEquality]
public sealed partial class SalesTurnoverDimensions : IRegisterDimensions<SalesTurnoverDimensions>
{
[Caption("Item")]
public Guid ItemId { get; set; }
[JsonIgnore]
public Item Item { get; set; } = null!;

[Caption("Warehouse")]
public Guid WarehouseId { get; set; }
[JsonIgnore]
public Warehouse Warehouse { get; set; } = null!;
}

[KandraRegisterEntity(TableName = "Reg_SalesTurnover")]
[RegisterCaption("SalesTurnover")]
public sealed class SalesTurnoverMovement
: Movement<SalesTurnoverDimensions, QuantityResources, NoDetails>
{
}

public sealed partial class SalesTurnoverRegister : TurnoverRegister<SalesTurnoverMovement>
{
}

Only [KandraRegisterEntity] on the Movement — there is no balance to annotate. [RegisterCaption] goes on the Movement, as in the Balance family, so the register appears in the document's Register Transactions tab.

2. DI — the one hand-written line

The DI generator registers a Balance register by walking discovered Balance entities and looking up the Movement with matching Dimensions and Resources types. A Movement with no matching Balance — which is exactly what a Turnover register is — is silently skipped. There is no separate "is this Turnover?" case in the generator. So you register it yourself, one line in Acme.Persistence/ConfigureServices.cs, next to the generated-entities call:

public static IServiceCollection AddAcmePersistence(this IServiceCollection services)
{
// ...
services.AddGeneratedAcmePersistenceEntities();

// Turnover registers are not wired by the DI generator — add each by hand.
services.AddTurnoverRegister<SalesTurnoverMovement, SalesTurnoverDimensions,
QuantityResources, NoDetails>();

return services;
}

Pass your Details class instead of NoDetails if you have one. This registers the reader (ITurnoverReader<SalesTurnoverMovement>) and a keyed writer (ITurnoverRegisterWriter<SalesTurnoverMovement>).

Everything else is still generated: EF configuration (table name, owned types, precision, FKs) and the register-transactions reader both cover a Turnover Movement exactly as they do a Balance one.

Forgetting the line fails at runtime, not at build time

Nothing fails to compile if you omit AddTurnoverRegister<>(). The first GetWriter<>() call for it, or the first injection of its reader, throws because the service isn't registered.

Re-check this against the DI generator's source before relying on it being permanent — if it ever grows a Turnover case, the manual call becomes redundant and should be removed rather than left as a duplicate registration.

3. Localization

SalesTurnover, Item, Warehouse — all three resx files.

4. Write it from a document Behavior

Identical to the Balance flow, with the turnover writer interface:

[KandraDocumentBehavior(typeof(SalesInvoiceDto))]
[RegisterTransactions(typeof(SalesTurnoverMovement))]
public class SalesInvoiceBehavior(/* ... */) : IDocumentBehavior<SalesInvoice>
{
public async ValueTask OnSubmitAsync(SalesInvoice entity, bool isInsert,
ISubmitScope registers, CancellationToken cancellationToken)
{
var movements = entity.Lines.Select(line => new SalesTurnoverMovement
{
LineId = line.Id,
IsExpense = false,
Dimensions = new SalesTurnoverDimensions
{
ItemId = line.ItemId,
WarehouseId = entity.WarehouseId,
},
Resources = new QuantityResources { Quantity = line.Quantity },
}).ToList();

var writer = registers.GetWriter<ITurnoverRegisterWriter<SalesTurnoverMovement>>();
await writer.PostAsync(entity.Id, entity.Date, movements, cancellationToken);
}

public async ValueTask OnUnsubmitAsync(SalesInvoice entity, ISubmitScope registers,
CancellationToken cancellationToken) =>
await registers.GetWriter<ITurnoverRegisterWriter<SalesTurnoverMovement>>()
.UnpostAsync(entity.Id, cancellationToken);

public ValueTask OnDeleteAsync(Guid id, ISubmitScope registers, CancellationToken cancellationToken) =>
new(registers.GetWriter<ITurnoverRegisterWriter<SalesTurnoverMovement>>()
.DeleteAsync(id, cancellationToken));
}

(SalesInvoice is an illustrative document; substitute yours.) The three writer calls behave as in the Balance family — PostAsync replaces the document's whole movement set, UnpostAsync marks the rows Active = false, DeleteAsync unposts and removes — minus the balance delta step.

5. Read it back

var sold = await turnoverReader.Movements(
from, to,
q => q.Where(m => m.Dimensions.ItemId == itemId)
.GroupBy(m => m.Dimensions.ItemId)
.Select(g => new { ItemId = g.Key, Quantity = g.Sum(m => m.Resources.Quantity) }),
cancellationToken);

The reader returns only Active movements in [from, to]. Aggregation is yours to write inside the query transform. Whether a given GroupBy/Sum over the owned-type Resources translates to SQL on your provider is exactly the sort of thing this unproven family hasn't been tested for — write a SQLite integration test for your aggregate (the pattern is InventoryBalancePredicateSqliteTests) so a silent client-side fallback shows up as a failure rather than a slow report.

Note that a movement's IsExpense flag is just a stored column here — nothing subtracts it for you the way the balance writer does. If your totals need to treat expense rows as negative, do that in your aggregation.

6. Migration and verify

Normal per-provider flow (Database migrations). Then dotnet build, and — because DI is manual — confirm the AddTurnoverRegister<>() line is present and the generated SalesTurnoverMovementConfiguration exists. Live: submit the document, run your aggregate query, unsubmit and confirm the rows drop out of it.

Gotchas specific to this family

  • The AddTurnoverRegister<>() line is manual and its omission surfaces only at runtime (section 2).
  • No overdraft protection at the register level. With no balance table nothing can be "insufficient". If you need "never sell more than we have", that needs a Balance register (or a check in your Behavior against another register).
  • No as-of-date balance and no maintenance tooling. IRegisterMaintenance<,> (rebuild/verify) exists only for Balance registers — there is no derived table to drift.
  • It's the least-proven family. No live consumer and no test — budget time for finding an edge in your first real use.

See also