From 0c33d1fa5c23959886c60da34c988c8e574841b0 Mon Sep 17 00:00:00 2001 From: Filipriec Date: Sun, 23 Aug 2026 15:50:03 +0200 Subject: [PATCH] conversions5 --- client | 2 +- common/build.rs | 2 + common/proto/accounting.proto | 4 +- common/proto/ecb.proto | 269 +----- common/proto/exchange_rates.proto | 139 +++ common/proto/table_definition.proto | 7 + common/proto/tables_data.proto | 6 +- common/src/lib.rs | 3 + common/src/money.rs | 21 +- common/src/proto/descriptor.bin | Bin 192702 -> 188246 bytes common/src/proto/komp_ac.accounting.rs | 2 +- common/src/proto/komp_ac.ecb.rs | 541 +----------- common/src/proto/komp_ac.exchange_rates.rs | 846 +++++++++++++++++++ common/src/proto/komp_ac.table_definition.rs | 7 + common/src/proto/komp_ac.tables_data.rs | 4 +- server | 2 +- web/src/lib.rs | 19 +- web/src/pages/add_table/draft.rs | 101 ++- web/src/pages/add_table/state.rs | 56 +- web/templates/pages/add_table/builder.html | 11 + 20 files changed, 1197 insertions(+), 845 deletions(-) create mode 100644 common/proto/exchange_rates.proto create mode 100644 common/src/proto/komp_ac.exchange_rates.rs diff --git a/client b/client index 78049ab8..097aba8b 160000 --- a/client +++ b/client @@ -1 +1 @@ -Subproject commit 78049ab882a32270bf0c976fd9ecef0a4409ee97 +Subproject commit 097aba8b61146e44161aeaf01886b7c95218abd4 diff --git a/common/build.rs b/common/build.rs index 29a3faaf..4d05e10a 100644 --- a/common/build.rs +++ b/common/build.rs @@ -233,6 +233,7 @@ fn main() -> Result<(), Box> { "proto/common.proto", "proto/document_data.proto", "proto/ecb.proto", + "proto/exchange_rates.proto", "proto/analytics.proto", "proto/auth.proto", "proto/backup.proto", @@ -259,6 +260,7 @@ fn main() -> Result<(), Box> { "proto/common.proto", "proto/document_data.proto", "proto/ecb.proto", + "proto/exchange_rates.proto", "proto/analytics.proto", "proto/auth.proto", "proto/backup.proto", diff --git a/common/proto/accounting.proto b/common/proto/accounting.proto index faf26c40..e10f6f09 100644 --- a/common/proto/accounting.proto +++ b/common/proto/accounting.proto @@ -1,7 +1,7 @@ syntax = "proto3"; package komp_ac.accounting; -import "ecb.proto"; +import "exchange_rates.proto"; // Mutable informational journals. A journal may contain only debits, only // credits, or any non-zero balance. Balance is reported but never enforced. @@ -167,7 +167,7 @@ message JournalLineInput { string description = 4; // Empty uses previous publication and ECB. Set this per line when the // accountant needs a different rule, a saved custom rate, or a one-off rate. - optional komp_ac.ecb.ExchangeRateSelection exchange_rate_selection = 5; + optional komp_ac.exchange_rates.ExchangeRateSelection exchange_rate_selection = 5; } message CloseJournalRequest { diff --git a/common/proto/ecb.proto b/common/proto/ecb.proto index b0b909d3..826fd3c1 100644 --- a/common/proto/ecb.proto +++ b/common/proto/ecb.proto @@ -2,293 +2,46 @@ syntax = "proto3"; package komp_ac.ecb; -// How the publication date is selected from the accounting basis date. -enum ExchangeRateDateRule { - EXCHANGE_RATE_DATE_RULE_UNSPECIFIED = 0; - // Latest TARGET publication strictly before the basis date. - EXCHANGE_RATE_DATE_RULE_PREVIOUS_PUBLICATION = 1; - // Latest TARGET publication on or before the basis date. - EXCHANGE_RATE_DATE_RULE_ON_OR_BEFORE_DATE = 2; - // The exact publication date supplied in specific_rate_date. - EXCHANGE_RATE_DATE_RULE_SPECIFIC_PUBLICATION_DATE = 3; -} - -enum ExchangeRateSource { - EXCHANGE_RATE_SOURCE_UNSPECIFIED = 0; - // The rate source's own published reference rate (ECB's, today). - EXCHANGE_RATE_SOURCE_OFFICIAL = 1; - // A reusable rate from the profile's custom_exchange_rates table. - EXCHANGE_RATE_SOURCE_SAVED_CUSTOM = 2; - // A one-off rate recorded only in this posting's immutable evidence. - EXCHANGE_RATE_SOURCE_MANUAL = 3; -} - -// Accountant-controlled conversion treatment. Exceptional choices remain -// explicit and auditable rather than being inferred from a legal label. -message ExchangeRateSelection { - ExchangeRateDateRule date_rule = 1; - ExchangeRateSource source = 2; - // Required for SPECIFIC_PUBLICATION_DATE and MANUAL; YYYY-MM-DD. - optional string specific_rate_date = 3; - // Required only for MANUAL. Exact foreign-currency units per one unit of - // the rate source's pivot currency (EUR for ECB). - optional string manual_units_per_pivot = 4; - // Required for SPECIFIC_PUBLICATION_DATE and MANUAL. - string reason = 5; -} - -// Read-only access to ECB conversion previews and audit evidence. -// -// This service exists for manual visual verification before and after posting. -// Preview performs the authoritative unrounded backend calculation without -// writing; evidence exposes conversions already recorded by posting workflows. +// ECB-only operational details. Conversion selection and evidence live in the +// provider-neutral exchange_rates contract. service EcbService { - // Preview the exact conversion the backend would currently perform. - // This reads local verified ECB data and never persists conversion evidence. - rpc PreviewEcbConversion(PreviewEcbConversionRequest) - returns (PreviewEcbConversionResponse); - - // List immutable ECB conversion evidence for one money value. - rpc ListEcbConversionEvidence(ListEcbConversionEvidenceRequest) - returns (ListEcbConversionEvidenceResponse); - - // Health of the reference-rate import pipeline: how far verified coverage - // reaches, how far it should reach by now, and how the recent import - // attempts went. Not profile-scoped -- one pipeline feeds every profile. rpc GetEcbPipelineStatus(GetEcbPipelineStatusRequest) returns (GetEcbPipelineStatusResponse); } -message GetEcbPipelineStatusRequest { - // Optional. How many recent import attempts to return. Zero uses 20; the - // maximum is 100. - int32 batch_limit = 1; -} +message GetEcbPipelineStatusRequest { int32 batch_limit = 1; } -// One row of the append-only import audit log. message EcbImportBatch { int64 batch_id = 1; - // "running", "succeeded" or "failed". string status = 2; string requested_from = 3; string requested_through = 4; string endpoint = 5; string started_at = 6; optional string completed_at = 7; - // Present on a batch that advanced coverage. optional string verified_through_date = 8; optional int32 observation_count = 9; - // May be lower than observation_count: an observation already recorded by an - // earlier batch is kept rather than replaced. optional int32 inserted_observation_count = 10; optional string error_message = 11; } +message EcbCurrencyCoverage { + string currency = 1; + string status = 2; + optional string verified_from_date = 3; + optional string verified_through_date = 4; +} + message GetEcbPipelineStatusResponse { - // Latest date a successful batch verified. Absent when nothing has ever - // succeeded, which is what a pipeline that has never run looks like. optional string verified_through_date = 1; - - // The date coverage should have reached by now, derived from the same - // publication rule the importer schedules against. Comparing the two is what - // "healthy" means. string latest_verifiable_date = 2; - - // True when verified coverage has reached latest_verifiable_date. A - // conversion whose publication date falls beyond coverage is refused, so - // this answers "will posting work right now". bool healthy = 3; - - // Publication days between coverage and latest_verifiable_date. Zero when - // healthy. int32 days_behind = 4; - - // True while a batch holds the single-running lock. bool import_running = 5; - - // When the scheduler next wakes, from the same cutoff the importer uses. string next_import_at = 6; - - // Newest first, running and failed attempts included. repeated EcbImportBatch batches = 7; - - // Distinct currencies observed on verified_through_date, so a coverage gap - // for one currency is visible even when the date itself is covered. repeated string covered_currencies = 8; - - // Current coverage restated as the question a reader actually has: what may - // be posted right now. Both are the latest basis date whose derived - // publication date is still covered, under the two rules - // required_publication_date applies -- transactions look strictly before the - // basis date, statements and decisive dates look at it directly, so the - // transaction bound is the later of the two. Derived here rather than by the - // caller so it cannot disagree with the calendar the conversion path uses. - // Absent when nothing has ever been verified and nothing can be posted. optional string transactions_postable_through = 9; optional string statements_postable_through = 10; -} - -// Exact inputs for a conversion preview. Decimal values are strings so the -// client never loses precision through binary floating point. -message PreviewEcbConversionRequest { - string original_amount = 1; - string original_currency = 2; - - // ISO calendar date (YYYY-MM-DD) of the accounting case. - string conversion_basis_date = 3; - - // Required. Profile whose rates apply. Consulted when the selection requests - // a saved custom rate, and always required so the preview names its books. - string profile_name = 4; - - // Empty uses previous publication and ECB. - optional ExchangeRateSelection exchange_rate_selection = 5; -} - -// The conversion and immutable local ECB observation that would be used now. -// No rounding is performed and no conversion evidence is persisted. -message PreviewEcbConversionResponse { - string original_amount = 1; - string original_currency = 2; - string converted_amount = 3; - string conversion_basis_date = 4; - string determination_method = 5; - string rounding_method = 6; - optional string rate_date = 7; - optional string units_per_pivot = 8; - optional int64 rate_observation_id = 9; - optional string observation_hash = 10; - optional string source_payload_hash = 11; - optional string rate_fetched_at = 12; - optional int64 import_batch_id = 13; - optional string source_endpoint = 14; - - // Present only when determination_method is custom_rate. rate_date and - // units_per_pivot then describe the hand-entered row identified here, and - // the ECB observation fields above are empty. - optional int64 custom_rate_table_definition_id = 15; - optional int64 custom_rate_record_id = 16; - optional int64 custom_rate_row_revision = 17; - optional string custom_rate_note = 18; - - // Accountant-supplied justification for an exceptional publication date or - // one-off manual rate. Empty for the normal/default rule. - ExchangeRateDateRule applied_date_rule = 19; - ExchangeRateSource applied_source = 20; - string selection_reason = 21; - - // The currency converted_amount is stated in: the profile's accounting - // currency (EUR unless the profile keeps its books in something else). - string converted_currency = 22; - - // True when reaching converted_currency needed two hops (original currency - // to EUR, then EUR to converted_currency) because neither is EUR. The - // fields above then describe only the second, final hop; the full - // provenance of both hops is available from ListEcbConversionEvidence - // after posting. - bool via_two_hop_conversion = 23; -} - -// Identify one logical money cell whose conversion history should be inspected. -message ListEcbConversionEvidenceRequest { - // Required. Profile containing the table. - string profile_name = 1; - - // Required. Logical table name within the profile. - string table_name = 2; - - // Required. Dynamic table row id. - int64 record_id = 3; - - // Required. Current display name of the money column. The server resolves it - // to the stable physical column name used by immutable evidence. - string column_name = 4; - - // Optional. Maximum rows to return. Zero uses 100; maximum is 500. - int32 limit = 5; - - // Optional cursor. Return evidence ids lower than this value. - optional int64 before_evidence_id = 6; -} - -// One immutable explanation of how a posted EUR value was determined. -message EcbConversionEvidence { - int64 evidence_id = 1; - - // Exact committed version of the target dynamic-table row. - int64 row_revision = 2; - string original_amount = 3; - string original_currency = 4; - string converted_amount = 5; - string conversion_context = 6; - string conversion_basis_date = 7; - string determination_method = 8; - string rounding_method = 9; - optional string rate_date = 10; - optional string units_per_pivot = 11; - optional int64 rate_observation_id = 12; - optional string observation_hash = 13; - optional string source_payload_hash = 14; - optional string rate_fetched_at = 15; - optional int64 import_batch_id = 16; - optional string source_endpoint = 17; - string evidence_created_at = 18; - - // Present only when determination_method is custom_rate. The hand-entered row - // that supplied rate_date and units_per_pivot. The row itself stays editable, so - // the figures above are the immutable record of what was actually applied. - optional int64 custom_rate_table_definition_id = 19; - optional int64 custom_rate_record_id = 20; - optional int64 custom_rate_row_revision = 21; - optional string custom_rate_note = 22; - - // Immutable audit data for an accountant-controlled exception. - string selection_reason = 23; - optional string selected_by_user_id = 24; - - // The currency converted_amount is stated in. Always EUR for the - // original-currency-to-EUR leg; the profile's native currency for the - // EUR-to-native leg that follows it when that native currency is not EUR. - string converted_currency = 25; - - // Set only on the EUR-to-native-currency leg of a two-hop conversion. Names - // the original-currency-to-EUR leg's evidence_id, which produced this row's - // original_amount/original_currency (always EUR). - optional int64 derived_from_evidence_id = 26; - - // The pivot currency of the rate source that priced this hop (EUR for - // every hop today). A rate-based hop always has this currency on exactly - // one side of original_currency/converted_currency. - string pivot_currency = 27; - - // Which rate source's policy governs this hop's profile ("ecb" for every - // hop today). Absent only for already_same_currency, where no policy - // needed to be consulted. This is about which policy applies, not about - // who actually supplied this specific rate -- see observation_source_id - // for that. - optional string policy_source_id = 28; - - // Which rate source actually supplied this hop's rate as its own official - // observation. Present only when determination_method is - // official_reference_rate; absent for custom_rate, manual_rate, and - // already_same_currency, since none of those were priced by the policy's - // source itself -- a human entered them. - optional string observation_source_id = 29; - - // Exact immutable profile policy version selected for this conversion's - // accounting date. Absent only when no conversion policy was needed because - // determination_method is already_same_currency. - optional int64 rate_source_policy_id = 30; -} - -message ListEcbConversionEvidenceResponse { - // True when at least one evidence row for this cell used an ECB, saved - // custom, or one-off manual rate. - bool has_exchange = 1; - - // Newest-first immutable history for this page. - repeated EcbConversionEvidence evidence = 2; - - // True when another page exists using the last evidence_id as the cursor. - bool has_more = 3; + repeated EcbCurrencyCoverage currency_coverage = 11; } diff --git a/common/proto/exchange_rates.proto b/common/proto/exchange_rates.proto new file mode 100644 index 00000000..ac377d5e --- /dev/null +++ b/common/proto/exchange_rates.proto @@ -0,0 +1,139 @@ +syntax = "proto3"; + +package komp_ac.exchange_rates; + +enum ExchangeRateDateRule { + EXCHANGE_RATE_DATE_RULE_UNSPECIFIED = 0; + EXCHANGE_RATE_DATE_RULE_PREVIOUS_PUBLICATION = 1; + EXCHANGE_RATE_DATE_RULE_ON_OR_BEFORE_DATE = 2; + EXCHANGE_RATE_DATE_RULE_SPECIFIC_PUBLICATION_DATE = 3; +} + +enum ExchangeRateSource { + EXCHANGE_RATE_SOURCE_UNSPECIFIED = 0; + EXCHANGE_RATE_SOURCE_OFFICIAL = 1; + EXCHANGE_RATE_SOURCE_SAVED_CUSTOM = 2; + EXCHANGE_RATE_SOURCE_MANUAL = 3; +} + +message ExchangeRateSelection { + ExchangeRateDateRule date_rule = 1; + ExchangeRateSource source = 2; + optional string specific_rate_date = 3; + optional string manual_foreign_units = 4; + string reason = 5; +} + +service ExchangeRateService { + rpc PreviewDirectConversion(PreviewDirectConversionRequest) + returns (PreviewDirectConversionResponse); + rpc ListConversionEvidence(ListConversionEvidenceRequest) + returns (ListConversionEvidenceResponse); + rpc GetProfileExchangeRateSettings(GetProfileExchangeRateSettingsRequest) + returns (ProfileExchangeRateSettings); + rpc AddProfileForeignCurrency(AddProfileForeignCurrencyRequest) + returns (ProfileForeignCurrency); +} + +message PreviewDirectConversionRequest { + string original_amount = 1; + string original_currency = 2; + string conversion_basis_date = 3; + string profile_name = 4; + optional ExchangeRateSelection exchange_rate_selection = 5; +} + +message PreviewDirectConversionResponse { + string original_amount = 1; + string original_currency = 2; + string converted_amount = 3; + string accounting_currency = 4; + string conversion_basis_date = 5; + string determination_method = 6; + string rounding_method = 7; + optional string rate_date = 8; + optional string accounting_units = 9; + optional string foreign_currency = 10; + optional string foreign_units = 11; + string source_id = 12; + optional int64 rate_observation_id = 13; + optional string observation_hash = 14; + optional string source_payload_hash = 15; + optional string rate_fetched_at = 16; + optional int64 import_batch_id = 17; + optional string source_endpoint = 18; + optional int64 custom_rate_table_definition_id = 19; + optional int64 custom_rate_record_id = 20; + optional int64 custom_rate_row_revision = 21; + optional string custom_rate_note = 22; + ExchangeRateDateRule applied_date_rule = 23; + ExchangeRateSource applied_source = 24; + string selection_reason = 25; +} + +message ListConversionEvidenceRequest { + string profile_name = 1; + string table_name = 2; + int64 record_id = 3; + string column_name = 4; + int32 limit = 5; + optional int64 before_evidence_id = 6; +} + +message ConversionEvidence { + int64 evidence_id = 1; + int64 row_revision = 2; + string original_amount = 3; + string original_currency = 4; + string converted_amount = 5; + string accounting_currency = 6; + string accounting_units = 7; + string foreign_currency = 8; + string foreign_units = 9; + string conversion_context = 10; + string conversion_basis_date = 11; + string determination_method = 12; + string rounding_method = 13; + optional string rate_date = 14; + string source_id = 15; + optional int64 rate_observation_id = 16; + optional string observation_hash = 17; + optional string source_payload_hash = 18; + optional string rate_fetched_at = 19; + optional int64 import_batch_id = 20; + optional string source_endpoint = 21; + string evidence_created_at = 22; + optional int64 custom_rate_table_definition_id = 23; + optional int64 custom_rate_record_id = 24; + optional int64 custom_rate_row_revision = 25; + optional string custom_rate_note = 26; + string selection_reason = 27; + optional string selected_by_user_id = 28; +} + +message ListConversionEvidenceResponse { + bool has_exchange = 1; + repeated ConversionEvidence evidence = 2; + bool has_more = 3; +} + +message GetProfileExchangeRateSettingsRequest { string profile_name = 1; } + +message ProfileForeignCurrency { + string currency = 1; + bool coverage_complete = 2; + optional string verified_from_date = 3; + optional string verified_through_date = 4; +} + +message ProfileExchangeRateSettings { + string profile_name = 1; + string accounting_currency = 2; + string rate_source_id = 3; + repeated ProfileForeignCurrency foreign_currencies = 4; +} + +message AddProfileForeignCurrencyRequest { + string profile_name = 1; + string currency = 2; +} diff --git a/common/proto/table_definition.proto b/common/proto/table_definition.proto index afc76d58..138dc008 100644 --- a/common/proto/table_definition.proto +++ b/common/proto/table_definition.proto @@ -104,6 +104,13 @@ message PostTableDefinitionRequest { // to this one when they reach the ledger. string accounting_currency = 8; + // Compiled direct-rate provider selected when the profile is created. + // Currently only "ecb" is available, and it requires EUR accounting. + string rate_source_id = 11; + + // Append-only foreign currencies initially enabled for this profile. + repeated string foreign_currencies = 12; + // When true, the table is stored once in the global physical schema and is // visible from every profile. profile_name and accounting_currency are ignored. bool global = 9; diff --git a/common/proto/tables_data.proto b/common/proto/tables_data.proto index c100f808..edad66df 100644 --- a/common/proto/tables_data.proto +++ b/common/proto/tables_data.proto @@ -3,7 +3,7 @@ syntax = "proto3"; package komp_ac.tables_data; import "common.proto"; -import "ecb.proto"; +import "exchange_rates.proto"; import "google/protobuf/struct.proto"; // Read and write row data for user-defined tables inside profiles (schemas). @@ -166,7 +166,7 @@ map data = 3; message PostAccountingTableDataRequest { PostTableDataRequest row = 1; - komp_ac.ecb.ExchangeRateSelection exchange_rate_selection = 2; + komp_ac.exchange_rates.ExchangeRateSelection exchange_rate_selection = 2; } // Insert response. @@ -259,7 +259,7 @@ message PutTableDataRequest { message PutAccountingTableDataRequest { PutTableDataRequest update = 1; - komp_ac.ecb.ExchangeRateSelection exchange_rate_selection = 2; + komp_ac.exchange_rates.ExchangeRateSelection exchange_rate_selection = 2; bool confirmed = 3; repeated string expected_affected_profiles = 4; } diff --git a/common/src/lib.rs b/common/src/lib.rs index 004b5fd1..5eda2fa7 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -31,6 +31,9 @@ pub mod proto { pub mod ecb { include!("proto/komp_ac.ecb.rs"); } + pub mod exchange_rates { + include!("proto/komp_ac.exchange_rates.rs"); + } pub mod table_structure { include!("proto/komp_ac.table_structure.rs"); } diff --git a/common/src/money.rs b/common/src/money.rs index e092cc95..dd479191 100644 --- a/common/src/money.rs +++ b/common/src/money.rs @@ -15,14 +15,6 @@ pub use rusty_money::iso; /// whitespace, and any length but three. Normalising here instead would let a /// value be written in one spelling and compared in another. pub fn require_iso_currency_code(currency_code: &str) -> Result<&'static iso::Currency, String> { - if currency_code.len() != 3 - || !currency_code - .chars() - .all(|character| character.is_ascii_uppercase()) - { - return Err("Currency must be a canonical uppercase ISO-4217 code".to_string()); - } - iso::find(currency_code).ok_or_else(|| format!("Unknown ISO-4217 currency: {currency_code}")) } @@ -42,7 +34,18 @@ mod tests { #[test] fn non_canonical_or_unknown_codes_are_rejected() { for currency_code in [ - "", "E", "EU", "EURO", "eur", "Eur", " EUR", "EUR ", "E R", "E\u{20AC}R", "AAA", "123", + "", + "E", + "EU", + "EURO", + "eur", + "Eur", + " EUR", + "EUR ", + "E R", + "E\u{20AC}R", + "AAA", + "123", ] { assert!( require_iso_currency_code(currency_code).is_err(), diff --git a/common/src/proto/descriptor.bin b/common/src/proto/descriptor.bin index ae11d9a1408ad44f3d062ec21a985fa03eb2cbf5..41a042a7e0a744c7f926a940e0ec438dcfa22ecd 100644 GIT binary patch delta 19324 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�QJ?^NNpL{kfX z&=W#4C=VIm00iYB@(p~#LegmcL$K;Can+Zh{(bUwhf9)~u5;tM zG(?hQsOwy;mX*X@wgqh;Kyn)@taeN?#5Nb{HC1#-l6-O-Nupq;$HWge4R!vTXb`P8 z9WF|KdXtNyrx7Jjy~#x^p>iB?;iR}Zc=X@JNv$^{xY&MlDBtXs*X4eI;LR>x{5P>6 zw(_mP><`7olW#qIL`uqUb@4u8Mx?a-Ru?ZMuNwnl``sD*+lS&$t#=+SNjcG-E<%@) zq^#&p7r|>84~e|!?%>Oh#EQvxA1+84(cLaSO)!F#65UO@K4XSNR`ft{^~d7bsSg~! zCX^RF;NmW4Bq=j`z=iY9(nFGq>~!5nf-RqjnaQ1Qezo^c_|l9lr=4V1@VigM|2=M( z8_q{LEM^+OyUQ&|9R3rScah=Gv*PHqCwTC$V)b!*jua% zTTqkx0c=mY!&-7bfa^(jVyh~6*U(PcAAJ5dv8ZJ~{su!G*7y-D`&~}h0l~4~Ez=bo zKrrkdTKAb4b(45H$X+jx`}${>LoZw}tAzV&{Lc}=n|Gnwo4-}g5o!7lno{5NpEH#W zZQUxD=b`rO4Lfy1@X0^Qn!w#I+x|DA0YuA(?$|Czmy6n=13!{Kt!`PO=7UoQ^SMvp zPuwW}0b)M)iSY{!#C+}(CoUekcfTAFXQ2Wmbfsh=KnPtaSqKn9S4s%|=S%XbVgC(K C2KeLv delta 24021 zcmbV!d7McZ(Av{Iue*nl zEM6lTgW^UTuZp6G5=lfvG^0@w$%}v?afwSJu1MmNbyf0FJ-EXEHbDY)K6VLR; z(_MX;R3YEho6L3frdDJN%_fk_=djK?Ier!mpP9^^NrT1dZ1T{-R#VsEaobL^TGsxq ze*5ds^>Xv~rqxQonJ<3Cihq8-rMTs{)2rLCmzFbg$rY(&|Lnw);(GQt>neVeJJoHa zTy}`<7VqK5587K{wU!`!!mls>rD8BMiw=(!-#40BS@Gn`b`~!7RJO55@!HDzabvCO zFK;TUHdQ4C^mcy0SaD}%bEF)j#vERJy>e{C5;KirZPmC)h4{Bw^vaHkREmF>759`K z5vdaM!^MyB??K{wq}UcdHc~CVM~ml$r$%bTzsrjo!c!u(;@?*BmGJnPwwPbhIVjPW zFJyZ13ji%4Rx~@q9L8w_A21n^}>}6|i2SFPBSZ5-Xb<#pGIlWTqOhCtHmxQt_@r zf3|CRwzo@8$b(#S6Hua^br@aTpPiHKohhi&(2PpU>Kc+GgQX44u&tUen_Dqk{b6O> zCERl6rSb(biRtBki##(37LR|QZ2h*$=aVSXx`pJ0h30Va<%)^Wx3+?)u!3@71=d$t zfz&7KJiHZLe+Zg-mnR%K_6)J?K`#>%tu$=uv-vIc+Ohv;W8p>!Tb zKPOnt-N{^P1(dlom+k4o5$lFVlU=Fq=9u86s$>kZw2jEO>Z`xO04XXMn}|J#V!VQ4w55_gzu45kF^@IOR%FAaWcFToFRnc&Z+LK zD;Y1vJjQAecB7V2i(<8*VlOqFHVsRRGX+OtMPYIO(%@z^TJ>FGMz;8^>Mz_GE!NY9loPO_p}UGUk)=!!{S z@`mDA)D{0;GjjV=b*q?rfLj*M$0A5Jl}OqbSoML649MG~5Amsoo6?#5&%R{75F7LV zFf-qq&E%8zx2(bA54RIxOm_Prgn~0YxG~mo*rD&$v=smT+aC8%87pF!g+h06W<@Pl zX&a%CP5{r^U-cwpkXcVzE=5IoW=vusrGX_AP-m3hQ#n#W<gfSoqH<3o+X$D;w-u6&8r|kZpwdQC6i@MgYdbe3XX3NqF=a zYmS(Rp(vjmo94^|t$e{*JiYU@*^3WhC|}5>5{2|iXGwBtHkTBv9J0P#1!vuey1YXz zLmQ4xoMg=w%K)(WgxGQaVwr5l$>#o-H8|F=_*k_BC#oIet+`?e!!|0pdtB@UXE7w2 zyfB_99I_1|*LHP9!x06dQO;S8dI%3x7*)wMxnIGpAvfc5OW1x6q7UTrIVFGdiVlBIEgtb7d1)!df zj!k!_gIZrcz9g-P`Bv4N%Pvi&lUQ7a@~-5CiRJOkGGu3D5>A0|{Kbg*dOkueO&X{_ z;z;XjVl8OEZRBmSSy(KaNw#N~E|rYul8J1t8ym`IU`7yqVL7a~H=8G0#=@zdo<6aR zqy2}!9%-VH4^iupCK{dL)-BB9CXP`{XcUWG!<^~zWW+OWp`Ai&E}KqcIs6le0R>#A zkW0o3J;_X=!N4^y1TR(S>q2-`+0Z=_uj zgo&+9j)ow{))6BW8is~&J;Z=Egx9Nyr1v2WEgAyakfH7{vN6zvpoZ}=urYFtDZwaC z*P=6L&zyVe+?icxo;73M+?mrC&t15nYu5C|v&DYrPhW7>^m)^qbMasM{Q2#(W=S;= zIg{uKizBEEP(`g;nM`7sJyJj}2EoX%BS%}`XMzzxZM;3UGGIg)0X&H#*CU0{StZ|+ zT9#?oIh-SMTn8CF8IypV>{3g(pF>#PU4#lx`aaiM7}b~ndz@$PmZEBO6n9tSZ?8!9#GURiaX6LxSh7;H6&$p6*i)94nF6m>d?-f*`Cr**Qh6%dqa0>Iv3CDMN0X$MdP}u@{&#CmBx{mgD@% z-nl6w`CyZ1b=R zNL~y%P;w^QKTZgg=8)Q80dU>eIxS>5@j@Zl(+dIj3x|%v(@{~(ZOl8S&)lgb>Jq9q+4xT<7t=D#)8ng(?vHacs>KL(@gsJY8*i6 zLX?U`M1u)0lc&a{ozIFkide%bgPF}6#1a7DvS#y$hA?q<)UDAV)}LKhZ%xAPh4@Z` zhaBa!<@z!ioa?r6PFubYUMmSt&_-!p+tN64jP5q&5rG5przwvJ2;@&w9uW|zp9YUe zfs>Ou=I~arunP!yb9_AFe@=Om;)--NC)T3y0LFi)5~St!yxcZuWwLzn0W?oR-BXxNcwxQ474-KrS1R$?F{Hn z?hX-so=m5}Nf?XIQy~fvh|E(V3J{36^X#E&g)peh8{TFeFJu(4jRkyMY#jNzJQ*7@ zo;X}^A+a3h6h}JKy&{f0l2Rgh5Pd~Nu;v1P%?Q?9;IA3MnhR`^(t^8);Q~Brtl~Us z8wEwzMO%yV5M;x$xeraD?Z4-(`9LJWN z>1bBK2>O>NGw`KIwvn(dk4yJwNklrC<1ixy3xRz4g^>ssF5IOs8bZmC%XDzAp_#0E z0pefk0u@Y%Ic)b_wOv5i?zw8afUu8qmGJ<=cF%?FY?Hgf*f&uEZDa_oNm=r0%hb_e zc^nCaj26fyL{M?58P#G0ZmP15jCXK1N(VIoNjzYO0f)SejY`@Of;M0 zIT8%T7qkPl6fCE0xd?VE+nk{p7hjOV-K-k|mQFT2j_Xnqy z;B~Qwu)kbiQffl$$3KHYCwhlSkx+kk$w^dliC!agX$5J7n*k-HNr!A#n8VU|I-SS) zS(&AjOPz7r5=uw}7N|O(rPLW;uq7O^ywld7N~aIWr<`1B+42HKU+t$2;cpW>EJXnS zi%lmYgOo(b;U;Qr>pWqp0G1&LE^^L1q?@sl_$u@#VT(RvlwN+-hD9(W)3qF>2wwv( zx$$KxC>fki%Ce~RtAJ>I_|;PcGce5ac`PNu^49d@YdKI=Nvk5g0Ql<91hThIEnEKDCO~M zZZcXVngyzmX}WKzlmz%3P!#F$?F~`HHF{LAGej8aL9m-4I6xTbG=_JszV;(Qj$@U3B4yw?(S3FWMQ`3F-) zfs&$z1$+=$zLKplc--bB(@V+11(}jzfPk-(H<<&@l~52hw_-1AlAs2kqgXI zt}Sc-t`hgy)$LNpNnX0-DNios;1a+-wnODj_5#rUr;Z;s;$`M*6k#+s1P%jb!k$0oS`&Tg& zN`&qC{+qm)4y#x+P_ciMC>^@Uw?RsWF7jtQ=yc^^{G%w;^J~eh9=5L+zFGc8>la4+TlxC=sLz?mowE{No9KbRP7&k+>)|X zbr49@iBu02wp1)as}ML@L1q~&Evtl$lnPme%m~t=S_e95p89LcQTQ)g?kq`V;wZ6D znxo{0=jqL+&~vk}Jp+Z3ZC=WvE_Dx0r1q%MFJtKKRUy0rDidH-gP437D{t1IL0}8j z0fdUd?=xig5*5V+ff^N&86fg|H44=4GbH$@3RLde=4!@Apcy@P(L(A^NaGL(Yjn{2 z8lz1S?HAp4PsH<-{7AYeMTn4Im3Ugs%7^Mvth$;ZL%)lBq=`^zUd0fRZeSkU%j;Yj z$f>>2F{kU9ZF$vq!+*ul7k^TgpX0}91P0?3|HVUq}wxU9<^^o zNFlSOp8pRSGIBD#GP2qmwAH!*Aw0lRcmOf^25q(gvDF*2*_M%sc-m|M0TrM|CA0Ok z*n;Gsg$WqEkSp;fSDu@A>%Oi|o;NHy8pO?6pyiQB1j3b*NP~(zR z*9C$?RSZy_?=lYixrL$cEtf@RLZGxR41&@v46d-s6j1tU;3%rRO&9yKZ1Z-8 zJ2AN|3Qz(yDO8{UeNrev0otSVWRSX@ji!4-l2nClZeS?sC8Y{b0;nlffYOG5QUxe& zV6HE!3Xs~sMjoL^RodnqfzT^K3ZN!c2~u|ilBxu$J6Nq&b|om?!A5AISJ~#qfT=1_ z0;nmuRUox7;HnCwHU|2u0;!F})Gx^gm+?Wic{k(7#2yt*Vbv%>$|l@xcM|DG55kwY zBN%6S)+85#<}WGU{$e+(Zh46cF7x zde2Z0g}TUVgzJ{K8l|3%92<&xDpcZ2P|t~oOij;~%57sertQ*xv@ixi7Iz0?9Ryk2 z&1xqobq<0o?q-vZRtl=N&CQIDQ-jqID?m-D8k9B%l&V2#Gpp4{s2Y?uvoUlF>ylJ! zZ1cyAk5{B>KnkEHRRdB#4oKC2)Q?%MK0`Gi^R_fZe&8>`g#8#Eu4i?(|``_~sJcC;gYM3LD!_B9EGgy)` z%lq64U1 zZ#{6g1;L^osM}cUP+85D@dn#`kn!QMH7Xpdr&B6iA})5tdnh47taN21fOC?WDy%)Y zOb)UpG%z#?aWmJKL2)8eel;EKxn!@OnxM%3ou|@uXX7z+S+f+zaAw z12*#@E7#7y0h@V{4Rx#s(2Ii*8*TGZO7`yJx)Xo{DtazNT4n&WC7;ENXj+RZ3J=8c z&S>0jErVA;t)+H>!-|`@vpR7R@6vIn+eUo(XIf)W86@IJNRTt57D1tcECjPVD9u5| zTY9`PT;e#ywI3N%9#>x(bZ?dl4#1GO9&pg~OQS5zx$q1bYV1<3x`b*3Rao+mSkRNNfolF7}OmQye1lSc$pF8UliVVvgl~*SZFj% z`PVK=q{rZf)HC<;nq{C?KwFO#N;1-ODEkhJ%nle4MUGh7!=f@l!Ke%Jdl+sVMC&C?sog%t2h(8{bBHgvN!82Iq0VP> z9S|N}#Rd1#DRI^7-$jVdslG^+iY}w@3v$vifw-Wq@;`v!8 zh;m;VlM55hElZ;3A}zXIy_TWsOHro70$O@qIxH`pel2fkg19RtZ~T@-q2N9gu0vnRaF+@sAAp>giKWnK#fXO%bS6a z(VPOPsW2Js+PBOR*%V`^p+?!Tw$#!TMjZjc9f<}WeY$}iprLQI{%DtDq9Zfsth1V z9b`w2QB0v~`Mu6RBb=%hfSM9jEx!+#qN>IHz0N-|8>D`($}$`wlr4YIRT&_V0;ow* z)$)gc6jd#M&{Y{GgVG;#RTiP5$Tn72Vta-xVE2 zz%SQmRF0viTv9zA)K_nKgOXa-hl_-g zf{tQr;QZw~C-VE^I+nb=f2*K=B;os28xnut4Mc83Ztn-$qsroaUHnCG8%dxl{vuRa ze4vXzKp+KBE3XceJ_smLq49w({xBJoK9I$qX~OO)=pMX2ofOK|uaKnb9xL|Ok|0*j)g|1Y=?lf@r!LFzAb_~r?ZfTaIp#DdsK z&RqXxseJl^CU0C+YN+;9g%Zyk@@CYp0ew+ZDhzVkBpb2z$1)(e6gw#w{+P-q>DZcV z^ONAXTp_O(6A2aPXTz=@UBmuL|qt&9?a&6=J{sszS^SDzTst!_0g)NK>m8+^^{O6VJg={;`z{6EpQv{_Oj z*%~fkROcEWzf1*130=>ChA-J>hyy#+l5K`KJ`2KSGsN*3MT{4vI0oD1#hj0i?Pks* zQbU~nBSA~kQFt0p}d)-`Q}4~CUsYuZW%Lo63_l}Tb0V!4>Bo(VvR zrvyMBObJrY!=#+KYVeP(W?mD za0-602^x_P0iDrp$;6U2VdrA=czIB|d-XQVNlg#yYg~}!UfI()#$kXq!UALa1@j3T6UZ`DqU zxfFVQ{E}E9cA*|v4qZjj4<7Ie0&ni)=xCI1-cjAxaZ+`9=6NB>GcDiHg08Ex^fGCRwP0L%rI!i5cfgf&Oxw5cT-)A z3-cG=9Tu)o#Pyq^PuYqxc-8f?M2&j5mil2u?Mq|`B!W$8vm646+!%PnA&|(8TsEU! z9R48?$&Fleqq!Ni!|S;C?G;_zQ8BZQN5@!wa1&7~h}Uy8cb1iO0uiUu_#G)l6MAMM ze`_dOGBR?Kc&Ke9ZWW7=!RYK(CEoi~v*@ zu~B-n25`7+zXQbD8~C_(>m0E*%I`Zl9`epD6%ajiKrR?v zr{#w5sNct^wu)wh%4Cq%BA`fZC*a`otxNsto?GGV2LN0=H*NUQt>7eAl{IDUEn}WS9s;? z*f+*s5ulL8T*1l*DdCaDT*2_XEOgtz+I4I&cW-7mYLovh@=P~eUpG{*X) zde3kt3vKoW*0cBAJ6Tj;WI`o(>5a)}0{|s+^4Y*$dSmk0z+HM{;@Q9^-LjF-1^{YQ zlF26BvXRdQHtCiP?nfY#AL+grAnXL7#wD5jNcY7sikYRgBi7VD4TvM&3y_Vi;Swm*JJw8M6C& zKMVbrci9GZdiDJ*I@D^%0-|C1fcM%4HopAK*Lp zj)4G$6f&T7DSMm)q|1%6jDbQP@ov3?-F3nvntyaN!T%$yLf&G@UgsmMRW^@huk#UV z9#0jkVlz9ui#D zqO}PgcCiZis0tt`Ag!1vL@FDfyVw-EKc-(Who5Ajy?C*XI7`1hHlM^vB}Iwf9-F(> z;g>%?CQzJq0evCC2-H2wv;mUn0E(tHftb99Z22Up4Y7S63tj3ByNjJzvyVla ztS8Iy+2#Tw@I;#LdaW(Gm1ry~Y-8J66`&efO z>I=y?KGIjNJO)6Ztl{t&v%rL9hp*+KJ>I-+Y^HrJkG5C~u^Y>Pp{?coW^cnbwxD(` zx9Y7iR+zpMSJ&TgKN~ycdSF}CRz!Zq8rSoRhTsEMxt`;|Zea=$Gj8&_?q^%4+*FE5 z+}*@08huRS?H%N*}qM`r*t#qMeYd{!i2frLkgLp5V6!&@9dwlSyQ&+ zkoyae$!_KNO_&xR8SPe*!WOPDvE2s;e*IH6o=thMbWOTDd5~8Ga-iFj2RRa*F+!Ex z4nNHKE3mi&cL8b>_&^hR-QeH@DDB{I%)-iufnDBzKFr!r+@+Ivi+(L`?!xOV zahL&u#x7nbV+BA^*v0YOZ4bp4i*&x*+y5|oYR+zb_VAIua@9u%2%Oz~7+uD2v%(HP z#Y4ZsAFQCFiEc=q;s}b1K&^rp+UNcGXY4F@pJoUjp|gFuNCgO#eH=ecBZmYK#P@M= zR|Q$v;iq}%Sw);KO`aytLQc4nh(E)*e4zvgCInE1BY@cTGrU4RpsVB~Xq9L9DEZJ0 z!&v1Rj=%n}5L>9S!_V{3OWwgp*n;ZkdDO9{VSy@I;6-oxqwK_v7qu+#5rkgkD*prs zLNAg*&I6%AcKBr;deytDT~*;C@XIAPDX!g0#s& zq>op)dO-;Yn*=B~2@song}e@l4xpIaB%fI{0tTs9IIixGzAKSxvmX$jK=qHO_`+X5&?%jG*wfIxkhekS<-qV@}1YCgf`Whf`-{VnPx&Q?3ds@>q1pSe7c`g9~6`)2X5Bf)r-`bL11d z20013UixpYN*kNF{FJLS2oTl)C^rreb3X;oV&ed@#;3fBenN)&k+}S;-XI{L0+bsB zC`Q#u1&CifU|5|jn^g5Tt{w>k0xm$kEK&X&Srf?{qgeKDsg5fD%TN~-`UMwKxF1nL!rGDf$aEb2-_J*(OFoX^qk7 zN|ORCkkx~Ydg9_LL$xLVNl=p_^SjDWO?!-j)K!Lh;0+Lzt}^h)o+MM4d$oa|7s+MI zzy(0PY8gG|zuFkup@)HcwSm9jBylnM8bg_1y?CtxP-}$D?;1m$7>ok-8bh2I(gqk$iip6Z=Dkx%fN_p;&b_#ma+AS`a9q1txBqhvQ}&mmbJ-1tteT9DQ))N-V5Pw*1~O&?NUR%I0Oi~ zn+atm1l4GVw-}-AUiv9E!`)&;Wx#DDLEUSpXU%}1VSw_?0R#{CYJvbk@LnQFcGXB? z+G?nv2?(eF<&FW0(Q+9y0RnZafuKpK_)}Am*L{Wx0DyoCP~#Hi`waCk7^A?w&rkuN zk)UmcdQA-or~ox8No|{f+Jo#1qhd0$eaSJTcE7;~PgSFI0s(7WI)V2a>cL~95a0a< zUbwBI-e8j*9xy_Wcz^gMyQF%+Knh0>6PrkW4}1Ol*!Si z?tW&d^av1`04SLNC`MJ+79dc6W*|MH=3KKK-eI8q)&48C$lYP6-!(N850C3`00;yD zN`e4E@Ns>50D|D-Ivg|;FFUnw00dNk8kKn2seJ=Rfx1)shBKQPygYDVZqul^Z!a{bd}owGnFMxuBYmJvI~ zyRMu!df$D9jqSh(xgiXL{O3^ry{6a3NBU|g1poxfYoq|we@MZ?2aM1M-b2r^GpY|5_~U*!pk<=RbI`MY z!{!e=sGJpjgt88jOL+=A1q{0HdKdkM?Hl&4A%CeyA2I1&1Jy7+Spy94eJ}Mq`_y?q zpd`K$r8^A>pw$ll)d)Q?@VytQ~qJ7hM4#crv725W|H`T?f%1nGs9(tM8%9ty&Hea{ygPU^bbU5f+muf zE;TD;4@i6f=A|Z-r6x}O@wlxbtYogB1Hq)(0Z@$HFnmN^`(fE2dy{p z_e3-zB|__I1NiZpTphX4M(>N)*sqS+SgI(>hBlgbE~GIjAKGZbqEC>BWH*~k^L~%L z!KT%3GAmoGoAK94Nt_TUEN8QK<{Rvm<2Re(N;JdD^#JH@Hmhn&KY{XQ(#Cl{Iwo!L zM!m_F9KYpIbh4ipO0n8MaF1k`Zk)D_ToG0ezbZ z=lz(WB!W!u(Se==`0Lv<9z{dRmq0lb^{83Z8hik?N6jG}!3WTK)SNh3^tf9|2s^#Z zK{lsjr>Wd6K7z_lQ>EzuL1U-cDBm0c1cjXg2M@AQH?Yl~^=*E91>IP8h&$|9ar&D> zycOT(pRk9$p6~E3_6_`b@R;|1F5%(B{f+Q}mhbY#W$Y%e_Y$7?U*>w4+ca?eDt<&0 zs~b4)X1=YZ;}j8V;BCM%F6m@ZB3sFKvys#EC|JImopkcR$v@>IDil(cK*Dn?i3AY) Uyo@U(fIzy81L@i)_>)8a2h@3R=Kufz diff --git a/common/src/proto/komp_ac.accounting.rs b/common/src/proto/komp_ac.accounting.rs index f7b8353a..b59eec42 100644 --- a/common/src/proto/komp_ac.accounting.rs +++ b/common/src/proto/komp_ac.accounting.rs @@ -72,7 +72,7 @@ pub struct JournalLineInput { /// 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< - super::ecb::ExchangeRateSelection, + super::exchange_rates::ExchangeRateSelection, >, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] diff --git a/common/src/proto/komp_ac.ecb.rs b/common/src/proto/komp_ac.ecb.rs index 313dddc1..2d604b7b 100644 --- a/common/src/proto/komp_ac.ecb.rs +++ b/common/src/proto/komp_ac.ecb.rs @@ -1,36 +1,13 @@ // This file is @generated by prost-build. -/// Accountant-controlled conversion treatment. Exceptional choices remain -/// explicit and auditable rather than being inferred from a legal label. -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct ExchangeRateSelection { - #[prost(enumeration = "ExchangeRateDateRule", tag = "1")] - pub date_rule: i32, - #[prost(enumeration = "ExchangeRateSource", tag = "2")] - pub source: i32, - /// Required for SPECIFIC_PUBLICATION_DATE and MANUAL; YYYY-MM-DD. - #[prost(string, optional, tag = "3")] - pub specific_rate_date: ::core::option::Option<::prost::alloc::string::String>, - /// Required only for MANUAL. Exact foreign-currency units per one unit of - /// the rate source's pivot currency (EUR for ECB). - #[prost(string, optional, tag = "4")] - pub manual_units_per_pivot: ::core::option::Option<::prost::alloc::string::String>, - /// Required for SPECIFIC_PUBLICATION_DATE and MANUAL. - #[prost(string, tag = "5")] - pub reason: ::prost::alloc::string::String, -} #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] pub struct GetEcbPipelineStatusRequest { - /// Optional. How many recent import attempts to return. Zero uses 20; the - /// maximum is 100. #[prost(int32, tag = "1")] pub batch_limit: i32, } -/// One row of the append-only import audit log. #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct EcbImportBatch { #[prost(int64, tag = "1")] pub batch_id: i64, - /// "running", "succeeded" or "failed". #[prost(string, tag = "2")] pub status: ::prost::alloc::string::String, #[prost(string, tag = "3")] @@ -43,59 +20,44 @@ pub struct EcbImportBatch { pub started_at: ::prost::alloc::string::String, #[prost(string, optional, tag = "7")] pub completed_at: ::core::option::Option<::prost::alloc::string::String>, - /// Present on a batch that advanced coverage. #[prost(string, optional, tag = "8")] pub verified_through_date: ::core::option::Option<::prost::alloc::string::String>, #[prost(int32, optional, tag = "9")] pub observation_count: ::core::option::Option, - /// May be lower than observation_count: an observation already recorded by an - /// earlier batch is kept rather than replaced. #[prost(int32, optional, tag = "10")] pub inserted_observation_count: ::core::option::Option, #[prost(string, optional, tag = "11")] pub error_message: ::core::option::Option<::prost::alloc::string::String>, } +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct EcbCurrencyCoverage { + #[prost(string, tag = "1")] + pub currency: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub status: ::prost::alloc::string::String, + #[prost(string, optional, tag = "3")] + pub verified_from_date: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag = "4")] + pub verified_through_date: ::core::option::Option<::prost::alloc::string::String>, +} #[derive(Clone, PartialEq, ::prost::Message)] pub struct GetEcbPipelineStatusResponse { - /// Latest date a successful batch verified. Absent when nothing has ever - /// succeeded, which is what a pipeline that has never run looks like. #[prost(string, optional, tag = "1")] pub verified_through_date: ::core::option::Option<::prost::alloc::string::String>, - /// The date coverage should have reached by now, derived from the same - /// publication rule the importer schedules against. Comparing the two is what - /// "healthy" means. #[prost(string, tag = "2")] pub latest_verifiable_date: ::prost::alloc::string::String, - /// True when verified coverage has reached latest_verifiable_date. A - /// conversion whose publication date falls beyond coverage is refused, so - /// this answers "will posting work right now". #[prost(bool, tag = "3")] pub healthy: bool, - /// Publication days between coverage and latest_verifiable_date. Zero when - /// healthy. #[prost(int32, tag = "4")] pub days_behind: i32, - /// True while a batch holds the single-running lock. #[prost(bool, tag = "5")] pub import_running: bool, - /// When the scheduler next wakes, from the same cutoff the importer uses. #[prost(string, tag = "6")] pub next_import_at: ::prost::alloc::string::String, - /// Newest first, running and failed attempts included. #[prost(message, repeated, tag = "7")] pub batches: ::prost::alloc::vec::Vec, - /// Distinct currencies observed on verified_through_date, so a coverage gap - /// for one currency is visible even when the date itself is covered. #[prost(string, repeated, tag = "8")] pub covered_currencies: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, - /// Current coverage restated as the question a reader actually has: what may - /// be posted right now. Both are the latest basis date whose derived - /// publication date is still covered, under the two rules - /// required_publication_date applies -- transactions look strictly before the - /// basis date, statements and decisive dates look at it directly, so the - /// transaction bound is the later of the two. Derived here rather than by the - /// caller so it cannot disagree with the calendar the conversion path uses. - /// Absent when nothing has ever been verified and nothing can be posted. #[prost(string, optional, tag = "9")] pub transactions_postable_through: ::core::option::Option< ::prost::alloc::string::String, @@ -104,292 +66,8 @@ pub struct GetEcbPipelineStatusResponse { pub statements_postable_through: ::core::option::Option< ::prost::alloc::string::String, >, -} -/// Exact inputs for a conversion preview. Decimal values are strings so the -/// client never loses precision through binary floating point. -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct PreviewEcbConversionRequest { - #[prost(string, tag = "1")] - pub original_amount: ::prost::alloc::string::String, - #[prost(string, tag = "2")] - pub original_currency: ::prost::alloc::string::String, - /// ISO calendar date (YYYY-MM-DD) of the accounting case. - #[prost(string, tag = "3")] - pub conversion_basis_date: ::prost::alloc::string::String, - /// Required. Profile whose rates apply. Consulted when the selection requests - /// a saved custom rate, and always required so the preview names its books. - #[prost(string, tag = "4")] - pub profile_name: ::prost::alloc::string::String, - /// Empty uses previous publication and ECB. - #[prost(message, optional, tag = "5")] - pub exchange_rate_selection: ::core::option::Option, -} -/// The conversion and immutable local ECB observation that would be used now. -/// No rounding is performed and no conversion evidence is persisted. -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct PreviewEcbConversionResponse { - #[prost(string, tag = "1")] - pub original_amount: ::prost::alloc::string::String, - #[prost(string, tag = "2")] - pub original_currency: ::prost::alloc::string::String, - #[prost(string, tag = "3")] - pub converted_amount: ::prost::alloc::string::String, - #[prost(string, tag = "4")] - pub conversion_basis_date: ::prost::alloc::string::String, - #[prost(string, tag = "5")] - pub determination_method: ::prost::alloc::string::String, - #[prost(string, tag = "6")] - pub rounding_method: ::prost::alloc::string::String, - #[prost(string, optional, tag = "7")] - pub rate_date: ::core::option::Option<::prost::alloc::string::String>, - #[prost(string, optional, tag = "8")] - pub units_per_pivot: ::core::option::Option<::prost::alloc::string::String>, - #[prost(int64, optional, tag = "9")] - pub rate_observation_id: ::core::option::Option, - #[prost(string, optional, tag = "10")] - pub observation_hash: ::core::option::Option<::prost::alloc::string::String>, - #[prost(string, optional, tag = "11")] - pub source_payload_hash: ::core::option::Option<::prost::alloc::string::String>, - #[prost(string, optional, tag = "12")] - pub rate_fetched_at: ::core::option::Option<::prost::alloc::string::String>, - #[prost(int64, optional, tag = "13")] - pub import_batch_id: ::core::option::Option, - #[prost(string, optional, tag = "14")] - pub source_endpoint: ::core::option::Option<::prost::alloc::string::String>, - /// Present only when determination_method is custom_rate. rate_date and - /// units_per_pivot then describe the hand-entered row identified here, and - /// the ECB observation fields above are empty. - #[prost(int64, optional, tag = "15")] - pub custom_rate_table_definition_id: ::core::option::Option, - #[prost(int64, optional, tag = "16")] - pub custom_rate_record_id: ::core::option::Option, - #[prost(int64, optional, tag = "17")] - pub custom_rate_row_revision: ::core::option::Option, - #[prost(string, optional, tag = "18")] - pub custom_rate_note: ::core::option::Option<::prost::alloc::string::String>, - /// Accountant-supplied justification for an exceptional publication date or - /// one-off manual rate. Empty for the normal/default rule. - #[prost(enumeration = "ExchangeRateDateRule", tag = "19")] - pub applied_date_rule: i32, - #[prost(enumeration = "ExchangeRateSource", tag = "20")] - pub applied_source: i32, - #[prost(string, tag = "21")] - pub selection_reason: ::prost::alloc::string::String, - /// The currency converted_amount is stated in: the profile's accounting - /// currency (EUR unless the profile keeps its books in something else). - #[prost(string, tag = "22")] - pub converted_currency: ::prost::alloc::string::String, - /// True when reaching converted_currency needed two hops (original currency - /// to EUR, then EUR to converted_currency) because neither is EUR. The - /// fields above then describe only the second, final hop; the full - /// provenance of both hops is available from ListEcbConversionEvidence - /// after posting. - #[prost(bool, tag = "23")] - pub via_two_hop_conversion: bool, -} -/// Identify one logical money cell whose conversion history should be inspected. -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct ListEcbConversionEvidenceRequest { - /// Required. Profile containing the table. - #[prost(string, tag = "1")] - pub profile_name: ::prost::alloc::string::String, - /// Required. Logical table name within the profile. - #[prost(string, tag = "2")] - pub table_name: ::prost::alloc::string::String, - /// Required. Dynamic table row id. - #[prost(int64, tag = "3")] - pub record_id: i64, - /// Required. Current display name of the money column. The server resolves it - /// to the stable physical column name used by immutable evidence. - #[prost(string, tag = "4")] - pub column_name: ::prost::alloc::string::String, - /// Optional. Maximum rows to return. Zero uses 100; maximum is 500. - #[prost(int32, tag = "5")] - pub limit: i32, - /// Optional cursor. Return evidence ids lower than this value. - #[prost(int64, optional, tag = "6")] - pub before_evidence_id: ::core::option::Option, -} -/// One immutable explanation of how a posted EUR value was determined. -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct EcbConversionEvidence { - #[prost(int64, tag = "1")] - pub evidence_id: i64, - /// Exact committed version of the target dynamic-table row. - #[prost(int64, tag = "2")] - pub row_revision: i64, - #[prost(string, tag = "3")] - pub original_amount: ::prost::alloc::string::String, - #[prost(string, tag = "4")] - pub original_currency: ::prost::alloc::string::String, - #[prost(string, tag = "5")] - pub converted_amount: ::prost::alloc::string::String, - #[prost(string, tag = "6")] - pub conversion_context: ::prost::alloc::string::String, - #[prost(string, tag = "7")] - pub conversion_basis_date: ::prost::alloc::string::String, - #[prost(string, tag = "8")] - pub determination_method: ::prost::alloc::string::String, - #[prost(string, tag = "9")] - pub rounding_method: ::prost::alloc::string::String, - #[prost(string, optional, tag = "10")] - pub rate_date: ::core::option::Option<::prost::alloc::string::String>, - #[prost(string, optional, tag = "11")] - pub units_per_pivot: ::core::option::Option<::prost::alloc::string::String>, - #[prost(int64, optional, tag = "12")] - pub rate_observation_id: ::core::option::Option, - #[prost(string, optional, tag = "13")] - pub observation_hash: ::core::option::Option<::prost::alloc::string::String>, - #[prost(string, optional, tag = "14")] - pub source_payload_hash: ::core::option::Option<::prost::alloc::string::String>, - #[prost(string, optional, tag = "15")] - pub rate_fetched_at: ::core::option::Option<::prost::alloc::string::String>, - #[prost(int64, optional, tag = "16")] - pub import_batch_id: ::core::option::Option, - #[prost(string, optional, tag = "17")] - pub source_endpoint: ::core::option::Option<::prost::alloc::string::String>, - #[prost(string, tag = "18")] - pub evidence_created_at: ::prost::alloc::string::String, - /// Present only when determination_method is custom_rate. The hand-entered row - /// that supplied rate_date and units_per_pivot. The row itself stays editable, so - /// the figures above are the immutable record of what was actually applied. - #[prost(int64, optional, tag = "19")] - pub custom_rate_table_definition_id: ::core::option::Option, - #[prost(int64, optional, tag = "20")] - pub custom_rate_record_id: ::core::option::Option, - #[prost(int64, optional, tag = "21")] - pub custom_rate_row_revision: ::core::option::Option, - #[prost(string, optional, tag = "22")] - pub custom_rate_note: ::core::option::Option<::prost::alloc::string::String>, - /// Immutable audit data for an accountant-controlled exception. - #[prost(string, tag = "23")] - pub selection_reason: ::prost::alloc::string::String, - #[prost(string, optional, tag = "24")] - pub selected_by_user_id: ::core::option::Option<::prost::alloc::string::String>, - /// The currency converted_amount is stated in. Always EUR for the - /// original-currency-to-EUR leg; the profile's native currency for the - /// EUR-to-native leg that follows it when that native currency is not EUR. - #[prost(string, tag = "25")] - pub converted_currency: ::prost::alloc::string::String, - /// Set only on the EUR-to-native-currency leg of a two-hop conversion. Names - /// the original-currency-to-EUR leg's evidence_id, which produced this row's - /// original_amount/original_currency (always EUR). - #[prost(int64, optional, tag = "26")] - pub derived_from_evidence_id: ::core::option::Option, - /// The pivot currency of the rate source that priced this hop (EUR for - /// every hop today). A rate-based hop always has this currency on exactly - /// one side of original_currency/converted_currency. - #[prost(string, tag = "27")] - pub pivot_currency: ::prost::alloc::string::String, - /// Which rate source's policy governs this hop's profile ("ecb" for every - /// hop today). Absent only for already_same_currency, where no policy - /// needed to be consulted. This is about which policy applies, not about - /// who actually supplied this specific rate -- see observation_source_id - /// for that. - #[prost(string, optional, tag = "28")] - pub policy_source_id: ::core::option::Option<::prost::alloc::string::String>, - /// Which rate source actually supplied this hop's rate as its own official - /// observation. Present only when determination_method is - /// official_reference_rate; absent for custom_rate, manual_rate, and - /// already_same_currency, since none of those were priced by the policy's - /// source itself -- a human entered them. - #[prost(string, optional, tag = "29")] - pub observation_source_id: ::core::option::Option<::prost::alloc::string::String>, - /// Exact immutable profile policy version selected for this conversion's - /// accounting date. Absent only when no conversion policy was needed because - /// determination_method is already_same_currency. - #[prost(int64, optional, tag = "30")] - pub rate_source_policy_id: ::core::option::Option, -} -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct ListEcbConversionEvidenceResponse { - /// True when at least one evidence row for this cell used an ECB, saved - /// custom, or one-off manual rate. - #[prost(bool, tag = "1")] - pub has_exchange: bool, - /// Newest-first immutable history for this page. - #[prost(message, repeated, tag = "2")] - pub evidence: ::prost::alloc::vec::Vec, - /// True when another page exists using the last evidence_id as the cursor. - #[prost(bool, tag = "3")] - pub has_more: bool, -} -/// How the publication date is selected from the accounting basis date. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] -#[repr(i32)] -pub enum ExchangeRateDateRule { - Unspecified = 0, - /// Latest TARGET publication strictly before the basis date. - PreviousPublication = 1, - /// Latest TARGET publication on or before the basis date. - OnOrBeforeDate = 2, - /// The exact publication date supplied in specific_rate_date. - SpecificPublicationDate = 3, -} -impl ExchangeRateDateRule { - /// String value of the enum field names used in the ProtoBuf definition. - /// - /// The values are not transformed in any way and thus are considered stable - /// (if the ProtoBuf definition does not change) and safe for programmatic use. - pub fn as_str_name(&self) -> &'static str { - match self { - Self::Unspecified => "EXCHANGE_RATE_DATE_RULE_UNSPECIFIED", - Self::PreviousPublication => "EXCHANGE_RATE_DATE_RULE_PREVIOUS_PUBLICATION", - Self::OnOrBeforeDate => "EXCHANGE_RATE_DATE_RULE_ON_OR_BEFORE_DATE", - Self::SpecificPublicationDate => { - "EXCHANGE_RATE_DATE_RULE_SPECIFIC_PUBLICATION_DATE" - } - } - } - /// Creates an enum from field names used in the ProtoBuf definition. - pub fn from_str_name(value: &str) -> ::core::option::Option { - match value { - "EXCHANGE_RATE_DATE_RULE_UNSPECIFIED" => Some(Self::Unspecified), - "EXCHANGE_RATE_DATE_RULE_PREVIOUS_PUBLICATION" => { - Some(Self::PreviousPublication) - } - "EXCHANGE_RATE_DATE_RULE_ON_OR_BEFORE_DATE" => Some(Self::OnOrBeforeDate), - "EXCHANGE_RATE_DATE_RULE_SPECIFIC_PUBLICATION_DATE" => { - Some(Self::SpecificPublicationDate) - } - _ => None, - } - } -} -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] -#[repr(i32)] -pub enum ExchangeRateSource { - Unspecified = 0, - /// The rate source's own published reference rate (ECB's, today). - Official = 1, - /// A reusable rate from the profile's custom_exchange_rates table. - SavedCustom = 2, - /// A one-off rate recorded only in this posting's immutable evidence. - Manual = 3, -} -impl ExchangeRateSource { - /// String value of the enum field names used in the ProtoBuf definition. - /// - /// The values are not transformed in any way and thus are considered stable - /// (if the ProtoBuf definition does not change) and safe for programmatic use. - pub fn as_str_name(&self) -> &'static str { - match self { - Self::Unspecified => "EXCHANGE_RATE_SOURCE_UNSPECIFIED", - Self::Official => "EXCHANGE_RATE_SOURCE_OFFICIAL", - Self::SavedCustom => "EXCHANGE_RATE_SOURCE_SAVED_CUSTOM", - Self::Manual => "EXCHANGE_RATE_SOURCE_MANUAL", - } - } - /// Creates an enum from field names used in the ProtoBuf definition. - pub fn from_str_name(value: &str) -> ::core::option::Option { - match value { - "EXCHANGE_RATE_SOURCE_UNSPECIFIED" => Some(Self::Unspecified), - "EXCHANGE_RATE_SOURCE_OFFICIAL" => Some(Self::Official), - "EXCHANGE_RATE_SOURCE_SAVED_CUSTOM" => Some(Self::SavedCustom), - "EXCHANGE_RATE_SOURCE_MANUAL" => Some(Self::Manual), - _ => None, - } - } + #[prost(message, repeated, tag = "11")] + pub currency_coverage: ::prost::alloc::vec::Vec, } /// Generated client implementations. pub mod ecb_service_client { @@ -402,11 +80,8 @@ pub mod ecb_service_client { )] use tonic::codegen::*; use tonic::codegen::http::Uri; - /// Read-only access to ECB conversion previews and audit evidence. - /// - /// This service exists for manual visual verification before and after posting. - /// Preview performs the authoritative unrounded backend calculation without - /// writing; evidence exposes conversions already recorded by posting workflows. + /// ECB-only operational details. Conversion selection and evidence live in the + /// provider-neutral exchange_rates contract. #[derive(Debug, Clone)] pub struct EcbServiceClient { inner: tonic::client::Grpc, @@ -487,67 +162,6 @@ pub mod ecb_service_client { self.inner = self.inner.max_encoding_message_size(limit); self } - /// Preview the exact conversion the backend would currently perform. - /// This reads local verified ECB data and never persists conversion evidence. - pub async fn preview_ecb_conversion( - &mut self, - request: impl tonic::IntoRequest, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - > { - self.inner - .ready() - .await - .map_err(|e| { - tonic::Status::unknown( - format!("Service was not ready: {}", e.into()), - ) - })?; - let codec = tonic_prost::ProstCodec::default(); - let path = http::uri::PathAndQuery::from_static( - "/komp_ac.ecb.EcbService/PreviewEcbConversion", - ); - let mut req = request.into_request(); - req.extensions_mut() - .insert( - GrpcMethod::new("komp_ac.ecb.EcbService", "PreviewEcbConversion"), - ); - self.inner.unary(req, path, codec).await - } - /// List immutable ECB conversion evidence for one money value. - pub async fn list_ecb_conversion_evidence( - &mut self, - request: impl tonic::IntoRequest, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - > { - self.inner - .ready() - .await - .map_err(|e| { - tonic::Status::unknown( - format!("Service was not ready: {}", e.into()), - ) - })?; - let codec = tonic_prost::ProstCodec::default(); - let path = http::uri::PathAndQuery::from_static( - "/komp_ac.ecb.EcbService/ListEcbConversionEvidence", - ); - let mut req = request.into_request(); - req.extensions_mut() - .insert( - GrpcMethod::new( - "komp_ac.ecb.EcbService", - "ListEcbConversionEvidence", - ), - ); - self.inner.unary(req, path, codec).await - } - /// Health of the reference-rate import pipeline: how far verified coverage - /// reaches, how far it should reach by now, and how the recent import - /// attempts went. Not profile-scoped -- one pipeline feeds every profile. pub async fn get_ecb_pipeline_status( &mut self, request: impl tonic::IntoRequest, @@ -589,26 +203,6 @@ pub mod ecb_service_server { /// Generated trait containing gRPC methods that should be implemented for use with EcbServiceServer. #[async_trait] pub trait EcbService: std::marker::Send + std::marker::Sync + 'static { - /// Preview the exact conversion the backend would currently perform. - /// This reads local verified ECB data and never persists conversion evidence. - async fn preview_ecb_conversion( - &self, - request: tonic::Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - >; - /// List immutable ECB conversion evidence for one money value. - async fn list_ecb_conversion_evidence( - &self, - request: tonic::Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - >; - /// Health of the reference-rate import pipeline: how far verified coverage - /// reaches, how far it should reach by now, and how the recent import - /// attempts went. Not profile-scoped -- one pipeline feeds every profile. async fn get_ecb_pipeline_status( &self, request: tonic::Request, @@ -617,11 +211,8 @@ pub mod ecb_service_server { tonic::Status, >; } - /// Read-only access to ECB conversion previews and audit evidence. - /// - /// This service exists for manual visual verification before and after posting. - /// Preview performs the authoritative unrounded backend calculation without - /// writing; evidence exposes conversions already recorded by posting workflows. + /// ECB-only operational details. Conversion selection and evidence live in the + /// provider-neutral exchange_rates contract. #[derive(Debug)] pub struct EcbServiceServer { inner: Arc, @@ -698,104 +289,6 @@ pub mod ecb_service_server { } fn call(&mut self, req: http::Request) -> Self::Future { match req.uri().path() { - "/komp_ac.ecb.EcbService/PreviewEcbConversion" => { - #[allow(non_camel_case_types)] - struct PreviewEcbConversionSvc(pub Arc); - impl< - T: EcbService, - > tonic::server::UnaryService - for PreviewEcbConversionSvc { - type Response = super::PreviewEcbConversionResponse; - type Future = BoxFuture< - tonic::Response, - tonic::Status, - >; - fn call( - &mut self, - request: tonic::Request, - ) -> Self::Future { - let inner = Arc::clone(&self.0); - let fut = async move { - ::preview_ecb_conversion(&inner, request) - .await - }; - Box::pin(fut) - } - } - let accept_compression_encodings = self.accept_compression_encodings; - let send_compression_encodings = self.send_compression_encodings; - let max_decoding_message_size = self.max_decoding_message_size; - let max_encoding_message_size = self.max_encoding_message_size; - let inner = self.inner.clone(); - let fut = async move { - let method = PreviewEcbConversionSvc(inner); - let codec = tonic_prost::ProstCodec::default(); - let mut grpc = tonic::server::Grpc::new(codec) - .apply_compression_config( - accept_compression_encodings, - send_compression_encodings, - ) - .apply_max_message_size_config( - max_decoding_message_size, - max_encoding_message_size, - ); - let res = grpc.unary(method, req).await; - Ok(res) - }; - Box::pin(fut) - } - "/komp_ac.ecb.EcbService/ListEcbConversionEvidence" => { - #[allow(non_camel_case_types)] - struct ListEcbConversionEvidenceSvc(pub Arc); - impl< - T: EcbService, - > tonic::server::UnaryService< - super::ListEcbConversionEvidenceRequest, - > for ListEcbConversionEvidenceSvc { - type Response = super::ListEcbConversionEvidenceResponse; - type Future = BoxFuture< - tonic::Response, - tonic::Status, - >; - fn call( - &mut self, - request: tonic::Request< - super::ListEcbConversionEvidenceRequest, - >, - ) -> Self::Future { - let inner = Arc::clone(&self.0); - let fut = async move { - ::list_ecb_conversion_evidence( - &inner, - request, - ) - .await - }; - Box::pin(fut) - } - } - let accept_compression_encodings = self.accept_compression_encodings; - let send_compression_encodings = self.send_compression_encodings; - let max_decoding_message_size = self.max_decoding_message_size; - let max_encoding_message_size = self.max_encoding_message_size; - let inner = self.inner.clone(); - let fut = async move { - let method = ListEcbConversionEvidenceSvc(inner); - let codec = tonic_prost::ProstCodec::default(); - let mut grpc = tonic::server::Grpc::new(codec) - .apply_compression_config( - accept_compression_encodings, - send_compression_encodings, - ) - .apply_max_message_size_config( - max_decoding_message_size, - max_encoding_message_size, - ); - let res = grpc.unary(method, req).await; - Ok(res) - }; - Box::pin(fut) - } "/komp_ac.ecb.EcbService/GetEcbPipelineStatus" => { #[allow(non_camel_case_types)] struct GetEcbPipelineStatusSvc(pub Arc); diff --git a/common/src/proto/komp_ac.exchange_rates.rs b/common/src/proto/komp_ac.exchange_rates.rs new file mode 100644 index 00000000..e4ff9888 --- /dev/null +++ b/common/src/proto/komp_ac.exchange_rates.rs @@ -0,0 +1,846 @@ +// This file is @generated by prost-build. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ExchangeRateSelection { + #[prost(enumeration = "ExchangeRateDateRule", tag = "1")] + pub date_rule: i32, + #[prost(enumeration = "ExchangeRateSource", tag = "2")] + pub source: i32, + #[prost(string, optional, tag = "3")] + pub specific_rate_date: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag = "4")] + pub manual_foreign_units: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, tag = "5")] + pub reason: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct PreviewDirectConversionRequest { + #[prost(string, tag = "1")] + pub original_amount: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub original_currency: ::prost::alloc::string::String, + #[prost(string, tag = "3")] + pub conversion_basis_date: ::prost::alloc::string::String, + #[prost(string, tag = "4")] + pub profile_name: ::prost::alloc::string::String, + #[prost(message, optional, tag = "5")] + pub exchange_rate_selection: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct PreviewDirectConversionResponse { + #[prost(string, tag = "1")] + pub original_amount: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub original_currency: ::prost::alloc::string::String, + #[prost(string, tag = "3")] + pub converted_amount: ::prost::alloc::string::String, + #[prost(string, tag = "4")] + pub accounting_currency: ::prost::alloc::string::String, + #[prost(string, tag = "5")] + pub conversion_basis_date: ::prost::alloc::string::String, + #[prost(string, tag = "6")] + pub determination_method: ::prost::alloc::string::String, + #[prost(string, tag = "7")] + pub rounding_method: ::prost::alloc::string::String, + #[prost(string, optional, tag = "8")] + pub rate_date: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag = "9")] + pub accounting_units: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag = "10")] + pub foreign_currency: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag = "11")] + pub foreign_units: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, tag = "12")] + pub source_id: ::prost::alloc::string::String, + #[prost(int64, optional, tag = "13")] + pub rate_observation_id: ::core::option::Option, + #[prost(string, optional, tag = "14")] + pub observation_hash: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag = "15")] + pub source_payload_hash: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag = "16")] + pub rate_fetched_at: ::core::option::Option<::prost::alloc::string::String>, + #[prost(int64, optional, tag = "17")] + pub import_batch_id: ::core::option::Option, + #[prost(string, optional, tag = "18")] + pub source_endpoint: ::core::option::Option<::prost::alloc::string::String>, + #[prost(int64, optional, tag = "19")] + pub custom_rate_table_definition_id: ::core::option::Option, + #[prost(int64, optional, tag = "20")] + pub custom_rate_record_id: ::core::option::Option, + #[prost(int64, optional, tag = "21")] + pub custom_rate_row_revision: ::core::option::Option, + #[prost(string, optional, tag = "22")] + pub custom_rate_note: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ExchangeRateDateRule", tag = "23")] + pub applied_date_rule: i32, + #[prost(enumeration = "ExchangeRateSource", tag = "24")] + pub applied_source: i32, + #[prost(string, tag = "25")] + pub selection_reason: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ListConversionEvidenceRequest { + #[prost(string, tag = "1")] + pub profile_name: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub table_name: ::prost::alloc::string::String, + #[prost(int64, tag = "3")] + pub record_id: i64, + #[prost(string, tag = "4")] + pub column_name: ::prost::alloc::string::String, + #[prost(int32, tag = "5")] + pub limit: i32, + #[prost(int64, optional, tag = "6")] + pub before_evidence_id: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ConversionEvidence { + #[prost(int64, tag = "1")] + pub evidence_id: i64, + #[prost(int64, tag = "2")] + pub row_revision: i64, + #[prost(string, tag = "3")] + pub original_amount: ::prost::alloc::string::String, + #[prost(string, tag = "4")] + pub original_currency: ::prost::alloc::string::String, + #[prost(string, tag = "5")] + pub converted_amount: ::prost::alloc::string::String, + #[prost(string, tag = "6")] + pub accounting_currency: ::prost::alloc::string::String, + #[prost(string, tag = "7")] + pub accounting_units: ::prost::alloc::string::String, + #[prost(string, tag = "8")] + pub foreign_currency: ::prost::alloc::string::String, + #[prost(string, tag = "9")] + pub foreign_units: ::prost::alloc::string::String, + #[prost(string, tag = "10")] + pub conversion_context: ::prost::alloc::string::String, + #[prost(string, tag = "11")] + pub conversion_basis_date: ::prost::alloc::string::String, + #[prost(string, tag = "12")] + pub determination_method: ::prost::alloc::string::String, + #[prost(string, tag = "13")] + pub rounding_method: ::prost::alloc::string::String, + #[prost(string, optional, tag = "14")] + pub rate_date: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, tag = "15")] + pub source_id: ::prost::alloc::string::String, + #[prost(int64, optional, tag = "16")] + pub rate_observation_id: ::core::option::Option, + #[prost(string, optional, tag = "17")] + pub observation_hash: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag = "18")] + pub source_payload_hash: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag = "19")] + pub rate_fetched_at: ::core::option::Option<::prost::alloc::string::String>, + #[prost(int64, optional, tag = "20")] + pub import_batch_id: ::core::option::Option, + #[prost(string, optional, tag = "21")] + pub source_endpoint: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, tag = "22")] + pub evidence_created_at: ::prost::alloc::string::String, + #[prost(int64, optional, tag = "23")] + pub custom_rate_table_definition_id: ::core::option::Option, + #[prost(int64, optional, tag = "24")] + pub custom_rate_record_id: ::core::option::Option, + #[prost(int64, optional, tag = "25")] + pub custom_rate_row_revision: ::core::option::Option, + #[prost(string, optional, tag = "26")] + pub custom_rate_note: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, tag = "27")] + pub selection_reason: ::prost::alloc::string::String, + #[prost(string, optional, tag = "28")] + pub selected_by_user_id: ::core::option::Option<::prost::alloc::string::String>, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ListConversionEvidenceResponse { + #[prost(bool, tag = "1")] + pub has_exchange: bool, + #[prost(message, repeated, tag = "2")] + pub evidence: ::prost::alloc::vec::Vec, + #[prost(bool, tag = "3")] + pub has_more: bool, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct GetProfileExchangeRateSettingsRequest { + #[prost(string, tag = "1")] + pub profile_name: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ProfileForeignCurrency { + #[prost(string, tag = "1")] + pub currency: ::prost::alloc::string::String, + #[prost(bool, tag = "2")] + pub coverage_complete: bool, + #[prost(string, optional, tag = "3")] + pub verified_from_date: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag = "4")] + pub verified_through_date: ::core::option::Option<::prost::alloc::string::String>, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ProfileExchangeRateSettings { + #[prost(string, tag = "1")] + pub profile_name: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub accounting_currency: ::prost::alloc::string::String, + #[prost(string, tag = "3")] + pub rate_source_id: ::prost::alloc::string::String, + #[prost(message, repeated, tag = "4")] + pub foreign_currencies: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct AddProfileForeignCurrencyRequest { + #[prost(string, tag = "1")] + pub profile_name: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub currency: ::prost::alloc::string::String, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum ExchangeRateDateRule { + Unspecified = 0, + PreviousPublication = 1, + OnOrBeforeDate = 2, + SpecificPublicationDate = 3, +} +impl ExchangeRateDateRule { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "EXCHANGE_RATE_DATE_RULE_UNSPECIFIED", + Self::PreviousPublication => "EXCHANGE_RATE_DATE_RULE_PREVIOUS_PUBLICATION", + Self::OnOrBeforeDate => "EXCHANGE_RATE_DATE_RULE_ON_OR_BEFORE_DATE", + Self::SpecificPublicationDate => { + "EXCHANGE_RATE_DATE_RULE_SPECIFIC_PUBLICATION_DATE" + } + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "EXCHANGE_RATE_DATE_RULE_UNSPECIFIED" => Some(Self::Unspecified), + "EXCHANGE_RATE_DATE_RULE_PREVIOUS_PUBLICATION" => { + Some(Self::PreviousPublication) + } + "EXCHANGE_RATE_DATE_RULE_ON_OR_BEFORE_DATE" => Some(Self::OnOrBeforeDate), + "EXCHANGE_RATE_DATE_RULE_SPECIFIC_PUBLICATION_DATE" => { + Some(Self::SpecificPublicationDate) + } + _ => None, + } + } +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum ExchangeRateSource { + Unspecified = 0, + Official = 1, + SavedCustom = 2, + Manual = 3, +} +impl ExchangeRateSource { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "EXCHANGE_RATE_SOURCE_UNSPECIFIED", + Self::Official => "EXCHANGE_RATE_SOURCE_OFFICIAL", + Self::SavedCustom => "EXCHANGE_RATE_SOURCE_SAVED_CUSTOM", + Self::Manual => "EXCHANGE_RATE_SOURCE_MANUAL", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "EXCHANGE_RATE_SOURCE_UNSPECIFIED" => Some(Self::Unspecified), + "EXCHANGE_RATE_SOURCE_OFFICIAL" => Some(Self::Official), + "EXCHANGE_RATE_SOURCE_SAVED_CUSTOM" => Some(Self::SavedCustom), + "EXCHANGE_RATE_SOURCE_MANUAL" => Some(Self::Manual), + _ => None, + } + } +} +/// Generated client implementations. +pub mod exchange_rate_service_client { + #![allow( + unused_variables, + dead_code, + missing_docs, + clippy::wildcard_imports, + clippy::let_unit_value, + )] + use tonic::codegen::*; + use tonic::codegen::http::Uri; + #[derive(Debug, Clone)] + pub struct ExchangeRateServiceClient { + inner: tonic::client::Grpc, + } + impl ExchangeRateServiceClient { + /// Attempt to create a new client by connecting to a given endpoint. + pub async fn connect(dst: D) -> Result + where + D: TryInto, + D::Error: Into, + { + let conn = tonic::transport::Endpoint::new(dst)?.connect().await?; + Ok(Self::new(conn)) + } + } + impl ExchangeRateServiceClient + where + T: tonic::client::GrpcService, + T::Error: Into, + T::ResponseBody: Body + std::marker::Send + 'static, + ::Error: Into + std::marker::Send, + { + pub fn new(inner: T) -> Self { + let inner = tonic::client::Grpc::new(inner); + Self { inner } + } + pub fn with_origin(inner: T, origin: Uri) -> Self { + let inner = tonic::client::Grpc::with_origin(inner, origin); + Self { inner } + } + pub fn with_interceptor( + inner: T, + interceptor: F, + ) -> ExchangeRateServiceClient> + where + F: tonic::service::Interceptor, + T::ResponseBody: Default, + T: tonic::codegen::Service< + http::Request, + Response = http::Response< + >::ResponseBody, + >, + >, + , + >>::Error: Into + std::marker::Send + std::marker::Sync, + { + ExchangeRateServiceClient::new(InterceptedService::new(inner, interceptor)) + } + /// Compress requests with the given encoding. + /// + /// This requires the server to support it otherwise it might respond with an + /// error. + #[must_use] + pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.inner = self.inner.send_compressed(encoding); + self + } + /// Enable decompressing responses. + #[must_use] + pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.inner = self.inner.accept_compressed(encoding); + self + } + /// Limits the maximum size of a decoded message. + /// + /// Default: `4MB` + #[must_use] + pub fn max_decoding_message_size(mut self, limit: usize) -> Self { + self.inner = self.inner.max_decoding_message_size(limit); + self + } + /// Limits the maximum size of an encoded message. + /// + /// Default: `usize::MAX` + #[must_use] + pub fn max_encoding_message_size(mut self, limit: usize) -> Self { + self.inner = self.inner.max_encoding_message_size(limit); + self + } + pub async fn preview_direct_conversion( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/komp_ac.exchange_rates.ExchangeRateService/PreviewDirectConversion", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "komp_ac.exchange_rates.ExchangeRateService", + "PreviewDirectConversion", + ), + ); + self.inner.unary(req, path, codec).await + } + pub async fn list_conversion_evidence( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/komp_ac.exchange_rates.ExchangeRateService/ListConversionEvidence", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "komp_ac.exchange_rates.ExchangeRateService", + "ListConversionEvidence", + ), + ); + self.inner.unary(req, path, codec).await + } + pub async fn get_profile_exchange_rate_settings( + &mut self, + request: impl tonic::IntoRequest< + super::GetProfileExchangeRateSettingsRequest, + >, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/komp_ac.exchange_rates.ExchangeRateService/GetProfileExchangeRateSettings", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "komp_ac.exchange_rates.ExchangeRateService", + "GetProfileExchangeRateSettings", + ), + ); + self.inner.unary(req, path, codec).await + } + pub async fn add_profile_foreign_currency( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/komp_ac.exchange_rates.ExchangeRateService/AddProfileForeignCurrency", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "komp_ac.exchange_rates.ExchangeRateService", + "AddProfileForeignCurrency", + ), + ); + self.inner.unary(req, path, codec).await + } + } +} +/// Generated server implementations. +pub mod exchange_rate_service_server { + #![allow( + unused_variables, + dead_code, + missing_docs, + clippy::wildcard_imports, + clippy::let_unit_value, + )] + use tonic::codegen::*; + /// Generated trait containing gRPC methods that should be implemented for use with ExchangeRateServiceServer. + #[async_trait] + pub trait ExchangeRateService: std::marker::Send + std::marker::Sync + 'static { + async fn preview_direct_conversion( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn list_conversion_evidence( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn get_profile_exchange_rate_settings( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn add_profile_foreign_currency( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + } + #[derive(Debug)] + pub struct ExchangeRateServiceServer { + inner: Arc, + accept_compression_encodings: EnabledCompressionEncodings, + send_compression_encodings: EnabledCompressionEncodings, + max_decoding_message_size: Option, + max_encoding_message_size: Option, + } + impl ExchangeRateServiceServer { + pub fn new(inner: T) -> Self { + Self::from_arc(Arc::new(inner)) + } + pub fn from_arc(inner: Arc) -> Self { + Self { + inner, + accept_compression_encodings: Default::default(), + send_compression_encodings: Default::default(), + max_decoding_message_size: None, + max_encoding_message_size: None, + } + } + pub fn with_interceptor( + inner: T, + interceptor: F, + ) -> InterceptedService + where + F: tonic::service::Interceptor, + { + InterceptedService::new(Self::new(inner), interceptor) + } + /// Enable decompressing requests with the given encoding. + #[must_use] + pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.accept_compression_encodings.enable(encoding); + self + } + /// Compress responses with the given encoding, if the client supports it. + #[must_use] + pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.send_compression_encodings.enable(encoding); + self + } + /// Limits the maximum size of a decoded message. + /// + /// Default: `4MB` + #[must_use] + pub fn max_decoding_message_size(mut self, limit: usize) -> Self { + self.max_decoding_message_size = Some(limit); + self + } + /// Limits the maximum size of an encoded message. + /// + /// Default: `usize::MAX` + #[must_use] + pub fn max_encoding_message_size(mut self, limit: usize) -> Self { + self.max_encoding_message_size = Some(limit); + self + } + } + impl tonic::codegen::Service> for ExchangeRateServiceServer + where + T: ExchangeRateService, + B: Body + std::marker::Send + 'static, + B::Error: Into + std::marker::Send + 'static, + { + type Response = http::Response; + type Error = std::convert::Infallible; + type Future = BoxFuture; + fn poll_ready( + &mut self, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + fn call(&mut self, req: http::Request) -> Self::Future { + match req.uri().path() { + "/komp_ac.exchange_rates.ExchangeRateService/PreviewDirectConversion" => { + #[allow(non_camel_case_types)] + struct PreviewDirectConversionSvc( + pub Arc, + ); + impl< + T: ExchangeRateService, + > tonic::server::UnaryService + for PreviewDirectConversionSvc { + type Response = super::PreviewDirectConversionResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request< + super::PreviewDirectConversionRequest, + >, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::preview_direct_conversion( + &inner, + request, + ) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = PreviewDirectConversionSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/komp_ac.exchange_rates.ExchangeRateService/ListConversionEvidence" => { + #[allow(non_camel_case_types)] + struct ListConversionEvidenceSvc(pub Arc); + impl< + T: ExchangeRateService, + > tonic::server::UnaryService + for ListConversionEvidenceSvc { + type Response = super::ListConversionEvidenceResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::list_conversion_evidence( + &inner, + request, + ) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = ListConversionEvidenceSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/komp_ac.exchange_rates.ExchangeRateService/GetProfileExchangeRateSettings" => { + #[allow(non_camel_case_types)] + struct GetProfileExchangeRateSettingsSvc( + pub Arc, + ); + impl< + T: ExchangeRateService, + > tonic::server::UnaryService< + super::GetProfileExchangeRateSettingsRequest, + > for GetProfileExchangeRateSettingsSvc { + type Response = super::ProfileExchangeRateSettings; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request< + super::GetProfileExchangeRateSettingsRequest, + >, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::get_profile_exchange_rate_settings( + &inner, + request, + ) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = GetProfileExchangeRateSettingsSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/komp_ac.exchange_rates.ExchangeRateService/AddProfileForeignCurrency" => { + #[allow(non_camel_case_types)] + struct AddProfileForeignCurrencySvc( + pub Arc, + ); + impl< + T: ExchangeRateService, + > tonic::server::UnaryService< + super::AddProfileForeignCurrencyRequest, + > for AddProfileForeignCurrencySvc { + type Response = super::ProfileForeignCurrency; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request< + super::AddProfileForeignCurrencyRequest, + >, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::add_profile_foreign_currency( + &inner, + request, + ) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = AddProfileForeignCurrencySvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + _ => { + Box::pin(async move { + let mut response = http::Response::new( + tonic::body::Body::default(), + ); + let headers = response.headers_mut(); + headers + .insert( + tonic::Status::GRPC_STATUS, + (tonic::Code::Unimplemented as i32).into(), + ); + headers + .insert( + http::header::CONTENT_TYPE, + tonic::metadata::GRPC_CONTENT_TYPE, + ); + Ok(response) + }) + } + } + } + } + impl Clone for ExchangeRateServiceServer { + fn clone(&self) -> Self { + let inner = self.inner.clone(); + Self { + inner, + accept_compression_encodings: self.accept_compression_encodings, + send_compression_encodings: self.send_compression_encodings, + max_decoding_message_size: self.max_decoding_message_size, + max_encoding_message_size: self.max_encoding_message_size, + } + } + } + /// Generated gRPC service name + pub const SERVICE_NAME: &str = "komp_ac.exchange_rates.ExchangeRateService"; + impl tonic::server::NamedService for ExchangeRateServiceServer { + const NAME: &'static str = SERVICE_NAME; + } +} diff --git a/common/src/proto/komp_ac.table_definition.rs b/common/src/proto/komp_ac.table_definition.rs index 6773edab..0a96ce46 100644 --- a/common/src/proto/komp_ac.table_definition.rs +++ b/common/src/proto/komp_ac.table_definition.rs @@ -51,6 +51,13 @@ pub struct PostTableDefinitionRequest { /// to this one when they reach the ledger. #[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. + #[prost(string, tag = "11")] + pub rate_source_id: ::prost::alloc::string::String, + /// Append-only foreign currencies initially enabled for this profile. + #[prost(string, repeated, tag = "12")] + pub foreign_currencies: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, /// When true, the table is stored once in the global physical schema and is /// visible from every profile. profile_name and accounting_currency are ignored. #[prost(bool, tag = "9")] diff --git a/common/src/proto/komp_ac.tables_data.rs b/common/src/proto/komp_ac.tables_data.rs index bedbe498..d259b25c 100644 --- a/common/src/proto/komp_ac.tables_data.rs +++ b/common/src/proto/komp_ac.tables_data.rs @@ -55,7 +55,7 @@ pub struct PostAccountingTableDataRequest { pub row: ::core::option::Option, #[prost(message, optional, tag = "2")] pub exchange_rate_selection: ::core::option::Option< - super::ecb::ExchangeRateSelection, + super::exchange_rates::ExchangeRateSelection, >, } /// Insert response. @@ -173,7 +173,7 @@ pub struct PutAccountingTableDataRequest { pub update: ::core::option::Option, #[prost(message, optional, tag = "2")] pub exchange_rate_selection: ::core::option::Option< - super::ecb::ExchangeRateSelection, + super::exchange_rates::ExchangeRateSelection, >, #[prost(bool, tag = "3")] pub confirmed: bool, diff --git a/server b/server index b864da0a..b929f654 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit b864da0a9b76062bb0f6236e9b6e3d0e82bc11fe +Subproject commit b929f654ed1f4443967fd959ee8e6b5213d45c10 diff --git a/web/src/lib.rs b/web/src/lib.rs index 9f6403f7..f51dbe74 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -7,21 +7,21 @@ use axum::{ routing::get, }; +mod authz; +mod i18n; mod pages; mod schema; mod services; mod ui; -mod authz; -mod i18n; // The server's system column vocabulary, read out of `common` the same way the // generated protos are: this crate compiles that source tree directly instead // of depending on the crate. Only `is_system_column` is used here. -#[path = "../../common/src/system_column.rs"] -#[allow(dead_code)] -mod system_column; #[path = "../../common/src/grpc_error.rs"] #[allow(dead_code)] mod grpc_error; +#[path = "../../common/src/system_column.rs"] +#[allow(dead_code)] +mod system_column; mod analytics { include!(concat!( env!("CARGO_MANIFEST_DIR"), @@ -78,12 +78,17 @@ mod definitions { "/../common/src/proto/komp_ac.ecb.rs" )); } + pub mod exchange_rates { + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../common/src/proto/komp_ac.exchange_rates.rs" + )); + } } use auth::auth_service_client::AuthServiceClient; use definitions::{ - ecb, - table_definition::table_definition_client::TableDefinitionClient, + ecb, table_definition::table_definition_client::TableDefinitionClient, table_script::table_script_client::TableScriptClient, table_structure::table_structure_service_client::TableStructureServiceClient, table_validation::table_validation_service_client::TableValidationServiceClient, diff --git a/web/src/pages/add_table/draft.rs b/web/src/pages/add_table/draft.rs index ed1ed4d5..e726d400 100644 --- a/web/src/pages/add_table/draft.rs +++ b/web/src/pages/add_table/draft.rs @@ -13,10 +13,8 @@ use crate::{ definitions::table_definition::{GeneratedColumnAlias, PostTableDefinitionRequest}, + schema::{ColumnCatalog, ColumnDraft, proto_columns, validate_identifier, validate_table_name}, {i18n::Locale, tr}, - schema::{ - ColumnCatalog, ColumnDraft, proto_columns, validate_identifier, validate_table_name, - }, }; /// The compound type that also brings a system column with it. Which columns @@ -68,6 +66,8 @@ pub(crate) struct TableDraft { pub creating_new_profile: bool, pub global: bool, pub accounting_currency: String, + pub rate_source_id: String, + pub foreign_currencies: String, pub table_name: String, @@ -98,6 +98,7 @@ impl TableDraft { pub(crate) fn new() -> Self { Self { accounting_currency: "EUR".to_string(), + rate_source_id: "ecb".to_string(), columns: ColumnDraft::new(ColumnCatalog::default()), ..Self::default() } @@ -116,11 +117,7 @@ impl TableDraft { // ---- mutations ------------------------------------------------------- /// Removes one column, and drops it from the display columns with it. - pub(crate) fn remove_column( - &mut self, - locale: Locale, - index: usize, - ) -> Result { + pub(crate) fn remove_column(&mut self, locale: Locale, index: usize) -> Result { let removed = self.columns.remove(locale, index)?; self.row_display_columns .retain(|display| display != &removed.name); @@ -364,9 +361,7 @@ impl TableDraft { ); for (source, alias) in &renames { - if let Some(error) = - validate_identifier(locale, alias, "label-column-alias", true) - { + if let Some(error) = validate_identifier(locale, alias, "label-column-alias", true) { return Err(error); } if taken.iter().filter(|name| *name == alias).count() > 1 { @@ -574,6 +569,21 @@ impl TableDraft { } else { String::new() }, + rate_source_id: if self.creating_new_profile && !self.global { + self.rate_source_id.trim().to_ascii_lowercase() + } else { + String::new() + }, + foreign_currencies: if self.creating_new_profile && !self.global { + self.foreign_currencies + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_ascii_uppercase) + .collect() + } else { + Vec::new() + }, row_display_columns: self.row_display_columns.clone(), global: self.global, // The generated columns are named in the same request that creates @@ -634,7 +644,13 @@ mod tests { draft.accounting_currency = "eur".to_string(); assert!(draft.validate(crate::i18n::Locale::default()).is_ok()); - assert_eq!(draft.into_request(crate::i18n::Locale::default()).unwrap().accounting_currency, "EUR"); + assert_eq!( + draft + .into_request(crate::i18n::Locale::default()) + .unwrap() + .accounting_currency, + "EUR" + ); } /// Every column an ACCOUNTING row generates may be aliased, including the @@ -740,16 +756,28 @@ mod tests { source: "debit".to_string(), alias: "Md".to_string(), }]; - assert!(draft.validate(crate::i18n::Locale::default()).is_err(), "an alias is a column name"); + assert!( + draft.validate(crate::i18n::Locale::default()).is_err(), + "an alias is a column name" + ); draft.generated_aliases[0].alias = "note".to_string(); - assert!(draft.validate(crate::i18n::Locale::default()).is_err(), "a declared column holds the name"); + assert!( + draft.validate(crate::i18n::Locale::default()).is_err(), + "a declared column holds the name" + ); draft.generated_aliases[0].alias = "credit".to_string(); - assert!(draft.validate(crate::i18n::Locale::default()).is_err(), "another generated column does"); + assert!( + draft.validate(crate::i18n::Locale::default()).is_err(), + "another generated column does" + ); draft.generated_aliases[0].alias = "id".to_string(); - assert!(draft.validate(crate::i18n::Locale::default()).is_err(), "a system column does"); + assert!( + draft.validate(crate::i18n::Locale::default()).is_err(), + "a system column does" + ); draft.generated_aliases[0].alias = "md".to_string(); assert!(draft.validate(crate::i18n::Locale::default()).is_ok()); @@ -791,7 +819,9 @@ mod tests { #[test] fn a_table_with_no_columns_is_refused() { let mut draft = draft_with_column("total", "int"); - draft.remove_column(crate::i18n::Locale::default(), 0).unwrap(); + draft + .remove_column(crate::i18n::Locale::default(), 0) + .unwrap(); assert!(draft.validate(crate::i18n::Locale::default()).is_err()); } @@ -850,9 +880,20 @@ mod tests { // never the definition row itself. assert_eq!( draft.row_display_column_names(), - ["number", "name", "tax_point_date", "debit", "credit", "account"] + [ + "number", + "name", + "tax_point_date", + "debit", + "credit", + "account" + ] + ); + assert!( + !draft + .row_display_column_names() + .contains(&"accounting".to_string()) ); - assert!(!draft.row_display_column_names().contains(&"accounting".to_string())); // `name` is the second candidate, and choosing it names the generated // column rather than the row it came from. @@ -879,7 +920,9 @@ mod tests { draft.toggle_row_display_candidate(1); assert_eq!(draft.row_display_columns, vec!["number"]); - draft.remove_column(crate::i18n::Locale::default(), 0).unwrap(); + draft + .remove_column(crate::i18n::Locale::default(), 0) + .unwrap(); assert!(draft.row_display_columns.is_empty()); } @@ -919,7 +962,9 @@ mod tests { draft.toggle_row_display_candidate(1); // number draft.toggle_row_display_candidate(2); // issued_on - draft.move_column(crate::i18n::Locale::default(), 0, 1).unwrap(); + draft + .move_column(crate::i18n::Locale::default(), 0, 1) + .unwrap(); assert_eq!( draft @@ -993,7 +1038,13 @@ mod tests { let mut draft = draft_with_column("number", "text"); draft.columns.toggle_indexed(0); - assert_eq!(draft.into_request(crate::i18n::Locale::default()).unwrap().indexes, vec!["number"]); + assert_eq!( + draft + .into_request(crate::i18n::Locale::default()) + .unwrap() + .indexes, + vec!["number"] + ); } /// The request the builder sends is one the server will take: a link @@ -1090,6 +1141,10 @@ mod tests { source: "name".to_string(), alias: "row_revision".to_string(), }]; - assert!(draft.validate_generated_aliases(crate::i18n::Locale::default()).is_err()); + assert!( + draft + .validate_generated_aliases(crate::i18n::Locale::default()) + .is_err() + ); } } diff --git a/web/src/pages/add_table/state.rs b/web/src/pages/add_table/state.rs index acfee64e..8870f363 100644 --- a/web/src/pages/add_table/state.rs +++ b/web/src/pages/add_table/state.rs @@ -37,6 +37,10 @@ pub(crate) struct BuilderForm { #[serde(default)] pub accounting_currency: String, #[serde(default)] + pub rate_source_id: String, + #[serde(default)] + pub foreign_currencies: String, + #[serde(default)] pub global: bool, #[serde(default)] pub table_name: String, @@ -163,6 +167,8 @@ impl BuilderForm { profile_name_input: self.profile_name_input.clone(), creating_new_profile, accounting_currency: self.accounting_currency.clone(), + rate_source_id: self.rate_source_id.clone(), + foreign_currencies: self.foreign_currencies.clone(), global: self.global, table_name: self.table_name.clone(), columns, @@ -233,7 +239,11 @@ impl AddTablePageState { tags.push("quantity ledger".to_string()); } if !column.currency.is_empty() { - tags.push(format!("{}, {}", column.currency, column.money_mode.label())); + tags.push(format!( + "{}, {}", + column.currency, + column.money_mode.label() + )); } rows.push(ColumnRow { index: Some(index), @@ -266,7 +276,11 @@ impl AddTablePageState { for generated in generated_columns { let mut tags = vec![format!("generated by {}", column.data_type)]; if generated.inherits_currency { - tags.push(format!("{}, {}", column.currency, column.money_mode.label())); + tags.push(format!( + "{}, {}", + column.currency, + column.money_mode.label() + )); } rows.push(ColumnRow { index: None, @@ -502,15 +516,18 @@ mod tests { error: None, }; page.draft.columns.catalog = crate::schema::tests::catalog(); - page.draft.columns.added.push(crate::schema::ColumnDefinition { - name: "accounting".to_string(), - data_type: "accounting".to_string(), - indexed: false, - quantity_ledger: false, - required: false, - money_mode: MoneyMode::Exact, - currency: "EUR".to_string(), - }); + page.draft + .columns + .added + .push(crate::schema::ColumnDefinition { + name: "accounting".to_string(), + data_type: "accounting".to_string(), + indexed: false, + quantity_ledger: false, + required: false, + money_mode: MoneyMode::Exact, + currency: "EUR".to_string(), + }); let rows = page.column_rows(); assert_eq!( @@ -537,9 +554,16 @@ mod tests { ); assert!(row("number").first); assert!(row("accounting").last); - assert!(!row("accounting").indexable, "a definition row is no column"); + assert!( + !row("accounting").indexable, + "a definition row is no column" + ); assert!(row("debit").tags.contains(&"EUR, exact".to_string())); - assert!(row("name").tags.contains(&"generated by accounting".to_string())); + assert!( + row("name") + .tags + .contains(&"generated by accounting".to_string()) + ); } /// The generated columns of an ACCOUNTING row are the user's to alias, and @@ -586,7 +610,11 @@ mod tests { assert_eq!(row("account").alias_source.as_deref(), Some("account")); assert_eq!(row("account").alias, "ucet"); assert!(row("account_id").alias_source.is_none()); - assert!(row("account_id").tags.contains(&"written as ucet".to_string())); + assert!( + row("account_id") + .tags + .contains(&"written as ucet".to_string()) + ); assert!( row("accounting").alias_source.is_none(), "a declared column is named where it is described" diff --git a/web/templates/pages/add_table/builder.html b/web/templates/pages/add_table/builder.html index a5924b3e..e76ed268 100644 --- a/web/templates/pages/add_table/builder.html +++ b/web/templates/pages/add_table/builder.html @@ -88,8 +88,19 @@ #} {{ nav.tr("builder-accounting-currency-hint") }} + + {% else %} + + {% endif %} {#