diff --git a/src/TALXIS.CLI.Core/Contracts/Dataverse/DateFormats.cs b/src/TALXIS.CLI.Core/Contracts/Dataverse/DateFormats.cs
new file mode 100644
index 00000000..55a6f0d7
--- /dev/null
+++ b/src/TALXIS.CLI.Core/Contracts/Dataverse/DateFormats.cs
@@ -0,0 +1,51 @@
+namespace TALXIS.CLI.Core.Contracts.Dataverse;
+
+///
+/// Maps Dataverse date format strings to their codes
+///
+public static class DateFormats
+{
+ /// Short date formats mapped to their dateformatcode.
+ public static readonly IReadOnlyDictionary Short = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["dd/MMMM/yy"] = 0,
+ ["M/d/yy"] = 1,
+ ["M/d/yyyy"] = 2,
+ ["MM/dd/yy"] = 3,
+ ["MM/dd/yyyy"] = 4,
+ ["yy/MM/dd"] = 5,
+ ["yyyy/MM/dd"] = 6,
+ };
+
+ /// Long date formats mapped to their longdateformatcode.
+ public static readonly IReadOnlyDictionary Long = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["d MMMM, yyyy"] = 0,
+ ["dddd, d MMMM, yyyy"] = 1,
+ ["dddd, MMMM d, yyyy"] = 2,
+ ["MMMM d, yyyy"] = 3,
+ };
+
+ ///
+ /// Turns user input into a format code.
+ ///
+ public static int ToCode(IReadOnlyDictionary formats, string input, string label)
+ {
+ if (string.IsNullOrWhiteSpace(input)) throw new ArgumentException($"{label} cannot be empty.");
+
+
+ if (int.TryParse(input, out var code)) return code;
+
+
+ if (formats.TryGetValue(input, out var mapped)) return mapped;
+
+ var known = string.Join(Environment.NewLine, formats.OrderBy(pair => pair.Value).Select(pair => $" {pair.Value} {pair.Key}"));
+ throw new ArgumentException(
+ $"'{input}' is not a known {label}. Pass a code or one of:{Environment.NewLine}{known}");
+ }
+
+ ///
+ /// Returns the format string for a code, or null when the code is unknown.
+ ///
+ public static string? Describe(IReadOnlyDictionary formats, int? code) => code is { } codeValue ? formats.FirstOrDefault(pair => pair.Value == codeValue).Key : null;
+}
diff --git a/src/TALXIS.CLI.Core/Contracts/Dataverse/IUserSettingsService.cs b/src/TALXIS.CLI.Core/Contracts/Dataverse/IUserSettingsService.cs
new file mode 100644
index 00000000..8ca7cc88
--- /dev/null
+++ b/src/TALXIS.CLI.Core/Contracts/Dataverse/IUserSettingsService.cs
@@ -0,0 +1,73 @@
+namespace TALXIS.CLI.Core.Contracts.Dataverse;
+
+///
+/// The connected user's personalization settings (timezone, locale, date/time
+/// formats, currency) read from the Dataverse usersettings entity.
+///
+public sealed record UserSettingsInfo(
+ Guid UserId,
+ string? FullName,
+ string? Email,
+ int? TimeZoneCode,
+ string? TimeZoneName,
+ int? LocaleId,
+ string? LocaleName,
+ int? ShortDateFormatCode,
+ string? ShortDateFormat,
+ int? LongDateFormatCode,
+ string? TimeFormat,
+ string? CurrencyCode,
+ string? CurrencyName);
+
+/// A Dataverse timezonedefinition row.
+public sealed record TimezoneInfo(int Code, string Name, string? StandardName);
+
+/// A Dataverse transactioncurrency row.
+public sealed record CurrencyInfo(Guid Id, string IsoCode, string Name, string? Symbol);
+
+///
+/// The subset of user settings to change. Null fields are left untouched.
+///
+public sealed record UserSettingsUpdate(
+ int? TimeZoneCode = null,
+ int? LocaleId = null,
+ int? ShortDateFormatCode = null,
+ int? LongDateFormatCode = null,
+ string? TimeFormat = null,
+ Guid? CurrencyId = null)
+{
+ public bool IsEmpty => TimeZoneCode is null && LocaleId is null && ShortDateFormatCode is null
+ && LongDateFormatCode is null && TimeFormat is null && CurrencyId is null;
+}
+
+///
+/// Reads and updates the connected user's personalization settings and looks
+/// up available timezones and currencies. Keyed off the current user resolved
+/// via WhoAmI, so it always targets the profile's own account.
+///
+public interface IUserSettingsService
+{
+ /// Gets the connected user's current settings.
+ Task GetCurrentAsync(string? profileName, CancellationToken ct);
+
+ /// Applies the non-null fields of to the connected user.
+ Task UpdateCurrentAsync(string? profileName, UserSettingsUpdate update, CancellationToken ct);
+
+ /// Lists timezones, optionally filtered by a substring of the display or standard name.
+ Task> ListTimezonesAsync(string? profileName, string? filter, CancellationToken ct);
+
+ ///
+ /// Resolves a timezone name (fuzzy) to its numeric code. Throws
+ /// when nothing matches or the query is ambiguous.
+ ///
+ Task ResolveTimezoneCodeAsync(string? profileName, string query, CancellationToken ct);
+
+ /// Lists currencies enabled in the environment, optionally filtered by a substring of the ISO code or name.
+ Task> ListCurrenciesAsync(string? profileName, string? filter, CancellationToken ct);
+
+ ///
+ /// Resolves a currency (ISO code or name, fuzzy) to its record id. Throws
+ /// when nothing matches or the query is ambiguous.
+ ///
+ Task ResolveCurrencyIdAsync(string? profileName, string query, CancellationToken ct);
+}
diff --git a/src/TALXIS.CLI.Features.Environment/Currency/CurrencyCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Currency/CurrencyCliCommand.cs
new file mode 100644
index 00000000..e1d6303f
--- /dev/null
+++ b/src/TALXIS.CLI.Features.Environment/Currency/CurrencyCliCommand.cs
@@ -0,0 +1,21 @@
+using DotMake.CommandLine;
+
+namespace TALXIS.CLI.Features.Environment.Currency;
+
+///
+/// txc environment currency - look up currencies enabled in the
+/// environment for use with user-settings set --currency.
+///
+[CliCommand(
+ Name = "currency",
+ Description = "Look up currencies enabled in the environment.",
+ Children = new[] { typeof(CurrencyListCliCommand) },
+ ShortFormAutoGenerate = CliNameAutoGenerate.None
+)]
+public class CurrencyCliCommand
+{
+ public void Run(CliContext context)
+ {
+ context.ShowHelp();
+ }
+}
diff --git a/src/TALXIS.CLI.Features.Environment/Currency/CurrencyListCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Currency/CurrencyListCliCommand.cs
new file mode 100644
index 00000000..66c52b59
--- /dev/null
+++ b/src/TALXIS.CLI.Features.Environment/Currency/CurrencyListCliCommand.cs
@@ -0,0 +1,53 @@
+using DotMake.CommandLine;
+using Microsoft.Extensions.Logging;
+using TALXIS.CLI.Core;
+using TALXIS.CLI.Core.Contracts.Dataverse;
+using TALXIS.CLI.Core.DependencyInjection;
+using TALXIS.CLI.Logging;
+
+namespace TALXIS.CLI.Features.Environment.Currency;
+
+///
+/// txc environment currency list - lists currencies enabled in the
+/// environment (ISO code + name), optionally filtered.
+///
+[CliReadOnly]
+[CliCommand(
+ Name = "list",
+ Description = "Lists currencies enabled in the LIVE connected environment (ISO code + name). Requires an active profile."
+)]
+public class CurrencyListCliCommand : ProfiledCliCommand
+{
+ protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(CurrencyListCliCommand));
+
+ [CliOption(Name = "--filter", Description = "Show only currencies whose ISO code or name contains this substring.", Required = false)]
+ public string? Filter { get; set; }
+
+ protected override async Task ExecuteAsync()
+ {
+ var service = TxcServices.Get();
+ var currencies = await service.ListCurrenciesAsync(Profile, Filter, CancellationToken.None).ConfigureAwait(false);
+
+ OutputFormatter.WriteList(currencies, PrintTable);
+ return ExitSuccess;
+ }
+
+ // Text-renderer callback invoked by OutputFormatter.WriteList; OutputWriter usage is intentional.
+#pragma warning disable TXC003
+ private static void PrintTable(IReadOnlyList currencies)
+ {
+ if (currencies.Count == 0)
+ {
+ OutputWriter.WriteLine("No currencies found.");
+ return;
+ }
+
+ OutputWriter.WriteLine($"{"ISO",-5} | Name");
+ OutputWriter.WriteLine(new string('-', 60));
+ foreach (var currency in currencies)
+ {
+ OutputWriter.WriteLine($"{currency.IsoCode,-5} | {currency.Name}");
+ }
+ }
+#pragma warning restore TXC003
+}
diff --git a/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs b/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs
index 3e26fc7f..f22637c9 100644
--- a/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs
+++ b/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs
@@ -6,7 +6,7 @@ namespace TALXIS.CLI.Features.Environment;
Name = "environment",
Alias = "env",
Description = "Manage the footprint of your project in a live target environment (packages, solutions, deployment history).",
- Children = new[] { typeof(EnvironmentListCliCommand), typeof(EnvironmentCreateCliCommand), typeof(EnvironmentUpdateCliCommand), typeof(EnvironmentDeleteCliCommand), typeof(Package.PackageCliCommand), typeof(Solution.SolutionCliCommand), typeof(Deployment.DeploymentCliCommand), typeof(Data.EnvDataCliCommand), typeof(Entity.EntityCliCommand), typeof(OptionSet.OptionSetCliCommand), typeof(Setting.SettingCliCommand), typeof(Changeset.ChangesetCliCommand), typeof(Component.ComponentCliCommand), typeof(Publisher.PublisherCliCommand) },
+ Children = new[] { typeof(EnvironmentListCliCommand), typeof(EnvironmentCreateCliCommand), typeof(EnvironmentUpdateCliCommand), typeof(EnvironmentDeleteCliCommand), typeof(Package.PackageCliCommand), typeof(Solution.SolutionCliCommand), typeof(Deployment.DeploymentCliCommand), typeof(Data.EnvDataCliCommand), typeof(Entity.EntityCliCommand), typeof(OptionSet.OptionSetCliCommand), typeof(Setting.SettingCliCommand), typeof(Changeset.ChangesetCliCommand), typeof(Component.ComponentCliCommand), typeof(Publisher.PublisherCliCommand), typeof(UserSettings.UserSettingsCliCommand), typeof(Timezone.TimezoneCliCommand), typeof(Currency.CurrencyCliCommand) },
ShortFormAutoGenerate = CliNameAutoGenerate.None
)]
public class EnvironmentCliCommand
diff --git a/src/TALXIS.CLI.Features.Environment/Timezone/TimezoneCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Timezone/TimezoneCliCommand.cs
new file mode 100644
index 00000000..4dc411db
--- /dev/null
+++ b/src/TALXIS.CLI.Features.Environment/Timezone/TimezoneCliCommand.cs
@@ -0,0 +1,21 @@
+using DotMake.CommandLine;
+
+namespace TALXIS.CLI.Features.Environment.Timezone;
+
+///
+/// txc environment timezone - look up Dataverse timezones for use with
+/// user-settings set --timezone.
+///
+[CliCommand(
+ Name = "timezone",
+ Description = "Look up Dataverse timezones.",
+ Children = new[] { typeof(TimezoneListCliCommand) },
+ ShortFormAutoGenerate = CliNameAutoGenerate.None
+)]
+public class TimezoneCliCommand
+{
+ public void Run(CliContext context)
+ {
+ context.ShowHelp();
+ }
+}
diff --git a/src/TALXIS.CLI.Features.Environment/Timezone/TimezoneListCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Timezone/TimezoneListCliCommand.cs
new file mode 100644
index 00000000..9f94df21
--- /dev/null
+++ b/src/TALXIS.CLI.Features.Environment/Timezone/TimezoneListCliCommand.cs
@@ -0,0 +1,53 @@
+using DotMake.CommandLine;
+using Microsoft.Extensions.Logging;
+using TALXIS.CLI.Core;
+using TALXIS.CLI.Core.Contracts.Dataverse;
+using TALXIS.CLI.Core.DependencyInjection;
+using TALXIS.CLI.Logging;
+
+namespace TALXIS.CLI.Features.Environment.Timezone;
+
+///
+/// txc environment timezone list - lists available timezones and their
+/// codes, optionally filtered by name.
+///
+[CliReadOnly]
+[CliCommand(
+ Name = "list",
+ Description = "Lists Dataverse timezones (code + name) from the LIVE connected environment. Requires an active profile."
+)]
+public class TimezoneListCliCommand : ProfiledCliCommand
+{
+ protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(TimezoneListCliCommand));
+
+ [CliOption(Name = "--filter", Description = "Show only timezones whose name contains this substring.", Required = false)]
+ public string? Filter { get; set; }
+
+ protected override async Task ExecuteAsync()
+ {
+ var service = TxcServices.Get();
+ var timezones = await service.ListTimezonesAsync(Profile, Filter, CancellationToken.None).ConfigureAwait(false);
+
+ OutputFormatter.WriteList(timezones, PrintTable);
+ return ExitSuccess;
+ }
+
+ // Text-renderer callback invoked by OutputFormatter.WriteList; OutputWriter usage is intentional.
+#pragma warning disable TXC003
+ private static void PrintTable(IReadOnlyList timezones)
+ {
+ if (timezones.Count == 0)
+ {
+ OutputWriter.WriteLine("No timezones found.");
+ return;
+ }
+
+ OutputWriter.WriteLine($"{"Code",-5} | Name");
+ OutputWriter.WriteLine(new string('-', 60));
+ foreach (var timezone in timezones)
+ {
+ OutputWriter.WriteLine($"{timezone.Code,-5} | {timezone.Name}");
+ }
+ }
+#pragma warning restore TXC003
+}
diff --git a/src/TALXIS.CLI.Features.Environment/UserSettings/UserSettingsCliCommand.cs b/src/TALXIS.CLI.Features.Environment/UserSettings/UserSettingsCliCommand.cs
new file mode 100644
index 00000000..f6d23b73
--- /dev/null
+++ b/src/TALXIS.CLI.Features.Environment/UserSettings/UserSettingsCliCommand.cs
@@ -0,0 +1,21 @@
+using DotMake.CommandLine;
+
+namespace TALXIS.CLI.Features.Environment.UserSettings;
+
+///
+/// txc environment user-settings - view and change the connected
+/// user's personalization settings (timezone, locale, date/time formats).
+///
+[CliCommand(
+ Name = "user-settings",
+ Description = "View and change the connected user's settings (timezone, locale, date/time formats).",
+ Children = new[] { typeof(UserSettingsGetCliCommand), typeof(UserSettingsSetCliCommand) },
+ ShortFormAutoGenerate = CliNameAutoGenerate.None
+)]
+public class UserSettingsCliCommand
+{
+ public void Run(CliContext context)
+ {
+ context.ShowHelp();
+ }
+}
diff --git a/src/TALXIS.CLI.Features.Environment/UserSettings/UserSettingsGetCliCommand.cs b/src/TALXIS.CLI.Features.Environment/UserSettings/UserSettingsGetCliCommand.cs
new file mode 100644
index 00000000..bed71355
--- /dev/null
+++ b/src/TALXIS.CLI.Features.Environment/UserSettings/UserSettingsGetCliCommand.cs
@@ -0,0 +1,67 @@
+using DotMake.CommandLine;
+using Microsoft.Extensions.Logging;
+using TALXIS.CLI.Core;
+using TALXIS.CLI.Core.Contracts.Dataverse;
+using TALXIS.CLI.Core.DependencyInjection;
+using TALXIS.CLI.Logging;
+
+namespace TALXIS.CLI.Features.Environment.UserSettings;
+
+///
+/// txc environment user-settings get - shows the connected user's
+/// timezone, locale, date/time formats, and currency.
+///
+[CliReadOnly]
+[CliCommand(
+ Name = "get",
+ Description = "Shows the connected user's settings (timezone, locale, date/time formats, currency) from the LIVE connected environment. Requires an active profile."
+)]
+public class UserSettingsGetCliCommand : ProfiledCliCommand
+{
+ protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(UserSettingsGetCliCommand));
+
+ protected override async Task ExecuteAsync()
+ {
+ var service = TxcServices.Get();
+ var settings = await service.GetCurrentAsync(Profile, CancellationToken.None).ConfigureAwait(false);
+
+ OutputFormatter.WriteData(settings, Print);
+ return ExitSuccess;
+ }
+
+ // Text-renderer callback invoked by OutputFormatter.WriteData; OutputWriter usage is intentional.
+#pragma warning disable TXC003
+ private static void Print(UserSettingsInfo userSettingsInfo)
+ {
+ var user = userSettingsInfo.FullName is { } name
+ ? (userSettingsInfo.Email is { } email ? $"{name} ({email})" : name)
+ : userSettingsInfo.UserId.ToString();
+ var timezone = userSettingsInfo.TimeZoneCode is { } code
+ ? (userSettingsInfo.TimeZoneName is { } tzName ? $"{tzName} (code: {code})" : $"code {code}")
+ : "(not set)";
+ var locale = userSettingsInfo.LocaleId is { } localeId
+ ? (userSettingsInfo.LocaleName is { } localeName ? $"{localeName} (code: {localeId})" : $"code {localeId}")
+ : "(not set)";
+ var currency = userSettingsInfo.CurrencyCode is { } isoCode
+ ? (userSettingsInfo.CurrencyName is { } currencyName ? $"{isoCode} ({currencyName})" : isoCode)
+ : "(not set)";
+ var shortDate = FormatCode(userSettingsInfo.ShortDateFormatCode, userSettingsInfo.ShortDateFormat ?? DateFormats.Describe(DateFormats.Short, userSettingsInfo.ShortDateFormatCode));
+ var longDate = FormatCode(userSettingsInfo.LongDateFormatCode, DateFormats.Describe(DateFormats.Long, userSettingsInfo.LongDateFormatCode));
+
+ OutputWriter.WriteLine($"User: {user}");
+ OutputWriter.WriteLine($"Timezone: {timezone}");
+ OutputWriter.WriteLine($"Locale: {locale}");
+ OutputWriter.WriteLine($"Short date: {shortDate}");
+ OutputWriter.WriteLine($"Long date: {longDate}");
+ OutputWriter.WriteLine($"Time format: {userSettingsInfo.TimeFormat ?? "(not set)"}");
+ OutputWriter.WriteLine($"Currency: {currency}");
+ }
+
+ private static string FormatCode(int? code, string? description)
+ {
+ if (code is not { } codeValue)
+ return "(not set)";
+ return description is { } format ? $"{format} (code: {codeValue})" : codeValue.ToString();
+ }
+#pragma warning restore TXC003
+}
diff --git a/src/TALXIS.CLI.Features.Environment/UserSettings/UserSettingsSetCliCommand.cs b/src/TALXIS.CLI.Features.Environment/UserSettings/UserSettingsSetCliCommand.cs
new file mode 100644
index 00000000..7df372f7
--- /dev/null
+++ b/src/TALXIS.CLI.Features.Environment/UserSettings/UserSettingsSetCliCommand.cs
@@ -0,0 +1,84 @@
+using DotMake.CommandLine;
+using Microsoft.Extensions.Logging;
+using TALXIS.CLI.Core;
+using TALXIS.CLI.Core.Contracts.Dataverse;
+using TALXIS.CLI.Core.DependencyInjection;
+using TALXIS.CLI.Logging;
+
+namespace TALXIS.CLI.Features.Environment.UserSettings;
+
+///
+/// txc environment user-settings set - updates one or more of the
+/// connected user's settings. Timezone and currency accept a name (fuzzy) or
+/// their code/ISO code.
+///
+[CliIdempotent]
+[CliCommand(
+ Name = "set",
+ Description = "Updates the connected user's settings (timezone, locale, date/time formats, currency) on the LIVE connected environment. Requires an active profile. Pass at least one setting."
+)]
+public class UserSettingsSetCliCommand : ProfiledCliCommand
+{
+ protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(UserSettingsSetCliCommand));
+
+ [CliOption(Name = "--timezone", Description = "Timezone by numeric code (e.g. 95) or by city/region name, fuzzy-matched (e.g. 'Prague').", Required = false)]
+ public string? Timezone { get; set; }
+
+ [CliOption(Name = "--locale", Description = "Locale ID / LCID (e.g. 1033 for en-US, 1029 for cs-CZ).", Required = false)]
+ public int? Locale { get; set; }
+
+ [CliOption(Name = "--short-date-format", Description = "Short date format string (e.g. 'M/d/yyyy') or its numeric code. See options via 'user-settings get'.", Required = false)]
+ public string? ShortDateFormat { get; set; }
+
+ [CliOption(Name = "--long-date-format", Description = "Long date format string (e.g. 'dddd, MMMM d, yyyy') or its numeric code. See options via 'user-settings get'.", Required = false)]
+ public string? LongDateFormat { get; set; }
+
+ [CliOption(Name = "--time-format", Description = "Time format string (e.g. 'H:mm', 'h:mm tt').", Required = false)]
+ public string? TimeFormat { get; set; }
+
+ [CliOption(Name = "--currency", Description = "Base currency by ISO code (e.g. 'CZK') or name, fuzzy-matched.", Required = false)]
+ public string? Currency { get; set; }
+
+ protected override async Task ExecuteAsync()
+ {
+ if (Timezone is null && Locale is null && ShortDateFormat is null && LongDateFormat is null && TimeFormat is null && Currency is null)
+ {
+ Logger.LogError("Nothing to update. Pass at least one of --timezone, --locale, --short-date-format, --long-date-format, --time-format, --currency.");
+ return ExitValidationError;
+ }
+
+ var service = TxcServices.Get();
+
+ int? timezoneCode = null;
+ if (Timezone is { } timezone)
+ {
+ timezoneCode = int.TryParse(timezone, out var code)
+ ? code
+ : await service.ResolveTimezoneCodeAsync(Profile, timezone, CancellationToken.None).ConfigureAwait(false);
+ }
+
+ Guid? currencyId = null;
+ if (Currency is { } currency)
+ currencyId = await service.ResolveCurrencyIdAsync(Profile, currency, CancellationToken.None).ConfigureAwait(false);
+
+ int? shortDateCode = ShortDateFormat is { } shortDateFormat
+ ? DateFormats.ToCode(DateFormats.Short, shortDateFormat, "short date format")
+ : null;
+ int? longDateCode = LongDateFormat is { } longDateFormat
+ ? DateFormats.ToCode(DateFormats.Long, longDateFormat, "long date format")
+ : null;
+
+ var update = new UserSettingsUpdate(
+ TimeZoneCode: timezoneCode,
+ LocaleId: Locale,
+ ShortDateFormatCode: shortDateCode,
+ LongDateFormatCode: longDateCode,
+ TimeFormat: TimeFormat,
+ CurrencyId: currencyId);
+
+ await service.UpdateCurrentAsync(Profile, update, CancellationToken.None).ConfigureAwait(false);
+
+ OutputFormatter.WriteResult("succeeded", "User settings updated.");
+ return ExitSuccess;
+ }
+}
diff --git a/src/TALXIS.CLI.Platform.Dataverse.Data/CurrencyResolver.cs b/src/TALXIS.CLI.Platform.Dataverse.Data/CurrencyResolver.cs
new file mode 100644
index 00000000..2b4e3d74
--- /dev/null
+++ b/src/TALXIS.CLI.Platform.Dataverse.Data/CurrencyResolver.cs
@@ -0,0 +1,47 @@
+using TALXIS.CLI.Core.Contracts.Dataverse;
+
+namespace TALXIS.CLI.Platform.Dataverse.Data;
+
+///
+/// Pure name/ISO-code-to-currency matching so user-settings set --currency
+/// can take "CZK" or "Czech Koruna" instead of a record id.
+///
+internal static class CurrencyResolver
+{
+ ///
+ /// Finds the single currency matching . An exact
+ /// (case-insensitive) ISO code wins; otherwise a substring match on the
+ /// code, name, or symbol must be unique. Throws
+ /// on no match or ambiguity.
+ ///
+ public static CurrencyInfo Resolve(IReadOnlyList currencies, string query)
+ {
+ if (string.IsNullOrWhiteSpace(query))
+ throw new ArgumentException("Currency query cannot be empty.");
+
+ var trimmedQuery = query.Trim();
+
+ var exactMatches = currencies
+ .Where(currency => string.Equals(currency.IsoCode, trimmedQuery, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+ if (exactMatches.Count == 1)
+ return exactMatches[0];
+
+ var matches = currencies
+ .Where(currency => currency.IsoCode.Contains(trimmedQuery, StringComparison.OrdinalIgnoreCase)
+ || currency.Name.Contains(trimmedQuery, StringComparison.OrdinalIgnoreCase)
+ || (currency.Symbol?.Contains(trimmedQuery, StringComparison.OrdinalIgnoreCase) ?? false))
+ .ToList();
+
+ if (matches.Count == 1)
+ return matches[0];
+
+ if (matches.Count == 0)
+ throw new ArgumentException(
+ $"No currency matches '{query}'. Run 'txc env currency list --filter {query}' to browse.");
+
+ var sample = string.Join(Environment.NewLine, matches.Take(10).Select(currency => $" {currency.IsoCode,-5} {currency.Name}"));
+ throw new ArgumentException(
+ $"'{query}' matches {matches.Count} currencies. Use the exact ISO code:{Environment.NewLine}{sample}");
+ }
+}
diff --git a/src/TALXIS.CLI.Platform.Dataverse.Data/DependencyInjection/DataverseDataServiceCollectionExtensions.cs b/src/TALXIS.CLI.Platform.Dataverse.Data/DependencyInjection/DataverseDataServiceCollectionExtensions.cs
index d1eb017c..bf6ea5ae 100644
--- a/src/TALXIS.CLI.Platform.Dataverse.Data/DependencyInjection/DataverseDataServiceCollectionExtensions.cs
+++ b/src/TALXIS.CLI.Platform.Dataverse.Data/DependencyInjection/DataverseDataServiceCollectionExtensions.cs
@@ -15,6 +15,7 @@ public static IServiceCollection AddTxcDataverseData(this IServiceCollection ser
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddTransient();
return services;
}
diff --git a/src/TALXIS.CLI.Platform.Dataverse.Data/TimezoneResolver.cs b/src/TALXIS.CLI.Platform.Dataverse.Data/TimezoneResolver.cs
new file mode 100644
index 00000000..c3691381
--- /dev/null
+++ b/src/TALXIS.CLI.Platform.Dataverse.Data/TimezoneResolver.cs
@@ -0,0 +1,45 @@
+using TALXIS.CLI.Core.Contracts.Dataverse;
+
+namespace TALXIS.CLI.Platform.Dataverse.Data;
+
+///
+/// Pure name-to-timezone matching so user-settings set --timezone can
+/// take a city or region name instead of a numeric code.
+///
+internal static class TimezoneResolver
+{
+ ///
+ /// Finds the single timezone matching . An exact
+ /// (case-insensitive) name wins; otherwise a substring match must be unique.
+ /// Throws on no match or ambiguity.
+ ///
+ public static TimezoneInfo Resolve(IReadOnlyList timezones, string query)
+ {
+ if (string.IsNullOrWhiteSpace(query))
+ throw new ArgumentException("Timezone query cannot be empty.");
+
+ var trimmedQuery = query.Trim();
+
+ var exactMatches = timezones
+ .Where(timezone => string.Equals(timezone.Name, trimmedQuery, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+ if (exactMatches.Count == 1)
+ return exactMatches[0];
+
+ var matches = timezones
+ .Where(timezone => timezone.Name.Contains(trimmedQuery, StringComparison.OrdinalIgnoreCase)
+ || (timezone.StandardName?.Contains(trimmedQuery, StringComparison.OrdinalIgnoreCase) ?? false))
+ .ToList();
+
+ if (matches.Count == 1)
+ return matches[0];
+
+ if (matches.Count == 0)
+ throw new ArgumentException(
+ $"No timezone matches '{query}'. Run 'txc env timezone list --filter {query}' to browse, or pass the numeric code to --timezone.");
+
+ var sample = string.Join(Environment.NewLine, matches.Take(10).Select(timezone => $" {timezone.Code,-5} {timezone.Name}"));
+ throw new ArgumentException(
+ $"'{query}' matches {matches.Count} timezones. Narrow it down or pass the numeric code to --timezone:{Environment.NewLine}{sample}");
+ }
+}
diff --git a/src/TALXIS.CLI.Platform.Dataverse.Data/UserSettingsService.cs b/src/TALXIS.CLI.Platform.Dataverse.Data/UserSettingsService.cs
new file mode 100644
index 00000000..eb26476e
--- /dev/null
+++ b/src/TALXIS.CLI.Platform.Dataverse.Data/UserSettingsService.cs
@@ -0,0 +1,198 @@
+using System.Globalization;
+using Microsoft.Crm.Sdk.Messages;
+using Microsoft.Xrm.Sdk;
+using Microsoft.Xrm.Sdk.Query;
+using TALXIS.CLI.Core.Contracts.Dataverse;
+using TALXIS.CLI.Platform.Dataverse.Runtime;
+
+namespace TALXIS.CLI.Platform.Dataverse.Data;
+
+///
+/// Reads and writes the connected user's usersettings record and looks
+/// up timezonedefinition rows via the ServiceClient SDK.
+///
+internal sealed class UserSettingsService : IUserSettingsService
+{
+ public async Task GetCurrentAsync(string? profileName, CancellationToken ct)
+ {
+ using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false);
+ var userId = await WhoAmIAsync(conn, ct).ConfigureAwait(false);
+
+ var settings = await conn.Client.RetrieveAsync(
+ "usersettings", userId,
+ new ColumnSet("timezonecode", "localeid", "dateformatcode", "dateformatstring", "longdateformatcode", "timeformatstring", "transactioncurrencyid"),
+ ct).ConfigureAwait(false);
+
+ var user = await conn.Client.RetrieveAsync(
+ "systemuser", userId,
+ new ColumnSet("fullname", "internalemailaddress"),
+ ct).ConfigureAwait(false);
+
+ var timezoneCode = settings.GetAttributeValue("timezonecode");
+ var localeId = settings.GetAttributeValue("localeid");
+ var currency = settings.GetAttributeValue("transactioncurrencyid") is { } currencyRef
+ ? await TryGetCurrencyAsync(conn, currencyRef.Id, ct).ConfigureAwait(false)
+ : null;
+
+ return new UserSettingsInfo(
+ UserId: userId,
+ FullName: user.GetAttributeValue("fullname"),
+ Email: user.GetAttributeValue("internalemailaddress"),
+ TimeZoneCode: timezoneCode,
+ TimeZoneName: timezoneCode is { } code ? await TryGetTimezoneNameAsync(conn, code, ct).ConfigureAwait(false) : null,
+ LocaleId: localeId,
+ LocaleName: LocaleName(localeId),
+ ShortDateFormatCode: settings.GetAttributeValue("dateformatcode"),
+ ShortDateFormat: settings.GetAttributeValue("dateformatstring"),
+ LongDateFormatCode: settings.GetAttributeValue("longdateformatcode"),
+ TimeFormat: settings.GetAttributeValue("timeformatstring"),
+ CurrencyCode: currency?.IsoCode,
+ CurrencyName: currency?.Name);
+ }
+
+ public async Task UpdateCurrentAsync(string? profileName, UserSettingsUpdate update, CancellationToken ct)
+ {
+ if (update.IsEmpty)
+ throw new ArgumentException("No settings to update.");
+
+ using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false);
+ var userId = await WhoAmIAsync(conn, ct).ConfigureAwait(false);
+
+ var entity = new Entity("usersettings", userId);
+
+ if (update.TimeZoneCode is { } timeZoneCode) entity["timezonecode"] = timeZoneCode;
+ if (update.LocaleId is { } localeId) entity["localeid"] = localeId;
+ if (update.ShortDateFormatCode is { } shortDateCode) entity["dateformatcode"] = shortDateCode;
+ if (update.LongDateFormatCode is { } longDateCode) entity["longdateformatcode"] = longDateCode;
+ if (update.TimeFormat is { } timeFormat) entity["timeformatstring"] = timeFormat;
+ if (update.CurrencyId is { } currencyId) entity["transactioncurrencyid"] = new EntityReference("transactioncurrency", currencyId);
+
+ await conn.Client.UpdateAsync(entity, ct).ConfigureAwait(false);
+ }
+
+ public async Task> ListTimezonesAsync(string? profileName, string? filter, CancellationToken ct)
+ {
+ using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false);
+ var all = await QueryTimezonesAsync(conn, ct).ConfigureAwait(false);
+
+ if (string.IsNullOrWhiteSpace(filter))
+ return all;
+
+ return all
+ .Where(timezone => timezone.Name.Contains(filter, StringComparison.OrdinalIgnoreCase)
+ || (timezone.StandardName?.Contains(filter, StringComparison.OrdinalIgnoreCase) ?? false))
+ .ToList();
+ }
+
+ public async Task ResolveTimezoneCodeAsync(string? profileName, string query, CancellationToken ct)
+ {
+ using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false);
+ var all = await QueryTimezonesAsync(conn, ct).ConfigureAwait(false);
+ return TimezoneResolver.Resolve(all, query).Code;
+ }
+
+ public async Task> ListCurrenciesAsync(string? profileName, string? filter, CancellationToken ct)
+ {
+ using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false);
+ var all = await QueryCurrenciesAsync(conn, ct).ConfigureAwait(false);
+
+ if (string.IsNullOrWhiteSpace(filter))
+ return all;
+
+ return all
+ .Where(currency => currency.IsoCode.Contains(filter, StringComparison.OrdinalIgnoreCase)
+ || currency.Name.Contains(filter, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+ }
+
+ public async Task ResolveCurrencyIdAsync(string? profileName, string query, CancellationToken ct)
+ {
+ using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false);
+ var all = await QueryCurrenciesAsync(conn, ct).ConfigureAwait(false);
+ return CurrencyResolver.Resolve(all, query).Id;
+ }
+
+ private static async Task WhoAmIAsync(DataverseConnection conn, CancellationToken ct)
+ {
+ var response = (WhoAmIResponse)await conn.Client.ExecuteAsync(new WhoAmIRequest(), ct).ConfigureAwait(false);
+ return response.UserId;
+ }
+
+ private static async Task> QueryTimezonesAsync(DataverseConnection conn, CancellationToken ct)
+ {
+ var query = new QueryExpression("timezonedefinition")
+ {
+ ColumnSet = new ColumnSet("timezonecode", "userinterfacename", "standardname"),
+ Orders = { new OrderExpression("userinterfacename", OrderType.Ascending) }
+ };
+
+ var result = await conn.Client.RetrieveMultipleAsync(query, ct).ConfigureAwait(false);
+ return result.Entities
+ .Select(entity => new TimezoneInfo(
+ entity.GetAttributeValue("timezonecode"),
+ entity.GetAttributeValue("userinterfacename") ?? string.Empty,
+ entity.GetAttributeValue("standardname")))
+ .ToList();
+ }
+
+ private static async Task TryGetTimezoneNameAsync(DataverseConnection conn, int code, CancellationToken ct)
+ {
+ var query = new QueryExpression("timezonedefinition")
+ {
+ ColumnSet = new ColumnSet("userinterfacename"),
+ Criteria = { Conditions = { new ConditionExpression("timezonecode", ConditionOperator.Equal, code) } },
+ TopCount = 1
+ };
+
+ var result = await conn.Client.RetrieveMultipleAsync(query, ct).ConfigureAwait(false);
+ return result.Entities.Count > 0
+ ? result.Entities[0].GetAttributeValue("userinterfacename")
+ : null;
+ }
+
+ private static async Task> QueryCurrenciesAsync(DataverseConnection conn, CancellationToken ct)
+ {
+ var query = new QueryExpression("transactioncurrency")
+ {
+ ColumnSet = new ColumnSet("transactioncurrencyid", "isocurrencycode", "currencyname", "currencysymbol"),
+ Orders = { new OrderExpression("isocurrencycode", OrderType.Ascending) }
+ };
+
+ var result = await conn.Client.RetrieveMultipleAsync(query, ct).ConfigureAwait(false);
+ return result.Entities
+ .Select(entity => new CurrencyInfo(
+ entity.Id,
+ entity.GetAttributeValue("isocurrencycode") ?? string.Empty,
+ entity.GetAttributeValue("currencyname") ?? string.Empty,
+ entity.GetAttributeValue("currencysymbol")))
+ .ToList();
+ }
+
+ private static async Task TryGetCurrencyAsync(DataverseConnection conn, Guid id, CancellationToken ct)
+ {
+ var currency = await conn.Client.RetrieveAsync(
+ "transactioncurrency", id,
+ new ColumnSet("isocurrencycode", "currencyname", "currencysymbol"),
+ ct).ConfigureAwait(false);
+
+ return new CurrencyInfo(
+ id,
+ currency.GetAttributeValue("isocurrencycode") ?? string.Empty,
+ currency.GetAttributeValue("currencyname") ?? string.Empty,
+ currency.GetAttributeValue("currencysymbol"));
+ }
+
+ private static string? LocaleName(int? localeId)
+ {
+ if (localeId is null)
+ return null;
+ try
+ {
+ return CultureInfo.GetCultureInfo(localeId.Value).Name;
+ }
+ catch (CultureNotFoundException)
+ {
+ return null;
+ }
+ }
+}
diff --git a/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/CurrencyResolverTests.cs b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/CurrencyResolverTests.cs
new file mode 100644
index 00000000..6f50c819
--- /dev/null
+++ b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/CurrencyResolverTests.cs
@@ -0,0 +1,73 @@
+using System;
+using System.Collections.Generic;
+using TALXIS.CLI.Core.Contracts.Dataverse;
+using TALXIS.CLI.Platform.Dataverse.Data;
+using Xunit;
+
+namespace TALXIS.CLI.Tests.Environment.Platforms.Dataverse;
+
+///
+/// Unit tests for ISO-code/name matching.
+///
+public class CurrencyResolverTests
+{
+ private static readonly Guid CzkId = Guid.NewGuid();
+ private static readonly Guid UsdId = Guid.NewGuid();
+ private static readonly Guid CadId = Guid.NewGuid();
+
+ private static readonly IReadOnlyList Currencies = new[]
+ {
+ new CurrencyInfo(CzkId, "CZK", "Czech Koruna", "Kč"),
+ new CurrencyInfo(UsdId, "USD", "US Dollar", "$"),
+ new CurrencyInfo(CadId, "CAD", "Canadian Dollar", "$"),
+ };
+
+ [Fact]
+ public void Resolve_ExactIsoCode_ReturnsMatch()
+ {
+ var match = CurrencyResolver.Resolve(Currencies, "CZK");
+
+ Assert.Equal(CzkId, match.Id);
+ }
+
+ [Fact]
+ public void Resolve_IsoCodeIsCaseInsensitive()
+ {
+ var match = CurrencyResolver.Resolve(Currencies, "czk");
+
+ Assert.Equal(CzkId, match.Id);
+ }
+
+ [Fact]
+ public void Resolve_UniqueNameSubstring_ReturnsMatch()
+ {
+ var match = CurrencyResolver.Resolve(Currencies, "Koruna");
+
+ Assert.Equal(CzkId, match.Id);
+ }
+
+ [Fact]
+ public void Resolve_NoMatch_ThrowsWithBrowseHint()
+ {
+ var ex = Assert.Throws(() => CurrencyResolver.Resolve(Currencies, "XYZ"));
+
+ Assert.Contains("XYZ", ex.Message);
+ Assert.Contains("currency list", ex.Message);
+ }
+
+ [Fact]
+ public void Resolve_Ambiguous_ThrowsAndListsCandidates()
+ {
+ var ex = Assert.Throws(() => CurrencyResolver.Resolve(Currencies, "Dollar"));
+
+ Assert.Contains("matches 2 currencies", ex.Message);
+ Assert.Contains("USD", ex.Message);
+ Assert.Contains("CAD", ex.Message);
+ }
+
+ [Fact]
+ public void Resolve_EmptyQuery_Throws()
+ {
+ Assert.Throws(() => CurrencyResolver.Resolve(Currencies, " "));
+ }
+}
diff --git a/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/DateFormatsTests.cs b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/DateFormatsTests.cs
new file mode 100644
index 00000000..e9ca4f0b
--- /dev/null
+++ b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/DateFormatsTests.cs
@@ -0,0 +1,63 @@
+using System;
+using TALXIS.CLI.Core.Contracts.Dataverse;
+using Xunit;
+
+namespace TALXIS.CLI.Tests.Environment.Platforms.Dataverse;
+
+///
+/// Unit tests for code/format mapping.
+///
+public class DateFormatsTests
+{
+ [Fact]
+ public void ResolveCode_FormatString_MapsToCode()
+ {
+ Assert.Equal(2, DateFormats.ToCode(DateFormats.Short, "M/d/yyyy", "short date format"));
+ Assert.Equal(1, DateFormats.ToCode(DateFormats.Long, "dddd, d MMMM, yyyy", "long date format"));
+ }
+
+ [Fact]
+ public void ResolveCode_IsCaseInsensitive()
+ {
+ Assert.Equal(2, DateFormats.ToCode(DateFormats.Short, "m/d/YYYY", "short date format"));
+ }
+
+ [Fact]
+ public void ResolveCode_NumericInput_PassesThrough()
+ {
+ Assert.Equal(5, DateFormats.ToCode(DateFormats.Short, "5", "short date format"));
+ // Codes outside the known set still pass through; the environment validates them.
+ Assert.Equal(42, DateFormats.ToCode(DateFormats.Long, "42", "long date format"));
+ }
+
+ [Fact]
+ public void ResolveCode_UnknownFormat_ThrowsAndListsOptions()
+ {
+ var ex = Assert.Throws(
+ () => DateFormats.ToCode(DateFormats.Short, "dd/MMM/yy", "short date format"));
+
+ Assert.Contains("dd/MMM/yy", ex.Message);
+ Assert.Contains("M/d/yyyy", ex.Message);
+ }
+
+ [Fact]
+ public void ResolveCode_EmptyInput_Throws()
+ {
+ Assert.Throws(() => DateFormats.ToCode(DateFormats.Short, " ", "short date format"));
+ }
+
+ [Fact]
+ public void Describe_InRange_ReturnsFormat()
+ {
+ Assert.Equal("M/d/yyyy", DateFormats.Describe(DateFormats.Short, 2));
+ Assert.Equal("dddd, d MMMM, yyyy", DateFormats.Describe(DateFormats.Long, 1));
+ }
+
+ [Fact]
+ public void Describe_OutOfRangeOrNull_ReturnsNull()
+ {
+ Assert.Null(DateFormats.Describe(DateFormats.Short, 99));
+ Assert.Null(DateFormats.Describe(DateFormats.Short, null));
+ Assert.Null(DateFormats.Describe(DateFormats.Long, -1));
+ }
+}
diff --git a/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/TimezoneResolverTests.cs b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/TimezoneResolverTests.cs
new file mode 100644
index 00000000..f16d8b5b
--- /dev/null
+++ b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/TimezoneResolverTests.cs
@@ -0,0 +1,85 @@
+using System;
+using System.Collections.Generic;
+using TALXIS.CLI.Core.Contracts.Dataverse;
+using TALXIS.CLI.Platform.Dataverse.Data;
+using Xunit;
+
+namespace TALXIS.CLI.Tests.Environment.Platforms.Dataverse;
+
+///
+/// Unit tests for name-to-code matching.
+///
+public class TimezoneResolverTests
+{
+ private static readonly IReadOnlyList Timezones = new[]
+ {
+ new TimezoneInfo(85, "(GMT) Dublin, Edinburgh, Lisbon, London", "GMT Standard Time"),
+ new TimezoneInfo(95, "(GMT+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague", "Central Europe Standard Time"),
+ new TimezoneInfo(105, "(GMT-05:00) Eastern Time (US & Canada)", "Eastern Standard Time"),
+ new TimezoneInfo(110, "(GMT-06:00) Central Time (US & Canada)", "Central Standard Time"),
+ };
+
+ [Fact]
+ public void Resolve_UniqueSubstring_ReturnsMatch()
+ {
+ var match = TimezoneResolver.Resolve(Timezones, "Prague");
+
+ Assert.Equal(95, match.Code);
+ }
+
+ [Fact]
+ public void Resolve_MatchesStandardName()
+ {
+ var match = TimezoneResolver.Resolve(Timezones, "Central Europe");
+
+ Assert.Equal(95, match.Code);
+ }
+
+ [Fact]
+ public void Resolve_IsCaseInsensitive()
+ {
+ var match = TimezoneResolver.Resolve(Timezones, "prague");
+
+ Assert.Equal(95, match.Code);
+ }
+
+ [Fact]
+ public void Resolve_ExactNameWinsOverSubstring()
+ {
+ var tzs = new[]
+ {
+ new TimezoneInfo(1, "Prague", null),
+ new TimezoneInfo(2, "Greater Prague Area", null),
+ };
+
+ var match = TimezoneResolver.Resolve(tzs, "Prague");
+
+ Assert.Equal(1, match.Code);
+ }
+
+ [Fact]
+ public void Resolve_NoMatch_ThrowsWithBrowseHint()
+ {
+ var ex = Assert.Throws(() => TimezoneResolver.Resolve(Timezones, "Atlantis"));
+
+ Assert.Contains("Atlantis", ex.Message);
+ Assert.Contains("timezone list", ex.Message);
+ }
+
+ [Fact]
+ public void Resolve_Ambiguous_ThrowsAndListsCandidates()
+ {
+ var ex = Assert.Throws(() => TimezoneResolver.Resolve(Timezones, "Canada"));
+
+ Assert.Contains("matches 2 timezones", ex.Message);
+ Assert.Contains("--timezone", ex.Message);
+ Assert.Contains("105", ex.Message);
+ Assert.Contains("110", ex.Message);
+ }
+
+ [Fact]
+ public void Resolve_EmptyQuery_Throws()
+ {
+ Assert.Throws(() => TimezoneResolver.Resolve(Timezones, " "));
+ }
+}