"""Models module.""" from abc import ABC, abstractmethod import datetime from decimal import Decimal import typing import urllib.parse from pydantic import ( AliasChoices, BaseModel, BeforeValidator, computed_field, ConfigDict, Field, field_validator, model_validator, PlainValidator, StringConstraints, TypeAdapter, ) from src.constants import ( TAX_CORRECTION_TYPE_TO_PAYABLE_DETAIL_TYPE_ID, TaxCorrectionTypes, TaxFormType, ) from src.utils import empty_str_to_none, parse_date, parse_str_to_bool class S3Bucket(BaseModel): """S3Bucket model.""" name: str class S3Object(BaseModel): """S3Object model.""" key: str size: int @field_validator('key') @classmethod def _unquote_key(cls, v: str) -> str: return urllib.parse.unquote_plus(v) class S3Data(BaseModel): """S3Data model.""" bucket: S3Bucket object: S3Object class S3Record(BaseModel): """S3Record model.""" event_time: datetime.datetime = Field(alias='eventTime') s3: S3Data class S3Event(BaseModel): """S3Event model.""" records: list[S3Record] = Field(alias='Records', min_length=1) class LambdaResponse(BaseModel): """LambdaResponse model.""" status: str class NewTaxCorrection(BaseModel): """New tax correction.""" account_id: int contract_id: int correction_statement_period_id: int correction_type: TaxCorrectionTypes amount: Decimal currency_code: str note: typing.Optional[str] @computed_field # type: ignore @property def payable_detail_type_id(self) -> typing.Optional[int]: """Payable detail type.""" return TAX_CORRECTION_TYPE_TO_PAYABLE_DETAIL_TYPE_ID.get(self.correction_type) @classmethod def list_validate( cls, data: typing.List[typing.Mapping[str, typing.Any]] ) -> typing.List[typing.Self]: """Validate the list of new tax corrections.""" return TypeAdapter( typing.List[cls] # type: ignore ).validate_python(data) class TaxCorrection(NewTaxCorrection): """Existing tax correction.""" worksheet_tax_correction_id: int class GetTaxCorrectionsResponse(BaseModel): """Get tax corrections response.""" items: typing.List[TaxCorrection] total_count: int class NewTaxCorrectionVAT(BaseModel): """New tax correction VAT.""" statement_period_id: int = Field( serialization_alias='correction_statement_period_id' ) account_id: int contract_id: int vat_category: str payee_currency_code: str vat_currency_code: str base_amount_payee_currency: Decimal vat_rate: Decimal vat_amount_payee_currency: Decimal vat_amount_vat_currency: Decimal net_amount_payee_currency: Decimal wht_rate: typing.Annotated[ typing.Optional[Decimal], BeforeValidator(empty_str_to_none) ] = None wht_amount_payee_currency: typing.Annotated[ typing.Optional[Decimal], BeforeValidator(empty_str_to_none) ] = None wht_amount_vat_currency: typing.Annotated[ typing.Optional[Decimal], BeforeValidator(empty_str_to_none) ] = None note: typing.Optional[str] @computed_field # type: ignore @property def payable_detail_type_id(self) -> typing.Optional[int]: """Payable detail type.""" return TAX_CORRECTION_TYPE_TO_PAYABLE_DETAIL_TYPE_ID.get(TaxCorrectionTypes.vat) @classmethod def list_validate( cls, data: typing.List[typing.Mapping[str, typing.Any]] ) -> typing.List[typing.Self]: """Validate the list of new tax corrections VAT.""" return TypeAdapter( typing.List[cls] # type: ignore ).validate_python(data) class TaxCorrectionVAT(BaseModel): """Tax correction VAT.""" worksheet_tax_correction_vat_id: int correction_statement_period_id: int account_id: int contract_id: int payable_detail_type_id: int payee_currency_code: str vat_currency_code: str base_amount_payee_currency: Decimal vat_rate: Decimal vat_amount_payee_currency: Decimal vat_amount_vat_currency: Decimal net_amount_payee_currency: Decimal note: typing.Optional[str] class GetTaxCorrectionsVATResponse(BaseModel): """Get tax corrections VAT response.""" items: typing.List[TaxCorrectionVAT] total_count: int class StatementPeriod(BaseModel): """Statment Period.""" statement_period_id: int statement_period_name: str statement_period_status: str class PaymentHoldInput(BaseModel): """Payment hold data from csv file.""" account_id: int reason: str @classmethod def list_validate( cls, data: typing.List[typing.Mapping[str, typing.Any]] ) -> typing.List[typing.Self]: """Validate the list of new tax corrections VAT.""" return TypeAdapter( typing.List[cls] # type: ignore ).validate_python(data) class PaymentHold(BaseModel): """Payment hold request body.""" account_id: int reason: str is_on_hold: bool start_date: datetime.date created_by: str last_modified_by: str class PaymentHoldResult(PaymentHold): """Payment hold result.""" payment_hold_id: int created_at: datetime.date last_modified: datetime.date class BankFieldDetail(BaseModel): name: str value: str class PayoutMethod(BaseModel): bank_account_type: typing.Optional[str] country: typing.Optional[str] currency: typing.Optional[str] bank_field_details: typing.List[BankFieldDetail] class Address(BaseModel): model_config = ConfigDict(populate_by_name=True) country_code: typing.Optional[str] = Field(None, alias='country') address_1: typing.Optional[str] = Field(None, alias='address1') address_2: typing.Optional[str] = Field(None, alias='address2') city: typing.Optional[str] = None province: typing.Optional[str] = Field(None, alias='state') zip: typing.Optional[str] = Field(None, alias='postal_code') class Contact(BaseModel): model_config = ConfigDict(populate_by_name=True) first_name: typing.Optional[str] = Field(None, alias='firstName') last_name: typing.Optional[str] = Field(None, alias='lastName') date_of_birth: typing.Optional[str] = Field(None, alias='dateOfBirth') email: typing.Optional[str] class Company(BaseModel): model_config = ConfigDict(populate_by_name=True) name: typing.Optional[str] = Field(None, alias='companyName') class PayeeDetails(BaseModel): account_payee_id: int | str type: typing.Optional[str] contact: typing.Optional[Contact] = None company: typing.Optional[Company] = None address: Address payout_method: PayoutMethod def model_dump(self, **kwargs: typing.Any) -> dict[str, typing.Any]: data = super().model_dump(exclude_none=True, **kwargs) return self._clean_empty_fields(data) def _clean_empty_fields(self, data: dict[str, typing.Any]) -> dict[str, typing.Any]: keys_mapping = { 'contact': data.get('contact'), 'address': data.get('address'), 'payout_method': data.get('payout_method'), } for key, value in keys_mapping.items(): if value and not any(value.values()): data.pop(key) if self.company and not self.company.name: data.pop('company') return data class AccountPayee(BaseModel): """Account payee class.""" account_payee_id: int account_id: int class AccountPayeeDataloaderAccountData(BaseModel): """Account payee dataloader account.""" data: typing.Optional[AccountPayee] class AccountPayeeDataloaderAccount(BaseModel): items: typing.List[AccountPayeeDataloaderAccountData] class AbacusState(BaseModel): """Abacus state.""" abacus_state_id: int action_name: str action_status: str @classmethod def list_validate( cls, data: typing.List[typing.Mapping[str, typing.Any]] ) -> typing.List[typing.Self]: """Validate the list of ABACUS states.""" return TypeAdapter( typing.List[cls] # type: ignore ).validate_python(data) class TaxFormInfoDetailsItem(BaseModel): """Tax form details item""" account_payee_tax_form_info_id: int account_payee_id: int tax_form_type: str tax_id_country: str tax_name: str tin: typing.Optional[str] = None tin_type: typing.Optional[str] = None signed_date: typing.Optional[str] = None expiration_date: typing.Optional[str] = None last_modified: typing.Optional[str] = None tax_classification: typing.Optional[str] = None tax_treaty_claim: typing.Optional[bool] = None lob: typing.Optional[str] = None type_of_entity: typing.Optional[str] = None class TaxFormInfoDetailsBulk(BaseModel): """Tax form details bulk response""" items: typing.List[TaxFormInfoDetailsItem] total_count: int class TaxFormInfoItem(BaseModel): """Tax form info item""" account_payee_tax_form_info_id: int account_payee_id: int tax_form_type: str expiration_date: typing.Optional[str] = None signed_date: typing.Optional[str] = None last_modified: typing.Optional[str] = None class TaxFormInfoBulk(BaseModel): """Tax form info bulk response""" items: typing.List[TaxFormInfoItem] total_count: int class BaseUSTaxForm(BaseModel): """Base account payee tax form info.""" tax_form_type: TaxFormType tax_id_country: typing.Annotated[str, StringConstraints(min_length=1)] tin_type: typing.Annotated[typing.Optional[str], BeforeValidator(empty_str_to_none)] tin: typing.Annotated[typing.Optional[str], BeforeValidator(empty_str_to_none)] tax_name: typing.Annotated[str, StringConstraints(min_length=1)] class USTaxFormW9(BaseUSTaxForm): """W-9 tax form.""" tax_classification: typing.Annotated[str, StringConstraints(min_length=1)] class BaseUSTaxFormW8(BaseUSTaxForm): """Base W-8 tax form.""" tax_treaty_claim: typing.Annotated[bool, PlainValidator(parse_str_to_bool)] signed_date: typing.Annotated[datetime.date, PlainValidator(parse_date)] class BaseUSTaxFormW8WithTypeEntity(BaseUSTaxFormW8): """Base W-8 tax form with type_of_entity.""" type_of_entity: typing.Annotated[str, StringConstraints(min_length=1)] class USTaxFormW8BEN(BaseUSTaxFormW8): """W-8BEN tax form.""" pass class USTaxFormW8BENE(BaseUSTaxFormW8WithTypeEntity): """W-8BEN-E tax form.""" lob: typing.Annotated[typing.Optional[str], BeforeValidator(empty_str_to_none)] = ( None ) @model_validator(mode='after') def validate_lob(self) -> typing.Self: if self.tax_treaty_claim and not self.lob: raise ValueError('lob is required for tax_treaty_claim') return self class USTaxFormW8IMY(BaseUSTaxFormW8WithTypeEntity): """W-8IMY tax form.""" pass class USTaxFormW8ECI(BaseUSTaxFormW8WithTypeEntity): """W-8ECI tax form.""" pass TAX_FORM_TYPE_MODEL: dict[TaxFormType, type[BaseUSTaxForm]] = { TaxFormType.W9: USTaxFormW9, TaxFormType.W8BEN: USTaxFormW8BEN, TaxFormType.W8BENE: USTaxFormW8BENE, TaxFormType.W8IMY: USTaxFormW8IMY, TaxFormType.W8ECI: USTaxFormW8ECI, } def validate_tax_form(value: dict[str, typing.Any] | BaseUSTaxForm) -> BaseUSTaxForm: """Parse tax forms.""" if isinstance(value, BaseUSTaxForm): return value tax_form_type = value.get('tax_form_type') if tax_form_type not in TAX_FORM_TYPE_MODEL: raise ValueError('Invalid tax form type') return TAX_FORM_TYPE_MODEL[TaxFormType(tax_form_type)].model_validate(value) class PostTaxFormsInput(BaseModel): account_id: int = Field(validation_alias=AliasChoices('account_id', 'vendor_id')) tax_form_type: typing.Annotated[str, StringConstraints(min_length=1)] country_of_tax_residence: typing.Annotated[ str, StringConstraints(min_length=1), Field( validation_alias=AliasChoices( 'country_of_tax_residence', 'tax_residence_country' ) ), ] tax_form: typing.Annotated[BaseUSTaxForm, PlainValidator(validate_tax_form)] override: typing.Annotated[ typing.Optional[bool], PlainValidator(parse_str_to_bool) ] = None account_payee_id: typing.Optional[int] = None account_tax_info_id: typing.Optional[int] = None account_payee_tax_form_info_id: typing.Optional[int] = None @model_validator(mode='before') @classmethod def set_tax_forms_data(cls, data: typing.Any) -> typing.Any: if 'tax_form' not in data: data['tax_form'] = data return data class AccountTaxInfo(BaseModel): """Account tax info.""" country_of_tax_residence: typing.Optional[str] = Field( None, min_length=3, max_length=3 ) is_sba_signed: typing.Optional[bool] is_vat_exempt: typing.Optional[bool] is_tax_treaty_claimed: typing.Optional[bool] tax_employment_type: typing.Optional[str] = None certificate_of_residence_expiration_date: typing.Annotated[ typing.Optional[datetime.date], PlainValidator(parse_date) ] = None is_wht_applicable: typing.Optional[bool] = None is_resident_of_spanish_islands: typing.Optional[bool] = None wht_rate_override: typing.Optional[float] = None account_tax_info_id: int account_id: int class AccountTaxInfoBulk(BaseModel): """Account tax info bulk response""" items: typing.List[AccountTaxInfo] total_count: int class BaseTaxDetails(BaseModel): """Base class for tax details.""" business_name: typing.Optional[str] = None business_number: typing.Optional[str] = None country_of_tax_residency_code: typing.Optional[str] = None address: typing.Optional[Address] = None local_tax_id: typing.Optional[str] = None vat_number: typing.Optional[str] = None class NewTaxDetails(BaseTaxDetails): """Tax details.""" is_vat_registered: typing.Optional[bool] = None class TaxDetails(BaseTaxDetails): """Tax details.""" account_payee_id: int class UpdateAccountTaxInfo(BaseModel): """Update account tax info.""" country_of_tax_residence: typing.Optional[str] = None is_resident_of_spanish_islands: typing.Optional[bool] = None tax_employment_type: typing.Optional[str] = None certificate_of_residence_expiration_date: typing.Annotated[ typing.Optional[datetime.date], PlainValidator(parse_date) ] = None is_sba_signed: typing.Optional[bool] = None wht_rate_override: typing.Optional[float] = None is_tax_treaty_claimed: typing.Optional[bool] = None class BasePostTaxDetailsInput(BaseModel, ABC): """Post tax details input base schema.""" vendor_id: int business_name: typing.Annotated[ typing.Optional[str], BeforeValidator(empty_str_to_none) ] = None first_name: typing.Annotated[ typing.Optional[str], BeforeValidator(empty_str_to_none) ] = None last_name: typing.Annotated[ typing.Optional[str], BeforeValidator(empty_str_to_none) ] = None is_vat_registered: typing.Annotated[ typing.Optional[bool], PlainValidator(parse_str_to_bool) ] = None address1: typing.Annotated[ typing.Optional[str], BeforeValidator(empty_str_to_none) ] = None address2: typing.Annotated[ typing.Optional[str], BeforeValidator(empty_str_to_none) ] = None province: typing.Annotated[ typing.Optional[str], BeforeValidator(empty_str_to_none) ] = None city: typing.Annotated[typing.Optional[str], BeforeValidator(empty_str_to_none)] = ( None ) zip: typing.Annotated[typing.Optional[str], BeforeValidator(empty_str_to_none)] = ( None ) country_code: typing.Annotated[ typing.Optional[str], BeforeValidator(empty_str_to_none) ] = None country_of_tax_residence_code: typing.Annotated[ str, StringConstraints(min_length=1) ] override: typing.Annotated[ typing.Optional[bool], PlainValidator(parse_str_to_bool) ] = None account_payee_id: typing.Optional[int] = None account_tax_info_id: typing.Optional[int] = None @classmethod def list_validate( cls, data: typing.List[typing.Mapping[str, typing.Any]] ) -> typing.List[typing.Self]: """Validate the list of tad details input data.""" return TypeAdapter( typing.List[cls] # type: ignore ).validate_python(data) @model_validator(mode='after') def validate_business_name(self) -> typing.Self: if not self.business_name and self.first_name and self.last_name: self.business_name = f'{self.first_name} {self.last_name}' return self @abstractmethod def get_account_payee_tax_details(self) -> NewTaxDetails: """Get account payee tax details to set.""" @abstractmethod def get_account_tax_info(self) -> UpdateAccountTaxInfo: """Get account tax info to set.""" class PostSpanishTaxDetailsInput(BasePostTaxDetailsInput): """Post spanish tax details input schema.""" local_tax_id: typing.Annotated[str, StringConstraints(min_length=1)] tax_employment_type: typing.Annotated[ typing.Optional[str], Field(validation_alias=AliasChoices('tax_employment_type', 'tax_entity_type')), BeforeValidator(empty_str_to_none), ] = None is_resident_of_spanish_islands: typing.Annotated[ typing.Optional[bool], PlainValidator(parse_str_to_bool) ] = None expiration_date: typing.Annotated[ typing.Optional[datetime.date], PlainValidator(parse_date) ] = None is_vat_registered: typing.Annotated[bool, PlainValidator(parse_str_to_bool)] def get_account_payee_tax_details(self) -> NewTaxDetails: """Get account payee tax details to set.""" return NewTaxDetails( local_tax_id=self.local_tax_id, is_vat_registered=self.is_vat_registered, business_name=self.business_name, country_of_tax_residency_code=self.country_of_tax_residence_code, address=Address( country=self.country_code, address1=self.address1, address2=self.address2, city=self.city, postal_code=self.zip, state=self.province, ) if self.is_vat_registered else None, ) def get_account_tax_info(self) -> UpdateAccountTaxInfo: """Get account tax info to set.""" return UpdateAccountTaxInfo( country_of_tax_residence=self.country_of_tax_residence_code, is_resident_of_spanish_islands=self.is_resident_of_spanish_islands, tax_employment_type=self.tax_employment_type, certificate_of_residence_expiration_date=self.expiration_date, is_sba_signed=self.is_vat_registered, ) class PostVatTaxDetailsInput(BasePostTaxDetailsInput): """Post tax details with VAT number input schema.""" vat_number: typing.Annotated[ typing.Optional[str], BeforeValidator(empty_str_to_none) ] = None @model_validator(mode='after') def set_is_vat_registered(self) -> typing.Self: """Set is_vat_registered based on VAT presence to prevent possible issues.""" self.is_vat_registered = bool(self.vat_number) return self def get_account_payee_tax_details(self) -> NewTaxDetails: """Get account payee tax details to set.""" return NewTaxDetails( vat_number=self.vat_number, business_name=self.business_name, country_of_tax_residency_code=self.country_of_tax_residence_code, address=Address( country=self.country_code, address1=self.address1, address2=self.address2, city=self.city, postal_code=self.zip, state=self.province, ) if self.is_vat_registered else None, ) def get_account_tax_info(self) -> UpdateAccountTaxInfo: """Get account tax info to set.""" return UpdateAccountTaxInfo( country_of_tax_residence=self.country_of_tax_residence_code, is_sba_signed=self.is_vat_registered, ) class PostGermanTaxDetailsInput(PostVatTaxDetailsInput): """Post German tax details input schema.""" local_tax_id: typing.Annotated[str, StringConstraints(min_length=1)] tax_employment_type: typing.Annotated[ typing.Optional[str], Field(validation_alias=AliasChoices('tax_employment_type', 'tax_entity_type')), BeforeValidator(empty_str_to_none), ] = None wht_rate_override: typing.Annotated[ typing.Optional[float], BeforeValidator(empty_str_to_none) ] = None expiration_date: typing.Annotated[ typing.Optional[datetime.date], PlainValidator(parse_date) ] = None def get_account_payee_tax_details(self) -> NewTaxDetails: """Get account payee tax details to set.""" return NewTaxDetails( local_tax_id=self.local_tax_id, vat_number=self.vat_number, business_name=self.business_name, country_of_tax_residency_code=self.country_of_tax_residence_code, address=Address( country=self.country_code, address1=self.address1, address2=self.address2, city=self.city, postal_code=self.zip, state=self.province, ) if self.is_vat_registered else None, ) def get_account_tax_info(self) -> UpdateAccountTaxInfo: """Get account tax info to set.""" return UpdateAccountTaxInfo( country_of_tax_residence=self.country_of_tax_residence_code, tax_employment_type=self.tax_employment_type, certificate_of_residence_expiration_date=self.expiration_date, is_sba_signed=self.is_vat_registered, wht_rate_override=self.wht_rate_override, ) class PostNorwayTaxDetailsInput(PostVatTaxDetailsInput): """Post norwegian tax details input schema.""" pass class PostUkTaxDetailsInput(PostVatTaxDetailsInput): """Post UK tax details input schema.""" business_number: typing.Annotated[ typing.Optional[str], BeforeValidator(empty_str_to_none) ] = None def get_account_payee_tax_details(self) -> NewTaxDetails: """Get account payee tax details to set.""" return NewTaxDetails( business_number=self.business_number, vat_number=self.vat_number, business_name=self.business_name, country_of_tax_residency_code=self.country_of_tax_residence_code, address=Address( country=self.country_code, address1=self.address1, address2=self.address2, city=self.city, postal_code=self.zip, state=self.province, ) if self.is_vat_registered else None, ) class TaxDetailsDataloaderItemAddress(BaseModel): address_1: typing.Optional[str] = None address_2: typing.Optional[str] = None province: typing.Optional[str] = None city: typing.Optional[str] = None zip: typing.Optional[str] = None country_code: typing.Optional[str] = None class TaxDetailsDataloaderItem(BaseModel): """Tax details dataloader item model.""" account_payee_id: int vat_number: typing.Optional[str] = None local_tax_id: typing.Optional[str] = None business_name: typing.Optional[str] = None business_number: typing.Optional[str] = None address: typing.Optional[TaxDetailsDataloaderItemAddress] = None @classmethod def list_validate( cls, data: typing.List[typing.Mapping[str, typing.Any]] ) -> typing.List[typing.Self]: """Validate the list of TaxDetailsDataloaderItem.""" return TypeAdapter( typing.List[cls] # type: ignore ).validate_python(data) class Payee(BaseModel): """Payee model.""" payee_id: int payoneer_client_reference_id: str @classmethod def list_validate( cls, data: typing.List[typing.Mapping[str, typing.Any]] ) -> typing.List[typing.Self]: """Validate the list of payees.""" return TypeAdapter( typing.List[cls] # type: ignore ).validate_python(data) # Payoneer API response models. # Shape returned by GET /v4/programs/{program_id}/payees/{payee_id}/details. # Separate from the ows-payee models above — those use camelCase aliases # matching our downstream CSV schema; these mirror Payoneer's snake_case # response fields. `extra='ignore'` so a Payoneer schema expansion never # breaks us at the ingress boundary. class PayoneerContact(BaseModel): model_config = ConfigDict(extra='ignore') first_name: str = '' last_name: str = '' date_of_birth: str = '' email: str = '' class PayoneerAddress(BaseModel): model_config = ConfigDict(extra='ignore') address_line_1: str = '' address_line_2: str = '' city: str = '' state: str = '' country: str = '' zip_code: str = '' class PayoneerCompany(BaseModel): model_config = ConfigDict(extra='ignore') name: str = '' class PayoneerBankField(BaseModel): model_config = ConfigDict(extra='ignore') # `name` may be missing on sparse entries — the mapper logs + skips. name: typing.Optional[str] = None value: str = '' class PayoneerPayoutMethod(BaseModel): model_config = ConfigDict(extra='ignore') bank_account_type: str = '' country: str = '' currency: str = '' bank_field_details: typing.List[PayoneerBankField] = Field(default_factory=list) @field_validator('bank_account_type', mode='before') @classmethod def _stringify(cls, v: typing.Any) -> str: # Payoneer sends this as a numeric code (int or str). return str(v) if v is not None else '' @field_validator('bank_field_details', mode='before') @classmethod def _null_to_list(cls, v: typing.Any) -> typing.Any: return v if v is not None else [] class PayoneerPayeeDetails(BaseModel): model_config = ConfigDict(extra='ignore') type: str = '' contact: PayoneerContact = Field(default_factory=PayoneerContact) address: PayoneerAddress = Field(default_factory=PayoneerAddress) company: PayoneerCompany = Field(default_factory=PayoneerCompany) payout_method: typing.Optional[PayoneerPayoutMethod] = None @field_validator('contact', 'address', 'company', mode='before') @classmethod def _null_to_empty_dict(cls, v: typing.Any) -> typing.Any: return v if v is not None else {} class PullBankingDetailsInputRow(BaseModel): """Input row for PullBankingDetailsProcessor. Parses & validates the CSV columns up-front so the fetch worker can work with typed values and no longer re-parse / re-strip strings. """ model_config = ConfigDict(str_strip_whitespace=True, extra='ignore') program_id: int payee_id: int vendor_id: str = Field(min_length=1) date_of_birth: typing.Optional[datetime.date] = None @field_validator('program_id', 'payee_id', mode='before') @classmethod def _int_strings_only(cls, v: typing.Any) -> typing.Any: # Pydantic's default int parser happily coerces '100.0' → 100 via # a float detour; the old `int(raw)` path raised, so preserve # that stricter contract at the processor boundary. if isinstance(v, str): v = v.strip() if not v: raise ValueError('must not be empty') return int(v) return v @field_validator('date_of_birth', mode='before') @classmethod def _strict_yyyy_mm_dd(cls, v: typing.Any) -> typing.Any: # Optional column: blank / missing → None. When provided, must be # strict yyyy-mm-dd so the downstream `dateOfBirth` cell is in the # format PostBankingDetailsProcessor / ows-payee accept. if v is None or isinstance(v, datetime.date): return v if isinstance(v, str): v = v.strip() if not v: return None try: return datetime.datetime.strptime(v, '%Y-%m-%d').date() except ValueError: raise ValueError('date_of_birth must be yyyy-mm-dd') return v