Перейти до основного вмісту

Document-bound info register: ExchangeRates

Full worked example for the document-bound Info family — see Creating a register for the concepts shared by every family, and Balance register: Inventory for the accumulating counterpart. If you have the create-register skill available, prefer it; this page is what its data/Dimensions.cs + data/Details.cs + data/DocumentInfo.cs skeletons produce once filled in.

An Info register stores facts that a document sets, not totals it accumulates. There is no balance table and no Resources class: the stored value is the register's Details. This is the most common shape in Kandra's reference configuration — three of its four live registers are document-bound Info registers:

RegisterDimensions (the key)Details (the stored value)Posted by
ExchangeRates (this page)from-currency, to-currencyRateRateImport
Pricesprice type, itemPricePriceList
CostingMethodSettingswarehouse?, item? (both nullable)costing method, rankCostingMethodSetting

ExchangeRates is the simplest of the three, so it's the one walked through here. It answers "what was the rate of currency X against the home currency on date D?" Acme stands in for your configuration's root name.

How it differs from Balance

  • No Resources class, no balance table, no EnsureNonNegative(). Nothing accumulates.
  • One row entity, not two. A DocumentInfoRecord — not a Balance + Movement pair — and a single table.
  • "Current value" is a date-sliced lookup, not a stored total. To get a rate you ask the reader for the last record at or before a date (SliceOfLast), per dimension combination.
  • Nullable dimensions are safe. With no balance table there is no unique upsert-target index over the dimensions for a NULL to break, so CostingMethodSettings uses nullable WarehouseId/ItemId as a deliberate "applies to all" wildcard. (Don't copy that into a Balance register.)

1. Details, Dimensions

Acme.Domain/Entities/Registers/ExchangeRates.cs, top to bottom:

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

namespace Acme.Domain.Entities.Registers;

/// <summary>The stored value: the exchange rate of FromCurrency against ToCurrency.</summary>
public sealed class ExchangeRateDetails : IRegisterDetails
{
[Precision(18, 6)]
[Caption("Rate")]
[Format("N6")]
public decimal Rate { get; set; }
}

/// <summary>
/// The analytical key: (from currency, to currency). Date and document are structural fields
/// on the record itself (Period / DocumentId), not part of this class.
/// </summary>
[KandraGeneratedEquality]
public sealed partial class ExchangeRateDimensions : IRegisterDimensions<ExchangeRateDimensions>
{
[Caption("FromCurrency")]
public Guid FromCurrencyId { get; set; }
[JsonIgnore]
public Currency FromCurrency { get; set; } = null!;

[Caption("ToCurrency")]
public Guid ToCurrencyId { get; set; }
[JsonIgnore]
public Currency ToCurrency { get; set; } = null!;
}

Both dimensions are ordinary navigation-paired foreign keys (Guid Id + [JsonIgnore] navigation) to the Currency dictionary. [Precision(18, 6)] on Rate sets the column's decimal precision; [Format("N6")] is the display format the register transactions viewer uses.

2. Record and register handle

Same file:

[KandraRegisterEntity(TableName = "Reg_ExchangeRates")]
[RegisterCaption("ExchangeRates")]
public sealed class ExchangeRateRecord : DocumentInfoRecord<ExchangeRateDimensions, ExchangeRateDetails>
{
}

public sealed partial class ExchangeRatesRegister : DocumentInfoRegister<ExchangeRateRecord>
{
}

Both [KandraRegisterEntity] and [RegisterCaption] go on the record class. The record inherits Id, Period, DocumentId, Document, LineId and Active — see Creating a register for who sets which.

3. DI and EF configuration — nothing to write

Build, then confirm this line was emitted into the generated persistence DI extensions:

services.AddDocumentInfoRegister<ExchangeRateRecord, ExchangeRateDimensions, ExchangeRateDetails>();

It registers the reader (IInfoRegisterReader<ExchangeRateRecord>) and a keyed writer (IDocumentInfoRegisterWriter<ExchangeRateRecord>). EF configuration for the record is generated the same way as for every family. If you use NoDetails there is no Details column at all — the configuration ignores it.

4. Localization

Add to all three resx files: ExchangeRates, FromCurrency, ToCurrency, Rate.

5. Write it from a document Behavior

From RateImportBehavior, the document whose lines are "currency X has rate R on this date":

[KandraDocumentBehavior(typeof(RateImportDto))]
[RegisterTransactions(typeof(ExchangeRateRecord))]
public class RateImportBehavior(/* numbering, constants, currencies */) : IDocumentBehavior<RateImport>
{
public async ValueTask OnSubmitAsync(RateImport entity, bool isInsert,
ISubmitScope registers, CancellationToken cancellationToken)
{
var homeCurrency = /* resolved from a constant + the Currency dictionary */;

var records = entity.Lines.Select(line => new ExchangeRateRecord
{
LineId = line.Id,
Dimensions = new ExchangeRateDimensions
{
FromCurrencyId = line.CurrencyId,
ToCurrencyId = homeCurrency.Id,
},
Details = new ExchangeRateDetails { Rate = line.Rate },
}).ToList();

var ratesWriter = registers.GetWriter<IDocumentInfoRegisterWriter<ExchangeRateRecord>>();
await ratesWriter.PostAsync(entity.Id, entity.Date, records, cancellationToken);
}

public ValueTask OnUnsubmitAsync(RateImport entity, ISubmitScope registers,
CancellationToken cancellationToken) =>
new(registers.GetWriter<IDocumentInfoRegisterWriter<ExchangeRateRecord>>()
.UnpostAsync(entity.Id, cancellationToken));

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

Notice how little there is compared to the Balance example: no IsExpense, no resources, no delta. PostAsync stamps Id, DocumentId, Period = entity.Date and Active = true on each record and replaces the document's whole record set — an edited-and-resubmitted rate import with one line fewer really does drop that rate. UnpostAsync sets Active = false so readers stop seeing the rates while the rows stay in the log; DeleteAsync unposts and removes them.

The [KandraDocumentBehavior]/[RegisterTransactions] attribute pair and the three hooks are covered in Creating a document.

6. Read it back

IInfoRegisterReader<TRecord> is the reader for both Info families:

// Injected via a repository/report constructor like any service:
// IInfoRegisterReader<ExchangeRateRecord> currencyRatesReader
var latest = await currencyRatesReader.SliceOfLast(
asOfDate,
q => q.Where(r => r.Dimensions.ToCurrencyId == homeCurrencyId),
cancellationToken);
  • SliceOfLast(at, adjust, ct) — for each dimension combination, the most recent active record with Period <= at. This is the "what's the rate today?" query.
  • Records(from, to, adjust, ct) — every active record in a period, i.e. the history. A second overload takes a project: transform instead of adjust: to shape the result inside the query (navigation such as r.Dimensions.FromCurrency.Code is pulled into the SQL, no Include()).

Both return AsNoTracking() rows. To fetch records for a specific set of dimension keys, use DimensionPredicateBuilder.BuildOr rather than filtering client-side (see Creating a register).

7. Migration and verify

Migration is the normal per-provider flow — Database migrations. Then: dotnet build, check the generated AddDocumentInfoRegister<...>() call and the generated record configuration, and run the tests (ExchangeRateDimensionsPredicateTests and PriceRecordTransactionsReaderSqliteTests are the closest existing shapes to copy). Live: submit a RateImport, open its Register Transactions tab, check the rates appear, unsubmit and confirm they stop being returned by the reader.

Gotchas specific to this family

  • SliceOfLast groups in memory. Grouping by an owned-type navigation (Dimensions) doesn't translate to SQL on any provider, so the reader loads every active record with Period <= at that survives your adjust filter and picks the latest per dimension combination client-side. That's fine for small reference-style logs like rates and prices, but always pass an adjust that narrows the query (by dimension) rather than slicing the whole register.
  • One record per (DocumentId, LineId). The record table has a unique index on those two columns, so a document can't post two records with the same LineId. Give each record its own source line's Id; if one line must fan out into several records, you'll need a different LineId for each. (Balance/Turnover movements have no such restriction — a transfer's two legs share a LineId.)
  • No uniqueness on (Dimensions, Period). Two records with the same key and the same Period can both exist (from different documents), and SliceOfLast then returns just one of them — not deterministically the "latest posted". If you need a hard "one rate per currency pair per day" rule, enforce it in the document's validator or Behavior.
  • Don't confuse this with the independent/periodic family. Records here belong to a document and disappear from readers when it is unposted. If the data must survive independent of any document — set from an admin screen or an import job — that's PeriodicInfoRegister<>.

See also