import logging
from collections import defaultdict
from dataclasses import asdict
from datetime import date, datetime
from decimal import Decimal
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, cast

import international_bank_account.core.contract as bank_account_contract
import international_payments.services.contract as payment_contract
import international_payments.services.contract as payment_service_contract
import international_payroll.modules.entity_management.external.sdk
import international_payroll.modules.payroll_payments_orchestration.internal.contract_tasks
import international_payroll.modules.payroll_processing.internal.workflow_tasks as payments
from common import groupby, is_mongo_id, request_cache, utc_now
from eta.chunker import (
    EtaGroupOutputType,
    launch_eta_chunks,
    process_parallelly_using_eta,
)
from eta.models import EtaTimeouts, eta_task, eta_task_with_args
from international_bank_account.core.enums import (
    BankAccountHolderTypes,
    PaymentAccountTypes,
)
from international_payments.types.data_bean import (
    PaymentOrder,
)
from international_payments.types.enums import (
    CurrencyExchangeOrderTypes,
    HoldOrderExecutionTypes,
    MoneyFlowTypes,
    PaymentOrderCompletionStatusTypes,
    PaymentOrderDirections,
    PaymentOrderStatusTypes,
)
from international_payroll import contract as gp_contract
from international_payroll.contract import (
    getOrNoneLatestPayrollAgencyInfoForPayrollEntity,
)
from international_payroll.logging import LogExceptionContext
from international_payroll.modules.payroll_payments_orchestration.internal import gp_payment_api
from international_payroll.modules.remittances.internal import (
    contract_tasks,
    legacy_payment_flow_functions,
    payroll_interface,
    stateless,
)
from international_payroll.modules.remittances.internal.beans import (
    AggregateLiabilityCalculationBean,
    CompanyLiabilityTypeSet,
    CompanyLiabilityVersionBean,
    CompanyLiabilityVersionKey,
    LocalBankAccountRemittanceInstruction,
    MoneyGraphParams,
    PaymentMemoBase,
    PaymentMemoBuilderData,
    PaymentMemoForRefund,
    PaymentOrderPayload,
    RemittanceBatchAttemptBean,
    RemittanceDebitConfig,
    RemittanceFFRequestBatch,
    RemittanceInstruction,
    RemittanceIntentBean,
    RemittanceIntentFFRequestBean,
    RemittanceIntentFulfillmentInfo,
    RemittanceIntentStatusBean,
    RemittanceLiabilitiyDebitMismatch,
    RemittanceOrderBean,
    RemittancePaymentActivity,
    RemittancePaymentActivityDisplayInfo,
    RemittancePaymentAttemptBean,
    RemittancePayrollObjectSnapshot,
    RemittanceVersionBean,
)
from international_payroll.modules.remittances.internal.constants import (
    BankAccountHolderTypesExceptRole,
    PaymentOrderInProgressOrSucceededStatuses,
    PaymentOrderSucceededStatuses,
    RemittanceInternalEtaTaskKwargs,
    RemittanceLiabilityCodes,
    RemittanceRecurringEtaTaskKwargs,
)
from international_payroll.modules.remittances.internal.countries.factory import (
    canPersistLiabilityVersionsForCountryCode,
    canPersistRemittanceOrdersForCountryCode,
    getPayrollCountryLiabilityInterface,
    isFulfillRemittancesEnabledForCountryCode,
    isReconProcessLiabilityDisabledForCountryCode,
)
from international_payroll.modules.remittances.internal.enums import (
    IntentStatus,
    LiabilityPayrollObjectTypes,
    MoneyLocation,
    PaymentMemoTypes,
    RemittancePaymentActivityAgencyDisplayName,
    RemittancePaymentActivityPaymentMethod,
    RemittancePaymentActivityStatus,
    RemittanceResolutionStrategy,
)
from international_payroll.modules.remittances.internal.exceptions import (
    LiabilityDefinitionNotFoundException,
    LiabilityVersionDebitInfoMismatchException,
    TaxAgencyFieldNotFoundException,
    TaxAgencyNotFoundException,
)
from international_payroll.modules.remittances.internal.feature_flags import isBatchedRemittanceFulfillmentEnabled
from international_payroll.modules.remittances.internal.global_logging import (
    setGlobalRemittanceContext,
)
from international_payroll.modules.remittances.internal.intent_status_utils import (
    IntentStatusUtil,
)
from international_payroll.modules.remittances.internal.liability_country_interface import (
    LiabilitySetParams,
    RoleLiabilityDefinition,
)
from international_payroll.modules.remittances.internal.payroll_interface import (
    PayrollObjectInfo,
    getFundingBankAccountIdForCompanyPayrollEntity,
    getPayrollObjectInfo,
    getPayrollObjectInfoForLiabilityVersion,
    isHQCompanyPayrollEntity,
)
from international_payroll.modules.remittances.internal.remittance_location_interface import (
    canResolveInstruction,
    canResolveInstructions,
    getOrNoneResolvedRemittanceLocation,
    isAwaitingAdminConfiguration,
)
from international_payroll.modules.remittances.internal.types import BatchGroupKey
from international_payroll.modules.run_management import contract as run_management
from international_payroll_beans.beans import (
    BoolWithReason,
    CompanyPayrollEntity,
    CompanyPayrollEntityVersion,
    CountryPayRunDetails,
    ReconciliationProcessDetails,
)
from international_payroll_sdk.contract import getCompanyPayrollEntity
from international_tax_engine.contract import getToday

logger = logging.getLogger(__name__)


@eta_task
def batchCreateLiabilityCalculationsForPayRun(work_ids, companyId: str, shouldOverwrite: bool = False):
    for runId in work_ids:
        try:
            createLiabilityCalculationsForPayrollObject(
                companyId, runId, LiabilityPayrollObjectTypes.PAYRUN, shouldOverwriteExisting=shouldOverwrite
            )
        except:
            logger.exception("Failed to create liability calculations for company %s run %s", companyId, runId)


@eta_task
def batchCreateLiabilityCalculationsForReconProcess(work_ids, companyId: str, shouldOverwrite: bool = False):
    for processId in work_ids:
        try:
            createLiabilityCalculationsForPayrollObject(
                companyId, processId, LiabilityPayrollObjectTypes.RECON_PROCESS, shouldOverwriteExisting=shouldOverwrite
            )
        except:
            logger.exception(
                "Failed to create liability calculations for company %s recon process %s", companyId, processId
            )


def createLiabilityCalculationsForPayrollObject(
    companyId: str,
    payrollObjectId: str,
    payrollObjectType: LiabilityPayrollObjectTypes,
    shouldOverwriteExisting: bool = False,
    shouldOverwriteInProgress: bool = False,
    shouldOverrideDebitVerification: bool = False,
    reasonCode: str = "INIT",
) -> Optional[CompanyLiabilityVersionBean]:
    payrollObject = payroll_interface.getPayrollObjectInfo(companyId, payrollObjectId, payrollObjectType)
    countryCode = payrollObject.getCountryCode()
    canCreate, reason = contract_tasks.canCreateCompanyLiabilityVersion(
        countryCode=countryCode,
        companyId=companyId,
        objectId=payrollObjectId,
        objectType=payrollObjectType,
        shouldOverwriteExisting=shouldOverwriteExisting,
        shouldOverwriteInProgress=shouldOverwriteInProgress,
    )
    if not canCreate:
        return None

    companyPayrollEntityId = payrollObject.getCompanyPayrollEntityId()
    setGlobalRemittanceContext(
        contextHint="createLiabilityCalculationsForPayrollObject",
        countryCode=countryCode,
        companyId=companyId,
        companyPayrollEntityId=companyPayrollEntityId,
        payrollObjectId=payrollObjectId,
        payrollObjectType=payrollObjectType,
    )

    computedLiabilityCalcs = contract_tasks.readOnlyComputeLiabilityCalculationsForPayrollObject(payrollObject)
    if not shouldOverrideDebitVerification and contract_tasks.verifyLiabilityCalculationsAgainstDebitInfo(
        payrollObject, computedLiabilityCalcs
    ):
        raise LiabilityVersionDebitInfoMismatchException(
            f"verifyLiabilityCalculationsForPayRun failed for company {companyId} payrollObjectId {payrollObjectId}"
        )

    liabilityVersion = contract_tasks.createCompanyLiabilityVersionForPayrollObject(payrollObject, reasonCode)
    return contract_tasks.createLiabilityCalculations(liabilityVersion, computedLiabilityCalcs)


def manuallyVerifyLiabilityCaclulationsAgainstDebitInfo(
    companyId: str,
    payrollObjectId: str,
    payrollObjectType: LiabilityPayrollObjectTypes,
    liabilitySet: CompanyLiabilityTypeSet,
) -> Optional[RemittanceLiabilitiyDebitMismatch]:
    payrollObject = payroll_interface.getPayrollObjectInfo(companyId, payrollObjectId, payrollObjectType)
    computedLiabilityCalcs = contract_tasks.readOnlyComputeLiabilityCalculationsForPayrollObject(
        payrollObject, liabilitySetOverride=liabilitySet
    )

    return contract_tasks.verifyLiabilityCalculationsAgainstDebitInfo(payrollObject, computedLiabilityCalcs)


def manuallyUpdateLiabilitySetForPayrollObject(
    companyId: str,
    payrollObjectId: str,
    payrollObjectType: LiabilityPayrollObjectTypes,
    liabilitySetId: str,
) -> Optional[RemittancePayrollObjectSnapshot]:
    payrollObject = payroll_interface.getPayrollObjectInfo(companyId, payrollObjectId, payrollObjectType)
    return contract_tasks.writeRemittancePayrollObjectSnapshot(payrollObject, liabilitySetId)


def manuallyCreateLiabilityCalculationsForPayrollObjectUsingLatestPREs(
    companyId: str,
    payrollObjectId: str,
    payrollObjectType: LiabilityPayrollObjectTypes,
    shouldOverwrite: bool = False,
    reasonCode: str = "INIT",
) -> Optional[CompanyLiabilityVersionBean]:
    with LogExceptionContext(logger, "company %s and payrollObjectId %s", companyId, payrollObjectId):
        payrollObject = payroll_interface.getPayrollObjectInfo(companyId, payrollObjectId, payrollObjectType)
        countryCode = payrollObject.getCountryCode()
        canCreate, reason = contract_tasks.canCreateCompanyLiabilityVersion(
            countryCode, companyId, payrollObjectId, payrollObjectType, shouldOverwrite
        )
        if not canCreate:
            return None

        companyPayrollEntityId = payrollObject.getCompanyPayrollEntityId()
        setGlobalRemittanceContext(
            contextHint="manuallyCreateLiabilityCalculationsForPayrollObjectUsingLatestPREs",
            countryCode=countryCode,
            companyId=companyId,
            companyPayrollEntityId=companyPayrollEntityId,
            payrollObjectId=payrollObjectId,
            payrollObjectType=payrollObjectType,
        )

        computedLiabilityCalcs = contract_tasks.readOnlyComputeLiabilityCalculationsForPayrollObject(
            payrollObject, useSnapshottedPREs=False
        )

        if contract_tasks.verifyLiabilityCalculationsAgainstDebitInfo(payrollObject, computedLiabilityCalcs):
            raise LiabilityVersionDebitInfoMismatchException(
                f"verifyLiabilityCalculationsForPayRun failed for company {companyId} payrollObjectId {payrollObjectId}"
            )

        liabilityVersion = contract_tasks.createCompanyLiabilityVersionForPayrollObject(payrollObject, reasonCode)
        return contract_tasks.createLiabilityCalculations(liabilityVersion, computedLiabilityCalcs)


def getTaxCodeToNetAmountMappingForIntent(intent: RemittanceIntentBean) -> Dict[str, Decimal]:
    taxLiabilityAmount = contract_tasks.readOnlyGetOrRaiseLiabilityAmountForPayrollObjectForLiabilityCode(
        intent.companyId,
        intent.remittanceOrder.payrollObjectId,
        intent.remittanceOrder.payrollObjectType,
        intent.remittanceOrder.liabilityCode,
    )

    return {code: amount.netAmount for code, amount in taxLiabilityAmount.taxCodeAmounts.items()}


def getCodeToNetAmountMappingForIntent(intent: RemittanceIntentBean) -> Dict[str, Decimal]:
    liabilityAmount = contract_tasks.readOnlyGetOrRaiseLiabilityAmountForPayrollObjectForLiabilityCode(
        intent.companyId,
        intent.remittanceOrder.payrollObjectId,
        intent.remittanceOrder.payrollObjectType,
        intent.remittanceOrder.liabilityCode,
    )
    return stateless.getCodeToNetAmountMappingForLiabilityAmount(liabilityAmount)


def generatePaymentMemo(intent: RemittanceIntentBean) -> PaymentMemoBase:
    payrollObject = payroll_interface.getPayrollObjectInfo(
        intent.companyId, intent.remittanceOrder.payrollObjectId, intent.remittanceOrder.payrollObjectType
    )
    cpe = payrollObject.getCompanyPayrollEntity()
    liabilityInterface = getPayrollCountryLiabilityInterface(cpe.countryCode)
    liabilitySetParams = LiabilitySetParams(isEOR=cpe.isEOR)
    liabilityDefinition = liabilityInterface.getLiabilityDefinitionFromCode(
        intent.remittanceOrder.liabilityCode, liabilitySetParams
    )

    if liabilityDefinition is None:
        raise LiabilityDefinitionNotFoundException(
            f"Cannot find liability definition for {intent.remittanceOrder.liabilityCode}"
        )

    agencyCode = liabilityDefinition.getAgencyCode()
    payrollAgencyInfo = getOrNoneLatestPayrollAgencyInfoForPayrollEntity(cpe.payrollEntityId, agencyCode, utc_now())
    payrunRemittanceInfo = payrollObject.getPayrunRemittanceInfo()

    paymentMemoBuilderData = PaymentMemoBuilderData(
        payrollAgencyBean=payrollAgencyInfo,
        countryCode=payrollObject.getCountryCode(),
        checkDate=payrollObject.getCheckDate(),
        payrollObjectType=payrollObject.getPayrollObjectType(),
        payrollObjectId=payrollObject.getPayrollObjectId(),
        liabilityCode=intent.remittanceOrder.liabilityCode,
        legalName=cpe.legalName,
        liabilityAmount=intent.remittanceOrder.liabilityAmount,
        payrunRemittanceInfo=payrunRemittanceInfo,
        liabilityBreakdown=getTaxCodeToNetAmountMappingForIntent(intent),
    )

    return liabilityDefinition.generatePaymentMemo(paymentMemoBuilderData)


def computeRemittancePaymentAttempt(
    intent: RemittanceIntentBean,
    paymentMemoOverride: Optional[PaymentMemoBase] = None,
    destRemittanceLocationOverride: Optional[RemittanceInstruction] = None,
    version: int = 0,
) -> RemittancePaymentAttemptBean:
    companyId = intent.remittanceOrder.companyId
    cpeId = intent.remittanceOrder.companyPayrollEntityId
    sourceRemittanceLocation = getOrNoneResolvedRemittanceLocation(intent.sourceInstruction, companyId, cpeId)
    if not sourceRemittanceLocation:
        raise Exception(f"Failed to resolve remittance location {intent.sourceInstruction} {companyId} {cpeId}")

    if destRemittanceLocationOverride:
        destRemittanceLocation: Optional[RemittanceInstruction] = destRemittanceLocationOverride
    else:
        destRemittanceLocation = getOrNoneResolvedRemittanceLocation(intent.destInstruction, companyId, cpeId)

    if not destRemittanceLocation:
        raise Exception(f"Failed to resolve remittance location {intent.destInstruction} {companyId} {cpeId}")

    if paymentMemoOverride:
        paymentMemo = paymentMemoOverride
    else:
        paymentMemo = generatePaymentMemo(intent)

    return RemittancePaymentAttemptBean(
        id=None,
        companyId=intent.companyId,
        remittanceIntent=intent,
        sourceRemittanceLocation=sourceRemittanceLocation,
        destRemittanceLocation=destRemittanceLocation,
        paymentMemo=paymentMemo,
        version=version,
    )


def computeRemittanceRefundAttempt(
    intent: RemittanceIntentBean,
    paymentMemo: PaymentMemoBase,
    version: int = 0,
) -> RemittancePaymentAttemptBean:
    companyId = intent.remittanceOrder.companyId
    cpeId = intent.remittanceOrder.companyPayrollEntityId

    sourceRemittanceLocation = getOrNoneResolvedRemittanceLocation(intent.sourceInstruction, companyId, cpeId)
    if not sourceRemittanceLocation:
        raise Exception(f"Failed to resolve remittance location {intent.sourceInstruction} {companyId} {cpeId}")

    # send back to the default funding source
    destRemittanceLocation = getOrNoneResolvedRemittanceLocation(MoneyLocation.DEFAULT_FUNDING_SRC, companyId, cpeId)
    if not destRemittanceLocation:
        raise Exception(
            f"Failed to resolve remittance location {MoneyLocation.DEFAULT_FUNDING_SRC} {companyId} {cpeId}"
        )

    return RemittancePaymentAttemptBean(
        id=None,
        companyId=intent.companyId,
        remittanceIntent=intent,
        sourceRemittanceLocation=sourceRemittanceLocation,
        destRemittanceLocation=destRemittanceLocation,
        paymentMemo=paymentMemo,
        version=version,
    )


def canCreatePaymentAttempt(attempt: RemittancePaymentAttemptBean) -> BoolWithReason:
    # We attempt the update the status to preparing, if we can do it then we know
    # no one is trying to create an attempt at the same time
    return contract_tasks.canUpdateIntentStatus(
        attempt.remittanceIntent, IntentStatusUtil.getPaymentProcessInitializedStatus()
    )


def writePaymentAttempt(attempt: RemittancePaymentAttemptBean) -> RemittancePaymentAttemptBean:
    can, reason = canCreatePaymentAttempt(attempt)
    if not can:
        raise Exception(f"Cannot create payment attempt due {reason}")
    contract_tasks.updateIntentStatus(attempt.remittanceIntent, IntentStatusUtil.getPaymentProcessInitializedStatus())

    attempt = contract_tasks.createPaymentAttempt(attempt)
    contract_tasks.updateIntentStatus(attempt.remittanceIntent, IntentStatusUtil.getPaymentAttemptCreatedStatus())

    return attempt


def canFulfillRemittanceAttempt(attempt: RemittancePaymentAttemptBean) -> BoolWithReason:
    if attempt.id is None:
        return BoolWithReason(False, "PaymentAttempt must have a populated ID field")
    return contract_tasks.canUpdateIntentStatus(attempt.remittanceIntent, IntentStatus.PAYLOAD_GENERATED)


def getClientIdFromCompanyPayrollEntity(
    companyId: str, companyPayrollEntityVersion: CompanyPayrollEntityVersion
) -> str:
    return international_payroll.modules.payroll_payments_orchestration.internal.contract_tasks.getOrCreatePaymentClientForFundDisbursement(
        companyId, companyPayrollEntityVersion
    )


def createPaymentOrderPayload(paymentAttempt: RemittancePaymentAttemptBean) -> PaymentOrderPayload:
    destinationInfo = cast(LocalBankAccountRemittanceInstruction, paymentAttempt.destRemittanceLocation)
    bankAccountBean = bank_account_contract.getBankAccount(
        destinationInfo.bankAccountId, destinationInfo.accountHolderType
    )

    payrollObject = payroll_interface.getPayrollObjectInfo(
        paymentAttempt.remittanceIntent.remittanceOrder.companyId,
        paymentAttempt.remittanceIntent.remittanceOrder.payrollObjectId,
        paymentAttempt.remittanceIntent.remittanceOrder.payrollObjectType,
    )
    companyPayrollEntityVersion = payrollObject.getCompanyPayrollEntityVersion()
    clientId = getClientIdFromCompanyPayrollEntity(paymentAttempt.companyId, companyPayrollEntityVersion)

    payrollObjectId = paymentAttempt.remittanceIntent.remittanceOrder.payrollObjectId
    moneyFlowId = payment_service_contract.getOrCreateMoneyFlow(f"{payrollObjectId}", MoneyFlowTypes.GLOBAL_PAYROLL_RUN)

    today = getToday(payrollObject.getCountryCode())
    expectedPaymentDate = getExpectedCheckDate(
        countryCode=payrollObject.getCountryCode(),
        liabilityCode=paymentAttempt.remittanceIntent.remittanceOrder.liabilityCode,
        liabilityDueDate=paymentAttempt.remittanceIntent.remittanceOrder.dueDate,
        payrollObjectCheckDate=payrollObject.getCheckDate(),
        isEOR=payrollObject.getCompanyPayrollEntity().isEOR,
        destination=cast(MoneyLocation, paymentAttempt.remittanceIntent.destInstruction),
    )

    return PaymentOrderPayload(
        moneyFlowId=moneyFlowId,
        clientId=clientId,
        amount=paymentAttempt.remittanceIntent.amount,
        bankAccount=bankAccountBean,
        orderCurrencyCode=paymentAttempt.remittanceIntent.currencyCode,
        externalReferenceId=f"{str(paymentAttempt.id)}",
        statementNarrative=paymentAttempt.paymentMemo.text,
        checkDate=max(today, expectedPaymentDate),
        paymentMemo=paymentAttempt.paymentMemo,
    )


def getExpectedCheckDate(
    countryCode: str,
    liabilityCode: str,
    liabilityDueDate: date,
    payrollObjectCheckDate: date,
    isEOR: bool,
    destination: MoneyLocation,
) -> date:
    countryInterface = getPayrollCountryLiabilityInterface(countryCode)
    liabilityDefinition = countryInterface.getLiabilityDefinitionFromCode(
        liabilityCode, LiabilitySetParams(isEOR=isEOR)
    )
    if not liabilityDefinition:
        raise ValueError(f"Could not find liability definition for {liabilityCode}")
    strategy = liabilityDefinition.getExpectedCheckDateForDestinationStrategy(isEOR=isEOR, destination=destination)
    return stateless.resolveRemittanceDate(
        strategy=strategy,
        countryCode=countryCode,
        today=getToday(countryCode),
        payrollObjectCheckDate=payrollObjectCheckDate,
        liabilityDueDate=liabilityDueDate,
    )


def isRemittedByUnifiedRemittanceSystemWithReason(
    countryCode: str,
    companyId: str,
    payrollObjectId: str,
    payrollObjectType: LiabilityPayrollObjectTypes,
    liabilityCode: str,
) -> BoolWithReason:
    # get the status of the intents and if they are marked as
    isRemitted, reason = _doesPaymentExistForPayrollObjectIdAndLiabilityCode(payrollObjectId, liabilityCode)
    if isRemitted:
        return BoolWithReason(True, f"Payment is already remitted with reason {reason}")

    if contract_tasks.allIntentsQueuedForExternalFulfillment(
        companyId, payrollObjectId, payrollObjectType, liabilityCode
    ):
        # this is the case some runs are still remitted by the external system after the company is enabled
        # for the new system. The new system will not pick up intents marked COMPLETED_BY_EXTERNAL_SYSTEM
        return BoolWithReason(False, "All intents are queued to be completed by external system")

    if isFulfillRemittancesEnabledForCountryCode(countryCode, companyId, liabilityCode=liabilityCode):
        return BoolWithReason(True, "Company is enabled for new system")

    return BoolWithReason(False, "Company is not enabled for new system and no intents are queued for external system")


def _doesPaymentExistForPayrollObjectIdAndLiabilityCode(payrollObjectId: str, liabilityCode: str) -> BoolWithReason:
    moneyFlowId = payment_contract.getOrNoneMoneyFlow(
        externalId=payrollObjectId, flowType=MoneyFlowTypes.GLOBAL_PAYROLL_RUN
    )
    if not moneyFlowId:
        return BoolWithReason(False, "No money flow")

    moneyFlowOrders = payment_contract.getOrdersFromMoneyFlow(moneyFlowId=moneyFlowId)
    for paymentOrder in moneyFlowOrders.paymentOrders:
        orderStatus = payment_contract.getPaymentOrderStatus(paymentOrder.id)
        if orderStatus in [PaymentOrderStatusTypes.cancelled, PaymentOrderStatusTypes.returned]:
            continue

        # In our new system, the externalReferenceId is always the paymentAttempt
        attemptId = paymentOrder.externalReferenceId
        if not is_mongo_id(attemptId):
            continue  # not created by our system.

        attempt = contract_tasks.getOrNonePaymentAttempt(attemptId)
        if attempt is not None:
            if attempt.remittanceIntent.remittanceOrder.liabilityCode == liabilityCode:
                BoolWithReason(True, f"Attempt was made for {liabilityCode}")
    return BoolWithReason(False, f"No order for {liabilityCode} is found in money flow")


def getAllPaymentOrdersCreatedForRemittanceIntent(intent: RemittanceIntentBean) -> Sequence[PaymentOrder]:
    orders: List[PaymentOrder] = []
    attemptIds = contract_tasks.getPaymentAttemptIdsForRemittanceIntent(intent)
    for attemptId in attemptIds:
        order = payment_contract.getPaymentOrderFromExternalReferenceId(attemptId)
        if order:
            orders.append(order)
    return orders


def fulfillRemittanceAttempt(paymentAttempt: RemittancePaymentAttemptBean) -> BoolWithReason:
    can, reason = canFulfillRemittanceAttempt(paymentAttempt)
    if not can:
        return BoolWithReason(False, f"Cannot create paymentOrder due to {reason}")

    contract_tasks.updateIntentStatus(paymentAttempt.remittanceIntent, IntentStatus.PAYLOAD_GENERATED)

    if paymentAttempt.destRemittanceLocation.locationType == RemittanceResolutionStrategy.GLOBAL_PAYMENT_ACCOUNT:
        payload = createPaymentOrderPayload(paymentAttempt)
        paymentMemo = payload.paymentMemo
        if paymentMemo.paymentMemoType == PaymentMemoTypes.DEFAULT:
            createCreditAndFundingOrdersArgs = stateless.getCreateCreditAndFundingOrderRequest(payload)
            paymentOrderId = gp_payment_api.callPaymentApiAndPublishToGL(
                glArgs=gp_payment_api.GLPublishRequestBean(
                    cpeId=paymentAttempt.remittanceIntent.remittanceOrder.companyPayrollEntityId,
                    payrollObjectId=paymentAttempt.remittanceIntent.remittanceOrder.payrollObjectId,
                    payrollObjectType=paymentAttempt.remittanceIntent.remittanceOrder.payrollObjectType,
                    # todo (srgarg): this might need change, not all remittance payments are tax payments, can be addressed later
                    glPurpose=gp_payment_api.Purpose.tax_payment,
                    specificGLMetadata=gp_payment_api.RemittanceSpecificGLMetadata(
                        liabilityCode=paymentAttempt.remittanceIntent.remittanceOrder.liabilityCode,
                        liabilityAmount=paymentAttempt.remittanceIntent.remittanceOrder.liabilityAmount,
                        codeAmountBreakdown=getCodeToNetAmountMappingForIntent(paymentAttempt.remittanceIntent),
                    ),
                ),
                paymentApiArgs=createCreditAndFundingOrdersArgs,
            )
            contract_tasks.createPaymentReceipt(paymentAttempt, paymentOrderId)
        elif paymentMemo.paymentMemoType == PaymentMemoTypes.CRA_FEDI_INSTRUCTION:
            raise NotImplementedError(f"{paymentMemo.paymentMemoType} has not been implemented")
        elif paymentMemo.paymentMemoType == PaymentMemoTypes.QC_FEDI_INSTRUCTION:
            raise NotImplementedError(f"{paymentMemo.paymentMemoType} has not been implemented")
        else:
            raise Exception(f"{paymentMemo.paymentMemoType} is not supported")

    elif paymentAttempt.destRemittanceLocation.locationType == RemittanceResolutionStrategy.DEFAULT_FUNDING_SRC:
        payload = createPaymentOrderPayload(paymentAttempt)
        paymentMemo = payload.paymentMemo
        if paymentMemo.paymentMemoType == PaymentMemoTypes.REFUND:
            # check if the bank account is the same currency then we can just create a credit order
            glArgs = gp_payment_api.GLPublishRequestBean(
                cpeId=paymentAttempt.remittanceIntent.remittanceOrder.companyPayrollEntityId,
                payrollObjectId=paymentAttempt.remittanceIntent.remittanceOrder.payrollObjectId,
                payrollObjectType=paymentAttempt.remittanceIntent.remittanceOrder.payrollObjectType,
                # todo (srgarg): this might need change, can be addressed later
                glPurpose=gp_payment_api.Purpose.refund_credit_to_company,
            )
            if payload.bankAccount.currencyCode == payload.orderCurrencyCode:
                createCreditAndFundingOrdersArgs = stateless.getCreateCreditAndFundingOrderRequest(payload)
                paymentOrderId = gp_payment_api.callPaymentApiAndPublishToGL(
                    glArgs=glArgs, paymentApiArgs=createCreditAndFundingOrdersArgs
                )
            else:
                createFxSellAndCreditOrdersArgs = stateless.getCreateFxSellAndCreditOrdersRequest(payload)
                paymentOrderId = gp_payment_api.callPaymentApiAndPublishToGL(
                    glArgs=glArgs, paymentApiArgs=createFxSellAndCreditOrdersArgs
                )
            contract_tasks.createPaymentReceipt(paymentAttempt, paymentOrderId)
        else:
            raise NotImplementedError(f"{paymentMemo.paymentMemoType} has not been implemented")

    elif paymentAttempt.destRemittanceLocation.locationType == RemittanceResolutionStrategy.MANUAL:
        pass

    else:
        raise Exception(f"{paymentAttempt.destRemittanceLocation.locationType} not supported")

    contract_tasks.updateIntentStatus(
        paymentAttempt.remittanceIntent, IntentStatusUtil.getSuccessfulPaymentSubmittedInternallyStatus()
    )
    return BoolWithReason(True)


def updateIntentStatusForExternalSystemToComplete(
    intent: RemittanceIntentBean, intentStatus: RemittanceIntentStatusBean
) -> None:
    newIntentStatus = IntentStatusUtil.getSuccesfulExternalRemittanceStatus()
    if IntentStatusUtil.isTransitionValid(intentStatus.status, newIntentStatus):
        contract_tasks.updateIntentStatus(intent, newIntentStatus)


def updateIntentStatusWithPaymentOrderStatus(
    intent: RemittanceIntentBean, intentStatus: RemittanceIntentStatusBean
) -> None:
    """
    This function is intended to be called via ETA every hour to update the intent status after submission
    """
    if IntentStatusUtil.isBeingHandledExternally(intentStatus.status):
        if hasCompletedRemittancePaymentsForPayrollObject(
            companyId=intent.companyId,
            payrollObjectId=intent.remittanceOrder.payrollObjectId,
            payrollObjectType=intent.remittanceOrder.payrollObjectType,
            asOf=utc_now(),
        ):
            updateIntentStatusForExternalSystemToComplete(intent, intentStatus)
        return

    if not IntentStatusUtil.isSuccessfullySubmittedInternally(intentStatus.status):
        return

    latestAttempt = contract_tasks.getOrNoneLatestPaymentAttemptForIntent(intent)
    if latestAttempt is None:
        return

    paymentReciept = contract_tasks.getOrNonePaymentReceiptForAttempt(latestAttempt)
    if not paymentReciept:
        return

    paymentOrderIds = list(gp_payment_api.getCreditOrderIdsForRequestId(paymentReciept.paymentOrderId))
    paymentOrderIds.extend(gp_payment_api.getFxSellAndCreditOrderIdsForRequestId(paymentReciept.paymentOrderId))

    if len(paymentOrderIds) != 1:
        logger.warning(
            f"updateIntentStatusWithPaymentOrderStatus: Payment Reciept {paymentReciept.id} has {len(paymentOrderIds)} paymentOrders. Expected 1"
        )
        return

    paymentOrderId = paymentOrderIds[0]

    paymentOrderStatus = payment_service_contract.getPaymentOrderStatus(paymentOrderId)
    if paymentOrderStatus.status == PaymentOrderCompletionStatusTypes.SUCCEEDED:
        newIntentStatus = IntentStatusUtil.getSuccesfulInternalRemittanceStatus()
        if latestAttempt.paymentMemo.paymentMemoType == PaymentMemoTypes.REFUND:
            # this is a refund, we need to update the status to REFUNDED instead of complete.
            newIntentStatus = IntentStatusUtil.getSuccessfulRefundStatus()
    elif paymentOrderStatus.status == PaymentOrderCompletionStatusTypes.FAILED:
        newIntentStatus = IntentStatusUtil.getReturnedStatus()
    elif paymentOrderStatus.status == PaymentOrderCompletionStatusTypes.CANCELLED:
        newIntentStatus = IntentStatusUtil.getCancelledStatus()
    elif paymentOrderStatus.status == PaymentOrderCompletionStatusTypes.IN_PROGRESS:
        return  # nothing to do
    else:
        raise NotImplementedError(f"PaymentOrderStatus {paymentOrderStatus.status} not supported")

    if not IntentStatusUtil.isTransitionValid(intentStatus.status, newIntentStatus):
        return  # nothing to do

    # Prevent us from updating status to the same thing and failing
    if intentStatus.status == newIntentStatus:
        return  # nothing to do

    contract_tasks.updateIntentStatus(intent, newIntentStatus)


def getAllLatestIntentStatusForRemittanceVersion(
    remittanceOrderVersion: RemittanceVersionBean,
) -> Sequence[RemittanceIntentStatusBean]:
    statuses = []

    remittanceOrders = contract_tasks.getRemittanceOrdersForRemittanceVersion(remittanceOrderVersion)
    for order in remittanceOrders:
        intents = contract_tasks.getRemittanceIntentsForRemittanceOrder(order)
        for intent in intents:
            statuses.append(contract_tasks.getLatestIntentStatus(intent))

    return statuses


def getAllIntentsForRemittanceVersion(remittanceOrderVersion: RemittanceVersionBean) -> Sequence[RemittanceIntentBean]:
    allIntents: list[RemittanceIntentBean] = []
    remittanceOrders = contract_tasks.getRemittanceOrdersForRemittanceVersion(remittanceOrderVersion)

    for order in remittanceOrders:
        intents = contract_tasks.getRemittanceIntentsForRemittanceOrder(order)
        allIntents.extend(intents)

    return allIntents


def getAllIntentStatusesForRemittanceVersion(
    remittanceOrderVersion: RemittanceVersionBean,
) -> Sequence[RemittanceIntentStatusBean]:
    intents = getAllIntentsForRemittanceVersion(remittanceOrderVersion)

    statuses: list[RemittanceIntentStatusBean] = []
    for intent in intents:
        statusBean = contract_tasks.getLatestIntentStatus(intent)
        statuses.append(statusBean)

    return statuses


def canFulfillIntent(
    intent: RemittanceIntentBean,
    isRefund: bool = False,
    isBatchingEnabled: bool = False,
    asOf: Optional[datetime] = None,
) -> BoolWithReason:
    if not (isReady := _isIntentReadyToFulfill(intent, isRefund, isBatchingEnabled, asOf)):
        return isReady

    if not (hasFunds := hasSufficientFundsToFulfillIntent(intent, asOf)):
        return hasFunds

    return BoolWithReason(True, "")


def _isIntentReadyToFulfill(
    intent: RemittanceIntentBean,
    isRefund: bool = False,
    isBatchingEnabled: bool = False,
    asOf: Optional[datetime] = None,
) -> BoolWithReason:
    if intent.destInstruction in [MoneyLocation.MANUAL] and not isRefund:
        return BoolWithReason(False, "MANUAL intent cannot be fulfilled")

    if not canResolveInstruction(
        intent.destInstruction, intent.companyId, intent.remittanceOrder.companyPayrollEntityId
    ):
        return BoolWithReason(False, f"Could not resolve instruction {intent.destInstruction}")

    if not asOf:
        asOf = utc_now()

    if asOf < intent.remittanceOrder.fulfillAfterDate and not isRefund:
        return BoolWithReason(
            False, f"{asOf} is earlier than fulfillAfterDate {intent.remittanceOrder.fulfillAfterDate}"
        )

    if contract_tasks.isIntentInProgress(intent):
        return BoolWithReason(False, "Intent is currently in progress")

    if contract_tasks.isIntentCompleted(intent):
        return BoolWithReason(False, "Intent is already completed")

    for parentIntent in intent.deps:
        if not contract_tasks.isIntentCompleted(parentIntent):
            return BoolWithReason(False, "Waiting for other payments to complete")

    countryCode = getPayrollObjectInfo(
        intent.companyId, intent.remittanceOrder.payrollObjectId, intent.remittanceOrder.payrollObjectType
    ).getCountryCode()
    if not isFulfillRemittancesEnabledForCountryCode(
        countryCode, intent.companyId, liabilityCode=intent.remittanceOrder.liabilityCode
    ):
        return BoolWithReason(False, "Not allowed to fulfill remittance for this country and company")

    if not contract_tasks.isIntentLatestVersion(intent):
        return BoolWithReason(False, "Intent is not for latest version")

    if not isBatchingEnabled and contract_tasks.getOrNoneLatestFFRequest(intent):
        return BoolWithReason(False, "FFRequest already exists for this intent, use batching apis")

    return BoolWithReason(True, "")


def submitPaymentAttempt(intent: RemittanceIntentBean, paymentAttempt: RemittancePaymentAttemptBean) -> BoolWithReason:
    logContext: dict[str, str] = {"intentId": str(intent.id), "companyId": str(intent.companyId)}

    try:
        paymentAttempt = writePaymentAttempt(paymentAttempt)
    except Exception as e:
        logger.exception("Failed to writePaymentAttempt", extra=logContext)
        contract_tasks.updateIntentStatus(intent, IntentStatusUtil.getFailedToInitializePaymentStatus(), reason=str(e))
        return BoolWithReason(False, f"Failed to init intent due to {e}")

    try:
        fulfillRemittanceAttempt(paymentAttempt)
    except Exception as e:
        logger.exception("Failed to fulfill RemittanceAttempt", extra=logContext)
        contract_tasks.updateIntentStatus(intent, IntentStatusUtil.getFailedToSubmitPaymentStatus(), reason=str(e))
        return BoolWithReason(False, f"Failed to submit intent due to {e}")

    return BoolWithReason(True, "")


def getRemittanceBatchesToFulfill() -> Sequence[RemittanceFFRequestBatch]:
    return []


def canFulfillBatch(batch: RemittanceFFRequestBatch) -> BoolWithReason:
    today = getToday(batch.destCountryCode)
    if today < batch.expectedDepartureDate:
        return BoolWithReason(
            False,
            f"Today {today} in {batch.destCountryCode} is before expectedDepartureDate {batch.expectedDepartureDate}",
        )

    status = contract_tasks.getLatestBatchStatus(batch)
    if IntentStatusUtil.isInProgress(status.status):
        return BoolWithReason(False, "Batch is currently in progress")
    if IntentStatusUtil.isCompleted(status.status):
        return BoolWithReason(False, "Batch is already completed")

    # ensure all intents can be fulfilled in the batch
    requests = contract_tasks.getFFRequestsForBatchId(batch.id)
    intents = [request.intent for request in requests]
    for intent in intents:
        if not (canFulfill := canFulfillIntent(intent, isBatchingEnabled=True)):
            return canFulfill

    if not (isValid := isValidBatch(batch)):
        return isValid

    return BoolWithReason(True, "")


def isValidBatch(batch: RemittanceFFRequestBatch) -> BoolWithReason:
    requests = contract_tasks.getFFRequestsForBatchId(batch.id)
    intents = [request.intent for request in requests]
    if not intents:
        return BoolWithReason(True, "No intents to fulfill")

    exampleIntent = next(iter(intents), None)
    if not exampleIntent:
        return BoolWithReason(True, "No intents to fulfill")

    exampleSource = exampleIntent.sourceInstruction
    exampleDest = exampleIntent.destInstruction
    exampleLiabilityCode = exampleIntent.remittanceOrder.liabilityCode
    exampleResolvedDest = getOrNoneResolvedRemittanceLocation(
        exampleDest, exampleIntent.companyId, exampleIntent.remittanceOrder.companyPayrollEntityId
    )
    if not exampleResolvedDest:
        raise Exception(f"Failed to resolve destination bank account for intent {exampleIntent.id}")
    exampleResolvedDestUniqueId = exampleResolvedDest.uniqueId

    if exampleSource == MoneyLocation.DEFAULT_FUNDING_SRC and len(intents) > 1:
        return BoolWithReason(False, "Cannot batch multiple intents with default funding source")

    for intent in intents:
        if intent.sourceInstruction != exampleSource:
            return BoolWithReason(False, "All intents in the batch must have the same source")
        if intent.destInstruction != exampleDest:
            return BoolWithReason(False, "All intents in the batch must have the same destination")
        if intent.remittanceOrder.liabilityCode != exampleLiabilityCode:
            return BoolWithReason(False, "All intents in the batch must have the same liability code")
        resolvedDest = getOrNoneResolvedRemittanceLocation(
            intent.destInstruction, intent.companyId, intent.remittanceOrder.companyPayrollEntityId
        )
        if not resolvedDest:
            raise Exception(f"Failed to resolve destination bank account for intent {intent.id}")
        resolvedDestUniqueId = resolvedDest.uniqueId
        if resolvedDestUniqueId != exampleResolvedDestUniqueId:
            return BoolWithReason(False, "All intents in the batch must have the same destination")
    return BoolWithReason(True, "")


def submitRemittancePaymentForBatch(batch: RemittanceFFRequestBatch) -> BoolWithReason:
    if not (canFulfill := canFulfillBatch(batch)):
        return canFulfill

    ffRequests = contract_tasks.getFFRequestsForBatchId(batch.id)
    if not ffRequests:
        contract_tasks.forceCompleteEmptyBatch(batch, IntentStatus.COMPLETED)
        return BoolWithReason(True, "No intents to fulfill")

    if not (canUpdate := contract_tasks.canUpdateBatchStatus(batch, IntentStatus.INIT)):
        return canUpdate

    contract_tasks.updateBatchStatus(batch, IntentStatus.INIT)
    batchAttempt = computeBatchAttempt(batch, ffRequests)

    try:
        batchAttempt = contract_tasks.createBatchAttempt(batchAttempt)
    except:
        logger.exception(f"Failed to create batch attempt for {batch.id}")
        contract_tasks.updateBatchStatus(batch, IntentStatus.FAILED_TO_INIT)
        return BoolWithReason(False, "Failed to create batch attempt")

    contract_tasks.updateBatchStatus(batch, IntentStatus.PREPARING)
    contract_tasks.updateBatchStatus(batch, IntentStatus.PAYLOAD_GENERATED)
    try:
        paymentOrderId = submitBatchAttempt(batchAttempt)
    except:
        logger.exception(f"Failed to submit batch attempt for {batch.id}")
        contract_tasks.updateBatchStatus(batch, IntentStatus.FAILED_TO_SUBMIT)
        return BoolWithReason(False, "Failed to submit batch attempt")

    contract_tasks.createBatchPaymentReciept(batchAttempt, paymentOrderId)

    contract_tasks.updateBatchStatus(batch, IntentStatus.SUBMITTED)
    return BoolWithReason(True, "")


def computeBatchAttempt(
    batch: RemittanceFFRequestBatch, ffRequests: Sequence[RemittanceIntentFFRequestBean]
) -> RemittanceBatchAttemptBean:
    ffRequest = next(iter(ffRequests), None)
    if not ffRequest:
        raise Exception(f"Cannot compute batch attempt for empty batch {batch.id}")

    intent = ffRequest.intent
    companyId = ffRequest.intent.companyId
    cpeId = ffRequest.intent.remittanceOrder.companyPayrollEntityId

    sourceInfo = getOrNoneResolvedRemittanceLocation(intent.sourceInstruction, companyId, cpeId)
    if sourceInfo is None:
        raise Exception(f"Cannot resolve source bank account for intent {intent.id}")
    sourceInfo = cast(LocalBankAccountRemittanceInstruction, sourceInfo)

    destInfo = getOrNoneResolvedRemittanceLocation(intent.destInstruction, companyId, cpeId)
    if destInfo is None:
        raise Exception(f"Cannot resolve destination bank account for intent {intent.id}")

    destInfo = cast(LocalBankAccountRemittanceInstruction, destInfo)

    # todo: this must be the same version as the original run
    payrollObject = getPayrollObjectInfo(
        companyId, intent.remittanceOrder.payrollObjectId, intent.remittanceOrder.payrollObjectType
    )
    companyPayrollEntityVersion = payrollObject.getCompanyPayrollEntityVersion()

    clientId = getClientIdFromCompanyPayrollEntity(ffRequest.intent.companyId, companyPayrollEntityVersion)

    today = getToday(payrollObject.getCountryCode())
    expectedPaymentDate = getExpectedCheckDate(
        countryCode=payrollObject.getCountryCode(),
        liabilityCode=intent.remittanceOrder.liabilityCode,
        liabilityDueDate=intent.remittanceOrder.dueDate,
        payrollObjectCheckDate=payrollObject.getCheckDate(),
        isEOR=payrollObject.getCompanyPayrollEntity().isEOR,
        destination=cast(MoneyLocation, intent.destInstruction),
    )

    return RemittanceBatchAttemptBean(
        id=None,
        batch=batch,
        clientId=str(clientId),
        sourceBankAccountId=str(sourceInfo.bankAccountId),
        sourceAccountHolderType=sourceInfo.accountHolderType,
        destBankAccountId=str(destInfo.bankAccountId),
        destAccountHolderType=destInfo.accountHolderType,
        amount=intent.amount,
        currencyCode=intent.currencyCode,
        checkDate=max(today, expectedPaymentDate),
        moneyFlowExternalId=intent.remittanceOrder.payrollObjectId,
    )


def getOrCreateRemittanceBatchesForRemittanceVersion(
    remittanceVersion: RemittanceVersionBean,
) -> Sequence[RemittanceFFRequestBatch]:
    intents = contract_tasks.getRemittanceIntentsForRemittanceVersion(remittanceVersion)
    return getOrCreateRemittanceBatches(intents)


def getOrCreateRemittanceBatches(intents: Sequence[RemittanceIntentBean]) -> Sequence[RemittanceFFRequestBatch]:
    """
    This method should be idempotent.

    1. For each intent, get the existing FF request or create a ff request for each intent.
        Conditions to create a new request:
            (No existing request) XOR (The existing batch is retryable meaning the request needs to be re-queued)
        ELSE return the existing request
    2. For each request, assign it to a batch.
    """
    allFFRequests = [contract_tasks.getOrCreateRemittanceIntentFFRequest(intent) for intent in intents]
    batches = []
    for ffRequest in allFFRequests:
        intent = ffRequest.intent
        if not intent.id:
            raise ValueError(f"Intent for {ffRequest.id=} does not have an id")
        groupKey = getGroupKeyForFFRequest(ffRequest)
        countryCode = contract_tasks.getCountryCodeFromIntentId(intent.id)
        payrollObjectCheckDate = contract_tasks.getCheckDateFromIntentId(intent.id)
        cpeId = contract_tasks.getCompanyPayrollEntityIdFromIntentId(intent.id)
        cpe = getCompanyPayrollEntity(cpeId)
        isEor = cpe.isEOR
        expectedCheckDate = getExpectedCheckDate(
            countryCode=countryCode,
            liabilityCode=intent.remittanceOrder.liabilityCode,
            liabilityDueDate=intent.remittanceOrder.dueDate,
            payrollObjectCheckDate=payrollObjectCheckDate,
            isEOR=isEor,
            destination=MoneyLocation(intent.destInstruction),
        )
        expectedDepartureDate = getExpectedDepartureDateForBatch(countryCode, expectedCheckDate)
        bean = stateless.toRemittanceFFRequestBatch(
            intent=ffRequest.intent,
            countryCode=countryCode,
            groupKey=groupKey,
            expectedDepartureDate=expectedDepartureDate,
            expectedCheckDate=expectedCheckDate,
        )
        batch = contract_tasks.assignFFRequestToBatch(ffRequest, bean)
        batches.append(batch)
    return batches


def getExpectedDepartureDateForBatch(
    countryCode: str,
    expectedCheckDate: date,
) -> date:
    today = getToday(countryCode)
    twoDaysBeforeCheckDate = stateless.getRemittanceBusinessDayTime(
        countryCode=countryCode,
        referenceDate=expectedCheckDate,
        offsetDays=-2,
    ).date()
    return max(today, twoDaysBeforeCheckDate)


def getGroupKeyForFFRequest(ffRequest: RemittanceIntentFFRequestBean) -> BatchGroupKey:
    # todo: get the batch group key from the ffRequest, for now we will just use the intent id
    #       this means there is no actual batches of intents made yet.
    return cast(BatchGroupKey, ffRequest.intent.id)


def canSubmitBatchAttempt(attempt: RemittanceBatchAttemptBean) -> BoolWithReason:
    return BoolWithReason(True, "")


def submitBatchAttempt(attempt: RemittanceBatchAttemptBean) -> str:
    can, reason = canSubmitBatchAttempt(attempt)
    if not can:
        raise Exception(f"Cannot submit batch attempt due to {reason}")

    moneyFlowId = payment_service_contract.getOrCreateMoneyFlow(
        attempt.moneyFlowExternalId, flowType=MoneyFlowTypes.GLOBAL_PAYROLL_TAX_REMITTANCE
    )

    sourceAccountHolder = attempt.sourceAccountHolderType
    destAccountHolder = attempt.destAccountHolderType

    if sourceAccountHolder == BankAccountHolderTypes.internal and destAccountHolder == BankAccountHolderTypes.internal:
        paymentOrderId = payment_contract.createInternalPaymentOrder(
            clientId=attempt.clientId,
            originatingPaymentAccountId=attempt.sourceBankAccountId,
            counterPartyBankAccountId=attempt.destBankAccountId,
            grossAmountInSmallestUnit=int(attempt.amount * 100),
            orderCurrencyCode=attempt.currencyCode,
            checkDate=attempt.checkDate,
            moneyFlowId=moneyFlowId,
            externalReferenceId="",
            direction=PaymentOrderDirections.CREDIT,
        )
    elif sourceAccountHolder == BankAccountHolderTypes.internal and destAccountHolder == BankAccountHolderTypes.company:
        paymentOrderId = payment_contract.createCreditPaymentOrderToOrganization(
            clientId=attempt.clientId,
            originatingAccountId=attempt.sourceBankAccountId,
            counterPartyBankAccountId=attempt.destBankAccountId,
            grossAmountInSmallestUnit=int(attempt.amount * 100),
            checkDate=attempt.checkDate,
            moneyFlowId=moneyFlowId,
            externalReferenceId="",
        )
    else:
        raise NotImplementedError(f"Cannot remit from {sourceAccountHolder} to {destAccountHolder}")

    return paymentOrderId


def updateIntentStatusIfCannotFulfill(intent: RemittanceIntentBean) -> None:
    cpe = getCompanyPayrollEntity(intent.remittanceOrder.companyPayrollEntityId)
    if cpe.isEOR:
        return  # EOR remittances are not acceptable in a misconfigured state.

    status = contract_tasks.getLatestIntentStatus(intent)
    if IntentStatusUtil.isInProgress(status.status) or IntentStatusUtil.isCompleted(status.status):
        return

    if isAwaitingAdminConfiguration(
        intent.destInstruction, intent.companyId, intent.remittanceOrder.companyPayrollEntityId
    ):
        contract_tasks.updateIntentStatus(intent, IntentStatus.MISSING_CONFIGURATION)
    elif not hasSufficientFundsToFulfillIntent(intent, asOf=utc_now()):
        contract_tasks.updateIntentStatus(intent, IntentStatus.NON_SUFFICIENT_FUNDS)


def updateIntentStatusAfterExceptionOnSubmit(intent: RemittanceIntentBean, e: Exception) -> None:
    if type(e) in [TaxAgencyNotFoundException, TaxAgencyFieldNotFoundException]:
        cpe = getCompanyPayrollEntity(intent.remittanceOrder.companyPayrollEntityId)
        if cpe.isEOR:
            # EOR should never have a misconfigured tax agency setting because Rippling owns the entity.
            raise Exception(f"Cannot find tax agency for EOR entity {intent.companyId=} {cpe.id=}")
        contract_tasks.updateIntentStatus(intent, IntentStatus.MISSING_CONFIGURATION)


def computeRemittancePaymentActivityFromRemittanceOrder(
    remittanceOrder: RemittanceOrderBean, status: RemittancePaymentActivityStatus
) -> RemittancePaymentActivity:
    payrollObject = payroll_interface.getPayrollObjectInfo(
        remittanceOrder.companyId, remittanceOrder.payrollObjectId, remittanceOrder.payrollObjectType
    )
    cpe = payrollObject.getCompanyPayrollEntity()
    liabilityInterface = getPayrollCountryLiabilityInterface(cpe.countryCode)
    liabilitySetParams = LiabilitySetParams(isEOR=cpe.isEOR)
    liabilityDefinition = liabilityInterface.getLiabilityDefinitionFromCode(
        remittanceOrder.liabilityCode, liabilitySetParams
    )

    if remittanceOrder.id is None:
        raise Exception("Cannot created RemittancePaymentActivity when there is no RemittanceOrderId")
    if liabilityDefinition is None:
        raise Exception("Cannot create RemittancePaymentActivity because liability defintion is None")

    displayInfo = liabilityDefinition.getRemittancePaymentActivityDisplayInfo(status)

    return RemittancePaymentActivity(
        id=None,
        companyId=cpe.companyId,
        groupKey=remittanceOrder.id,
        groupItem="",
        liabilityCode=remittanceOrder.liabilityCode,
        payrollObjectId=remittanceOrder.payrollObjectId,
        payrollObjectType=remittanceOrder.payrollObjectType,
        status=status,
        companyPayrollEntityId=cpe.id,
        currencyCode=remittanceOrder.currencyCode,
        displayInfo=displayInfo,
        amount=remittanceOrder.liabilityAmount,
        createdAt=None,
        version=0,
    )


def fulfillIntent(intent: RemittanceIntentBean, dryRun=False) -> RemittanceIntentFulfillmentInfo:
    return fulfillIntents([intent], dryRun)[0]


def fulfillIntents(
    intents: Sequence[RemittanceIntentBean],
    dryRun=False,
) -> Sequence[RemittanceIntentFulfillmentInfo]:
    fulfillmentIntentInfos = [
        RemittanceIntentFulfillmentInfo(
            intent=intent, canFulfill=BoolWithReason(False), isSubmitted=BoolWithReason(False)
        )
        for intent in intents
    ]
    for fulfillmentIntentInfo in fulfillmentIntentInfos:
        fulfillmentIntentInfo.canFulfill = canFulfillIntent(fulfillmentIntentInfo.intent)

    if dryRun:
        for fulfillmentIntentInfo in fulfillmentIntentInfos:
            fulfillmentIntentInfo.isSubmitted = BoolWithReason(False, "Not submitting dry run")
        return fulfillmentIntentInfos

    for fulfillmentIntentInfo in fulfillmentIntentInfos:
        if not fulfillmentIntentInfo.canFulfill.val:
            updateIntentStatusIfCannotFulfill(fulfillmentIntentInfo.intent)
            fulfillmentIntentInfo.isSubmitted = fulfillmentIntentInfo.canFulfill
            continue
        try:
            if contract_tasks.isIntentInRetryableStatus(fulfillmentIntentInfo.intent, isAutoRetry=True):
                # if the task is in a retryable state.
                isSubmitted = retryRemittanceIntent(fulfillmentIntentInfo.intent, isAutoRetry=True)
            elif contract_tasks.isIntentInRetryableStatus(fulfillmentIntentInfo.intent, isAutoRetry=False):
                # if the task is in a retryable state but can't be submitted automatically.
                isSubmitted = BoolWithReason(False, "Cannot retry intent automatically")
            else:
                # else, this is the first time we are trying to submit the intent.
                paymentAttempt = computeRemittancePaymentAttempt(fulfillmentIntentInfo.intent)
                isSubmitted = submitPaymentAttempt(fulfillmentIntentInfo.intent, paymentAttempt)

        except (TaxAgencyNotFoundException, TaxAgencyFieldNotFoundException) as e:
            updateIntentStatusAfterExceptionOnSubmit(fulfillmentIntentInfo.intent, e)
            isSubmitted = BoolWithReason(False, f"Cannot submit due to {e}")
        fulfillmentIntentInfo.isSubmitted = isSubmitted

    return fulfillmentIntentInfos


def cancelRemittanceVersion(remittanceOrderVersion: RemittanceVersionBean) -> BoolWithReason:
    # TODO: Make this operation atomic with Redis lock
    intents: list[RemittanceIntentBean] = []
    remittanceOrders = contract_tasks.getRemittanceOrdersForRemittanceVersion(remittanceOrderVersion)
    for order in remittanceOrders:
        intents.extend(contract_tasks.getRemittanceIntentsForRemittanceOrder(order))

    canCancel = contract_tasks.canCancelRemittanceVersion(intents)
    if not canCancel.val:
        return BoolWithReason(False, f"Cannot cancel remittance version due to {canCancel.reason}")

    for intent in intents:
        contract_tasks.updateIntentStatus(intent, IntentStatus.CANCELLED)

    return BoolWithReason(True, "")


def canRetryRemittanceIntent(
    intent: RemittanceIntentBean, isAutoRetry: bool, skipCanFulfill: bool = False
) -> BoolWithReason:
    # we can retry only if we can fulfill
    if not skipCanFulfill and not (canFulfill := canFulfillIntent(intent)):
        return canFulfill

    if not contract_tasks.isIntentInRetryableStatus(intent, isAutoRetry):
        return BoolWithReason(False, "Cannot retry intent because of incorrect status")

    return BoolWithReason(True, "")


def retryRemittanceIntent(
    intent: RemittanceIntentBean,
    paymentMemoOverride: Optional[PaymentMemoBase] = None,
    destRemittanceLocationOverride: Optional[RemittanceInstruction] = None,
    isAutoRetry: bool = False,  # whether the system is retrying the intent automatically
    skipCanFulfill: bool = False,
) -> BoolWithReason:
    can, reason = canRetryRemittanceIntent(intent, isAutoRetry, skipCanFulfill=skipCanFulfill)
    if not can:
        return BoolWithReason(can, reason)
    latestAttempt = contract_tasks.getOrNoneLatestPaymentAttemptForIntent(intent)
    version = latestAttempt.version + 1 if latestAttempt else 0
    paymentAttempt = computeRemittancePaymentAttempt(
        intent,
        paymentMemoOverride=paymentMemoOverride,
        destRemittanceLocationOverride=destRemittanceLocationOverride,
        version=version,
    )
    return submitPaymentAttempt(intent, paymentAttempt)


@eta_task(**RemittanceRecurringEtaTaskKwargs)
def recreateAllLiabilityVersionsForCountryCode(countryCode: str) -> None:
    """Force re-evaluate all liability versions for a given country code. This is especially useful when we have to backfill a country"""
    if not canPersistLiabilityVersionsForCountryCode(countryCode):
        logger.error("Cannot persist default liability definitions for country %s", countryCode)
        return

    joinedLogs: list[str] = []
    cpes = gp_contract.getAllCompanyPayrollEntitiesNonDemoForCountryCode(countryCode)
    for cpe in cpes:
        try:
            joinedLogs.append(f"Trying to recreate liability versions for company {cpe.companyId} cpe {cpe.id}")
            recreateAllRunLiabilityVersionsForCompanyPayrollEntity(cpe)
            recreateAllReconProcessLiabilityVersionsForCompanyPayrollEntity(cpe)
        except:
            logger.exception("Failed to create liability versions for company %s cpe %s", cpe.companyId, cpe.id)

    logger.info(
        "\n".join(joinedLogs),
        extra={"fnName": "recreateAllLiabilityVersionsForCountryCode", "countryCode": countryCode},
    )


@eta_task(**RemittanceRecurringEtaTaskKwargs)
def recreateAllRunLiabilityVersionsForCountryCode(countryCode: str) -> None:
    """Force re-evaluate all liability versions for a given country code. This is especially useful when we have to backfill a country"""
    if not canPersistLiabilityVersionsForCountryCode(countryCode):
        logger.error("Cannot persist default liability definitions for country %s", countryCode)
        return

    joinedLogs: list[str] = []
    cpes = gp_contract.getAllCompanyPayrollEntitiesNonDemoForCountryCode(countryCode)
    for cpe in cpes:
        try:
            joinedLogs.append(f"Trying to recreate liability versions for company {cpe.companyId} cpe {cpe.id}")
            recreateAllRunLiabilityVersionsForCompanyPayrollEntity(cpe)
        except:
            logger.exception("Failed to create liability versions for company %s cpe %s", cpe.companyId, cpe.id)

    logger.info(
        "\n".join(joinedLogs),
        extra={"fnName": "recreateAllRunLiabilityVersionsForCountryCode", "countryCode": countryCode},
    )


@eta_task(**RemittanceRecurringEtaTaskKwargs)
def recreateAllReconProcessLiabilityVersionsForCountryCode(countryCode: str) -> None:
    """Force re-evaluate all liability versions for a given country code. This is especially useful when we have to backfill a country"""
    if not canPersistLiabilityVersionsForCountryCode(countryCode):
        logger.error("Cannot persist default liability definitions for country %s", countryCode)
        return

    joinedLogs: list[str] = []
    cpes = gp_contract.getAllCompanyPayrollEntitiesNonDemoForCountryCode(countryCode)
    for cpe in cpes:
        try:
            joinedLogs.append(f"Trying to recreate liability versions for company {cpe.companyId} cpe {cpe.id}")
            recreateAllReconProcessLiabilityVersionsForCompanyPayrollEntity(cpe)
        except:
            logger.exception("Failed to create liability versions for company %s cpe %s", cpe.companyId, cpe.id)

    logger.info(
        "\n".join(joinedLogs),
        extra={"fnName": "recreateAllReconProcessLiabilityVersionsForCountryCode", "countryCode": countryCode},
    )


def recreateAllRunLiabilityVersionsForCompanyPayrollEntity(cpe: CompanyPayrollEntity) -> None:
    allRuns = getAllPayRunsReadyForLiabilityVersionCreation(cpe.companyId, cpe.id)
    allRunIds = [run.countryRunId for run in allRuns]
    launch_eta_chunks(
        batchCreateLiabilityCalculationsForPayRun,
        allRunIds,
        companyId=cpe.companyId,
        shouldOverwrite=True,
        chunk_size=10,
    )


def recreateAllReconProcessLiabilityVersionsForCompanyPayrollEntity(cpe: CompanyPayrollEntity) -> None:
    allReconProcesses = getAllReconProcessesReadyForLiabilityVersionCreation(cpe.companyId, cpe.id)
    allReconProcessIds = [process.processId for process in allReconProcesses]
    launch_eta_chunks(
        batchCreateLiabilityCalculationsForReconProcess,
        allReconProcessIds,
        companyId=cpe.companyId,
        shouldOverwrite=True,
        chunk_size=10,
    )


@eta_task(**RemittanceRecurringEtaTaskKwargs)
def recreateAllRemittanceOrdersForCountryCode(countryCode: str) -> None:
    """Force re-evaluate all remittance orders for a given country code. This is especially useful when we have to backfill a country"""
    if not canPersistRemittanceOrdersForCountryCode(countryCode):
        logger.error("Cannot persist remittance orders for country %s", countryCode)
        return

    joinedLogs: list[str] = []
    cpes = gp_contract.getAllCompanyPayrollEntitiesNonDemoForCountryCode(countryCode)
    for cpe in cpes:
        try:
            joinedLogs.append(f"Trying to recreate remittance orders for company {cpe.companyId} cpe {cpe.id}")
            recreateAllRemittanceOrdersForCompanyPayrollEntityPayRuns(cpe)
            recreateAllRemittanceOrdersForCompanyPayrollEntityPayReconProcesses(cpe)
        except:
            logger.exception("Failed to create remittance orders for company %s cpe %s", cpe.companyId, cpe.id)

    logger.info(
        "\n".join(joinedLogs),
        extra={"fnName": "recreateAllRemittanceOrdersForCountryCode", "countryCode": countryCode},
    )


def recreateAllRemittanceOrdersForCompanyPayrollEntityPayRuns(cpe: CompanyPayrollEntity) -> None:
    # get all the latest liability versions for this entity
    allRuns = getAllPayRunsReadyForLiabilityVersionCreation(cpe.companyId, cpe.id)
    allRunIds = [run.countryRunId for run in allRuns]
    launch_eta_chunks(
        batchCreateRemittanceOrdersForPayRun,
        allRunIds,
        shouldOverwrite=True,
        chunk_size=10,
    )


def recreateAllRemittanceOrdersForCompanyPayrollEntityPayReconProcesses(cpe: CompanyPayrollEntity) -> None:
    allProcesses = getAllReconProcessesReadyForLiabilityVersionCreation(cpe.companyId, cpe.id)
    allProcessIds = [process.processId for process in allProcesses]
    launch_eta_chunks(
        batchCreateRemittanceOrdersForReconProcess,
        allProcessIds,
        shouldOverwrite=True,
        chunk_size=10,
    )


@eta_task
def batchCreateRemittanceOrdersForPayRun(
    work_ids: Sequence[str],
    shouldOverwrite: bool = False,
) -> None:
    liabilityVersions = contract_tasks.getAllLiabilityVersionsForPayrollObjectIds(
        work_ids,
        "PAYRUN",
    )
    for liabilityVersion in liabilityVersions:
        createRemittanceOrdersForLiabilityVersion(
            liabilityVersion,
            "batchCreateRemittanceOrdersForPayRun",
            shouldOverwrite,
        )


@eta_task
def batchCreateRemittanceOrdersForReconProcess(
    work_ids: Sequence[str],
    shouldOverwrite: bool = False,
) -> None:
    liabilityVersions = contract_tasks.getAllLiabilityVersionsForPayrollObjectIds(
        work_ids,
        "RECON_PROCESS",
    )
    for liabilityVersion in liabilityVersions:
        createRemittanceOrdersForLiabilityVersion(
            liabilityVersion,
            "batchCreateRemittanceOrdersForPayRun",
            shouldOverwrite,
        )


def getAllPayRunsReadyForLiabilityVersionCreation(
    companyId: str,
    companyPayrollEntityId: str,
    checkDateStart: Optional[date] = None,
    checkDateEnd: Optional[date] = None,
) -> Sequence[CountryPayRunDetails]:
    allRunDetails = run_management.getIPCountryPayRunsByCheckDate(
        companyId=companyId,
        companyPayrollEntityId=companyPayrollEntityId,
        statusOptions=["PAID"],
        startDate=checkDateStart,
        endDate=checkDateEnd,
    )
    filteredRunDetails = []
    for runDetails in allRunDetails:
        if runDetails.runType == "CORRECTION":
            continue
        debitInfo = payments.getOrNonePayRunDebitInfo(runDetails.companyId, runDetails.countryRunId)
        if debitInfo is None:
            continue
        filteredRunDetails.append(runDetails)
    return filteredRunDetails


def getAllReconProcessesReadyForLiabilityVersionCreation(
    companyId: str,
    companyPayrollEntityId: str,
    checkStartDate: Optional[date] = None,
    checkEndDate: Optional[date] = None,
) -> Sequence[ReconciliationProcessDetails]:
    countryCode = getCompanyPayrollEntity(companyPayrollEntityId).countryCode
    if isReconProcessLiabilityDisabledForCountryCode(countryCode):
        return []
    allReconProcesses = run_management.getIPReconProcessesByCheckDate(
        companyId=companyId,
        companyPayrollEntityId=companyPayrollEntityId,
        statusOptions=["PAID"],
        startDate=checkStartDate,
        endDate=checkEndDate,
    )
    filteredProcesses = []
    for processDetails in allReconProcesses:
        debitInfo = payments.getOrNoneReconProcessDebitInfo(processDetails.companyId, processDetails.processId)
        if debitInfo is None:
            continue
        filteredProcesses.append(processDetails)
    return filteredProcesses


def createAllReconProcessLiabilitiesForCompanyPayrollEntity(
    cpe: CompanyPayrollEntity, startDate: Optional[date] = None, endDate: Optional[date] = None
) -> Sequence[CompanyLiabilityVersionBean]:
    allReconProcesses = getAllReconProcessesReadyForLiabilityVersionCreation(cpe.companyId, cpe.id, startDate, endDate)
    payloads = [
        CompanyLiabilityVersionKey(
            companyId=reconProcess.companyId,
            payrollObjectId=reconProcess.processId,
            payrollObjectType=LiabilityPayrollObjectTypes.RECON_PROCESS,
        )
        for reconProcess in allReconProcesses
    ]
    return createAllLiabilitiesForPayrollObjects(payloads)


def createAllPayRunLiabilitiesForCompanyPayrollEntity(
    cpe: CompanyPayrollEntity, startDate: Optional[date] = None, endDate: Optional[date] = None
) -> Sequence[CompanyLiabilityVersionBean]:
    allRunDetails = getAllPayRunsReadyForLiabilityVersionCreation(cpe.companyId, cpe.id, startDate, endDate)
    payloads = [
        CompanyLiabilityVersionKey(
            companyId=runDetail.companyId,
            payrollObjectId=runDetail.countryRunId,
            payrollObjectType=LiabilityPayrollObjectTypes.PAYRUN,
        )
        for runDetail in allRunDetails
    ]
    return createAllLiabilitiesForPayrollObjects(payloads)


def createAllLiabilitiesForPayrollObjects(
    payloads: list[CompanyLiabilityVersionKey],
    shouldOverwriteExisting: bool = False,
    shouldOverwriteInProgress: bool = False,
    shouldOverrideDebitVerification: bool = False,
    reasonCode: str = "INIT",
) -> Sequence[CompanyLiabilityVersionBean]:
    createdLiabilityVersions: list[CompanyLiabilityVersionBean] = []
    for payload in payloads:
        try:
            liabilityVersion = createLiabilityCalculationsForPayrollObject(
                payload.companyId,
                payload.payrollObjectId,
                payload.payrollObjectType,
                shouldOverwriteExisting=shouldOverwriteExisting,
                shouldOverwriteInProgress=shouldOverwriteInProgress,
                shouldOverrideDebitVerification=shouldOverrideDebitVerification,
                reasonCode=reasonCode,
            )
            if liabilityVersion:
                createdLiabilityVersions.append(liabilityVersion)
        except LiabilityVersionDebitInfoMismatchException:
            # this will be caught in an audit
            ...
        except:
            logger.exception(
                "Failed to create liability version for company %s payrollObjectId %s payrollObjectType %s",
                payload.companyId,
                payload.payrollObjectId,
                payload.payrollObjectType,
                extra={"fnName": "createLiabilitiesForPayrollObjects"},
            )

    return createdLiabilityVersions


def manuallyCreateEmptyRemittanceVersionsForLiabilityVersion(
    liabilityVersion: CompanyLiabilityVersionBean, reason: str
) -> None:
    for liabilityCode in contract_tasks.getAllLiabilityCodesForRemittanceOrderCreation(liabilityVersion):
        _ = contract_tasks.createRemittanceVersion(
            liabilityVersion=liabilityVersion,
            liabilityCode=liabilityCode,
            dueDate=utc_now(),
            fulfillAfterDate=utc_now(),
            reasonCode=reason,
        )


def createRemittanceOrdersForLiabilityVersion(
    liabilityVersion: CompanyLiabilityVersionBean,
    reason: str,
    shouldOverwrite: bool = False,
) -> BoolWithReason:
    """
    Given a CompanyLiabilityVersionBean
    Then create multiple RemittanceVersionBean for each CompanyLiabilityVersionBean
    Then create multiple RemittanceOrderBean for each RemittanceVersionBean
    Then create multiple RemittanceIntentBean for each RemittanceOrderBean

    This function will not try to fulfill the remittance orders. The fulfillment is owned by
    another recurring process.
    """
    canCreateWithReason = contract_tasks.canCreateRemittanceOrders(
        countryCode=liabilityVersion.countryCode,
        companyId=liabilityVersion.companyId,
        objectId=liabilityVersion.payrollObjectId,
        objectType=liabilityVersion.payrollObjectType,
        shouldOverwrite=shouldOverwrite,
    )
    if not canCreateWithReason.val:
        return canCreateWithReason

    liabilityCodeToRemittanceOrders: dict[str, list[RemittanceOrderBean]] = defaultdict(list)

    # for any code without a remittance order we want to create a remittance version without and order
    # so that we "disable" remittance if the liability code is removed in the latest version.
    for liabilityCode in contract_tasks.getAllLiabilityCodesForRemittanceOrderCreation(liabilityVersion):
        liabilityCodeToRemittanceOrders[liabilityCode] = []

    # fill in all remittance orders by liability code.
    allRemittanceOrderBeans = readOnlyComputeRemittanceOrdersForLiabilityVersion(liabilityVersion)
    for remittanceOrder in allRemittanceOrderBeans:
        liabilityCodeToRemittanceOrders[remittanceOrder.liabilityCode].append(remittanceOrder)

    # creating all remittance versions and orders
    for liabilityCode, remittanceOrderBeans in liabilityCodeToRemittanceOrders.items():
        canCreateWithReason = contract_tasks.canCreateRemittanceVersion(
            countryCode=liabilityVersion.countryCode,
            companyId=liabilityVersion.companyId,
            liabilityCode=liabilityCode,
        )
        if not canCreateWithReason.val:
            continue

        dueDate = max([x.dueDate for x in remittanceOrderBeans], default=utc_now())
        fulfillAfterDate = min([x.fulfillAfterDate for x in remittanceOrderBeans], default=utc_now())

        remittanceVersion = contract_tasks.createRemittanceVersion(
            liabilityVersion=liabilityVersion,
            liabilityCode=liabilityCode,
            dueDate=dueDate,
            fulfillAfterDate=fulfillAfterDate,
            reasonCode=reason,
        )
        for remittanceOrder in remittanceOrderBeans:
            contract_tasks.createRemittanceOrder(remittanceVersion, remittanceOrder)

        # create the remittance intent beans
        intents = contract_tasks.createRemittanceIntentsForRemittanceVersion(remittanceVersion)
        batchableIntents = [intent for intent in intents if isBatchedRemittanceFulfillmentEnabled(intent.companyId)]
        if batchableIntents:
            getOrCreateRemittanceBatches(batchableIntents)

    return BoolWithReason(True)


def createNewLiabilityVersionAndCorrectRemittanceVersions(
    companyId: str,
    payrollObjectId: str,
    payrollObjectType: LiabilityPayrollObjectTypes,
    shouldOverwriteExisting: bool,
    shouldOverwriteInProgress: bool,
    shouldOverrideDebitVerification: bool,
    reason: str,
) -> None:
    newLiabilityVersion = createLiabilityCalculationsForPayrollObject(
        companyId,
        payrollObjectId,
        payrollObjectType,
        shouldOverwriteExisting=shouldOverwriteExisting,
        shouldOverwriteInProgress=shouldOverwriteInProgress,
        shouldOverrideDebitVerification=shouldOverrideDebitVerification,
    )
    if not newLiabilityVersion:
        raise Exception("Failed to create new liability version")
    createRemittanceVersionCorrections(newLiabilityVersion, reason)


def createRemittanceVersionCorrections(
    liabilityVersion: CompanyLiabilityVersionBean,
    reason: str,
) -> None:
    # Added for https://rippling.atlassian.net/browse/GP-21473.
    # Given a single liability version, create a new remittance version that overwrites the old.
    #
    # The background is that we have multiple previously incorrect liabilities calculated in the UK that led to tax
    # underpayment. This solution is based on the idea that a remittance version designates a "workflow run" where the
    # workflow is a payment orchestration for a particular liability code. To correct a previous workflow we HALT it
    # by overwriting the old remittance version so it is no longer the latest version.
    #
    # The new remittance version then keeps a more up-to-date record of where money has already been sent and what
    # money we should send soon.
    #
    # To resolve this issue we take the following steps:
    #   1. Create a new liability version
    #   2. For the old orders. If they are completed copy them into the new remittance version. They are already sent
    #       and cannot be changed.
    #   3. For each new orders. If an old order was already completed, reduce the new order amount by the old amount.
    #       This means that we now have two orders in the new remittance version, splitting the payment in two so that
    #       we can pay the underpayment.
    #
    # todo: we can adapt this logic to support more complex scenarios, but for now, we only support underpaid remittance
    #       to the same entity with no complex changes to statement narrative etc.
    remittanceVersions = contract_tasks.getLatestRemittanceVersions(
        liabilityVersion.companyId, liabilityVersion.payrollObjectId, liabilityVersion.payrollObjectType
    )
    codeToRemittanceVersion = {rv.liabilityCode: rv for rv in remittanceVersions}
    allCodes = contract_tasks.getAllLiabilityCodesForRemittanceOrderCreation(liabilityVersion)
    for liabilityCode in allCodes:
        remittanceVersion = codeToRemittanceVersion.get(liabilityCode)
        createRemittanceVersionCorrection(liabilityVersion, liabilityCode, remittanceVersion, reason)
    return


def createRemittanceVersionCorrection(
    liabilityVersion: CompanyLiabilityVersionBean,
    liabilityCode: str,
    remittanceVersion: Optional[RemittanceVersionBean],
    reason: str,
) -> None:
    # analyze the old remittance orders and new remittance orders to try and resovle the difference in the new remittance
    # version.
    # If an old order has already been fulfilled, copy it into the new remittance version so that we
    oldOrders = contract_tasks.getRemittanceOrdersForRemittanceVersion(remittanceVersion) if remittanceVersion else []
    oldCodeToOrders: dict[str, list[RemittanceOrderBean]] = groupby(oldOrders, key=lambda o: o.liabilityCode)

    newOrders = readOnlyComputeRemittanceOrdersForLiabilityVersion(liabilityVersion)
    newCodeToOrders: dict[str, list[RemittanceOrderBean]] = groupby(newOrders, key=lambda o: o.liabilityCode)

    # now re-balance the old orders and new orders into a new corrected remittance version.
    ordersToCopy: list[RemittanceOrderBean] = []
    ordersToCreate: list[RemittanceOrderBean] = []

    oldOrders = oldCodeToOrders.get(liabilityCode, [])
    if len(oldOrders) > 1:
        raise NotImplementedError("We do not support corrections to liabilities with more than one order yet")

    newOrders = newCodeToOrders.get(liabilityCode, [])
    if len(newOrders) > 1:
        raise NotImplementedError("We do not support corrections to liabilities with more than one order yet")

    oldOrder = oldOrders[0] if oldOrders else None
    newOrder = newOrders[0] if newOrders else None

    if oldOrder and contract_tasks.isOrderInProgressOrComplete(oldOrder):
        # merge in the new orders with the old order.
        # this is a bit complicated, but let's only handle a very simple case for now.
        if not contract_tasks.isOrderComplete(oldOrder):
            # we don't know what to do here yet.
            raise NotImplementedError("We do not support corrections to in-progress remittance orders yet")

        # copy the old order into the new remittance version
        ordersToCopy.append(oldOrder)

        if newOrder:
            newOrder.liabilityAmount -= oldOrder.liabilityAmount
            if newOrder.liabilityAmount < Decimal(0):
                raise NotImplementedError("We do not support overpayment corrections yet.")
            if newOrder.liabilityAmount > Decimal(0):
                ordersToCreate.append(newOrder)

    elif newOrder:
        ordersToCreate.append(newOrder)

    # sanity check
    mergedOrders = ordersToCreate + ordersToCopy
    contract_tasks.doesLiabilityAmountMatchOrderAmounts(
        liabilityVersion,
        {liabilityCode: mergedOrders},
    )

    contract_tasks.writeRemittanceVersionCorrections(
        liabilityVersion, liabilityCode, ordersToCopy=ordersToCopy, ordersToCreate=ordersToCreate, reason=reason
    )


def getRemittanceSummaryForCompany(
    companyId: str,
    runIds: Optional[Sequence[str]] = None,
    processIds: Optional[Sequence[str]] = None,
) -> Mapping[str, Any]:
    """User-Friendly summary of remittance orders for a company"""
    liabilityVersions = contract_tasks.getLiabilityVersionsForCompanyId(companyId)
    if runIds or processIds:
        liabilityVersions = [
            liabilityVersion
            for liabilityVersion in liabilityVersions
            if (runIds and liabilityVersion.payrollObjectId in runIds)
            or (processIds and liabilityVersion.payrollObjectId in processIds)
        ]

    result: dict[str, Any] = {
        "companyId": companyId,
        "summaries": [],
    }

    for liabilityVersion in liabilityVersions:
        # print out the aggregate values for each liability code
        liabilityCodeToData: dict[str, dict[str, Any]] = {}
        aggregates = contract_tasks.getAggregateLiabilityCalculationsForCompanyLiabilityVersion(liabilityVersion)
        for aggregate in aggregates:
            if aggregate.liabilityCode not in liabilityCodeToData:
                liabilityCodeToData[aggregate.liabilityCode] = {
                    "liabilityCode": aggregate.liabilityCode,
                    "countryCode": liabilityVersion.countryCode,
                    "amount": aggregate.liabilityAmount.totalAmount,
                    "dueDate": None,
                }
            else:
                liabilityCodeToData[aggregate.liabilityCode]["amount"] += aggregate.liabilityAmount

        remittanceVersions = contract_tasks.getLatestRemittanceVersions(
            liabilityVersion.companyId, liabilityVersion.payrollObjectId, liabilityVersion.payrollObjectType
        )
        for remittanceVersion in remittanceVersions:
            dateStr = date.strftime(remittanceVersion.dueDate, "%Y-%m-%d")
            if remittanceVersion.liabilityCode not in liabilityCodeToData:
                liabilityCodeToData[remittanceVersion.liabilityCode] = {
                    "liabilityCode": remittanceVersion.liabilityCode,
                    "amount": Decimal(0),
                    "dueDate": dateStr,
                }
            elif not liabilityCodeToData[remittanceVersion.liabilityCode]["dueDate"]:
                liabilityCodeToData[remittanceVersion.liabilityCode]["dueDate"] = dateStr
            else:
                liabilityCodeToData[remittanceVersion.liabilityCode]["dueDate"] = min(
                    liabilityCodeToData[remittanceVersion.liabilityCode]["dueDate"], dateStr
                )

            intents = contract_tasks.getRemittanceIntentsForRemittanceVersion(remittanceVersion)
            for intent in intents:
                status = contract_tasks.getLatestIntentStatus(intent)
                if "intents" not in liabilityCodeToData[remittanceVersion.liabilityCode]:
                    liabilityCodeToData[remittanceVersion.liabilityCode]["intents"] = []
                liabilityCodeToData[remittanceVersion.liabilityCode]["intents"].append(
                    {
                        "intentId": intent.id,
                        "amount": intent.amount,
                        "destination": intent.destInstruction,
                        "status": status.status,
                        "statusReason": status.reason,
                    }
                )

        result["summaries"].append(
            {
                "liabilityVersion": liabilityVersion.id,
                "payrollObjectId": liabilityVersion.payrollObjectId,
                "payrollObjectType": liabilityVersion.payrollObjectType,
                "liabilityCodeToData": liabilityCodeToData,
            }
        )

    return result


def fulfillRemittancesForIntentId(intentId: str, dryRun: bool) -> Dict[str, Any]:
    """provides quick staff api to fulfill a run-level remittance"""
    intent = contract_tasks.getOrNoneRemittanceIntentForId(intentId)
    if not intent:
        return {"error": f"Cannot find intent for id {intentId}"}

    fulfillmentIntentInfo = fulfillIntent(intent, dryRun)
    attempt = contract_tasks.getOrNoneLatestPaymentAttemptForIntent(intent)
    return {
        "intentId": intentId,
        "attempt": asdict(attempt) if attempt else None,
        "canFulfill": fulfillmentIntentInfo.canFulfill.val,
        "canFulfillReason": fulfillmentIntentInfo.canFulfill.reason,
        "isSubmitted": fulfillmentIntentInfo.isSubmitted.val,
        "isSubmittedReason": fulfillmentIntentInfo.isSubmitted.reason,
        "dryRun": dryRun,
    }


def fulfillIntentsForRemittanceVersionId(remittanceVersionId: str) -> None:
    remittanceVersion = contract_tasks.getOrNoneRemittanceVersionForId(remittanceVersionId)
    if not remittanceVersion:
        raise Exception(f"Cannot find remittance version for id {remittanceVersionId}")
    intents = contract_tasks.getRemittanceIntentsForRemittanceVersion(remittanceVersion)
    fulfillIntents(intents)


def fulfillRemittanceBatchForId(batchId: str) -> None:
    batch = contract_tasks.getOrNoneRemittanceFFRequestBatch(batchId)
    if not batch:
        raise Exception(f"Cannot find batch for id {batchId}")
    submitRemittancePaymentForBatch(batch)


@eta_task(**RemittanceRecurringEtaTaskKwargs)
def recreateAllLiabilitySetsForCountryCode(countryCode: str) -> None:
    # this should include demo companies
    for cpe in gp_contract.getAllCompanyPayrollEntitiesForCountryCode(countryCode):
        try:
            contract_tasks.initializeDefaultLiabilityForCompany(
                companyId=str(cpe.companyId),
                companyPayrollEntityId=cpe.id,
                countryCode=countryCode,
                effectiveDateTime=utc_now(),
            )
            updateLiabilitySetDoNotRemitSettings(cpe.companyId, cpe.id)
        except:
            logger.exception(
                f"Failed to recreate liability set for companyId {cpe.companyId} companyPayrollEntityId {cpe.id} countryCode {countryCode}"
            )


def postPayRunApprovalTasks(companyId: str, runId: str) -> None:
    try:
        payrollObject = payroll_interface.getPayrollObjectInfo(companyId, runId, LiabilityPayrollObjectTypes.PAYRUN)
        _runPostPayrollObjectApprovalTasks(payrollObject)
    except:
        logger.exception(f"Failed to run post payrun approval tasks for companyId={companyId} and runId={runId}")


def postReconProcessApprovalTasks(processDetails: ReconciliationProcessDetails) -> None:
    try:
        payrollObject = payroll_interface.getPayrollObjectInfo(
            processDetails.companyId, processDetails.processId, LiabilityPayrollObjectTypes.RECON_PROCESS
        )
        _runPostPayrollObjectApprovalTasks(payrollObject)
    except:
        logger.exception(
            f"Failed to run post reconprocess approval tasks for companyId={processDetails.companyId} and processId={processDetails.processId}"
        )


def _runPostPayrollObjectApprovalTasks(payrollObject: PayrollObjectInfo) -> None:
    contract_tasks.writeRemittancePayrollObjectSnapshot(payrollObject)
    writeRemittanceDebitConfig(payrollObject)


@request_cache
def getRemittanceDebitConfig(
    companyId: str, payrollObjectId: str, payrollObjectType: LiabilityPayrollObjectTypes
) -> Optional[RemittanceDebitConfig]:
    payrollObject = payroll_interface.getPayrollObjectInfo(companyId, payrollObjectId, payrollObjectType)

    # fallback in the case that the remittance debit config is not found
    # try-except because we don't want to block a payrun flow.
    try:
        return readOnlyGetOrComputeRemittanceDebitConfig(payrollObject)
    except:
        logger.exception(
            f"Failed to get remittance debit config for companyId={companyId} and payrollObjectId={payrollObjectId}"
        )
        return None


def writeRemittanceDebitConfig(
    payrollObject: PayrollObjectInfo, shouldOverwrite: bool = False
) -> Optional[RemittanceDebitConfig]:
    debitConfig = readOnlyGetOrComputeRemittanceDebitConfig(payrollObject, shouldOverwrite)

    if not debitConfig:
        return None

    if not payrollObject.isLocked():
        # do not write until the payroll object is locked
        return debitConfig

    if not shouldOverwrite and debitConfig.id:
        return debitConfig

    return contract_tasks.saveRemittanceDebitConfig(debitConfig)


def readOnlyGetOrComputeRemittanceDebitConfig(
    payrollObject: PayrollObjectInfo, shouldRecompute: bool = False
) -> Optional[RemittanceDebitConfig]:
    lastDebitConfig = payrollObject.getOrNoneLatestRemittanceDebitConfig()
    if lastDebitConfig and not shouldRecompute:
        return lastDebitConfig  # only write once.

    liabilitySet = payrollObject.getOrNoneLiabilitySet()
    if not liabilitySet:
        return lastDebitConfig

    hasDebit = bool(payrollObject.getOrNoneDebitInfo())
    if hasDebit and not shouldRecompute:
        return lastDebitConfig  # freeze after debit

    debitConfig = stateless.newRemittanceDebitConfig(
        liabilitySet=liabilitySet,
        companyId=payrollObject.getCompanyId(),
        companyPayrollEntityId=payrollObject.getCompanyPayrollEntityId(),
        countryCode=payrollObject.getCountryCode(),
        payrollObjectId=payrollObject.getPayrollObjectId(),
        payrollObjectType=payrollObject.getPayrollObjectType(),
        adminSettings=payrollObject.getOrNoneAdminSettingInfoDict(),
    )
    return debitConfig


def readOnlyComputeRemittanceOrdersForLiabilityVersion(
    liabilityVersion: CompanyLiabilityVersionBean,
) -> List[RemittanceOrderBean]:
    liabilityCalculations = contract_tasks.getAggregateLiabilityCalculationsForCompanyLiabilityVersion(liabilityVersion)
    shards = computeShardsWithAggregateLiabilityCalculations(liabilityVersion, liabilityCalculations)
    remittanceOrders = stateless.aggregateShardsToRemittanceOrders(shards)
    return remittanceOrders


def computeShardsWithAggregateLiabilityCalculations(
    liabilityVersion: CompanyLiabilityVersionBean,
    liabilityCalculations: List[AggregateLiabilityCalculationBean],
) -> List[RemittanceOrderBean]:
    """
    Converts ee liability calculations into remittance order shards. First, fetches the liability definition for
    each calculation. Then, uses the definition to get the MoneyGraph using which we can create the remittance order shards
    """
    payrollObject = getPayrollObjectInfoForLiabilityVersion(liabilityVersion)
    payoutSettings = payrollObject.getOrNoneRunPayoutSettings()
    debitConfig = payrollObject.getOrNoneLatestRemittanceDebitConfig()
    liabilityCalculations = stateless.filterAggregateLiabilityCalculationsForOrderCreation(
        liabilityCalculations, liabilityVersion.liabilitySet, payoutSettings, debitConfig
    )

    shards: List[RemittanceOrderBean] = []
    for liabilityCalculation in liabilityCalculations:
        payrollInterface = getPayrollObjectInfo(
            liabilityCalculation.companyId,
            liabilityCalculation.payrollObjectId,
            liabilityCalculation.payrollObjectType,
        )
        checkDate = payrollInterface.getCheckDate()
        cpe = payrollInterface.getCompanyPayrollEntity()
        orderCreationInfo = stateless.getMoneyGraphAndDatesFromAggregateLiabilityCalculation(
            liabilityCalculation, cpe.countryCode, cpe.isEOR, checkDate
        )
        for moneyPath in stateless.convertMoneyGraphToMoneyPaths(orderCreationInfo.moneyGraph):
            shards.append(
                stateless._getShardFromAggregateLiabilityCalculation(
                    liabilityVersion=liabilityVersion,
                    liabilityCalculation=liabilityCalculation,
                    moneyPath=moneyPath,
                    dueDate=orderCreationInfo.dueDate,
                    fulfillAfterDate=orderCreationInfo.fulfillAfterDate,
                )
            )
    return shards


def updateLiabilitySetDoNotRemitSettings(
    companyId: str, companyPayrollEntityId: str
) -> Optional[CompanyLiabilityTypeSet]:
    liabilitySet = contract_tasks.getLiabilitySetOn(companyId, companyPayrollEntityId, utc_now())
    if not liabilitySet:
        return None
    cpe = getCompanyPayrollEntity(companyPayrollEntityId)
    liabilityCodesWithACycle = getLiabilityCodesWithACycle(liabilitySet)
    liabilitySetParams = LiabilitySetParams(isEOR=cpe.isEOR)
    defaultLiabilities = contract_tasks.getDefaultLiabilityTypeBeans(cpe.countryCode, liabilitySetParams)
    return contract_tasks.updateLiabilitySetDoNotRemitSettingsForLiabilityCodes(
        liabilitySet, defaultLiabilities, liabilityCodesWithACycle
    )


def getLiabilityCodesWithACycle(liabilitySet: CompanyLiabilityTypeSet) -> Sequence[str]:
    # iterate all the liability codes and find the ones with a cycle
    liabilityCodes = list(set(liability.liabilityCode for liability in liabilitySet.companyLiabilities))
    return [
        liabilityCode
        for liabilityCode in liabilityCodes
        if isLiabilityCodeWithACyclicMoneyGraph(liabilitySet, liabilityCode)
    ]


def isLiabilityCodeWithACyclicMoneyGraph(liabilitySet: CompanyLiabilityTypeSet, liabilityCode: str) -> BoolWithReason:
    liability = next(
        (liability for liability in liabilitySet.companyLiabilities if liability.liabilityCode == liabilityCode), None
    )
    if liability is None:
        return BoolWithReason(False, "Liability code not found in liability set")
    # dry-run a remittance money graph for this liability code and see if it would have cycles.
    # in the case of HQ accounts the target would just be the same funding bank account today in most cases.
    return hasCycleInMoneyGraph(liabilitySet, liabilityCode)


def hasCycleInMoneyGraph(liabilitySet: CompanyLiabilityTypeSet, liabilityCode: str) -> BoolWithReason:
    isEOR = getCompanyPayrollEntity(liabilitySet.companyPayrollEntityId).isEOR
    liabilityInterface = getPayrollCountryLiabilityInterface(liabilitySet.countryCode)
    liabilityDefinition = liabilityInterface.getLiabilityDefinitionFromCode(
        liabilityCode, LiabilitySetParams(isEOR=isEOR)
    )
    if not liabilityDefinition or not isinstance(liabilityDefinition, RoleLiabilityDefinition):
        return BoolWithReason(False, "Liability definition not found")

    try:
        moneyGraph = liabilityDefinition.getMoneyGraph(Decimal(1), MoneyGraphParams(isEOR=isEOR))
        moneyPaths = stateless.convertMoneyGraphToMoneyPaths(moneyGraph)
    except NotImplementedError:
        moneyPaths = []

    resolvedPaths: list[Sequence[Optional[str]]] = []
    for moneyPath in moneyPaths:
        instructions = moneyPath.path
        if not canResolveInstructions(instructions, liabilitySet.companyId, liabilitySet.companyPayrollEntityId):
            return BoolWithReason(False, "Cannot resolve instruction path to make a decision")
        resolvedPath = resolveMoneyPathUniqueIdentifiers(
            instructions, liabilitySet.companyId, liabilitySet.companyPayrollEntityId
        )
        resolvedPaths.append(resolvedPath)

    for resolvedPath in resolvedPaths:
        if stateless.hasCycleInPathIgnoreNone(resolvedPath):
            pathStr = " -> ".join(str(p) for p in resolvedPath)
            return BoolWithReason(True, f"Cycle found in money path {pathStr}")

    return BoolWithReason(False, "No cycle found in money paths")


def resolveMoneyPathUniqueIdentifiers(
    instructions: Sequence[str], companyId: str, companyPayrollEntityId: str
) -> Sequence[Optional[str]]:
    resolvedPath: list[Optional[str]] = []
    for instruction in instructions:
        location = getOrNoneResolvedRemittanceLocation(instruction, companyId, companyPayrollEntityId)
        if not location:
            raise Exception(f"Could not resolve instruction {instruction} for {companyId} {companyPayrollEntityId}")
        resolvedPath.append(location.uniqueId)
    return resolvedPath


def hasSufficientFundsToFulfillIntent(
    intent: RemittanceIntentBean,
    asOf: Optional[datetime] = None,
) -> BoolWithReason:
    if not asOf:
        asOf = utc_now()

    companyId = intent.remittanceOrder.companyId
    payrollObjectId = intent.remittanceOrder.payrollObjectId
    payrollObjectType = intent.remittanceOrder.payrollObjectType
    payrollObject = getPayrollObjectInfo(companyId, payrollObjectId, payrollObjectType)

    # get payment system amounts
    debitAmountsPerCurrencyCode = getDebitAmountByCurrencyCodeForPayrollObjectId(payrollObject, asOf)
    creditAmountPerCurrencyCode = getCreditAmountByCurrencyCodeForPayrollObjectId(
        payrollObjectId=payrollObject.getPayrollObjectId(), includeRolePayments=True, asOf=asOf
    )

    # get the upcoming intents we would fulfill for this payroll object
    lv = contract_tasks.getOrNoneLatestCompanyLiabilityVersion(
        companyId=companyId, payrollObjectId=payrollObjectId, payrollObjectType=payrollObjectType
    )
    if not lv:
        raise Exception(
            f"Cannot find liability version for companyId {companyId} "
            f"payrollObjectId {payrollObjectId} payrollObjectType {payrollObjectType}"
        )

    otherReadyIntents = [
        i
        for i in contract_tasks.getRemittanceIntentsForLiabilityVersion(lv)
        if i.id != intent.id and i.currencyCode == intent.currencyCode and _isIntentReadyToFulfill(i)
    ]
    nextPaymentAmount = intent.amount + sum([i.amount for i in otherReadyIntents], start=Decimal(0))
    debitAmount = debitAmountsPerCurrencyCode.get(intent.currencyCode, Decimal(0))
    creditAmount = creditAmountPerCurrencyCode.get(intent.currencyCode, Decimal(0))
    if creditAmount + nextPaymentAmount > debitAmount:
        return BoolWithReason(
            False,
            f"creditAmount {creditAmount} + nextPaymentAmount {nextPaymentAmount} greater than debitAmount {debitAmount} "
            f"for currency {intent.currencyCode} payrollObjectId {payrollObject.getPayrollObjectId()}",
        )

    return BoolWithReason(True, "")


def hasCompletedRemittancePaymentsForPayrollObject(
    companyId: str, payrollObjectId: str, payrollObjectType: LiabilityPayrollObjectTypes, asOf: datetime
) -> BoolWithReason:
    # we need to get all the intents for the liability version (payroll object id)
    liabilityVersion = contract_tasks.getOrNoneLatestCompanyLiabilityVersion(
        companyId=companyId, payrollObjectId=payrollObjectId, payrollObjectType=payrollObjectType
    )
    if not liabilityVersion:
        raise Exception(f"Cannot find liability version for companyId {companyId} payrollObjectId {payrollObjectId}")
    allIntentsForLiabilityVersion = contract_tasks.getRemittanceIntentsForLiabilityVersion(liabilityVersion)
    expectedCreditAmountByCurrencyCode = stateless.sumExpectedIntentAmountsByCurrencyCodeAsOfFulfillmentDate(
        allIntentsForLiabilityVersion, asOf=asOf
    )
    actualPaymentAmountByCurrencyCode = getCreditAmountByCurrencyCodeForPayrollObjectId(
        payrollObjectId, includeRolePayments=False, asOf=asOf
    )
    for currencyCode, expectedAmount in expectedCreditAmountByCurrencyCode.items():
        actualAmount = actualPaymentAmountByCurrencyCode.get(currencyCode, Decimal(0))
        if actualAmount < expectedAmount:
            reason = f"Currency {currencyCode=} {actualAmount=} < {expectedAmount=} for {payrollObjectId=}"
            return BoolWithReason(False, reason)
    return BoolWithReason(True, "")


def getCreditAmountByCurrencyCodeForPayrollObjectId(
    payrollObjectId: str,
    includeRolePayments: bool,
    asOf: datetime,
    customFilters: Optional[Sequence[Callable[[PaymentOrder], bool]]] = None,
) -> Mapping[str, Decimal]:
    moneyFlowId = payment_service_contract.getOrNoneMoneyFlow(
        externalId=payrollObjectId,
        flowType=MoneyFlowTypes.GLOBAL_PAYROLL_RUN,
    )
    if not moneyFlowId:
        return {}

    moneyFlowOrders = payment_service_contract.getOrdersFromMoneyFlow(moneyFlowId=moneyFlowId)
    paymentOrders = moneyFlowOrders.paymentOrders
    paymentStatuses = payment_service_contract.getPaymentOrderStatuses(
        paymentOrderIds=[paymentOrder.id for paymentOrder in paymentOrders]
    )
    paymentStatusesByPaymentOrderId = {status.paymentOrderId: status.status for status in paymentStatuses}

    counterPartyBankAccountTypeFilter: Sequence[BankAccountHolderTypes] = []
    if not includeRolePayments:
        counterPartyBankAccountTypeFilter = BankAccountHolderTypesExceptRole

    paymentOrderCredit = stateless.getPaymentAmountByCurrencyCode(
        paymentOrders,
        paymentStatusesByPaymentOrderId,
        directionFilter=[PaymentOrderDirections.CREDIT],
        paymentStatusFilter=PaymentOrderInProgressOrSucceededStatuses,
        counterPartyBankAccountTypeFilter=counterPartyBankAccountTypeFilter,
        # todo: confirm 100% how to exclude internal transfer credits in the sum
        #       but for now this should work to unblock our validation that we are only counting
        #       terminal state credits.
        counterPartyPaymentAccountTypeFilter=[
            PaymentAccountTypes.BANK_ACCOUNT,
            PaymentAccountTypes.GOVERNMENT_AGENCY_VIRTUAL_ACCOUNT,
            PaymentAccountTypes.STRIPE_PAYMENT_ACCOUNT,
        ],
        customFilters=customFilters if customFilters else [],
        asOf=asOf,
    )
    return paymentOrderCredit


def getDebitAmountByCurrencyCodeForPayrollObjectId(
    payrollObjectInfo: PayrollObjectInfo,
    asOf: datetime,
) -> Mapping[str, Decimal]:
    moneyFlowId = payment_service_contract.getOrNoneMoneyFlow(
        externalId=payrollObjectInfo.getPayrollObjectId(),
        flowType=MoneyFlowTypes.GLOBAL_PAYROLL_RUN,
    )
    if not moneyFlowId:
        return {}

    moneyFlowStatus = payment_service_contract.getLatestMoneyFlowStatus(moneyFlowId=moneyFlowId)
    moneyFlowOrders = payment_service_contract.getOrdersFromMoneyFlow(moneyFlowId=moneyFlowId)
    paymentOrders = moneyFlowOrders.paymentOrders
    paymentStatuses = payment_service_contract.getPaymentOrderStatuses(
        paymentOrderIds=[paymentOrder.id for paymentOrder in paymentOrders]
    )
    paymentStatusesByPaymentOrderId = {status.paymentOrderId: status.status for status in paymentStatuses}

    fxOrders = payment_service_contract.getCurrencyExchangeOrdersFromMoneyFlow(moneyFlowId=moneyFlowId)
    fxOrderStatuses = payment_service_contract.getCurrencyExchangeOrderStatuses(
        currencyExchangeOrderIds=[fxFlowOrder.id for fxFlowOrder in fxOrders]
    )
    fxOrderStatusesByFxOrderId = {status.currencyExchangeOrderId: status.status for status in fxOrderStatuses}

    paymentOrderDebit = stateless.getPaymentAmountByCurrencyCode(
        paymentOrders,
        paymentStatusesByPaymentOrderId,
        directionFilter=[PaymentOrderDirections.DEBIT],
        paymentStatusFilter=PaymentOrderSucceededStatuses,
        counterPartyBankAccountTypeFilter=BankAccountHolderTypesExceptRole,
        counterPartyPaymentAccountTypeFilter=[],
        customFilters=[],
        asOf=asOf,
    )
    # Only include returned debits if the money flow is unlocked and the successful debit is 0.
    # In other cases, we will need a manual intervention.
    if not paymentOrderDebit and moneyFlowStatus.holdOrderExecutionType == HoldOrderExecutionTypes.UNLOCKED:
        returnedDebits = stateless.getPaymentAmountByCurrencyCode(
            paymentOrders,
            paymentStatusesByPaymentOrderId,
            directionFilter=[PaymentOrderDirections.DEBIT],
            paymentStatusFilter=[PaymentOrderCompletionStatusTypes.FAILED],
            counterPartyBankAccountTypeFilter=BankAccountHolderTypesExceptRole,
            counterPartyPaymentAccountTypeFilter=[],
            customFilters=[],
            asOf=asOf,
        )
        paymentOrderDebit = stateless.sumMappingStrDecimal(paymentOrderDebit, returnedDebits)

    fxOrderDebits = stateless.getFxOrderAmountByCurrencyCode(
        fxOrders,
        fxOrderStatusesByFxOrderId,
        orderTypeFilter=[CurrencyExchangeOrderTypes.BUY],
        paymentStatusFilter=PaymentOrderSucceededStatuses,
        asOf=asOf,
    )
    paymentOrderDebit = stateless.sumMappingStrDecimal(paymentOrderDebit, fxOrderDebits)

    amountDebitedFromOtherSourcesByCurrencyCode = payrollObjectInfo.getAmountDebitedFromOtherSourcesByCurrencyCode()
    paymentOrderDebit = stateless.sumMappingStrDecimal(paymentOrderDebit, amountDebitedFromOtherSourcesByCurrencyCode)

    return paymentOrderDebit


def syncExternalDependenciesForCompanyPayrollEntityId(companyPayrollEntityId: str) -> None:
    cpe = getCompanyPayrollEntity(companyPayrollEntityId)

    # handle LLA accounts for HQ companies
    syncHQCompanyPayrollEntityLLABank(cpe)

    updateLiabilitySetDoNotRemitSettings(cpe.companyId, cpe.id)


def syncHQCompanyPayrollEntityLLABank(companyPayrollEntity: CompanyPayrollEntity) -> BoolWithReason:
    if not isHQCompanyPayrollEntity(companyPayrollEntity):
        return BoolWithReason(False, "Not an HQ company")

    # sync the LLA accounts for the company
    fundingAccountId = getFundingBankAccountIdForCompanyPayrollEntity(companyPayrollEntity)
    if not fundingAccountId:
        return BoolWithReason(False, "No funding account found")

    if not legacy_payment_flow_functions.shouldLinkBankAccountForLocalLiability(
        companyId=companyPayrollEntity.companyId,
        companyPayrollEntityId=companyPayrollEntity.id,
        shouldIncludeHQLLA=True,
    ):
        return BoolWithReason(False, "Not a local liability company")

    llaBankAccount = legacy_payment_flow_functions.getOrNoneLocalLiabilityBankAccount(
        companyId=companyPayrollEntity.companyId,
        companyPayrollEntityId=companyPayrollEntity.id,
    )
    if not llaBankAccount or llaBankAccount.bankAccountId != fundingAccountId:
        legacy_payment_flow_functions.linkLocalLiabilityBankAccount(
            companyId=companyPayrollEntity.companyId,
            companyPayrollEntityId=companyPayrollEntity.id,
            bankAccountId=fundingAccountId,
        )
        return BoolWithReason(True, "Linked LLA bank account")

    return BoolWithReason(False, "LLA bank account already linked")


@eta_task_with_args(timeout=EtaTimeouts.DEFAULT)
def updateRemittanceIntentStatusWithPaymentStatus() -> None:
    remittanceVersionIds = contract_tasks.getAllInProgressRemittanceVersionIdsAcrossCompanies()
    process_parallelly_using_eta(
        updateRemittanceIntentStatusWithPaymentStatus_internal,
        cast(list, remittanceVersionIds),
        output_type=EtaGroupOutputType.LIST,
        timeout_seconds=EtaTimeouts.MINUTES_60,
    )


@eta_task_with_args(**RemittanceInternalEtaTaskKwargs)
def updateRemittanceIntentStatusWithPaymentStatus_internal(work_ids: List[str], **kwargs) -> None:
    for work_id in work_ids:
        remittanceVersionBean = contract_tasks.getOrNoneRemittanceVersionForId(work_id)
        if remittanceVersionBean is None:
            continue

        intents = getAllIntentsForRemittanceVersion(remittanceVersionBean)

        for intent in intents:
            intentStatus = contract_tasks.getLatestIntentStatus(intent)
            if not IntentStatusUtil.isInProgress(intentStatus.status):
                continue
            updateIntentStatusWithPaymentOrderStatus(intent, intentStatus)


def getDebitAmountForLiabilityCode(
    companyId: str, payrollObjectId: str, payrollObjectType: LiabilityPayrollObjectTypes, liabilityCode: str
) -> Optional[Decimal]:
    lv = contract_tasks.getOrNoneLatestCompanyLiabilityVersion(companyId, payrollObjectId, payrollObjectType)
    if not lv:
        return None
    return getDebitAmountForLiabilityCodeForLiabilityVersion(lv, liabilityCode)


def getDebitAmountForLiabilityCodeForLiabilityVersion(
    liabilityVersion: CompanyLiabilityVersionBean, liabilityCode: str
) -> Decimal:
    payrollObject = getPayrollObjectInfoForLiabilityVersion(liabilityVersion)
    if not stateless.isLiabilityCodeDebited(
        liabilityCode=liabilityCode,
        liabilitySet=liabilityVersion.liabilitySet,
        payoutSettings=payrollObject.getOrNoneRunPayoutSettings(),
        debitConfig=payrollObject.getOrNoneLatestRemittanceDebitConfig(),
    ):
        return Decimal(0)

    liability = contract_tasks.readOnlyGetLiabilityAmountForLiabilityVersionForLiabilityCode(
        liabilityVersion, liabilityCode
    )
    return liability.totalAmount


def refundRemittanceIntent(intent: RemittanceIntentBean, statementNarrative: str) -> BoolWithReason:
    if not (canFulfill := canFulfillIntent(intent, isRefund=True)):
        return canFulfill

    latestAttempt = contract_tasks.getOrNoneLatestPaymentAttemptForIntent(intent)
    version = latestAttempt.version + 1 if latestAttempt else 0
    paymentAttempt = computeRemittanceRefundAttempt(
        intent=intent,
        paymentMemo=PaymentMemoForRefund(paymentMemoType=PaymentMemoTypes.REFUND, text=statementNarrative),
        version=version,
    )

    # the intent will move to submitted
    return submitPaymentAttempt(intent, paymentAttempt)


def getDebitedAmountForLiabilityCodeInTimeRange(
    countryCode: str,
    liabilityCode: str,
    startDate: date,
    endDate: date,
    payrollEntityId: Optional[str] = None,
):
    if payrollEntityId:
        cpes = gp_contract.getCompanyPayrollEntities([payrollEntityId])
    else:
        cpes = gp_contract.getAllCompanyPayrollEntitiesNonDemoForCountryCode(countryCode)

    totalAmount = Decimal(0)
    for cpe in cpes:
        totalAmount += getDebitedAmountForLiabilityCodeInTimeRangeForCompanyPayrollEntity(
            cpe.id, liabilityCode, startDate, endDate
        )
    return totalAmount


def getDebitedAmountForLiabilityCodeInTimeRangeForCompanyPayrollEntity(
    companyPayrollEntityId: str,
    liabilityCode: str,
    startDate: date,
    endDate: date,
) -> Decimal:
    liabilityVersionsInTimeRange = contract_tasks.getCompanyLiabilityVersionsInTimeRange(
        companyPayrollEntityId, startDate, endDate
    )
    totalAmount = Decimal(0)
    for liabilityVersion in liabilityVersionsInTimeRange:
        totalAmount += getDebitAmountForLiabilityCodeForLiabilityVersion(liabilityVersion, liabilityCode)
    return totalAmount


def createRemittancePaymentActivityForSuperFilingDocument(
    companyId: str,
    payrollObjectId: str,
    payrollObjectType: LiabilityPayrollObjectTypes,
    filingDocumentId: str,
    amount: Decimal,
):
    from international_payroll.modules.remittances.legacy.countries.australia.contract import (
        getSuperFilingDocumentIdempotencyKey,
    )

    idempotencyKey = getSuperFilingDocumentIdempotencyKey(filingDocumentId)
    paymentOrder = payment_contract.getPaymentOrderFromExternalReferenceId(externalReferenceId=idempotencyKey)

    if paymentOrder is None:
        return

    orderStatus = payment_contract.getPaymentOrderStatus(paymentOrder.id)
    paymentActivityStatus = RemittancePaymentActivityStatus.PLANNED
    if orderStatus.status == PaymentOrderCompletionStatusTypes.IN_PROGRESS:
        paymentActivityStatus = RemittancePaymentActivityStatus.IN_PROGRESS
    elif orderStatus.status == PaymentOrderCompletionStatusTypes.SUCCEEDED:
        paymentActivityStatus = RemittancePaymentActivityStatus.SENT

    if paymentActivityStatus is None:
        return

    liabilityCode = RemittanceLiabilityCodes.AU_SUPERANNUATION
    runDetails = run_management.getRunDetailsForRunId(companyId, payrollObjectId)
    activity = RemittancePaymentActivity(
        id=None,
        companyId=companyId,
        groupKey=f"{liabilityCode}_{payrollObjectId}",
        groupItem=str(paymentOrder.id),
        liabilityCode=liabilityCode,
        payrollObjectId=payrollObjectId,
        payrollObjectType=payrollObjectType,
        status=paymentActivityStatus,
        companyPayrollEntityId=runDetails.companyPayrollEntityId,
        currencyCode="AUD",
        displayInfo=RemittancePaymentActivityDisplayInfo(
            method=RemittancePaymentActivityPaymentMethod.DIRECT_DEBIT,
            agencyName=RemittancePaymentActivityAgencyDisplayName.SUPER_CHOICE,
        ),
        amount=amount,
        createdAt=None,
        version=0,
    )
    contract_tasks.createRemittancePaymentActivity(activity)


def createRemittancePaymentActivityForRemittanceVersionId(remittanceVersionId: str) -> None:
    remittanceVersion = contract_tasks.getOrNoneRemittanceVersionForId(remittanceVersionId)
    if not remittanceVersion:
        raise Exception(f"Cannot find remittance version for id {remittanceVersionId}")

    orders = contract_tasks.getRemittanceOrdersForRemittanceVersion(remittanceVersion)

    for order in orders:
        intents = contract_tasks.getRemittanceIntentsForRemittanceOrder(order)
        if len(intents) == 0:
            return

        if len(intents) > 1:
            logger.error("We can only create PaymentActivity of remittance order has more than 1 intent")
            return

        intent = intents[0]

        intentStatusBean = contract_tasks.getLatestIntentStatus(intent)
        status = intentStatusBean.status

        paymentActivityStatus = None
        if IntentStatusUtil.isJustCreated(status):
            paymentActivityStatus = RemittancePaymentActivityStatus.PLANNED
        elif IntentStatusUtil.isSuccessfullySubmitted(status):
            paymentActivityStatus = RemittancePaymentActivityStatus.IN_PROGRESS
        elif IntentStatusUtil.isCompleted(status):
            paymentActivityStatus = RemittancePaymentActivityStatus.SENT

        if paymentActivityStatus is not None:
            activity = computeRemittancePaymentActivityFromRemittanceOrder(
                intent.remittanceOrder, paymentActivityStatus
            )
            contract_tasks.createRemittancePaymentActivity(activity)
