using AccountsDAL.CustomCode.Voucher; using AccountsDAL.DTO.Voucher; using Dapr.Client; using GB5Shared.DaprCache; using GB5Shared.DTO.Framework.AutoNumber; using GB5Shared.DTO.Framework.Criteria; using GB5Shared.DTO.Framework.HybridCache; using GB5Shared.DTO.Framework.Login; using GB5Shared.GenerateAutoNumber; using GB5Shared.QueryExecutor; using GB5Shared.Resource.Response; using GB5Shared.Telemetry; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using System.Data; using System.Linq.Expressions; using System.Reflection; using System.Threading; using static GB5Shared.GB5Constant.Constant; namespace AccountsBLL.Voucher { public class VoucherBLL : IVoucherBLL { private readonly IVoucherDAL _VoucherDAL; private readonly AutoNumber _AutoNumber; private readonly KeyInvalidate _KeyInvalidate; private readonly IQueryExecutor _QueryExecutor; private readonly VoucherNumberService _VoucherNumberService; private readonly DaprClient _DaprClient; private readonly Microsoft.Extensions.Logging.ILogger _Logger; public VoucherBLL( IVoucherDAL voucherDAL, AutoNumber autoNumber, KeyInvalidate keyInvalidate, IQueryExecutor queryExecutor, VoucherNumberService voucherNumberService, DaprClient daprClient, Microsoft.Extensions.Logging.ILogger logger) { _VoucherDAL = voucherDAL; _AutoNumber = autoNumber; _KeyInvalidate = keyInvalidate; _QueryExecutor = queryExecutor; _VoucherNumberService = voucherNumberService; _DaprClient = daprClient; _Logger = logger; } // ── Save/Update Voucher ─────────────────────────────────────────────── // Full-parity port of GB4 VoucherBLL.SaveVoucher (Voucher.svc's single POST / upsert, // keyed by VoucherId: 0 = insert, non-zero = update — GB4 has no separate Update endpoint). // Scope, per explicit direction: core save/update + full ValidateVoucher rules. NOT ported // (flagged as follow-ups, do not implement without asking first): inter-company mirror- // voucher creation, TDS-payment FK-drop/re-add + VoucherTDSPaymentArray linking, recursive // auto-generated "info voucher" for cheque-bounce tracking, TCLAIM/TPAYMENTDETAIL sync, // bank-account instrument auto-forming (this port fails validation instead of inventing a // default instrument row), "immediate against" bill-allocation auto-resolution, the // bill-allocation "reallocation" pass for orphaned against-references, client-specific // self-approval cleanup, and WorkFlowApproval/BulkImport special persistence paths. // VoucherVoucherNumber IS auto-generated on insert via VoucherNumberService (GB5Shared's // existing port of GB4's VnoGenerationDLL.GetNextVnoWithSameSession) — see the tx block // below. // // Persistence is explicit parameterized Dapper SQL in one transaction (see VoucherQB.cs's // Save/Update region) — GB4 itself does this via one NHibernate Session.Save/SaveOrUpdate // cascade, not a stored procedure; the previous GB5 scaffold assumed a stored procedure // (POST_VOUCHER_DOCUMENT) that was never authored or deployed and whose call was commented // out, so nothing ever actually persisted. Per explicit decision, this is not being // replaced with a new stored procedure either. private const int OPENING_BIZ_TRANSACTION_CLASS_ID = -1399999983; private const int BATCH_ALLOCATION_BIZ_TRANSACTION_CLASS_ID = -1399999872; public async Task SaveVoucherAsync(VoucherDTO voucherDTO, LoginDTO loginDTO) { bool isInsert = voucherDTO.VoucherId == 0; int originalVoucherId = voucherDTO.VoucherId; var bizType = await _VoucherDAL.GetBizTransactionTypeForSave(voucherDTO.BizTransactionTypeId, loginDTO).ConfigureAwait(false) ?? throw new InvalidOperationException($"BizTransactionType not found for id {voucherDTO.BizTransactionTypeId}."); bool isOpeningClass = bizType.BizTransactionClassId == OPENING_BIZ_TRANSACTION_CLASS_ID; await ValidateVoucherAsync(voucherDTO, bizType, isOpeningClass, loginDTO).ConfigureAwait(false); // MAUTONUMBER only has rows for VOUCHER and INSTRUMENT entity codes on this DB — the // per-child-table entity codes (VOUCHERDETAIL/BILLALLOCATION/VOUCHERCOSTCENTER/ // VOUCHERTDS/VOUCHERADDON) were never seeded, confirmed live. Per explicit direction, // every child that doesn't have its own row draws from the SAME counter as the parent // header (AUTONUMBERCONSTANT.VOUCHER) — one linear sequence covering header + addons + // details + bill allocations + cost centers + TDS rows, reserved atomically as a single // block so there's no risk of two rows landing on the same id (unlike GB4's original // hand-rolled offset math, which this replicates the *intent* of — one shared counter — // without replicating the arithmetic bug). Instruments keep their own separate // AUTONUMBERCONSTANT.INSTRUMENT sequence since that row already exists and is shared // with other flows (ChequeBook/BRS). var counts = CountEntitiesForSave(voucherDTO, isInsert); int sharedCountNeeded = counts.SharedCount + (isInsert ? 1 : 0); var sharedTask = sharedCountNeeded > 0 ? _AutoNumber.GetNumberAsync(sharedCountNeeded, AUTONUMBERCONSTANT.VOUCHER, loginDTO) : Task.FromResult(null!); var instrumentTask = counts.TotalInstruments > 0 ? _AutoNumber.GetNumberAsync(counts.TotalInstruments, AUTONUMBERCONSTANT.INSTRUMENT, loginDTO) : Task.FromResult(null!); await Task.WhenAll(sharedTask, instrumentTask).ConfigureAwait(false); int sharedStartId = (await sharedTask)?.StartNumber ?? 0; int instrumentStartId = (await instrumentTask)?.StartNumber ?? 0; AssignIdsForSave(voucherDTO, sharedStartId, instrumentStartId, isInsert); var now = DateTime.UtcNow; if (isInsert) { voucherDTO.VoucherCreatedById = loginDTO.UserId; voucherDTO.VoucherCreatedOn = now; } voucherDTO.VoucherModifiedById = loginDTO.UserId; voucherDTO.VoucherModifiedOn = now; if (voucherDTO.VoucherStatus == 0) voucherDTO.VoucherStatus = 1; // default Active var tx = await _QueryExecutor.BeginTransactionAsync(loginDTO).ConfigureAwait(false); try { // Voucher-number generation (GB4's VnoGenerationDLL.GetNextVnoWithSameSession, // already ported to GB5Shared as VoucherNumberService, just never wired in here). // Only on insert — GB4's default OverRideVocuherNumber=true means the generated // number always replaces whatever the caller sent; on update the number is fixed // once assigned, so it's left untouched. Returns null when the BizTransactionType // is configured for Manual numbering — the caller's own VoucherVoucherNumber is // kept as-is in that case (and must be non-empty, per the CK_TVOUCHER_NO check // constraint — that's on the caller to supply for manual-numbering transaction // types; automatic types no longer need to send one). if (isInsert) { var generatedNumber = await _VoucherNumberService .GetNextVoucherNumberAsync(voucherDTO.BizTransactionTypeId, voucherDTO.VoucherVoucherDate, loginDTO, tx) .ConfigureAwait(false); if (generatedNumber != null) voucherDTO.VoucherVoucherNumber = generatedNumber; } if (string.IsNullOrEmpty(voucherDTO.VoucherReferenceNumber)) voucherDTO.VoucherReferenceNumber = voucherDTO.VoucherVoucherNumber; if (!isInsert) await _VoucherDAL.DeleteAllChildrenForVoucher(originalVoucherId, loginDTO, tx).ConfigureAwait(false); if (isInsert) await _VoucherDAL.InsertVoucherHeader(voucherDTO, loginDTO, tx).ConfigureAwait(false); else await _VoucherDAL.UpdateVoucherHeader(voucherDTO, loginDTO, tx).ConfigureAwait(false); foreach (var addon in voucherDTO.VoucherAddonArray ?? new List()) await _VoucherDAL.InsertVoucherAddon(addon, loginDTO, tx).ConfigureAwait(false); foreach (var detail in voucherDTO.VoucherDetailArray ?? new List()) { await _VoucherDAL.InsertVoucherDetail(detail, voucherDTO.VoucherVoucherDate, voucherDTO.VoucherValidTillDate, loginDTO, tx).ConfigureAwait(false); foreach (var ba in detail.VoucherBillAllocationArray ?? new List()) await _VoucherDAL.InsertBillAllocation(ba, loginDTO, tx).ConfigureAwait(false); foreach (var cc in detail.VoucherCostCenterArray ?? new List()) await _VoucherDAL.InsertCostCenter(cc, loginDTO, tx).ConfigureAwait(false); foreach (var ins in detail.VoucherInstrumentArray ?? new List()) await _VoucherDAL.InsertInstrument(ins, loginDTO, tx).ConfigureAwait(false); foreach (var tds in detail.VoucherTDSArray ?? new List()) await _VoucherDAL.InsertVoucherTDS(tds, loginDTO, tx).ConfigureAwait(false); foreach (var da in detail.VoucherDetailAddonArray ?? new List()) await _VoucherDAL.InsertVoucherAddon(da, loginDTO, tx).ConfigureAwait(false); } // Post-write DB objects, same order GB4 runs them in, inside the same transaction — // see VoucherQB.cs comment for confirmation these still exist on the GB5 DB. var validationRows = await _VoucherDAL .ExecutePostSaveDbObjects(voucherDTO.VoucherId, loginDTO.ClientId, loginDTO, tx) .ConfigureAwait(false); if (validationRows.Count != 0) throw new VoucherEndValidationException(validationRows); await _QueryExecutor.CommitAsync(tx).ConfigureAwait(false); } catch { await _QueryExecutor.RollbackAsync(tx).ConfigureAwait(false); throw; } // Best-effort only — the voucher is already committed at this point. KeyInvalidate. // AllInvalidateCache rethrows on any Dapr failure (sidecar not running/reachable is // common in local dev), which would otherwise surface a successful save as a failed // request. Matches the established pattern elsewhere in GB5 (see BaseEndpoint's cache // read/write paths) of treating Dapr unavailability as a logged warning, not an error. try { var cacheKey = CacheKeyGenerator.KeyGeneration( 0, EntityConstant.OBJECTVOUCHER, CacheKeyLevel.NOT_REQUIRED, string.Empty, loginDTO); await _KeyInvalidate.AllInvalidateCache(cacheKey).ConfigureAwait(false); } catch (Exception cacheEx) { _Logger.LogWarning(cacheEx, "Cache invalidation skipped after voucher save (VoucherId={VoucherId}) — Dapr unavailable.", voucherDTO.VoucherId); } // Best-effort warehouse delta event — the voucher is already committed at this point. // VoucherBLL stays warehouse-unaware; the subscriber in AccountsSL picks this up and // enqueues TWAREHOUSECHANGEQUEUE rows for POSTINGMODE=1 facts only. // ConnectionName = loginDTO.DatabaseName = MSERVERCONFIG.CONNECTIONNAME (set by // AuthenticationBLL for real user sessions). try { await _DaprClient.PublishEventAsync("pubsub", "accounting.voucher.saved", new { VoucherId = voucherDTO.VoucherId, OuId = voucherDTO.OrganizationUnitId, VoucherDate = voucherDTO.VoucherVoucherDate, TenantId = loginDTO.ClientId, ConnectionName = loginDTO.DatabaseName }).ConfigureAwait(false); } catch (Exception daprEx) { _Logger.LogWarning(daprEx, "Warehouse delta event skipped for VoucherId={VoucherId} — Dapr unavailable.", voucherDTO.VoucherId); } return (isInsert ? "Details Saved Successfully with Number :" : "Details Updated Successfully with Number :") + voucherDTO.VoucherVoucherNumber + " And Id:" + voucherDTO.VoucherId; } // Simplification over the old TVP-batch design (which called one stored procedure for the // whole batch): loop calling SaveVoucherAsync, each voucher its own transaction. The // stored-procedure/TVP dependency is gone entirely — see SaveVoucherAsync's header comment. public async Task> SaveBatchVoucherAsync(List vouchers, LoginDTO loginDTO) { if (vouchers == null || vouchers.Count == 0) return new List(); var results = new List(); foreach (var v in vouchers) results.Add(await SaveVoucherAsync(v, loginDTO).ConfigureAwait(false)); return results; } public async Task> SaveBulkVoucherAsync(List vouchers, LoginDTO loginDTO) => await SaveBatchVoucherAsync(vouchers, loginDTO).ConfigureAwait(false); // ── ValidateVoucher — full rule set, ported from GB4 VoucherBLL.ValidateVoucher ───────── // Some GB4 sub-conditions (e.g. the exact "Type != 2" bill-allocation exemption, and the // "unless multiple-cost-category is enabled" cost-center exemption) were only summarized, // not verbatim-confirmed, during research — approximated here and flagged inline rather // than guessed silently. Revisit if live testing surfaces a mismatch. private async Task ValidateVoucherAsync(VoucherDTO voucherDTO, BizTransactionTypeForSaveDTO bizType, bool isOpeningClass, LoginDTO loginDTO) { var details = voucherDTO.VoucherDetailArray ?? new List(); // Rule 2 — OU match: only enforced when the BizTransactionType restricts to one OU (OUType==0). if (bizType.OUType == 0 && bizType.OrganizationUnitId != voucherDTO.OrganizationUnitId) throw new InvalidOperationException("The selected transaction type is not applicable for this organization unit."); // Rule 1 — period date range (skipped for Opening class, matching GB4). if (!isOpeningClass) { var period = await _VoucherDAL.GetPeriodDateRange(voucherDTO.PeriodId, loginDTO).ConfigureAwait(false) ?? throw new InvalidOperationException($"Period not found for id {voucherDTO.PeriodId}."); if (voucherDTO.VoucherVoucherDate.Date < period.FromDate.Date || voucherDTO.VoucherVoucherDate.Date > period.ToDate.Date) throw new InvalidOperationException("Voucher date does not fall within the selected period."); } // Rule 4 — every line's amount must be non-zero. foreach (var d in details) if (d.VoucherDetailAmount == 0) throw new InvalidOperationException("Voucher amount cannot be zero."); // Rule 3 — total debit must equal total credit (skipped for Opening class). if (!isOpeningClass) { double debit = details.Where(d => d.VoucherDetailDetailType == 0).Sum(d => d.VoucherDetailAmount); double credit = details.Where(d => d.VoucherDetailDetailType == 1).Sum(d => d.VoucherDetailAmount); if (Math.Round(debit, 2) != Math.Round(credit, 2)) throw new InvalidOperationException($"Total Debit Amount ({debit}) and Total Credit Amount ({credit}) is not matching."); } // Rule 5 — batch-allocation class requires every line to share one account. if (bizType.BizTransactionClassId == BATCH_ALLOCATION_BIZ_TRANSACTION_CLASS_ID && details.Select(d => d.VoucherAccountId).Distinct().Count() > 1) throw new InvalidOperationException("All detail lines must use the same account for this transaction type."); var accountInfos = new Dictionary(); foreach (var accountId in details.Select(d => d.VoucherAccountId).Distinct()) { var info = await _VoucherDAL.GetAccountTypeInfo(accountId, loginDTO).ConfigureAwait(false); if (info != null) accountInfos[accountId] = info; } foreach (var d in details) { accountInfos.TryGetValue(d.VoucherAccountId, out var accountInfo); bool isBankAccount = accountInfo?.AccountType == 1; int instrumentCount = d.VoucherInstrumentArray?.Count ?? 0; // Rule 6 — instrument requirement + validity + amount match. if (isBankAccount && instrumentCount == 0) throw new InvalidOperationException($"Account {d.VoucherAccountCode} is a bank account and requires at least one instrument entry."); if (!isBankAccount && instrumentCount > 0) throw new InvalidOperationException($"Account {d.VoucherAccountCode} is not a bank account and cannot have instrument entries."); foreach (var ins in d.VoucherInstrumentArray ?? new List()) { if (ins.InstrumentId <= 0) throw new InvalidOperationException("Every instrument entry requires a valid instrument type."); if (isBankAccount && ins.BankAccountId != d.VoucherAccountId) throw new InvalidOperationException("An instrument's bank account must match the voucher line's account."); } if (instrumentCount > 0) { // Base-currency amount, not the *FC field — real client payloads only populate // the FC-suffixed fields when the line's CurrencyId differs from the base // currency; comparing FC-to-FC left this rule comparing 0 against a real amount // whenever a caller (correctly) omits FC fields for a same-currency line. double instrumentSum = d.VoucherInstrumentArray.Sum(i => i.VoucherInstrumentAmount); if (Math.Round(Math.Abs(instrumentSum), 2) != Math.Round(Math.Abs(d.VoucherDetailAmount), 2)) throw new InvalidOperationException($"Instrument amounts for account {d.VoucherAccountCode} do not sum to the line's amount."); } // Rule 7 — TDS rows require a real TDS category. foreach (var tds in d.VoucherTDSArray ?? new List()) if (tds.TDSCategoryId <= 0) throw new InvalidOperationException($"TDS category is required for account {d.VoucherAccountCode}."); // Rule 8 — bill-allocation requirement + amount match. bool billAllocationRequired = accountInfo != null && accountInfo.BillAllocationType != 0 && bizType.BillAccountType != 0; int billAllocCount = d.VoucherBillAllocationArray?.Count ?? 0; if (billAllocationRequired && billAllocCount == 0) throw new InvalidOperationException($"Bill allocation is required for account {d.VoucherAccountCode}."); if (billAllocCount > 0) { // Base-currency amount, not the *FC field — see instrument check above for why. double allocSum = d.VoucherBillAllocationArray.Sum(a => a.VoucherBillAllocationAllocatedAmount); if (Math.Round(Math.Abs(allocSum), 2) != Math.Round(Math.Abs(d.VoucherDetailAmount), 2)) throw new InvalidOperationException($"Bill allocation amounts for account {d.VoucherAccountCode} do not sum to the line's amount."); } // Rule 9 — cost-center requirement + amount match. bool costCenterRequired = bizType.IsCostCenter == 0 && d.VoucherDetailCostCenterApplicable == 0; int costCenterCount = d.VoucherCostCenterArray?.Count ?? 0; if (costCenterRequired && costCenterCount == 0) throw new InvalidOperationException($"Cost center allocation is required for account {d.VoucherAccountCode}."); if (costCenterCount > 0) { double ccSum = d.VoucherCostCenterArray.Sum(c => c.VoucherCostCenterAllocatedAmount); if (Math.Round(Math.Abs(ccSum), 2) != Math.Round(Math.Abs(d.VoucherDetailAmount), 2)) throw new InvalidOperationException($"Cost center allocations for account {d.VoucherAccountCode} do not sum to the line amount."); } } } // ── ID counting/assignment for Save (single voucher; batch just loops SaveVoucherAsync) ── // SharedCount covers everything drawing from the parent's own AUTONUMBERCONSTANT.VOUCHER // sequence (header + addons + details + bill allocations + cost centers + TDS rows) — see // the comment in SaveVoucherAsync for why. TotalInstruments is kept separate since // AUTONUMBERCONSTANT.INSTRUMENT already has its own MAUTONUMBER row. private record EntityCounts(int SharedCount, int TotalInstruments); private static EntityCounts CountEntitiesForSave(VoucherDTO v, bool isInsert) { int shared = 0, instruments = 0; shared += v.VoucherAddonArray?.Count ?? 0; foreach (var d in v.VoucherDetailArray ?? []) { shared++; // the detail row itself // On update, a bill allocation keeps its submitted (non-zero) id — only rows with // id==0 need a fresh one. On insert every id is 0, so this is equivalent to "all". shared += isInsert ? d.VoucherBillAllocationArray?.Count ?? 0 : d.VoucherBillAllocationArray?.Count(a => a.VoucherBillAllocationId == 0) ?? 0; shared += d.VoucherCostCenterArray?.Count ?? 0; shared += d.VoucherTDSArray?.Count ?? 0; shared += d.VoucherDetailAddonArray?.Count ?? 0; instruments += d.VoucherInstrumentArray?.Count ?? 0; } return new EntityCounts(shared, instruments); } // On insert, the header itself consumes the first id from sharedStartId, then every child // draws sequentially from that same running counter. On update, the header id is left // unchanged (already set on v.VoucherId) and sharedStartId is sized for children only — see // SaveVoucherAsync. Every child always gets a brand-new id EXCEPT bill allocations on update // whose VoucherBillAllocationId was already non-zero — those are preserved as-is, matching // GB4's one exception (avoids orphaning other vouchers' against-allocation references). private static void AssignIdsForSave(VoucherDTO v, int sharedStartId, int instrumentStartId, bool isInsert) { int sharedIdx = sharedStartId; int iIdx = instrumentStartId; if (isInsert) v.VoucherId = sharedIdx++; foreach (var addon in v.VoucherAddonArray ?? []) { addon.VoucherAddonId = sharedIdx++; addon.VoucherId = v.VoucherId; addon.VoucherLineId = -1; } foreach (var d in v.VoucherDetailArray ?? []) { d.VoucherDetailId = sharedIdx++; d.VoucherId = v.VoucherId; foreach (var ba in d.VoucherBillAllocationArray ?? []) { if (isInsert || ba.VoucherBillAllocationId == 0) ba.VoucherBillAllocationId = sharedIdx++; ba.VoucherLineId = d.VoucherDetailId; // CK_TBILLALLOCATION_ALLOCATIONLINEID forbids ALLOCATIONLINEID = -1 whenever // BILLALLOCATIONLINEID is a real (non -1) id — which it always is once // assigned above. A bill with no "against" reference (client sends -1, meaning // "new bill, not yet allotted against anything") must self-reference its own id // instead of the -1 sentinel. if (ba.AllocationLineId <= 0) ba.AllocationLineId = ba.VoucherBillAllocationId; // CK_TBILLALLOCATION_FULLAMOUNTFC (and the same pattern for other *FC columns // across TBILLALLOCATION/TINSTRUMENT) requires the FC-suffixed amount to be // non-zero whenever the row is meaningful — but real client payloads only ever // populate the base-currency field, leaving *FC at its 0 default. When there's // no real foreign-currency conversion (CurrencyConversion == 1, the common // case), FC amount is just the base amount — default it here rather than reject // a perfectly valid same-currency save. if (ba.VoucherBillAllocationFullAmountFC == 0) ba.VoucherBillAllocationFullAmountFC = ba.VoucherBillAllocationFullAmount; if (ba.VoucherBillAllocationAllocatedAmountFC == 0) ba.VoucherBillAllocationAllocatedAmountFC = ba.VoucherBillAllocationAllocatedAmount; } foreach (var cc in d.VoucherCostCenterArray ?? []) { cc.VoucherCostCenterId = sharedIdx++; cc.VoucherLineId = d.VoucherDetailId; } foreach (var ins in d.VoucherInstrumentArray ?? []) { ins.VoucherInstrumentId = iIdx++; ins.VoucherLineId = d.VoucherDetailId; // Same base→FC default as bill allocations above. if (ins.VoucherInstrumentFCAmount == 0) ins.VoucherInstrumentFCAmount = ins.VoucherInstrumentAmount; } foreach (var tds in d.VoucherTDSArray ?? []) { tds.VoucherTDSId = sharedIdx++; tds.VoucherLineId = d.VoucherDetailId; } foreach (var da in d.VoucherDetailAddonArray ?? []) { da.VoucherAddonId = sharedIdx++; da.VoucherId = v.VoucherId; da.VoucherLineId = d.VoucherDetailId; } } } // ── Read delegates ──────────────────────────────────────────────────── // Full-parity port of GB4 VoucherBLL.GetVoucher (Voucher.svc's bare GET /?VoucherId= // operation). Hydrates the full voucher edit/view payload — header + line details, each // line's bill allocations/cost centers/instruments/TDS rows/detail parameters, head-level // addons, cross-OU bill-allocation stitching, and TDS/cost-center applicability recomputed // at read time — then serializes to JSON here (matching GB4's own // CommonFunctionDAL.SerializeJosn(VoucherDTO) call inside the BLL, not the SL layer). // // Embedded business rules preserved from GB4 (see AccountsBLL.md/commit notes for detail): // - NotFoundException-equivalent when the header query returns no row. // - Head-vs-line addon partitioning by sentinel VoucherLineId = -1 (not a discriminator column). // - Cost-center applicability: a per-voucher BizTransactionType flag check // (GetVoucherBizTypeCostCenterRequiredCount) short-circuits to "applicable" when 0; // otherwise falls through to a per-line CostCategoryCondition-by-AccountGroup check. // - TDS applicability per line recomputed against the live MTDSCATEGORY table, not trusted // from the account's stored IsTDSApplicable flag. // - Cross-OU/inter-voucher stitching: when a detail line's InterVoucherId is set, its // bill allocations are re-fetched from the OTHER voucher's matching line (by // VoucherAccountId+VoucherId+VoucherAmount) and the Inter*/primary account fields are // swapped so the caller sees the account as belonging to the other OU's leg. public async Task GetVoucher(int voucherId, LoginDTO loginDTO) { var voucherDto = await _VoucherDAL.GetVoucherHeader(voucherId, loginDTO).ConfigureAwait(false); if (voucherDto == null) throw new InvalidOperationException("Voucher not found for the given VoucherId"); var details = await _VoucherDAL.GetVoucherDetails(voucherId, loginDTO).ConfigureAwait(false); var addons = await _VoucherDAL.GetVoucherAddons(voucherId, loginDTO).ConfigureAwait(false); var billAllocs = await _VoucherDAL.GetVoucherBillAllocationsForVoucher(voucherId, loginDTO).ConfigureAwait(false); var costCenters = await _VoucherDAL.GetVoucherCostCentersForVoucher(voucherId, loginDTO).ConfigureAwait(false); var instruments = await _VoucherDAL.GetVoucherInstrumentsForVoucher(voucherId, loginDTO).ConfigureAwait(false); var tdsRows = await _VoucherDAL.GetVoucherTDSForVoucher(voucherId, loginDTO).ConfigureAwait(false); var detailParams = await _VoucherDAL.GetVoucherDetailParameters(voucherId, loginDTO).ConfigureAwait(false); // Head-level addons: sentinel VoucherLineId = -1 (matches GB4's "VOUCHERDETAILID In (-1)"). voucherDto.VoucherAddonArray = addons.Where(a => a.VoucherLineId == -1).ToList(); voucherDto.VoucherDetailArray = details; // Cost-center applicability: one count for the whole voucher (its BizTransactionType), // and the set of AccountGroupIds that have a CostCategoryCondition row (fetched once, // checked in-memory per line — see GET_COST_CATEGORY_CONDITION_ACCOUNT_GROUP_IDS). int costCenterRequiredCount = await _VoucherDAL.GetVoucherBizTypeCostCenterRequiredCount(voucherId, loginDTO).ConfigureAwait(false); var costCategoryConditionGroupIds = costCenterRequiredCount == 0 ? new HashSet() : new HashSet(await _VoucherDAL.GetCostCategoryConditionAccountGroupIds(loginDTO).ConfigureAwait(false)); var tdsCategories = await _VoucherDAL.GetAllTDSCategoryAccounts(loginDTO).ConfigureAwait(false); foreach (var detail in details) { detail.VoucherDetailAddonArray = addons.Where(a => a.VoucherLineId == detail.VoucherDetailId).ToList(); detail.VoucherBillAllocationArray = billAllocs.Where(b => b.VoucherLineId == detail.VoucherDetailId).ToList(); detail.VoucherCostCenterArray = costCenters.Where(c => c.VoucherLineId == detail.VoucherDetailId).ToList(); detail.VoucherInstrumentArray = instruments.Where(i => i.VoucherLineId == detail.VoucherDetailId).ToList(); detail.VoucherTDSArray = tdsRows.Where(t => t.VoucherLineId == detail.VoucherDetailId).ToList(); detail.VoucherDetailParameterArray = detailParams.Where(p => p.ObjectId == detail.VoucherDetailId).ToList(); // ── TDS applicability recompute (read-time, not trusted from the account flag) ── var tdsMatch = tdsCategories.FirstOrDefault(t => t.DeducationAccountId == detail.VoucherAccountId || t.PaidAccountId == detail.VoucherAccountId || t.SufferedAccountId == detail.VoucherAccountId || t.ClaimAccountId == detail.VoucherAccountId); if (tdsMatch != null) { detail.VoucherAccountIsTDSApplicable = 0; detail.VoucherDeducationAccountId = tdsMatch.DeducationAccountId; detail.VoucherSufferedAccountId = tdsMatch.SufferedAccountId; detail.VoucherPaidAccountId = tdsMatch.PaidAccountId; detail.VoucherClaimedAccountId = tdsMatch.ClaimAccountId; } else { detail.VoucherAccountIsTDSApplicable = 1; } // ── Cost-center applicability ── detail.VoucherDetailCostCenterApplicable = costCenterRequiredCount == 0 || !costCategoryConditionGroupIds.Contains(detail.VoucherAccountAccountGroupId) ? 1 : 0; // ── Cross-OU / inter-voucher bill-allocation stitching ── if (detail.InterVoucherId != -1) { var interDetailId = await _VoucherDAL .GetInterVoucherDetailId(detail.InterAccountId, detail.InterVoucherId, detail.VoucherDetailAmount, loginDTO) .ConfigureAwait(false); if (interDetailId.HasValue) { var interBillAllocs = await _VoucherDAL .GetVoucherBillAllocationsForDetail(interDetailId.Value, loginDTO) .ConfigureAwait(false); if (interBillAllocs.Count != 0) { foreach (var b in interBillAllocs) b.VoucherBillAllocationId = 0; detail.VoucherBillAllocationArray = interBillAllocs; } } var otherAccount = await _VoucherDAL.GetAccountForInterSwap(detail.InterAccountId, loginDTO).ConfigureAwait(false); if (otherAccount != null) { detail.InterAccountId = detail.VoucherAccountId; detail.InterAccountCode = detail.VoucherAccountCode; detail.InterAccountName = detail.VoucherAccountName; detail.VoucherAccountId = otherAccount.AccountId; detail.VoucherAccountCode = otherAccount.AccountCode; detail.VoucherAccountName = otherAccount.AccountName; detail.VoucherAccountType = otherAccount.AccountType; detail.VoucherAccountBillAllocationType = otherAccount.BillAllocationType; detail.VoucherAccountAccountGroupId = otherAccount.AccountGroupId; detail.VoucherAccountAccountGroupAccountGroupAccountScheduleNature = otherAccount.AccountScheduleNature; detail.VoucherAccountIsTDSApplicable = otherAccount.IsTDSApplicable; } } } return JsonConvert.SerializeObject(voucherDto); } public async Task GetVoucherTDS(CriteriaDTO criteriaDTO, LoginDTO loginDTO) { try { return await _VoucherDAL.GetVoucherTDS(criteriaDTO, loginDTO).ConfigureAwait(false); } catch (Exception) { throw; } } public async Task GetSelectListVoucherTDS(CriteriaDTO criteriaDTO, LoginDTO loginDTO) { try { return await _VoucherDAL.GetSelectListVoucherTDS(criteriaDTO, loginDTO).ConfigureAwait(false); } catch (Exception) { throw; } } public async Task GetBillAllocation(int voucherId, LoginDTO loginDTO) { try { return await _VoucherDAL.GetBillAllocation(voucherId, loginDTO).ConfigureAwait(false); } catch (Exception) { throw; } } public async Task GetPendingBillAllocation(BillAllocationFilterDTO filterDTO, LoginDTO loginDTO) { try { return await _VoucherDAL.GetPendingBillAllocation(filterDTO, loginDTO).ConfigureAwait(false); } catch (Exception) { throw; } } public async Task GetSelectListBillAllocation(CriteriaDTO criteriaDTO, LoginDTO loginDTO) { try { return await _VoucherDAL.GetSelectListBillAllocation(criteriaDTO, loginDTO).ConfigureAwait(false); } catch (Exception) { throw; } } // Legacy GB4 VoucherBLL.PaymentEntryValidation runs this count but never acts on it — the // block that would reject on Count > 0 was deliberately commented out in production // (Bug #40438, 20 Nov 2023: "Bill Allocation lightbox screen / Document no"). Matching that // current (disabled) behavior exactly per explicit confirmation — always succeeds. The // count is still fetched (not just skipped) so re-enabling the block later is a one-line // change if the business decides to restore it. public async Task PaymentEntryValidation(string referenceNumber, LoginDTO loginDTO) { try { await _VoucherDAL.PaymentEntryValidationCount(referenceNumber, loginDTO).ConfigureAwait(false); return ""; } catch (Exception) { throw; } } public async Task GetSelectListVoucher(int FirstNumber, int MaxResult, CriteriaDTO criteriaDTO, LoginDTO loginDTO) { try { return await _VoucherDAL.GetSelectListVoucher(FirstNumber, MaxResult, criteriaDTO, loginDTO).ConfigureAwait(false); } catch (Exception) { throw; } } public async Task GetSelectListVoucherInstrument(CriteriaDTO criteriaDTO, LoginDTO loginDTO) { try { return await _VoucherDAL.GetSelectListVoucherInstrument(criteriaDTO, loginDTO).ConfigureAwait(false); } catch (Exception) { throw; } } // ── Opening Balance Save ────────────────────────────────────────────── public async Task SaveOpeningBalanceAsync(VoucherDTO voucherDTO, LoginDTO loginDTO) { // Opening balance goes through the same SaveVoucherAsync path as a normal voucher save // — the BizTransactionType's Opening class id (see OPENING_BIZ_TRANSACTION_CLASS_ID) // is what distinguishes it (skips the period/debit-credit validation rules). return await SaveVoucherAsync(voucherDTO, loginDTO).ConfigureAwait(false); } // ── Delete/Cancel Voucher ───────────────────────────────────────────── // Full-parity port of GB4 VoucherBLL.DeleteVoucher (Voucher.svc's DELETE // /?VoucherId=&CancelApplicable= operation). CancelApplicable: 0 = hard delete (removes // header + all children), 1 = soft "cancel" (STATUS=2 flip, header kept, most children // removed, TVOUCHERDETAIL deleted row-by-row per GB4's own comment — "for Trigger to // affect" — instead of one bulk DELETE). // // Deliberate fixes over GB4, per explicit direction: // - Existence check added (GB4 has none — a bad VoucherId would NullReferenceException // deep inside the BizTransactionType lookup instead of a clear error). // - TPARAMETERDETAIL rows for the voucher's lines are actually deleted (GB4's own delete // statement for this is dead code — a copy-paste bug re-executes the TBIZDIMENSION // delete string instead of the intended TPARAMETERDETAIL one; this port's // DeleteChildrenExceptDetailsForVoucher/DeleteAllChildrenForVoucher genuinely run it). // - Any CancelApplicable value other than 0/1 throws a clear validation error instead of // GB4's silent no-op that still reports "Details Deleted Successfully". // // Explicitly out of scope (flagged, matching boundaries already excluded from // SaveVoucherAsync — do not implement without asking first): // - Inter-company mirror-voucher cleanup (GB4's DeleteVcVoucher) — nothing in this port's // Save creates mirror vouchers, so there's nothing of ours to clean up. // - MLOADTEMPLATE / TPREVOUCHERSCHEDULE / TPAYMENTDETAIL / TCLAIM cleanup — same // claim/payment-detail-sync boundary already excluded from Save. // - Lock-setting / back-date validation (ILockChecking / CheckBackDateSettings) — same // boundary already excluded from Save's validation scope. // - WorkFlow removal (GB4's RemoveWorkFlow) — not wired up. // No stored procedures involved (confirmed — GB4's DeleteVoucher calls none), and no forced // no-op update is needed to fire TRG_VOUCHER_POST — the real DELETE/UPDATE this method // issues fires it on its own, unlike Save's INSERT which needed the trick. public async Task DeleteVoucherAsync(int voucherId, int cancelApplicable, LoginDTO loginDTO) { if (cancelApplicable != 0 && cancelApplicable != 1) throw new InvalidOperationException("CancelApplicable must be 0 (delete) or 1 (cancel)."); _ = await _VoucherDAL.GetVoucherHeader(voucherId, loginDTO).ConfigureAwait(false) ?? throw new InvalidOperationException("Voucher not found for the given VoucherId"); var tx = await _QueryExecutor.BeginTransactionAsync(loginDTO).ConfigureAwait(false); try { // Unconditional pre-cleanup, matching GB4's structure (runs before the // CancelApplicable branch, for both delete modes). await _VoucherDAL.DetachBillAllocationAgainstReferences(voucherId, loginDTO, tx).ConfigureAwait(false); await _VoucherDAL.DeleteBizDimensionForVoucher(voucherId, loginDTO, tx).ConfigureAwait(false); if (cancelApplicable == 0) { await _VoucherDAL.DeleteAllChildrenForVoucher(voucherId, loginDTO, tx).ConfigureAwait(false); await _VoucherDAL.DeleteVoucherHeader(voucherId, loginDTO, tx).ConfigureAwait(false); } else { await _VoucherDAL.DeleteChildrenExceptDetailsForVoucher(voucherId, loginDTO, tx).ConfigureAwait(false); var detailIds = await _VoucherDAL.GetVoucherDetailIds(voucherId, loginDTO).ConfigureAwait(false); foreach (var detailId in detailIds) await _VoucherDAL.DeleteVoucherDetailById(detailId, loginDTO, tx).ConfigureAwait(false); await _VoucherDAL.SoftCancelVoucherHeader(voucherId, loginDTO.UserId, DateTime.UtcNow, loginDTO, tx).ConfigureAwait(false); } await _QueryExecutor.CommitAsync(tx).ConfigureAwait(false); } catch { await _QueryExecutor.RollbackAsync(tx).ConfigureAwait(false); throw; } // Best-effort only — see the same reasoning in SaveVoucherAsync's cache-invalidation block. try { var cacheKey = CacheKeyGenerator.KeyGeneration( 0, EntityConstant.OBJECTVOUCHER, CacheKeyLevel.NOT_REQUIRED, string.Empty, loginDTO); await _KeyInvalidate.AllInvalidateCache(cacheKey).ConfigureAwait(false); } catch (Exception cacheEx) { _Logger.LogWarning(cacheEx, "Cache invalidation skipped after voucher delete (VoucherId={VoucherId}) — Dapr unavailable.", voucherId); } return "Details Deleted Successfully"; } // Ported from GB4 VoucherBLL.SaveUpdateOpeningVoucher (Voucher.svc's // /OpeningBalanceSaveUpdate/?BizTransactionTypeId=&OUId= operation) — architecturally // distinct from SaveOpeningBalanceAsync above: it never creates/updates a Voucher header, // it requires one to already exist for the given BizTransactionTypeId+OUId, and it // inserts/updates VoucherDetail (+ BillAllocation) rows one at a time rather than going // through the TVP full-document save. // // MAUTONUMBER has no rows for the per-child entity codes (VOUCHERDETAIL/BILLALLOCATION // confirmed live) — only VOUCHER and INSTRUMENT are seeded. Per explicit direction, both // new details and new bill allocations draw from the SAME AUTONUMBERCONSTANT.VOUCHER // sequence as the parent header, reserved as ONE atomic block (detail ids first, then // allocation ids continuing right after) so there's no risk of two rows landing on the // same id — same shared-counter intent as GB4's original design, without GB4's actual // position-based-offset bug (which could hand out the same id twice once more than one // detail carried allocations). public async Task SaveUpdateOpeningVoucher(List voucherDetails, int bizTransactionTypeId, int ouId, LoginDTO loginDTO) { if (voucherDetails == null || voucherDetails.Count == 0) return "Details Saved Successfully"; var firstPeriodId = await _VoucherDAL.GetFirstPeriodId(loginDTO).ConfigureAwait(false); if (firstPeriodId.HasValue && firstPeriodId.Value != loginDTO.WorkPeriodId) throw new InvalidOperationException("Please login into the first financial period from user settings and proceed."); var header = await _VoucherDAL.GetVoucherOnOU(bizTransactionTypeId, ouId, loginDTO).ConfigureAwait(false); int newDetailCount = voucherDetails.Count(d => d.VoucherDetailId == 0); int newAllocCount = voucherDetails.Sum(d => d.VoucherBillAllocationArray?.Count(a => a.VoucherBillAllocationId == 0) ?? 0); int totalNew = newDetailCount + newAllocCount; var sharedNumbers = totalNew > 0 ? await _AutoNumber.GetNumberAsync(totalNew, AUTONUMBERCONSTANT.VOUCHER, loginDTO).ConfigureAwait(false) : null; int nextDetailId = sharedNumbers?.StartNumber ?? 0; int nextAllocId = nextDetailId + newDetailCount; var tx = await _QueryExecutor.BeginTransactionAsync(loginDTO).ConfigureAwait(false); try { foreach (var detail in voucherDetails) { var allottedAmount = detail.VoucherBillAllocationArray?.Sum(a => Math.Abs(a.VoucherBillAllocationAllocatedAmount)) ?? 0; if (allottedAmount != 0 && allottedAmount != detail.VoucherDetailAmountFC) throw new InvalidOperationException($"Allocated amount does not match the voucher amount for account {detail.VoucherAccountId}."); bool isNewDetail = detail.VoucherDetailId == 0; int detailId = isNewDetail ? nextDetailId++ : detail.VoucherDetailId; if (isNewDetail) await _VoucherDAL.SaveOpeningVoucherDetail(detail, detailId, header?.VoucherId ?? detail.VoucherId, loginDTO, tx).ConfigureAwait(false); else await _VoucherDAL.UpdateOpeningVoucherDetail(detail, loginDTO, tx).ConfigureAwait(false); foreach (var allocation in detail.VoucherBillAllocationArray ?? new List()) { bool isNewAlloc = allocation.VoucherBillAllocationId == 0; int allocId = isNewAlloc ? nextAllocId++ : allocation.VoucherBillAllocationId; if (isNewAlloc) await _VoucherDAL.SaveOpeningBillAllocation(allocation, allocId, detailId, loginDTO, tx).ConfigureAwait(false); else await _VoucherDAL.UpdateOpeningBillAllocation(allocation, loginDTO, tx).ConfigureAwait(false); } } await _QueryExecutor.CommitAsync(tx).ConfigureAwait(false); return "Details Saved Successfully"; } catch { await _QueryExecutor.RollbackAsync(tx).ConfigureAwait(false); throw; } } // Ported from GB4 VoucherBLL.ReAccountPostFastAsynchronousCalling / ReAccounPostingFast // (Voucher.svc's ReAccountPostFast operation) — full delete + rebuild + recompute of // TACCOUNTPOST for the caller's current OU (LoginDTO.WorkOUId), scoped to one Period (or // every period, via the -1 sentinel) and one Account (or every account, via 0/-1). Runs // synchronously and does not send an email, per explicit direction — GB4's version was // fire-and-forget with an email-on-completion notification. public async Task ReAccountPostFast(int periodId, int accountId, LoginDTO loginDTO) { int normalizedAccountId = accountId == 0 ? -1 : accountId; await _VoucherDAL.ReAccountPostingFast(periodId, normalizedAccountId, loginDTO.WorkOUId, loginDTO).ConfigureAwait(false); return "Account post completed."; } // Ported from GB4 VoucherBLL.ReAccountPostFastAsynchronousCallingForAllOU / // ReAccountPostFastProcessAsync (Voucher.svc's ReAccountPostFastAllOU operation) — // confirmed in GB4 to be literally a loop over every active OU calling the exact same // single-OU worker used by ReAccountPostFast above; ported the same way here. public async Task ReAccountPostFastAllOU(int periodId, int accountId, LoginDTO loginDTO) { int normalizedAccountId = accountId == 0 ? -1 : accountId; var ouIds = await _VoucherDAL.GetActiveOUIds(loginDTO).ConfigureAwait(false); foreach (var ouId in ouIds) await _VoucherDAL.ReAccountPostingFast(periodId, normalizedAccountId, ouId, loginDTO).ConfigureAwait(false); return "Account post completed for all organizational units."; } // Ported from GB4 VoucherBLL.GetVoucherDetailForOpeningBalance (Voucher.svc's // /VoucherDetail/?BizTransactionTypeId=&OUId=&VoucherAccountId= operation) — read-side // sibling of SaveUpdateOpeningVoucher above. public Task GetVoucherDetailForOpeningBalance(int bizTransactionTypeId, int ouId, int voucherAccountId, LoginDTO loginDTO) => _VoucherDAL.GetVoucherDetailForOpeningBalance(bizTransactionTypeId, ouId, voucherAccountId, loginDTO); // Ported from GB4 VoucherBLL.CheckReferenceNumberDuplication (Voucher.svc's /Check/ // Reference/Number/Duplication/ operation). GB4 signaled "duplicate found" purely via a // thrown exception (empty-string return on success) — same shape kept here. public async Task CheckReferenceNumberDuplication(int ouId, int periodId, int bizTransactionTypeId, int accountId, string referenceNumber, LoginDTO loginDTO) { var existingVoucherNumber = await _VoucherDAL .FindDuplicateReferenceNumberVoucher(ouId, periodId, bizTransactionTypeId, accountId, referenceNumber, loginDTO) .ConfigureAwait(false); if (existingVoucherNumber != null) throw new InvalidOperationException($"This number {referenceNumber} is already used in {existingVoucherNumber}, please recheck."); return string.Empty; } // Ported from GB4 VoucherBLL.GetUsedBillAllocationForVoucher (Voucher.svc's // /GetUsedVoucherInBillAllocation/?VoucherId= operation). public Task GetUsedVoucherInBillAllocation(int voucherId, LoginDTO loginDTO) => _VoucherDAL.GetUsedVoucherInBillAllocation(voucherId, loginDTO); // Ported from GB4 VoucherBLL.GetVoucherSummaryInfo (Voucher.svc's // /Voucher/Summary/Info/?VoucherId= operation). public Task GetVoucherSummaryInfo(int voucherId, LoginDTO loginDTO) => _VoucherDAL.GetVoucherSummaryInfo(voucherId, loginDTO); // ── Cross-module posting bridges ────────────────────────────────────── // Builds and posts a Claim's settlement voucher: one Debit line per already-resolved // expense account, one Credit line for the employee advance account. The caller // (Maintenance's ClaimBLL) resolves ClaimDetail/Activity/Employee accounts itself — // this method only knows about accounts and amounts, never Claim/Activity semantics. public async Task PostClaimVoucherAsync(ClaimVoucherPostingRequestDTO request, LoginDTO loginDTO, CancellationToken ct) { try { GB5Trace.Step("validate-claim-voucher", new { request.ClaimId }); if (request.ExpenseLines == null || request.ExpenseLines.Count == 0) throw new ArgumentException("At least one expense line is required to post a claim voucher."); if (request.CreditAccountId <= 0) throw new ArgumentException("A valid credit (employee advance) account is required to post a claim voucher."); if (request.BizTransactionTypeId <= 0) throw new ArgumentException("A valid BizTransactionType is required to post a claim voucher."); var totalAmount = request.ExpenseLines.Sum(l => l.Amount); var details = request.ExpenseLines.Select((line, idx) => new VoucherDetailDTO { VoucherAccountId = line.AccountId, VoucherDetailAmount = line.Amount, VoucherDetailDetailType = 0, // Debit VoucherDetailVoucherLineNarration = request.Narration, VoucherDetailSlno = (short)(idx + 1) }).ToList(); details.Add(new VoucherDetailDTO { VoucherAccountId = request.CreditAccountId, VoucherDetailAmount = request.CreditAmount > 0 ? request.CreditAmount : totalAmount, VoucherDetailDetailType = 1, // Credit VoucherDetailVoucherLineNarration = request.Narration, VoucherDetailSlno = (short)(details.Count + 1) }); var voucherDTO = new VoucherDTO { OrganizationUnitId = request.OUId, PeriodId = request.PeriodId, BizTransactionTypeId = request.BizTransactionTypeId, VoucherVoucherDate = request.ClaimDate, VoucherValidTillDate = request.ClaimDate, VoucherVoucherAmount = totalAmount, VoucherNarration = request.Narration, ClaimIds = request.ClaimId.ToString(), VoucherDetailArray = details }; GB5Trace.Step("post-claim-voucher", new { request.ClaimId, totalAmount }); await SaveVoucherAsync(voucherDTO, loginDTO).ConfigureAwait(false); return voucherDTO.VoucherId; } catch (Exception ex) { GB5Trace.MarkFailed("post-claim-voucher-failed", ex); throw; } } // ── Bill Allocation Write ───────────────────────────────────────────── public async Task UpdateBillAllocation(VoucherBillAllocationDTO dto, LoginDTO loginDTO) { try { return await _VoucherDAL.UpdateBillAllocation(dto, loginDTO).ConfigureAwait(false); } catch (Exception) { throw; } } // Auto-distributes totalAmount across this account's pending bills, oldest ReferenceDate // first, until the amount is exhausted (matches legacy GB4 VoucherBLL.GetAutoPendingBillAllocation // exactly — including its field-reuse convention: each fetched bill's // VoucherBillAllocationAllocatedAmount carries its PENDING amount going into this loop, // then gets overwritten with the amount actually applied by this run). public async Task AutoPendingBillAllocation(double amount, BillAllocationFilterDTO filterDTO, LoginDTO loginDTO) { try { var pendingBills = await _VoucherDAL.AutoPendingBillAllocation(filterDTO, loginDTO).ConfigureAwait(false); var autoAssigned = new List(); decimal balanceAmt = 0; decimal remaining = Convert.ToDecimal(amount); foreach (var bill in pendingBills) { decimal pendingAmt = Convert.ToDecimal(bill.VoucherBillAllocationAllocatedAmount); balanceAmt = remaining - pendingAmt; // AllocationLineId/AllocationLineDetailNumber preserve a reference back to the // original bill line; VoucherBillAllocationId is reset to 0 because this row now // represents a NEW allocation line to be inserted, not the original pending row. bill.AllocationLineId = bill.VoucherBillAllocationId; bill.AllocationLineDetailNumber = bill.VoucherBillAllocationDetailNumber; bill.VoucherBillAllocationId = 0; bill.VoucherBillAllocationFullAmount = Convert.ToDouble(pendingAmt); if (balanceAmt <= 0) { // Remaining payment doesn't fully cover (or exactly covers) this bill — // apply whatever's left and stop; later bills get nothing. bill.VoucherBillAllocationAllocatedAmount = Convert.ToDouble(-remaining); autoAssigned.Add(bill); break; } // Payment more than covers this bill — pay it off fully, carry the remainder // into the next iteration. bill.VoucherBillAllocationAllocatedAmount = Convert.ToDouble(-pendingAmt); autoAssigned.Add(bill); remaining = balanceAmt; } var dto = new VoucherAutoBillAllocationDTO { VoucherBillAllocationDTOs = autoAssigned, BalanceAmount = balanceAmt > 0 ? Convert.ToDouble(balanceAmt) : 0 }; return JsonConvert.SerializeObject(dto); } catch (Exception) { throw; } } // ── BRS ─────────────────────────────────────────────────────────────── public async Task LoadBRS(int firstNumber, int maxResult, CriteriaDTO criteriaDTO, LoginDTO loginDTO, bool count = false) { try { return await _VoucherDAL.LoadBRS(firstNumber, maxResult, criteriaDTO, loginDTO, count).ConfigureAwait(false); } catch (Exception) { throw; } } public async Task UpdateBRS(List brsLoadDTOs, LoginDTO loginDTO) { try { return await _VoucherDAL.UpdateBRS(brsLoadDTOs, loginDTO).ConfigureAwait(false); } catch (Exception) { throw; } } // ── Re-Account Post ─────────────────────────────────────────────────── public async Task ReAccountPost(int voucherId, LoginDTO loginDTO) { try { return await _VoucherDAL.ReAccountPost(voucherId, loginDTO).ConfigureAwait(false); } catch (Exception) { throw; } } } }