From e97b35a4df1bd7a168d74c1110303f6f9833b334 Mon Sep 17 00:00:00 2001 From: fy59 Date: Sun, 2 Aug 2026 11:17:12 +0200 Subject: [PATCH] =?UTF-8?q?feat(finance):=20ajouter=20le=20socle=20m=C3=A9?= =?UTF-8?q?tier=20bancaire=20canonique?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + Makefile | 10 ++ include/core/bank_text_extractor.h | 8 + include/core/bic_validator.h | 18 +++ include/core/french_bban.h | 7 + include/core/iban_analyzer.h | 19 +++ include/models/financial_foundation.h | 122 ++++++++++++++++ src/core/bank_text_extractor.c | 103 +++++++++++++ src/core/bic_validator.c | 14 ++ src/core/french_bban.c | 11 ++ src/core/iban_analyzer.c | 38 +++++ src/models/financial_foundation.c | 149 +++++++++++++++++++ tests/test_financial_foundation.c | 202 ++++++++++++++++++++++++++ 13 files changed, 704 insertions(+) create mode 100644 include/core/bank_text_extractor.h create mode 100644 include/core/bic_validator.h create mode 100644 include/core/french_bban.h create mode 100644 include/models/financial_foundation.h create mode 100644 src/core/bank_text_extractor.c create mode 100644 src/core/bic_validator.c create mode 100644 src/core/french_bban.c create mode 100644 src/models/financial_foundation.c create mode 100644 tests/test_financial_foundation.c diff --git a/.gitignore b/.gitignore index 0b6cd58..5ebbd89 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,9 @@ compile_commands.json /AGENTS.md /prompt.md +# Copies locales des tickets Forgejo +/ticket[0-9]*.md + # OpenCode local /opencode.json /.opencode/ diff --git a/Makefile b/Makefile index 3654853..b7c4dc7 100644 --- a/Makefile +++ b/Makefile @@ -175,6 +175,7 @@ TEST_PERSON_CREATION_COORDINATOR := tests/test_person_creation_coordinator TEST_EML_ANALYZER := tests/test_eml_analyzer TEST_EML_INTEGRATION := tests/test_eml_integration TEST_IBAN_ANALYZER := tests/test_iban_analyzer +TEST_FINANCIAL_FOUNDATION := tests/test_financial_foundation TEST_EXIFTOOL_METADATA := tests/test_exiftool_metadata TEST_PDF_PASSWORD_RECOVERY := tests/test_pdf_password_recovery TEST_EXTRACTION_DROP_SERVICE := tests/test_extraction_drop_service @@ -1130,6 +1131,12 @@ $(TEST_EML_INTEGRATION): tests/test_eml_integration.c \ $(TEST_IBAN_ANALYZER): tests/test_iban_analyzer.c src/core/iban_analyzer.c $(CC) $(TEST_CFLAGS) $^ -o $@ $(TEST_LDFLAGS) +$(TEST_FINANCIAL_FOUNDATION): tests/test_financial_foundation.c \ + src/models/financial_foundation.c src/core/bank_text_extractor.c \ + src/core/bic_validator.c src/core/french_bban.c \ + src/core/iban_analyzer.c src/core/bank_proposal.c src/core/controlled_vocab.c + $(CC) $(TEST_CFLAGS) -Wpedantic $^ -o $@ $(TEST_LDFLAGS) + $(TEST_EXIFTOOL_METADATA): tests/test_exiftool_metadata.c \ src/core/exiftool_metadata.c $(CC) $(TEST_CFLAGS) $^ -o $@ $(TEST_LDFLAGS) @@ -1238,6 +1245,7 @@ test: \ $(TEST_EML_ANALYZER) \ $(TEST_EML_INTEGRATION) \ $(TEST_IBAN_ANALYZER) \ + $(TEST_FINANCIAL_FOUNDATION) \ $(TEST_EXIFTOOL_METADATA) \ $(TEST_PDF_PASSWORD_RECOVERY) \ $(TEST_EXTRACTION_DROP_SERVICE) \ @@ -1339,6 +1347,7 @@ test: \ @$(TEST_EML_ANALYZER) @$(TEST_EML_INTEGRATION) @$(TEST_IBAN_ANALYZER) + @$(TEST_FINANCIAL_FOUNDATION) @$(TEST_EXIFTOOL_METADATA) @$(TEST_PDF_PASSWORD_RECOVERY) @$(TEST_EXTRACTION_DROP_SERVICE) @@ -1451,6 +1460,7 @@ clean: $(TEST_RELATION_TYPE_SERVICE) \ $(TEST_CONTROLLED_VOCAB) \ $(TEST_BANK_PROPOSAL) \ + $(TEST_FINANCIAL_FOUNDATION) \ $(TEST_EML_PIPELINE_TASK) \ $(TEST_EML_MIME_EXTRACTOR) \ $(TEST_EXIFTOOL_ANALYSIS) \ diff --git a/include/core/bank_text_extractor.h b/include/core/bank_text_extractor.h new file mode 100644 index 0000000..e067211 --- /dev/null +++ b/include/core/bank_text_extractor.h @@ -0,0 +1,8 @@ +#ifndef LABFY_INVESTIGATION_BANK_TEXT_EXTRACTOR_H +#define LABFY_INVESTIGATION_BANK_TEXT_EXTRACTOR_H +#include "models/financial_foundation.h" +G_BEGIN_DECLS +/** Extrait des BankCandidate possédés. Les offsets sont des octets UTF-8. */ +GPtrArray *bank_text_extractor_extract(const char *text, GError **error); +G_END_DECLS +#endif diff --git a/include/core/bic_validator.h b/include/core/bic_validator.h new file mode 100644 index 0000000..4b5d145 --- /dev/null +++ b/include/core/bic_validator.h @@ -0,0 +1,18 @@ +#ifndef LABFY_INVESTIGATION_BIC_VALIDATOR_H +#define LABFY_INVESTIGATION_BIC_VALIDATOR_H +#include +G_BEGIN_DECLS +typedef enum +{ + BIC_VALIDATOR_ABSENT, + BIC_VALIDATOR_VALID, + BIC_VALIDATOR_INVALID +} BicValidatorResult; +/** Retourne une copie majuscule, ou NULL pour une valeur absente/invalide. */ +char *bic_validator_normalize(const char *bic); +/** Distingue explicitement absence, validité syntaxique et invalidité. */ +BicValidatorResult bic_validator_check(const char *bic); +/** Validation syntaxique ISO 9362 uniquement ; une absence retourne FALSE. */ +gboolean bic_validator_validate(const char *bic); +G_END_DECLS +#endif diff --git a/include/core/french_bban.h b/include/core/french_bban.h new file mode 100644 index 0000000..7e025fc --- /dev/null +++ b/include/core/french_bban.h @@ -0,0 +1,7 @@ +#ifndef LABFY_INVESTIGATION_FRENCH_BBAN_H +#define LABFY_INVESTIGATION_FRENCH_BBAN_H +#include +typedef struct { char *bank_code; char *branch_code; char *account_number; char *rib_key; } FrenchBban; +FrenchBban *french_bban_derive(const char *normalized_iban); +void french_bban_free(FrenchBban *bban); +#endif diff --git a/include/core/iban_analyzer.h b/include/core/iban_analyzer.h index cb27ee1..f48de0e 100644 --- a/include/core/iban_analyzer.h +++ b/include/core/iban_analyzer.h @@ -6,8 +6,27 @@ #define LABFY_INVESTIGATION_IBAN_ANALYZER_H #include G_BEGIN_DECLS + +typedef enum +{ + IBAN_ANALYZER_RESULT_VALID, + IBAN_ANALYZER_RESULT_INVALID, + IBAN_ANALYZER_RESULT_UNSUPPORTED_COUNTRY +} IbanAnalyzerResult; /** @brief Normalise un IBAN en majuscules sans séparateurs. */ char *iban_analyzer_normalize(const char *text); +/** Normalise uniquement espaces ASCII, tabulations, CR/LF et tirets. */ +char *iban_analyzer_normalize_canonical(const char *text); +/** + * Vérifie format, longueur nationale prise en charge et MOD-97. + * + * La table volontairement partielle de cette tranche couvre BE, DE, ES, FR, + * GB, IT, LU, NL et PT. Un code pays absent ne peut jamais être déclaré + * valide : iban_analyzer_validate_canonical_result() retourne alors + * IBAN_ANALYZER_RESULT_UNSUPPORTED_COUNTRY. + */ +gboolean iban_analyzer_validate_canonical(const char *iban); +IbanAnalyzerResult iban_analyzer_validate_canonical_result(const char *iban); /** @brief Vérifie la structure et la clé de contrôle modulo 97. */ gboolean iban_analyzer_validate(const char *iban); /** diff --git a/include/models/financial_foundation.h b/include/models/financial_foundation.h new file mode 100644 index 0000000..1d06ff9 --- /dev/null +++ b/include/models/financial_foundation.h @@ -0,0 +1,122 @@ +#ifndef LABFY_INVESTIGATION_FINANCIAL_FOUNDATION_H +#define LABFY_INVESTIGATION_FINANCIAL_FOUNDATION_H + +#include + +G_BEGIN_DECLS + +#define FINANCIAL_TEXT_MAX_BYTES (1024U * 1024U) +#define FINANCIAL_CANDIDATE_MAX_RESULTS 4096U + +typedef enum { BANK_VALIDATION_VALID, BANK_VALIDATION_INVALID, + BANK_VALIDATION_AMBIGUOUS, BANK_VALIDATION_UNVERIFIABLE, + BANK_VALIDATION_COUNT } BankValidationState; +typedef enum { BANK_DECISION_PROPOSED, BANK_DECISION_CONFIRMED, + BANK_DECISION_REJECTED, BANK_DECISION_COUNT } BankHumanDecision; +typedef enum { BANK_ORIGIN_AUTOMATIC, BANK_ORIGIN_HUMAN, + BANK_ORIGIN_LEGACY, BANK_ORIGIN_COUNT } BankOrigin; +typedef enum { BANK_ACCOUNT_STATE_UNKNOWN, BANK_ACCOUNT_STATE_ACTIVE, + BANK_ACCOUNT_STATE_CLOSED } BankAccountState; + +/* Contrat d'ownership commun : tous les constructeurs copient les chaînes + * reçues. Tous les champs pointeurs des structures retournées sont possédés + * par la structure et libérés par sa fonction *_free(), qui accepte NULL. + * Les champs bruts d'une observation ou d'un candidat sont immuables par + * contrat ; une correction appartient à BankObservationRevision. */ + +/* Les conversions retournent "unknown" pour toute valeur hors enum. */ +const char *bank_validation_state_to_code(BankValidationState state); +const char *bank_human_decision_to_code(BankHumanDecision decision); +const char *bank_origin_to_code(BankOrigin origin); + +typedef struct { + char *source_text; char *raw_iban; char *normalized_iban; + char *raw_bic; char *normalized_bic; char *declared_holder_raw; + char *declared_institution; char *declared_address; + gsize start_offset; gsize end_offset; BankValidationState validation; + GPtrArray *warnings; BankOrigin origin; BankHumanDecision decision; + gboolean proposed_grouping; char *proposed_group_identifier; +} BankCandidate; + +BankCandidate *bank_candidate_new(const char *source_text, const char *raw_iban, + gsize start_offset, gsize end_offset, GError **error); +void bank_candidate_free(BankCandidate *candidate); + +typedef struct { + char *identifier; char *evidence_identifier; char *evidence_sha256; + char *raw_value; char *source_type; char *extraction_method; + char *source_page_or_image; char *ocr_run_identifier; + gboolean has_region; double region_x; double region_y; + double region_width; double region_height; BankOrigin origin; + char *created_at_utc; char *factual_notes; +} BankObservation; + +BankObservation *bank_observation_new(const char *identifier, + const char *evidence_identifier, const char *evidence_sha256, + const char *raw_value, const char *source_type, + const char *extraction_method, BankOrigin origin, + const char *created_at_utc, GError **error); +void bank_observation_free(BankObservation *observation); + +typedef struct { + char *observation_identifier; char *corrected_iban; char *normalized_iban; + char *corrected_bic; char *normalized_bic; char *corrected_holder; + BankValidationState validation; BankOrigin origin; + char *created_at_utc; char *factual_notes; +} BankObservationRevision; + +BankObservationRevision *bank_observation_revision_new( + const char *observation_identifier, const char *corrected_iban, + const char *normalized_iban, BankValidationState validation, + const char *created_at_utc, GError **error); +void bank_observation_revision_free(BankObservationRevision *revision); + +typedef struct { + char *entity_identifier; char *normalized_iban; char *country_code; + char *bic; char *declared_institution; BankAccountState state; + char *factual_notes; +} BankAccount; +BankAccount *bank_account_new(const char *entity_identifier, + const char *normalized_iban, const char *bic, + const char *declared_institution, BankAccountState state, GError **error); +void bank_account_free(BankAccount *account); + +typedef struct { + char *declared_name; char *evidence_identifier; + char *observation_identifier; BankOrigin origin; char *factual_notes; +} DeclaredBankHolder; +DeclaredBankHolder *declared_bank_holder_new(const char *declared_name, + const char *evidence_identifier, const char *observation_identifier, + BankOrigin origin, GError **error); +void declared_bank_holder_free(DeclaredBankHolder *holder); + +typedef struct { + char *transaction_type; char *raw_date; char *normalized_date; + char *amount_decimal; char *currency; char *observed_status; + char *bank_reference; char *reference_type; char *uetr; + char *debtor_account_identifier; char *creditor_account_identifier; + char *declared_payer; char *declared_beneficiary; + char *source_institution; char *evidence_identifier; + char *observation_identifier; BankOrigin origin; + char *factual_notes; char *created_at_utc; +} FinancialTransaction; +/* amount_decimal est une chaîne ASCII validée : signe facultatif, chiffres, + * point décimal facultatif. Elle préserve une valeur exacte sans flottant. */ +FinancialTransaction *financial_transaction_new(const char *transaction_type, + const char *raw_date, const char *amount_decimal, const char *currency, + const char *observed_status, const char *evidence_identifier, + const char *created_at_utc, GError **error); +void financial_transaction_free(FinancialTransaction *transaction); + +typedef struct { + GPtrArray *candidates; BankHumanDecision decision; + gboolean create_account; gboolean reuse_account; gboolean create_person; + gboolean create_relation; gboolean create_transaction; +} FinancialReviewModel; +/* candidates est possédé et détruit avec ses BankCandidate. Les options de + * création restent FALSE jusqu'à une action humaine explicite. */ +FinancialReviewModel *financial_review_model_new(void); +void financial_review_model_free(FinancialReviewModel *model); + +G_END_DECLS +#endif diff --git a/src/core/bank_text_extractor.c b/src/core/bank_text_extractor.c new file mode 100644 index 0000000..92349b0 --- /dev/null +++ b/src/core/bank_text_extractor.c @@ -0,0 +1,103 @@ +#include "core/bank_text_extractor.h" +#include "core/bic_validator.h" +#include "core/iban_analyzer.h" +#include + +static char *extract_label(const char *block,const char *labels) +{ + char *pattern=g_strdup_printf("(?im)^(?:%s)[ \\t]*:[ \\t]*(.+)$",labels); + GRegex *regex=g_regex_new(pattern,0,0,NULL);GMatchInfo *match=NULL;char *value=NULL; + g_regex_match(regex,block,0,&match);if(g_match_info_matches(match))value=g_match_info_fetch(match,1); + if(value!=NULL) + g_strstrip(value); + g_match_info_free(match);g_regex_unref(regex);g_free(pattern);return value; +} +static char *candidate_block(const char *text,gsize start,gsize end) +{ + gsize left=start,right=end,length=strlen(text); + while(left>0&&start-left<4096U&&!(text[left-1]=='\n'&&left>1&&text[left-2]=='\n'))left--; + while(right=4&&(!g_ascii_isdigit(compact[2])||!g_ascii_isdigit(compact[3])); + g_free(compact);return ambiguous; +} +static void trim_at_first_valid_iban(char **raw,gint *end) +{ + gsize length=strlen(*raw); + for(gsize size=15;size<=length;size++) { + unsigned char next=(unsigned char)(*raw)[size]; + if(next!='\0'&&g_ascii_isalnum(next))continue; + char *prefix=g_strndup(*raw,size); + if(iban_analyzer_validate_canonical(prefix)) { + g_free(*raw);*raw=prefix;*end-=((gint)length-(gint)size);return; + } + g_free(prefix); + } +} +static void enrich_candidate(BankCandidate *candidate,const char *text) +{ + char *block=candidate_block(text,candidate->start_offset,candidate->end_offset); + g_free(candidate->source_text);candidate->source_text=g_strdup(block); + candidate->declared_holder_raw=extract_label(block,"Titulaire|Holder|Payeur|Bénéficiaire"); + candidate->declared_institution=extract_label(block,"Banque|Bank|Établissement"); + candidate->declared_address=extract_label(block,"Adresse(?: de la banque)?|Bank address"); + candidate->raw_bic=extract_label(block,"BIC|SWIFT"); + candidate->normalized_bic=bic_validator_normalize(candidate->raw_bic); + if(candidate->raw_bic!=NULL&&candidate->normalized_bic==NULL) + g_ptr_array_add(candidate->warnings,g_strdup("BIC syntaxiquement invalide.")); + g_free(block); +} +GPtrArray *bank_text_extractor_extract(const char *text,GError **error) +{ + GPtrArray *results=g_ptr_array_new_with_free_func((GDestroyNotify)bank_candidate_free); + if(text==NULL||text[0]=='\0')return results; + if(strlen(text)>FINANCIAL_TEXT_MAX_BYTES){g_set_error_literal(error, + g_quark_from_static_string("bank-text-extractor-error"),1,"Le texte bancaire dépasse la limite autorisée.");return results;} + GRegex *regex=g_regex_new("(?i)\\b[A-Z]{2}[0-9OIl]{2}(?:[ \\t\\r\\n-]*[A-Z0-9OIl]){3,30}\\b", + G_REGEX_OPTIMIZE,0,NULL);GMatchInfo *matches=NULL;g_regex_match(regex,text,0,&matches); + while(g_match_info_matches(matches)&&results->lennormalized_iban=iban_analyzer_normalize_canonical(raw); + if(has_ocr_confusion(raw)) { + candidate->validation=BANK_VALIDATION_AMBIGUOUS; + g_clear_pointer(&candidate->normalized_iban,g_free); + g_ptr_array_add(candidate->warnings,g_strdup("Confusion OCR possible, sans correction automatique.")); + } else if(candidate->normalized_iban==NULL) + candidate->validation=BANK_VALIDATION_UNVERIFIABLE; + else { + IbanAnalyzerResult validation=iban_analyzer_validate_canonical_result( + candidate->normalized_iban); + if(validation==IBAN_ANALYZER_RESULT_VALID) + candidate->validation=BANK_VALIDATION_VALID; + else if(validation==IBAN_ANALYZER_RESULT_UNSUPPORTED_COUNTRY) { + candidate->validation=BANK_VALIDATION_UNVERIFIABLE; + g_ptr_array_add(candidate->warnings,g_strdup( + "Pays IBAN non pris en charge.")); + } else candidate->validation=BANK_VALIDATION_INVALID; + } + enrich_candidate(candidate,text);g_ptr_array_add(results,candidate);g_free(raw); + if(!g_match_info_next(matches,NULL))break; + } + g_match_info_free(matches);g_regex_unref(regex); + regex=g_regex_new("(?im)^IBAN[ \\t]*:[ \\t]*([^\\r\\n]{8,80})",0,0,NULL); + g_regex_match(regex,text,0,&matches); + while(g_match_info_matches(matches)&&results->len=128)non_ascii=TRUE; + if(non_ascii) { + BankCandidate *candidate=bank_candidate_new(text,raw,(gsize)start,(gsize)end,NULL); + candidate->validation=BANK_VALIDATION_AMBIGUOUS; + g_ptr_array_add(candidate->warnings,g_strdup("Caractère Unicode non normalisé.")); + enrich_candidate(candidate,text);g_ptr_array_add(results,candidate); + } + g_free(raw);if(!g_match_info_next(matches,NULL))break; + } + g_match_info_free(matches);g_regex_unref(regex);return results; +} diff --git a/src/core/bic_validator.c b/src/core/bic_validator.c new file mode 100644 index 0000000..9377fa3 --- /dev/null +++ b/src/core/bic_validator.c @@ -0,0 +1,14 @@ +#include "core/bic_validator.h" +#include +char *bic_validator_normalize(const char *bic) +{ if(bic==NULL||bic[0]=='\0')return NULL; char *value=g_ascii_strup(bic,-1); + if(!bic_validator_validate(value)){g_free(value);return NULL;} return value; } +gboolean bic_validator_validate(const char *bic) +{ return bic_validator_check(bic)==BIC_VALIDATOR_VALID; } +BicValidatorResult bic_validator_check(const char *bic) +{ if(bic==NULL||bic[0]=='\0')return BIC_VALIDATOR_ABSENT; + gsize n=strlen(bic);if(n!=8&&n!=11)return BIC_VALIDATOR_INVALID; + for(gsize i=0;i<4;i++)if(!g_ascii_isalpha(bic[i]))return BIC_VALIDATOR_INVALID; + for(gsize i=4;i<6;i++)if(!g_ascii_isalpha(bic[i]))return BIC_VALIDATOR_INVALID; + for(gsize i=6;i +FrenchBban *french_bban_derive(const char *iban) +{if(iban==NULL||strlen(iban)!=27||strncmp(iban,"FR",2)!=0|| + !iban_analyzer_validate_canonical(iban))return NULL; + FrenchBban *b=g_new0(FrenchBban,1);b->bank_code=g_strndup(iban+4,5); + b->branch_code=g_strndup(iban+9,5);b->account_number=g_strndup(iban+14,11); + b->rib_key=g_strndup(iban+25,2);return b;} +void french_bban_free(FrenchBban *b) +{if(!b)return;g_free(b->bank_code);g_free(b->branch_code);g_free(b->account_number);g_free(b->rib_key);g_free(b);} diff --git a/src/core/iban_analyzer.c b/src/core/iban_analyzer.c index 80ef5fc..d5ddca2 100644 --- a/src/core/iban_analyzer.c +++ b/src/core/iban_analyzer.c @@ -3,6 +3,18 @@ * @brief Normalisation, validation et extraction locale d'IBAN. ******************************************************************************/ #include "core/iban_analyzer.h" +#include +char *iban_analyzer_normalize_canonical(const char *text) +{ + if(text==NULL||strlen(text)>(1024U * 1024U))return NULL; + GString *r=g_string_new(NULL); + for(const unsigned char *p=(const unsigned char *)text;*p;p++) { + if(*p==' '||*p=='\t'||*p=='\r'||*p=='\n'||*p=='-')continue; + if(!g_ascii_isalnum(*p)){g_string_free(r,TRUE);return NULL;} + g_string_append_c(r,g_ascii_toupper(*p)); + } + if(r->len==0){g_string_free(r,TRUE);return NULL;}return g_string_free(r,FALSE); +} char *iban_analyzer_normalize(const char *text) { GString *result = NULL; @@ -38,6 +50,32 @@ gboolean iban_analyzer_validate(const char *iban) } g_free(normalized); return remainder == 1; } +IbanAnalyzerResult iban_analyzer_validate_canonical_result(const char *iban) +{ + char *normalized=iban_analyzer_normalize_canonical(iban); + static const struct { const char *country; gsize length; } lengths[]={ + {"BE",16},{"DE",22},{"ES",24},{"FR",27},{"GB",22}, + {"IT",27},{"LU",20},{"NL",18},{"PT",25}}; + if(normalized==NULL||strlen(normalized)<15||strlen(normalized)>34|| + !g_ascii_isalpha(normalized[0])||!g_ascii_isalpha(normalized[1])|| + !g_ascii_isdigit(normalized[2])||!g_ascii_isdigit(normalized[3])) { + g_free(normalized);return IBAN_ANALYZER_RESULT_INVALID; + } + for(guint i=0;i + +static GQuark financial_error_quark(void) +{ return g_quark_from_static_string("financial-foundation-error"); } +static gboolean required(const char *value) +{ return value != NULL && value[0] != '\0' && strlen(value) <= FINANCIAL_TEXT_MAX_BYTES; } +static gboolean timestamp_valid(const char *value) +{ return value != NULL && strlen(value) == 20 && value[4] == '-' && + value[7] == '-' && value[10] == 'T' && value[13] == ':' && + value[16] == ':' && value[19] == 'Z'; } +static void invalid(GError **error, const char *message) +{ if (error != NULL && *error == NULL) g_set_error_literal(error, + financial_error_quark(), 1, message); } + +const char *bank_validation_state_to_code(BankValidationState state) +{ switch(state) { case BANK_VALIDATION_VALID:return "valid"; + case BANK_VALIDATION_INVALID:return "invalid"; + case BANK_VALIDATION_AMBIGUOUS:return "ambiguous"; + case BANK_VALIDATION_UNVERIFIABLE:return "unverifiable"; + case BANK_VALIDATION_COUNT:break; } return "unknown"; } +const char *bank_human_decision_to_code(BankHumanDecision decision) +{ switch(decision) { case BANK_DECISION_PROPOSED:return "proposed"; + case BANK_DECISION_CONFIRMED:return "confirmed"; + case BANK_DECISION_REJECTED:return "rejected"; + case BANK_DECISION_COUNT:break; } return "unknown"; } +const char *bank_origin_to_code(BankOrigin origin) +{ switch(origin) { case BANK_ORIGIN_AUTOMATIC:return "automatic"; + case BANK_ORIGIN_HUMAN:return "human"; + case BANK_ORIGIN_LEGACY:return "legacy"; + case BANK_ORIGIN_COUNT:break; } return "unknown"; } + +BankCandidate *bank_candidate_new(const char *source_text, const char *raw_iban, + gsize start, gsize end, GError **error) +{ + if (!required(source_text) || !required(raw_iban)) { + invalid(error, "Le candidat bancaire est invalide."); return NULL; + } + gsize source_length = strlen(source_text); + if (start > end || start > source_length || end > source_length) { + invalid(error, "Le candidat bancaire est invalide."); return NULL; + } + BankCandidate *item = g_new0(BankCandidate, 1); + item->source_text = g_strdup(source_text); item->raw_iban = g_strdup(raw_iban); + item->start_offset = start; item->end_offset = end; + item->validation = BANK_VALIDATION_UNVERIFIABLE; + item->warnings = g_ptr_array_new_with_free_func(g_free); + item->origin = BANK_ORIGIN_AUTOMATIC; item->decision=BANK_DECISION_PROPOSED; + item->proposed_grouping = TRUE; + return item; +} +void bank_candidate_free(BankCandidate *c) +{ if (!c) return; g_free(c->source_text); g_free(c->raw_iban); + g_free(c->normalized_iban); g_free(c->raw_bic); g_free(c->normalized_bic); + g_free(c->declared_holder_raw); g_free(c->declared_institution); + g_free(c->declared_address);g_free(c->proposed_group_identifier); + g_clear_pointer(&c->warnings,g_ptr_array_unref); g_free(c); } + +BankObservation *bank_observation_new(const char *id, const char *evidence, + const char *sha256, const char *raw, const char *source, + const char *method, BankOrigin origin, const char *created, GError **error) +{ + if (!required(id)||!required(evidence)||!required(sha256)||strlen(sha256)!=64|| + !required(raw)||!required(source)||!required(method)||!timestamp_valid(created)) + { invalid(error,"L’observation bancaire est invalide."); return NULL; } + BankObservation *o=g_new0(BankObservation,1); o->identifier=g_strdup(id); + o->evidence_identifier=g_strdup(evidence); o->evidence_sha256=g_strdup(sha256); + o->raw_value=g_strdup(raw); o->source_type=g_strdup(source); + o->extraction_method=g_strdup(method); o->origin=origin; + o->created_at_utc=g_strdup(created); return o; +} +void bank_observation_free(BankObservation *o) +{ if(!o)return; g_free(o->identifier);g_free(o->evidence_identifier);g_free(o->evidence_sha256); + g_free(o->raw_value);g_free(o->source_type);g_free(o->extraction_method); + g_free(o->source_page_or_image);g_free(o->ocr_run_identifier); + g_free(o->created_at_utc);g_free(o->factual_notes);g_free(o); } + +BankObservationRevision *bank_observation_revision_new(const char *id, + const char *corrected, const char *normalized, BankValidationState validation, + const char *created, GError **error) +{ + if(!required(id)||!required(corrected)||!required(normalized)||!timestamp_valid(created)|| + (validation==BANK_VALIDATION_VALID&&!iban_analyzer_validate_canonical(normalized))) + { invalid(error,"La révision bancaire est invalide."); return NULL; } + BankObservationRevision *r=g_new0(BankObservationRevision,1); + r->observation_identifier=g_strdup(id);r->corrected_iban=g_strdup(corrected); + r->normalized_iban=g_strdup(normalized);r->validation=validation; + r->origin=BANK_ORIGIN_HUMAN;r->created_at_utc=g_strdup(created);return r; +} +void bank_observation_revision_free(BankObservationRevision *r) +{ if(!r)return;g_free(r->observation_identifier);g_free(r->corrected_iban); + g_free(r->normalized_iban);g_free(r->corrected_bic);g_free(r->normalized_bic); + g_free(r->corrected_holder);g_free(r->created_at_utc);g_free(r->factual_notes);g_free(r); } + +BankAccount *bank_account_new(const char *id,const char *iban,const char *bic, + const char *institution,BankAccountState state,GError **error) +{ + char *canonical=iban_analyzer_normalize_canonical(iban); + if(!required(id)||canonical==NULL||g_strcmp0(canonical,iban)!=0|| + !iban_analyzer_validate_canonical(iban)|| + bic_validator_check(bic)==BIC_VALIDATOR_INVALID) + { g_free(canonical);invalid(error,"Le compte bancaire est invalide.");return NULL; } + BankAccount *a=g_new0(BankAccount,1);a->entity_identifier=g_strdup(id); + a->normalized_iban=canonical;a->country_code=g_strndup(iban,2); + a->bic=bic_validator_normalize(bic);a->declared_institution=g_strdup(institution); + a->state=state;return a; +} +void bank_account_free(BankAccount *a) +{if(!a)return;g_free(a->entity_identifier);g_free(a->normalized_iban);g_free(a->country_code); + g_free(a->bic);g_free(a->declared_institution);g_free(a->factual_notes);g_free(a);} + +DeclaredBankHolder *declared_bank_holder_new(const char *name,const char *evidence, + const char *observation,BankOrigin origin,GError **error) +{if(!required(name)||!required(evidence)){invalid(error,"Le titulaire déclaré est invalide.");return NULL;} + DeclaredBankHolder *h=g_new0(DeclaredBankHolder,1);h->declared_name=g_strdup(name); + h->evidence_identifier=g_strdup(evidence);h->observation_identifier=g_strdup(observation); + h->origin=origin;return h;} +void declared_bank_holder_free(DeclaredBankHolder *h) +{if(!h)return;g_free(h->declared_name);g_free(h->evidence_identifier); + g_free(h->observation_identifier);g_free(h->factual_notes);g_free(h);} + +static gboolean decimal_valid(const char *v) +{if(!required(v))return FALSE;gsize i=v[0]=='-'?1:0;gboolean digit=FALSE,dot=FALSE; + for(;v[i];i++){if(g_ascii_isdigit(v[i]))digit=TRUE;else if(v[i]=='.'&&!dot)dot=TRUE;else return FALSE;} + return digit&&v[strlen(v)-1]!='.';} +FinancialTransaction *financial_transaction_new(const char *type,const char *raw_date, + const char *amount,const char *currency,const char *status,const char *evidence, + const char *created,GError **error) +{if(!required(type)||!required(raw_date)||!decimal_valid(amount)||!required(currency)|| + !required(status)||!required(evidence)||!timestamp_valid(created)) + {invalid(error,"La transaction financière est invalide.");return NULL;} + FinancialTransaction *t=g_new0(FinancialTransaction,1);t->transaction_type=g_strdup(type); + t->raw_date=g_strdup(raw_date);t->amount_decimal=g_strdup(amount);t->currency=g_ascii_strup(currency,-1); + t->observed_status=g_strdup(status);t->evidence_identifier=g_strdup(evidence); + t->origin=BANK_ORIGIN_HUMAN;t->created_at_utc=g_strdup(created);return t;} +void financial_transaction_free(FinancialTransaction *t) +{if(!t)return;g_free(t->transaction_type);g_free(t->raw_date);g_free(t->normalized_date); + g_free(t->amount_decimal);g_free(t->currency);g_free(t->observed_status);g_free(t->bank_reference); + g_free(t->reference_type);g_free(t->uetr);g_free(t->debtor_account_identifier); + g_free(t->creditor_account_identifier);g_free(t->declared_payer);g_free(t->declared_beneficiary); + g_free(t->source_institution);g_free(t->evidence_identifier);g_free(t->observation_identifier); + g_free(t->factual_notes);g_free(t->created_at_utc);g_free(t);} +FinancialReviewModel *financial_review_model_new(void) +{FinancialReviewModel *m=g_new0(FinancialReviewModel,1);m->candidates=g_ptr_array_new_with_free_func( + (GDestroyNotify)bank_candidate_free);m->decision=BANK_DECISION_PROPOSED;return m;} +void financial_review_model_free(FinancialReviewModel *m) +{if(!m)return;g_clear_pointer(&m->candidates,g_ptr_array_unref);g_free(m);} diff --git a/tests/test_financial_foundation.c b/tests/test_financial_foundation.c new file mode 100644 index 0000000..95007c4 --- /dev/null +++ b/tests/test_financial_foundation.c @@ -0,0 +1,202 @@ +#include "core/bank_proposal.h" +#include "core/bank_text_extractor.h" +#include "core/bic_validator.h" +#include "core/french_bban.h" +#include "core/iban_analyzer.h" +#include "models/financial_foundation.h" +#include +#include + +static const char *valid_fr="FR4830002005500000000000052"; +static const char *valid_de="DE89370400440532013000"; +static const char *valid_gb="GB82WEST12345698765432"; +static const char *stamp="2026-01-02T03:04:05Z"; + +static char *test_make_mod97_iban(const char *country,gsize length) +{ + char *value=g_malloc0(length+1);memcpy(value,country,2);memset(value+2,'0',length-2); + for(guint key=0;key<100;key++){value[2]=(char)('0'+key/10);value[3]=(char)('0'+key%10); + if(iban_analyzer_validate(value))return value;}g_free(value);return NULL; +} + +static void test_states_independent(void) +{ + g_assert_cmpstr(bank_validation_state_to_code(BANK_VALIDATION_VALID),==,"valid"); + g_assert_cmpstr(bank_human_decision_to_code(BANK_DECISION_PROPOSED),==,"proposed"); + g_assert_cmpstr(bank_origin_to_code(BANK_ORIGIN_HUMAN),==,"human"); + FinancialReviewModel *m=financial_review_model_new();g_assert_nonnull(m); + g_assert_false(m->create_account);g_assert_false(m->reuse_account); + g_assert_false(m->create_person);g_assert_false(m->create_relation); + g_assert_false(m->create_transaction);BankCandidate *c=bank_candidate_new("RAW","RAW",0,3,NULL); + g_ptr_array_add(m->candidates,c);c->decision=BANK_DECISION_REJECTED; + g_assert_cmpstr(c->raw_iban,==,"RAW");financial_review_model_free(m); +} +static void test_enum_conversion_bounds(void) +{ + const char *validation_codes[]={"valid","invalid","ambiguous","unverifiable"}; + for(gint value=0;valuestart_offset,==,8); + g_assert_cmpuint(candidate->end_offset,==,10);bank_candidate_free(candidate); + g_assert_null(bank_candidate_new("0123456789","SPECIMEN",100,101,NULL)); + g_assert_null(bank_candidate_new("0123456789","SPECIMEN",8,7,NULL)); + g_assert_null(bank_candidate_new("0123456789","SPECIMEN",11,11,NULL)); + g_assert_null(bank_candidate_new("0123456789","SPECIMEN",10,11,NULL)); + g_assert_null(bank_candidate_new("0123456789","SPECIMEN",G_MAXSIZE,G_MAXSIZE,NULL)); + g_assert_null(bank_candidate_new("","SPECIMEN",1,1,NULL)); +} +static void test_iban_canonical_and_legacy_parity(void) +{ + const char *values[]={valid_fr,valid_de,valid_gb,"FR0030002005500000000000021","FR763000",NULL}; + for(guint i=0;values[i]!=NULL;i++)g_assert_cmpint(iban_analyzer_validate(values[i]),==, + bank_proposal_validate_iban(values[i])); + char *n=iban_analyzer_normalize_canonical("fr48 3000-2005\n5000 0000 0000 052"); + g_assert_cmpstr(n,==,valid_fr);g_free(n); + g_assert_null(iban_analyzer_normalize_canonical("FR48.30002005500000000000052")); + g_assert_null(iban_analyzer_normalize_canonical("FR4830002005500000000000052")); + char *wrong_length=test_make_mod97_iban("FR",26);g_assert_nonnull(wrong_length); + g_assert_false(bank_proposal_validate_iban(wrong_length)); + g_assert_cmpint(iban_analyzer_validate_canonical_result(wrong_length),==,IBAN_ANALYZER_RESULT_INVALID); + char *unsupported=test_make_mod97_iban("AD",24);g_assert_nonnull(unsupported); + g_assert_cmpint(iban_analyzer_validate_canonical_result(unsupported),==,IBAN_ANALYZER_RESULT_UNSUPPORTED_COUNTRY); + g_assert_false(iban_analyzer_validate_canonical(unsupported)); + char *unknown=test_make_mod97_iban("ZZ",24);g_assert_nonnull(unknown); + g_assert_cmpint(iban_analyzer_validate_canonical_result(unknown),==,IBAN_ANALYZER_RESULT_UNSUPPORTED_COUNTRY); + g_assert_cmpint(iban_analyzer_validate_canonical_result("F100"),==,IBAN_ANALYZER_RESULT_INVALID); + g_assert_cmpint(iban_analyzer_validate_canonical_result("FR00"),==,IBAN_ANALYZER_RESULT_INVALID); + g_assert_cmpint(iban_analyzer_validate_canonical_result(valid_fr),==,IBAN_ANALYZER_RESULT_VALID); + g_assert_cmpint(iban_analyzer_validate_canonical_result("FR0030002005500000000000021"),==,IBAN_ANALYZER_RESULT_INVALID); + g_free(unknown);g_free(unsupported);g_free(wrong_length); +} +static void test_bic_and_bban(void) +{ + g_assert_cmpint(bic_validator_check(NULL),==,BIC_VALIDATOR_ABSENT); + g_assert_cmpint(bic_validator_check(""),==,BIC_VALIDATOR_ABSENT); + g_assert_false(bic_validator_validate(NULL));g_assert_true(bic_validator_validate("BNPAFRPP")); + g_assert_cmpint(bic_validator_check("BNPAFRPP"),==,BIC_VALIDATOR_VALID); + g_assert_cmpint(bic_validator_check("BNPAFR"),==,BIC_VALIDATOR_INVALID); + g_assert_true(bic_validator_validate("BNPAFRPPXXX"));g_assert_false(bic_validator_validate("BNPAFR")); + g_assert_false(bic_validator_validate("BNPAFRP!"));char *bic=bic_validator_normalize("bnpafrppxxx"); + g_assert_cmpstr(bic,==,"BNPAFRPPXXX");g_free(bic);FrenchBban *b=french_bban_derive(valid_fr); + g_assert_nonnull(b);g_assert_cmpstr(b->bank_code,==,"30002");g_assert_cmpstr(b->branch_code,==,"00550"); + g_assert_cmpstr(b->account_number,==,"00000000000");g_assert_cmpstr(b->rib_key,==,"52"); + french_bban_free(b);g_assert_null(french_bban_derive("FR0030002005500000000000021")); +} +static void test_punctuation_and_utf8_offsets(void) +{ + const char *formats[]={"(%s)","IBAN: %s","%s,","[%s]",NULL}; + for(guint i=0;formats[i]!=NULL;i++){char *text=g_strdup_printf(formats[i],valid_fr); + GPtrArray *items=bank_text_extractor_extract(text,NULL);g_assert_cmpuint(items->len,==,1); + BankCandidate *candidate=g_ptr_array_index(items,0);g_assert_cmpstr(candidate->raw_iban,==,valid_fr); + g_assert_cmpmem(text+candidate->start_offset,candidate->end_offset-candidate->start_offset, + valid_fr,strlen(valid_fr));g_assert_nonnull(strstr(candidate->source_text,valid_fr)); + g_ptr_array_unref(items);g_free(text);} + const char *utf8="Préfixe SPECIMEN : [FR4830002005500000000000052]"; + GPtrArray *items=bank_text_extractor_extract(utf8,NULL);g_assert_cmpuint(items->len,==,1); + BankCandidate *candidate=g_ptr_array_index(items,0);g_assert_cmpuint(candidate->start_offset,==, + (gsize)(strstr(utf8,valid_fr)-utf8));g_assert_cmpuint(candidate->end_offset,==, + candidate->start_offset+strlen(valid_fr));g_ptr_array_unref(items); +} +static void test_multi_occurrences_and_grouping(void) +{ + const char *text="Titulaire : SPECIMEN ALPHA\nBIC : bnpafrpp\n" + "IBAN : FR48 3000 2005 5000 0000 0000 052\n" + "IBAN : DE89-3704-0044-0532-0130-00\n\n" + "Titulaire : SPECIMEN BETA\nIBAN : GB82 WEST 1234 5698 7654 32"; + GPtrArray *items=bank_text_extractor_extract(text,NULL);g_assert_cmpuint(items->len,==,3); + BankCandidate *a=g_ptr_array_index(items,0),*b=g_ptr_array_index(items,1),*c=g_ptr_array_index(items,2); + g_assert_cmpint(a->validation,==,BANK_VALIDATION_VALID);g_assert_cmpint(b->validation,==,BANK_VALIDATION_VALID); + g_assert_cmpint(c->validation,==,BANK_VALIDATION_VALID);g_assert_cmpstr(a->declared_holder_raw,==,"SPECIMEN ALPHA"); + g_assert_cmpstr(b->declared_holder_raw,==,"SPECIMEN ALPHA");g_assert_cmpstr(c->declared_holder_raw,==,"SPECIMEN BETA"); + g_assert_cmpstr(a->normalized_bic,==,"BNPAFRPP");g_assert_true(a->proposed_grouping); + a->proposed_group_identifier=g_strdup("group-specimen");b->proposed_group_identifier=g_strdup("group-specimen"); + c->proposed_group_identifier=g_strdup("separate-specimen");g_assert_cmpstr(a->proposed_group_identifier,==,b->proposed_group_identifier); + g_assert_cmpstr(a->proposed_group_identifier,!=,c->proposed_group_identifier); + g_assert_cmpmem(text+a->start_offset,a->end_offset-a->start_offset,a->raw_iban,strlen(a->raw_iban)); + g_ptr_array_unref(items); +} +static void test_duplicate_offsets_and_invalid(void) +{ + char *text=g_strdup_printf("%s puis %s",valid_fr,valid_fr);GPtrArray *items=bank_text_extractor_extract(text,NULL); + g_assert_cmpuint(items->len,==,2);BankCandidate *a=g_ptr_array_index(items,0),*b=g_ptr_array_index(items,1); + g_assert_cmpuint(a->start_offset,<,b->start_offset);g_assert_cmpstr(a->raw_iban,==,b->raw_iban);g_ptr_array_unref(items);g_free(text); + items=bank_text_extractor_extract("IBAN : FR00 3000 2005 5000 0000 0000 021",NULL); + g_assert_cmpuint(items->len,==,1);a=g_ptr_array_index(items,0);g_assert_cmpint(a->validation,==,BANK_VALIDATION_INVALID); + g_assert_nonnull(a->raw_iban);g_ptr_array_unref(items); + items=bank_text_extractor_extract("IBAN : FR48 3000",NULL);g_assert_cmpuint(items->len,==,1); + g_assert_cmpint(((BankCandidate*)g_ptr_array_index(items,0))->validation,==,BANK_VALIDATION_INVALID);g_ptr_array_unref(items); +} +static void test_ambiguities_no_silent_fix(void) +{ + const char *texts[]={"IBAN : FRO8 3000 2005 5000 0000 0000 052", + "IBAN : FRI8 3000 2005 5000 0000 0000 052","IBAN : FR48 3000 2005 5000 0000 0000 052",NULL}; + for(guint i=0;texts[i];i++){GPtrArray *items=bank_text_extractor_extract(texts[i],NULL); + g_assert_cmpuint(items->len,==,1);BankCandidate *c=g_ptr_array_index(items,0); + g_assert_cmpint(c->validation,==,BANK_VALIDATION_AMBIGUOUS);g_assert_null(c->normalized_iban); + g_assert_true(strstr(texts[i],c->raw_iban)!=NULL);g_ptr_array_unref(items);} +} +static void test_models_and_transaction(void) +{ + const char *sha="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";GError *error=NULL; + BankObservation *o=bank_observation_new("obs-specimen","proof-specimen",sha,"FRO8 raw","image","manual",BANK_ORIGIN_AUTOMATIC,stamp,&error); + g_assert_no_error(error);g_assert_nonnull(o);BankObservationRevision *r=bank_observation_revision_new( + "obs-specimen",valid_fr,valid_fr,BANK_VALIDATION_VALID,stamp,&error);g_assert_nonnull(r); + g_assert_cmpstr(o->raw_value,==,"FRO8 raw");g_assert_cmpint(r->origin,==,BANK_ORIGIN_HUMAN); + BankAccount *a=bank_account_new("entity-specimen",valid_fr,"BNPAFRPP",NULL,BANK_ACCOUNT_STATE_UNKNOWN,&error);g_assert_nonnull(a); + DeclaredBankHolder *h=declared_bank_holder_new("SPECIMEN HOLDER","proof-specimen","obs-specimen",BANK_ORIGIN_HUMAN,&error);g_assert_nonnull(h); + FinancialTransaction *t=financial_transaction_new("transfer","02/01/2026","1234.56","eur","envoyée","proof-specimen",stamp,&error); + g_assert_nonnull(t);g_assert_cmpstr(t->amount_decimal,==,"1234.56");g_assert_cmpstr(t->observed_status,==,"envoyée"); + t->uetr=g_strdup("11111111-2222-4333-8444-555555555555");g_assert_cmpstr(t->uetr,==,"11111111-2222-4333-8444-555555555555"); + financial_transaction_free(t);declared_bank_holder_free(h);bank_account_free(a); + bank_observation_revision_free(r);bank_observation_free(o); + g_assert_null(financial_transaction_new("transfer","date","1.2.3","EUR","vu","proof",stamp,&error));g_clear_error(&error); +} +static void test_null_empty_and_limits(void) +{ + GPtrArray *items=bank_text_extractor_extract(NULL,NULL);g_assert_cmpuint(items->len,==,0);g_ptr_array_unref(items); + items=bank_text_extractor_extract("",NULL);g_assert_cmpuint(items->len,==,0);g_ptr_array_unref(items); + char *large=g_malloc0(FINANCIAL_TEXT_MAX_BYTES+2);memset(large,'A',FINANCIAL_TEXT_MAX_BYTES+1); + GError *error=NULL;items=bank_text_extractor_extract(large,&error);g_assert_error(error, + g_quark_from_static_string("bank-text-extractor-error"),1);g_assert_cmpuint(items->len,==,0); + g_clear_error(&error);g_ptr_array_unref(items);g_free(large); +} +int main(int argc,char **argv) +{g_test_init(&argc,&argv,NULL);g_test_add_func("/financial/states",test_states_independent); + g_test_add_func("/financial/enum-bounds",test_enum_conversion_bounds); + g_test_add_func("/financial/candidate-offset-bounds",test_candidate_offset_bounds); + g_test_add_func("/financial/iban-parity",test_iban_canonical_and_legacy_parity); + g_test_add_func("/financial/bic-bban",test_bic_and_bban); + g_test_add_func("/financial/multi-grouping",test_multi_occurrences_and_grouping); + g_test_add_func("/financial/duplicates-invalid",test_duplicate_offsets_and_invalid); + g_test_add_func("/financial/punctuation-offsets",test_punctuation_and_utf8_offsets); + g_test_add_func("/financial/ambiguities",test_ambiguities_no_silent_fix); + g_test_add_func("/financial/models-transaction",test_models_and_transaction); + g_test_add_func("/financial/null-empty-limits",test_null_empty_and_limits);return g_test_run();}