conversions6

This commit is contained in:
Filipriec
2026-08-23 18:36:51 +02:00
parent 0c33d1fa5c
commit 6666f7a5b1
24 changed files with 45 additions and 51 deletions

2
client

Submodule client updated: 097aba8b61...b36ef1decb

View File

@@ -165,7 +165,7 @@ message JournalLineInput {
string account = 2;
string amount = 3;
string description = 4;
// Empty uses previous publication and ECB. Set this per line when the
// Empty uses the profile provider's previous publication. Set this per line when the
// accountant needs a different rule, a saved custom rate, or a one-off rate.
optional komp_ac.exchange_rates.ExchangeRateSelection exchange_rate_selection = 5;
}

View File

@@ -56,7 +56,7 @@ message PreviewDirectConversionResponse {
optional string foreign_currency = 10;
optional string foreign_units = 11;
string source_id = 12;
optional int64 rate_observation_id = 13;
optional int64 official_observation_id = 13;
optional string observation_hash = 14;
optional string source_payload_hash = 15;
optional string rate_fetched_at = 16;
@@ -96,7 +96,7 @@ message ConversionEvidence {
string rounding_method = 13;
optional string rate_date = 14;
string source_id = 15;
optional int64 rate_observation_id = 16;
optional int64 official_observation_id = 16;
optional string observation_hash = 17;
optional string source_payload_hash = 18;
optional string rate_fetched_at = 19;

View File

@@ -105,7 +105,7 @@ message PostTableDefinitionRequest {
string accounting_currency = 8;
// Compiled direct-rate provider selected when the profile is created.
// Currently only "ecb" is available, and it requires EUR accounting.
// The server validates this identifier against its compiled provider registry.
string rate_source_id = 11;
// Append-only foreign currencies initially enabled for this profile.

Binary file not shown.

View File

@@ -68,7 +68,7 @@ pub struct JournalLineInput {
pub amount: ::prost::alloc::string::String,
#[prost(string, tag = "4")]
pub description: ::prost::alloc::string::String,
/// Empty uses previous publication and ECB. Set this per line when the
/// Empty uses the profile provider's previous publication. Set this per line when the
/// accountant needs a different rule, a saved custom rate, or a one-off rate.
#[prost(message, optional, tag = "5")]
pub exchange_rate_selection: ::core::option::Option<

View File

@@ -52,7 +52,7 @@ pub struct PreviewDirectConversionResponse {
#[prost(string, tag = "12")]
pub source_id: ::prost::alloc::string::String,
#[prost(int64, optional, tag = "13")]
pub rate_observation_id: ::core::option::Option<i64>,
pub official_observation_id: ::core::option::Option<i64>,
#[prost(string, optional, tag = "14")]
pub observation_hash: ::core::option::Option<::prost::alloc::string::String>,
#[prost(string, optional, tag = "15")]
@@ -126,7 +126,7 @@ pub struct ConversionEvidence {
#[prost(string, tag = "15")]
pub source_id: ::prost::alloc::string::String,
#[prost(int64, optional, tag = "16")]
pub rate_observation_id: ::core::option::Option<i64>,
pub official_observation_id: ::core::option::Option<i64>,
#[prost(string, optional, tag = "17")]
pub observation_hash: ::core::option::Option<::prost::alloc::string::String>,
#[prost(string, optional, tag = "18")]

View File

@@ -52,7 +52,7 @@ pub struct PostTableDefinitionRequest {
#[prost(string, tag = "8")]
pub accounting_currency: ::prost::alloc::string::String,
/// Compiled direct-rate provider selected when the profile is created.
/// Currently only "ecb" is available, and it requires EUR accounting.
/// The server validates this identifier against its compiled provider registry.
#[prost(string, tag = "11")]
pub rate_source_id: ::prost::alloc::string::String,
/// Append-only foreign currencies initially enabled for this profile.

2
server

Submodule server updated: b929f654ed...d21be00ac2

View File

@@ -12,7 +12,7 @@ pub(crate) const READ: &str = "read";
/// Every ECB object, which is what the pipeline status is checked against:
/// one importer feeds every profile, so reading its health is not a
/// per-profile question.
pub(crate) const ALL_ECB: &str = "ecb:*";
pub(crate) const ALL_EXCHANGE_RATES: &str = "exchange-rates:*";
/// The two bulk-transfer actions, mirroring
/// `server/src/auth/rbac/objects.rs`. Taking a whole table out as a file is
@@ -44,8 +44,8 @@ pub(crate) fn can_manage(snapshot: &AuthorizationSnapshot, area: &str) -> bool {
/// Whether the caller may see the reference-rate pipeline. Mirrors the
/// server's own check in `server/src/ecb/grpc.rs`.
pub(crate) fn can_read_ecb(snapshot: &AuthorizationSnapshot) -> bool {
permits(snapshot, ALL_ECB, READ)
pub(crate) fn can_read_exchange_rates(snapshot: &AuthorizationSnapshot) -> bool {
permits(snapshot, ALL_EXCHANGE_RATES, READ)
}
/// Whether the caller holds a transfer action on any table at all — what the

View File

@@ -97,8 +97,8 @@ impl TableDraft {
/// thing that knows it.
pub(crate) fn new() -> Self {
Self {
accounting_currency: "EUR".to_string(),
rate_source_id: "ecb".to_string(),
accounting_currency: String::new(),
rate_source_id: String::new(),
columns: ColumnDraft::new(ColumnCatalog::default()),
..Self::default()
}

View File

@@ -182,7 +182,7 @@ pub(crate) async fn load_admin_page(
can_manage_validations: crate::authz::can_manage(&authorization, crate::authz::STRUCT_VALIDATION),
can_import: crate::authz::can_transfer_anything(&authorization, crate::authz::IMPORT),
can_export: crate::authz::can_transfer_anything(&authorization, crate::authz::EXPORT),
can_ecb: crate::authz::can_read_ecb(&authorization),
can_exchange_rates: crate::authz::can_read_exchange_rates(&authorization),
})
}

View File

@@ -28,7 +28,7 @@ pub(crate) struct AdminPageState {
/// Whether the exchange-rate pipeline is visible to this caller. Not a
/// structural area: it is granted through the ECB object, like the
/// conversions it reports on.
pub can_ecb: bool,
pub can_exchange_rates: bool,
}
impl AdminPageState {

View File

@@ -64,7 +64,7 @@ mod tests {
can_permissions: true,
can_import: false,
can_export: false,
can_ecb: false,
can_exchange_rates: false,
active: "admin",
};
let page = AdminPageState {
@@ -80,7 +80,7 @@ mod tests {
can_manage_validations: true,
can_import: true,
can_export: true,
can_ecb: true,
can_exchange_rates: true,
};
let html = render_page(&page);
for route in [
@@ -126,7 +126,7 @@ mod tests {
can_manage_validations: true,
can_import: true,
can_export: true,
can_ecb: true,
can_exchange_rates: true,
};
let html = render_workspace(&page);
@@ -163,7 +163,7 @@ mod tests {
can_manage_validations: true,
can_import: true,
can_export: true,
can_ecb: true,
can_exchange_rates: true,
};
let html = render_workspace(&page);
@@ -203,7 +203,7 @@ mod tests {
can_manage_validations: true,
can_import: true,
can_export: true,
can_ecb: true,
can_exchange_rates: true,
};
let html = render_workspace(&page);
@@ -242,7 +242,7 @@ mod tests {
can_manage_validations: true,
can_import: true,
can_export: true,
can_ecb: true,
can_exchange_rates: true,
};
let html = render_workspace(&page);

View File

@@ -37,7 +37,7 @@ pub(crate) async fn load_ecb_page(
})?
.into_inner();
if !crate::authz::can_read_ecb(&authorization) {
if !crate::authz::can_read_exchange_rates(&authorization) {
return Err(LoadError::Forbidden);
}

View File

@@ -59,7 +59,7 @@ pub(crate) struct RoleOption {
/// The objects of one profile, or the wildcards that cover every profile.
pub(crate) struct ObjectGroup {
pub title: String,
/// The `data:*` / `journal:*` / `ecb:*` group, which is the one whose
/// The `data:*` / `journal:*` / `exchange-rates:*` group, which is the one whose
/// shortcuts mean "everything, including what is created later".
pub global: bool,
pub rows: Vec<ObjectRow>,
@@ -228,7 +228,7 @@ mod tests {
cells: cells(&ACTIONS, &["read"]),
},
ObjectRow {
object: "ecb:acme".to_string(),
object: "exchange-rates:acme".to_string(),
label: "ECB rates".to_string(),
note: String::new(),
wildcard: true,
@@ -259,12 +259,12 @@ mod tests {
"data:acme/*|delete",
"data:acme/*|export",
"data:acme/*|import",
"ecb:acme|read",
"exchange-rates:acme|read",
]
);
assert_eq!(
group.read_pairs(),
vec!["data:acme/*|read", "ecb:acme|read"]
vec!["data:acme/*|read", "exchange-rates:acme|read"]
);
}

View File

@@ -49,7 +49,7 @@ mod tests {
can_permissions: true,
can_import: false,
can_export: false,
can_ecb: true,
can_exchange_rates: true,
active: "permissions",
},
tabs: Tabs {

View File

@@ -82,10 +82,10 @@ pub(crate) fn starter_grants(
"read" => Ok(vec![
("data:*", "read"),
("journal:*", "read"),
("ecb:*", "read"),
("exchange-rates:*", "read"),
]),
"full" => {
let mut grants = vec![("ecb:*", "read")];
let mut grants = vec![("exchange-rates:*", "read")];
for object in ["data:*", "journal:*"] {
for action in ["read", "insert", "update", "delete"] {
grants.push((object, action));
@@ -120,7 +120,7 @@ mod tests {
assert!(
grants
.iter()
.all(|(object, action)| *object != "ecb:*" || *action == "read"),
.all(|(object, action)| *object != "exchange-rates:*" || *action == "read"),
"{level} granted a write on ECB rates"
);
}

View File

@@ -38,7 +38,7 @@ mod tests {
can_permissions: true,
can_import: false,
can_export: false,
can_ecb: true,
can_exchange_rates: true,
active: "permissions",
},
tabs: Tabs {

View File

@@ -38,7 +38,7 @@ mod tests {
can_permissions: true,
can_import: false,
can_export: false,
can_ecb: true,
can_exchange_rates: true,
active: "permissions",
},
tabs: Tabs {

View File

@@ -112,7 +112,7 @@ pub(crate) struct Nav {
pub can_permissions: bool,
pub can_import: bool,
pub can_export: bool,
pub can_ecb: bool,
pub can_exchange_rates: bool,
pub active: &'static str,
}
@@ -164,7 +164,7 @@ impl Nav {
crate::authz::can_transfer_anything(authorization, crate::authz::IMPORT);
nav.can_export =
crate::authz::can_transfer_anything(authorization, crate::authz::EXPORT);
nav.can_ecb = crate::authz::can_read_ecb(authorization);
nav.can_exchange_rates = crate::authz::can_read_exchange_rates(authorization);
nav
}
@@ -185,7 +185,7 @@ impl Nav {
can_permissions: false,
can_import: false,
can_export: false,
can_ecb: false,
can_exchange_rates: false,
active,
}
}
@@ -268,7 +268,7 @@ impl Default for Nav {
can_permissions: false,
can_import: false,
can_export: false,
can_ecb: false,
can_exchange_rates: false,
active: "",
}
}

View File

@@ -80,18 +80,12 @@
{% if page.draft.show_accounting_currency() %}
<label>{{ nav.tr("builder-accounting-currency") }}
<input name="accounting_currency" value="{{ page.draft.accounting_currency }}" list="currency-codes"
maxlength="3" placeholder="EUR">
{#
The list stays complete: EUR-only books are the server's current
limitation, not the shape of the field. When it accepts more, this
hint goes and nothing else here has to change.
#}
maxlength="3" placeholder="USD">
<small>{{ nav.tr("builder-accounting-currency-hint") }}</small>
</label>
<label>Rate source
<select name="rate_source_id">
<option value="ecb" {% if page.draft.rate_source_id == "ecb" %}selected{% endif %}>ECB</option>
</select>
<input name="rate_source_id" value="{{ page.draft.rate_source_id }}"
placeholder="provider-id">
</label>
<label>Initial foreign currencies
<input name="foreign_currencies" value="{{ page.draft.foreign_currencies }}"
@@ -212,7 +206,7 @@
{% if page.draft.columns.show_money_options() %}
<label>{{ nav.tr("td-currency") }}
<input name="column_currency_input" value="{{ page.draft.columns.currency_input }}" list="currency-codes"
maxlength="3" placeholder="EUR">
maxlength="3" placeholder="USD">
</label>
<label>{{ nav.tr("td-rounding") }}
<select name="column_rounding_input">

View File

@@ -18,7 +18,7 @@
{% if page.can_manage_validations %}<a href="/admin/validation/sets/new">{{ nav.tr("admin-add-rule") }}</a>{% endif %}
{% if page.can_import %}<a href="/admin/import">{{ nav.tr("admin-import") }}</a>{% endif %}
{% if page.can_export %}<a href="/admin/export">{{ nav.tr("admin-export") }}</a>{% endif %}
{% if page.can_ecb %}<a href="/admin/ecb">{{ nav.tr("admin-exchange-rates") }}</a>{% endif %}
{% if page.can_exchange_rates %}<a href="/admin/ecb">{{ nav.tr("admin-exchange-rates") }}</a>{% endif %}
</div>
</section>
<div id="admin-workspace">{% include "pages/admin/admin/workspace.html" %}</div>

View File

@@ -25,7 +25,7 @@
{% if nav.can_admin %}<li><a href="/admin" class="{% if nav.active == "admin" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark dark:hover:text-primary-dark{% endif %} underline-offset-2 hover:text-primary focus:outline-hidden focus:underline" {% if nav.active == "admin" %}aria-current="page"{% endif %}>{{ nav.tr("nav-admin") }}</a></li>{% endif %}
{% if nav.can_permissions %}<li><a href="/permissions" class="{% if nav.active == "permissions" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark dark:hover:text-primary-dark{% endif %} underline-offset-2 hover:text-primary focus:outline-hidden focus:underline" {% if nav.active == "permissions" %}aria-current="page"{% endif %}>{{ nav.tr("nav-permissions") }}</a></li>{% endif %}
<li><a href="/" class="{% if nav.active == "analytics" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark dark:hover:text-primary-dark{% endif %} underline-offset-2 hover:text-primary focus:outline-hidden focus:underline" {% if nav.active == "analytics" %}aria-current="page"{% endif %}>{{ nav.tr("nav-analytics") }}</a></li>
{% if nav.can_ecb %}<li><a href="/admin/ecb" class="{% if nav.active == "ecb" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark dark:hover:text-primary-dark{% endif %} underline-offset-2 hover:text-primary focus:outline-hidden focus:underline" {% if nav.active == "ecb" %}aria-current="page"{% endif %}>{{ nav.tr("nav-rates") }}</a></li>{% endif %}
{% if nav.can_exchange_rates %}<li><a href="/admin/ecb" class="{% if nav.active == "ecb" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark dark:hover:text-primary-dark{% endif %} underline-offset-2 hover:text-primary focus:outline-hidden focus:underline" {% if nav.active == "ecb" %}aria-current="page"{% endif %}>{{ nav.tr("nav-rates") }}</a></li>{% endif %}
{% if nav.can_import %}<li><a href="/admin/import" class="font-medium text-on-surface underline-offset-2 hover:text-primary focus:outline-hidden focus:underline dark:text-on-surface-dark dark:hover:text-primary-dark">{{ nav.tr("nav-import") }}</a></li>{% endif %}
{% if nav.can_export %}<li><a href="/admin/export" class="font-medium text-on-surface underline-offset-2 hover:text-primary focus:outline-hidden focus:underline dark:text-on-surface-dark dark:hover:text-primary-dark">{{ nav.tr("nav-export") }}</a></li>{% endif %}
{% if nav.authenticated %}
@@ -51,7 +51,7 @@
{% if nav.can_admin %}<li class="py-4"><a href="/admin" class="w-full text-lg {% if nav.active == "admin" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark{% endif %} focus:underline" {% if nav.active == "admin" %}aria-current="page"{% endif %}>{{ nav.tr("nav-admin") }}</a></li>{% endif %}
{% if nav.can_permissions %}<li class="py-4"><a href="/permissions" class="w-full text-lg {% if nav.active == "permissions" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark{% endif %} focus:underline" {% if nav.active == "permissions" %}aria-current="page"{% endif %}>{{ nav.tr("nav-permissions") }}</a></li>{% endif %}
<li class="py-4"><a href="/" class="w-full text-lg {% if nav.active == "analytics" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark{% endif %} focus:underline" {% if nav.active == "analytics" %}aria-current="page"{% endif %}>{{ nav.tr("nav-analytics") }}</a></li>
{% if nav.can_ecb %}<li class="py-4"><a href="/admin/ecb" class="w-full text-lg {% if nav.active == "ecb" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark{% endif %} focus:underline" {% if nav.active == "ecb" %}aria-current="page"{% endif %}>{{ nav.tr("nav-rates") }}</a></li>{% endif %}
{% if nav.can_exchange_rates %}<li class="py-4"><a href="/admin/ecb" class="w-full text-lg {% if nav.active == "ecb" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark{% endif %} focus:underline" {% if nav.active == "ecb" %}aria-current="page"{% endif %}>{{ nav.tr("nav-rates") }}</a></li>{% endif %}
{% if nav.can_import %}<li class="py-4"><a href="/admin/import" class="w-full text-lg font-medium text-on-surface focus:underline dark:text-on-surface-dark">{{ nav.tr("nav-import") }}</a></li>{% endif %}
{% if nav.can_export %}<li class="py-4"><a href="/admin/export" class="w-full text-lg font-medium text-on-surface focus:underline dark:text-on-surface-dark">{{ nav.tr("nav-export") }}</a></li>{% endif %}
{% if nav.authenticated %}