Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da2bb46d5a | |||
| 843d1c3c51 | |||
| c59d55529f |
@@ -1,285 +0,0 @@
|
||||
# Accounting System Audit
|
||||
|
||||
> Living record of the accounting/GL audit and its remediation status.
|
||||
> **Keep this file updated** whenever an accounting finding is opened or closed — the
|
||||
> original audit was lost once because it was never written down. Don't let that happen again.
|
||||
|
||||
Last reconciled against code: **2026-06-19** (branch `dev`, commits through `9532812`).
|
||||
Verification at that point: `dotnet build` clean (0 errors); `dotnet test tests/PowderCoating.UnitTests` → **290 passed, 0 failed**.
|
||||
|
||||
---
|
||||
|
||||
## How the GL is modeled (context for the findings)
|
||||
|
||||
Balances are computed two different ways, and they must agree:
|
||||
|
||||
1. **At posting time** — controllers call `AccountBalanceService.DebitAsync/CreditAsync`, which
|
||||
mutate the denormalized `Account.CurrentBalance`.
|
||||
2. **At report time** — `FinancialReportService` (Trial Balance, Balance Sheet, P&L, AR/AP aging,
|
||||
statements) and `LedgerService` (per-account ledger + `RecalculateBalances`) recompute balances
|
||||
from source documents.
|
||||
|
||||
`LedgerService` also backs the **Balance Reconciliation report** (the detective control added in
|
||||
`c2cd19e`), which compares stored `CurrentBalance` vs the ledger-recomputed balance. Any account
|
||||
whose recompute path is incomplete will (a) be silently corrupted by a "Recalculate Balances" run
|
||||
and (b) produce misleading output in the reconciliation report.
|
||||
|
||||
---
|
||||
|
||||
## Resolved findings (this audit batch)
|
||||
|
||||
| # | Finding | Fix | Commit | Verified |
|
||||
|---|---------|-----|--------|----------|
|
||||
| **#4** | `Sales Tax Payable` (2200) was credited on every invoice but never relieved — the liability grew forever (no remittance flow). | `JournalEntries/SalesTaxPayment` GET+POST: DR 2200 / CR bank, balanced, in a transaction, honors the period lock and validates amount + bank-account tenancy. | `0c921ba` | Posting reviewed `JournalEntriesController:235-296`. Reporting needs no change (posted JE lines already flow through TB/BS/ledger). |
|
||||
| **#7** | Most report queries relied on the global tenant filter, which is **bypassed for SuperAdmin** → cross-company leakage on P&L / Balance Sheet / Trial Balance / aging / statements. | Explicit `CompanyId == companyId` predicate added to every query in `FinancialReportService`. | `9532812` | Confirmed: every query in `FinancialReportService.cs` carries an explicit `CompanyId` predicate. |
|
||||
| **#9a** | A cash refund posted **DR AR** (control up) while the customer subledger went down — opposite directions → guaranteed AR drift. | Refund now **reverses the sale**: DR Sales Returns (4960) + DR Sales Tax Payable (tax portion) / CR bank; AR untouched. Split centralized in `Core/Accounting/RefundAllocation.Split`. | `2a82a1d` | Posting (`InvoicesController.IssueRefund`/`CancelRefund`) and both recompute paths (`LedgerService` §5b, `FinancialReportService` TB/BS/P&L) all use `RefundAllocation.Split` — they agree by construction. |
|
||||
| **#9b** | Store-credit refunds & credit memos posted nothing to the GL on issue (only a `CreditMemo` + `Customer.CreditBalance`) → outstanding store credit invisible on the balance sheet. | New **2350 Customer Credits** liability. Issue: DR 4950 / CR 2350. Apply: DR 2350 / CR AR. Void remainder: DR 2350 / CR 4950. Updated in all 8 posting sites + TB/BS/P&L + ledger §12b. | `9ce3612` | Posting sites confirmed (`CreditMemosController` 180-185/262-270/314-320; `InvoicesController` store-credit path). Reporting `cmContraRevenue`/`cmIssuedNonVoided` math reviewed. |
|
||||
| — | No detective control to catch denormalized-balance drift. | **Balance Reconciliation report** (`Reports/Reconciliation`): per-account stored vs recomputed, plus AR/AP control-vs-subledger. | `c2cd19e` | Reviewed `FinancialReportService.GetBalanceReconciliationAsync`. NOTE: its usefulness is limited by O2 below. |
|
||||
|
||||
### Original numbering gap
|
||||
The commits reference findings **#4, #7, #9a, #9b** — implying a list of at least #1–#9. The original
|
||||
audit document was never persisted and is unrecoverable. Findings **#1, #2, #3, #5, #6, #8** cannot be
|
||||
matched to commits and their status is **unknown**. If the original list resurfaces, reconcile it here.
|
||||
|
||||
---
|
||||
|
||||
## Resolved findings (2026-06-19 remediation)
|
||||
|
||||
### O1 — Account 2300 mislabeled "Payroll Liabilities" while used as Customer Deposits — **RESOLVED**
|
||||
- **Root cause refined:** there is no payroll feature posting to 2300; the deposit GL code has always used
|
||||
2300 (resolved by number) as the Customer Deposits liability. Migration `AccountingDepositsGL` already
|
||||
renamed it to "Customer Deposits" for tenants that lacked a 2300, but its `NOT EXISTS` guard **skipped**
|
||||
pre-existing tenants, leaving them mislabeled. The two seed files still created new tenants with the
|
||||
wrong name.
|
||||
- **Fix (chosen approach: rename 2300, move payroll to a new 2400):**
|
||||
- Seed files now create **2300 = "Customer Deposits" (IsSystem)** and a separate **2400 = "Payroll
|
||||
Liabilities"** — `SeedDataService.Accounts.cs`, `SeedData.cs`.
|
||||
- `EnsureSystemAccountsAsync` self-heals: renames any 2300 still named "Payroll Liabilities" → "Customer
|
||||
Deposits" (preserving user renames) and ensures 2400 exists.
|
||||
- Migration `20260619233108_RenameDepositsAccountAddPayroll` does the same for existing tenants (rename
|
||||
only where Name is still the default; insert 2400 where missing). Down is best-effort (soft-deletes
|
||||
only empty 2400s; does not re-introduce the mislabel).
|
||||
- Account **number 2300 is unchanged**, so the deposit posting code (`DepositsController`,
|
||||
`InvoicesController`) needed no changes — its lookups are by `"2300"`.
|
||||
|
||||
### O2 — `LedgerService` never recomputed 4950 Sales Discounts → recalc corrupted it — **RESOLVED**
|
||||
- **Fix:** added a 4950 section to both `LedgerService.GetAccountLedgerAsync` and `ComputePriorBalanceAsync`
|
||||
that reproduces the *actual postings* so a balance recalc matches the stored balance:
|
||||
invoice discounts (DR at invoice date) + credit-memo issuance (DR full amount at issue) − the unapplied
|
||||
remainder of voided memos (CR at void). This mirrors what `AccountBalanceService` posts at
|
||||
`InvoicesController:849/1108/1629` and `CreditMemosController`, so the Balance Reconciliation report no
|
||||
longer shows false drift on 4950.
|
||||
- **Note / micro-discrepancy:** the ledger uses `memo.AmountApplied` for the voided remainder (matching the
|
||||
true posting), whereas `FinancialReportService` TB/BS derive the voided portion from applications against
|
||||
non-voided invoices. These differ only in the rare case of a voided memo applied to a since-voided
|
||||
invoice. Left as-is (report-side nuance, not O2's target); documented here so it isn't mistaken for a bug.
|
||||
- Regression test: `LedgerServiceTests.GetAccountLedgerAsync_SalesDiscounts4950_IncludesInvoiceDiscountsAndCreditMemoContraRevenue`.
|
||||
|
||||
Verification of O1+O2: `dotnet build` clean; `dotnet test tests/PowderCoating.UnitTests` → **291 passed**;
|
||||
migration applied to the dev database successfully.
|
||||
|
||||
---
|
||||
|
||||
### O3 — Write-path account lookups omitted explicit `CompanyId` — **RESOLVED**
|
||||
- Added `CompanyId` to every **GL posting-path** account lookup that determines where money posts:
|
||||
- `InvoicesController` — all account-resolver helpers (`GetCheckingAccountIdAsync`,
|
||||
`GetCustomerDepositsAccountIdAsync`, `GetSalesReturnsAccountIdAsync`, `GetCustomerCreditsAccountIdAsync`,
|
||||
`GetArAccountIdAsync`, `GetBadDebtAccountIdAsync`, `ResolveSalesTaxAccountIdAsync`,
|
||||
`GetSalesDiscountAccountIdAsync`, `GetGcLiabilityAccountIdAsync`) plus the bank-account and write-off
|
||||
expense dropdowns (scoped via `_tenantContext`).
|
||||
- `CreditMemosController` — Create/Apply/Void GL lookups (scoped via the in-scope `customer`/`invoice`/`memo`).
|
||||
- `GiftCertificatesController` — Create, BulkCreate, Void GL lookups + `GetGcLiabilityAccountIdAsync`.
|
||||
- `BillsController` — AP/expense account resolution that pre-fills `APAccountId` (Create + CreateFromPO).
|
||||
- `DepositsController` and `JournalEntriesController.SalesTaxPayment` already scoped correctly.
|
||||
|
||||
### O4 — Sales-tax remittance could over-remit (drive 2200 negative) — **RESOLVED**
|
||||
- `JournalEntriesController.SalesTaxPayment` (POST) now rejects any amount exceeding `taxAcct.CurrentBalance`
|
||||
(0.005 rounding tolerance), so a typo can no longer push Sales Tax Payable into an abnormal debit balance.
|
||||
|
||||
Verification of O3+O4: `dotnet build` clean; `dotnet test tests/PowderCoating.UnitTests` → **291 passed**.
|
||||
|
||||
### O5 — Gift Certificate Liability 2500 missing for new tenants / mislabeled on default company — **RESOLVED**
|
||||
- **Root cause (same shape as O1):** 2500 is resolved by number as the GC liability
|
||||
(`GiftCertificatesController`). The `AccountingGapsPhase2` migration seeded it for tenants existing at
|
||||
deploy, but (a) the per-tenant seeder `SeedDataService.Accounts.cs` never created a 2500, so tenants
|
||||
onboarded afterward had **no GC liability account** and GC GL postings silently no-op'd; and (b) the
|
||||
default-company seeder `SeedData.cs` created 2500 as **"Long-Term Loan"**, so that company's GC
|
||||
obligations were mislabeled (and the migration's `NOT EXISTS` guard skipped it).
|
||||
- **Fix:**
|
||||
- `SeedDataService.Accounts.cs` now seeds **2500 "Gift Certificate Liability" (IsSystem)**.
|
||||
- `SeedData.cs` now seeds 2500 as GC liability and moves the long-term loan to **2900**.
|
||||
- `EnsureSystemAccountsAsync` self-heals: renames any 2500 still named "Long-Term Loan" → "Gift
|
||||
Certificate Liability" (preserving user renames) and ensures a 2500 exists.
|
||||
- Migration `20260620002950_FixGiftCertificateLiabilityAccount`: moves long-term loan to 2900 where a
|
||||
2500="Long-Term Loan" exists and no 2900 is present; relabels the mislabeled 2500; safety-net inserts a
|
||||
2500 for any company lacking one. Non-destructive (no Id/number/balance changes); Down is best-effort.
|
||||
- Verified on the dev DB: existing 2500 GC-liability rows preserved; no spurious accounts added; build
|
||||
clean; migration applied; **291 unit tests pass**.
|
||||
|
||||
---
|
||||
|
||||
## Read-path account lookup sweep (Tier 1–3) — **RESOLVED**
|
||||
Completed the app-wide defense-in-depth pass: every `Accounts` lookup in a controller now carries an
|
||||
explicit `CompanyId` predicate, matching the standing rule in CLAUDE.md. ~19 lookups across 12 controllers:
|
||||
- **Tier 1 (write-path validation):** `AccountsController` duplicate account-number check on Create/Edit.
|
||||
- **Tier 2 (dropdowns/lists):** `AccountsController` (Index/year-end/parent dropdown), `BankReconciliations`,
|
||||
`Bills` (bank list + receipt-scan + suggest), `Budgets`, `CatalogItems`, `Expenses`, `FixedAssets`,
|
||||
`Inventory`, `JournalEntries` (chart dropdown), `Vendors`.
|
||||
- **Tier 3 (`accountIds.Contains` display maps):** `JournalEntries` Details, `Reports` budget vs actual,
|
||||
`VendorCredits` Details — scoped via the in-scope entity's `CompanyId` for uniformity.
|
||||
`companyId` source per controller: `_tenantContext.GetCurrentCompanyId()` where available, else the
|
||||
in-scope entity's `CompanyId`, else `_userManager` current user. Build clean; 291 unit tests pass.
|
||||
|
||||
---
|
||||
|
||||
## Whole-system re-audit (2026-06-19) — NEW findings, OPEN
|
||||
|
||||
Root cause shared by all three: the GL is kept two ways — **direct postings** via
|
||||
`AccountBalanceService.Debit/CreditAsync` (authoritative `Account.CurrentBalance`) and **recomputes**
|
||||
via `LedgerService` (drives `RecalculateAllAsync`) and `FinancialReportService` (drives the reports).
|
||||
Every posting type must be mirrored in *both* recompute engines or balances diverge. Several postings
|
||||
are **not** mirrored, so: (a) "Recalculate Balances" corrupts those accounts, (b) the Balance
|
||||
Reconciliation report shows false drift, and in one case (O7) the **Trial Balance does not balance**.
|
||||
O2 (Sales Discounts 4950) was the first instance found and fixed; these are the rest.
|
||||
|
||||
### O6 — Inventory consumption COGS not in the recompute — **RESOLVED (recompute approach)**
|
||||
- `JobsController` and `InventoryController` post **DR COGS / CR Inventory** when an item with both
|
||||
`CogsAccountId` and `InventoryAccountId` is consumed. The COGS posting fires only for `JobUsage` and
|
||||
`Waste` transaction types, and those types are created **only** at the two COGS-posting sites — so the
|
||||
consumption is exactly identifiable from `InventoryTransaction`. (This is why the recompute approach was
|
||||
used instead of the originally-planned JE+backfill: it reads existing transactions, so historical data is
|
||||
covered automatically with no fuzzy backfill.)
|
||||
- **Fix applied:**
|
||||
- Both posting sites now record the consumption at the effective (weighted-average) unit cost, so the
|
||||
transaction's `TotalCost` equals the COGS actually posted (the recompute reads `TotalCost`).
|
||||
- `LedgerService` (dated rows + prior balance): new section 12d — for a COGS or Inventory account, sum
|
||||
`TotalCost` of JobUsage/Waste transactions on items with both accounts (DR COGS / CR Inventory).
|
||||
- `FinancialReportService` **Trial Balance** (`cogsConsumptionByAcct` DR / `invConsumptionByAcct` CR) and
|
||||
**P&L** (accrual COGS) include consumption. Cash-basis P&L excludes it (cash recognises cost at purchase).
|
||||
- Regression test `GetAccountLedgerAsync_InventoryConsumption_DebitsCogsAndCreditsInventory`.
|
||||
- **Deliberately NOT changed — see O9:** the **Balance Sheet** inventory-asset line is left as-is because it
|
||||
already does not track inventory *purchases* (it expenses them through retained earnings), so relieving it
|
||||
for consumption alone would drive inventory negative or unbalance the sheet. That's a separate inventory
|
||||
*capitalization* policy decision (O9). TB and recalc are now correct and balanced; build clean; 293 tests pass.
|
||||
|
||||
### O7 — Gift-certificate redemptions credit AR but AR recompute omitted it — **RESOLVED**
|
||||
- `InvoicesController.ApplyGiftCertificate:3137` posts **DR 2500 GC Liability / CR AR** (no Payment row,
|
||||
no `AmountPaid` change — only `invoice.GiftCertificateRedeemed`). The **2500 debit IS** recomputed
|
||||
(GC redemptions), but the **AR credit is NOT**: AR recompute = `sum(Total) − Payments − CreditMemoApplications`
|
||||
in both `FinancialReportService` (BS line 358 / TB line 1129) and `LedgerService` (AR section).
|
||||
- **Impact:** because only one side of the entry is recomputed, the **Trial Balance is out of balance by the
|
||||
total GC redeemed**; Balance Sheet AR is overstated; recalc corrupts AR. (AR **Aging** is correct — it uses
|
||||
`BalanceDue`, which includes `GiftCertificateRedeemed`.) Active for any company that redeems GCs on invoices.
|
||||
- **Fix applied:** GC redemptions now subtracted from AR in both recompute engines — `FinancialReportService`
|
||||
Balance Sheet (`gcRedeemedBs`) and Trial Balance (`gcRedeemedTb`), and `LedgerService` AR section + prior
|
||||
balance. Mirrors the `cmApplied` treatment. Regression test
|
||||
`GetAccountLedgerAsync_AR_GiftCertificateRedemption_CreditsAccountsReceivable`. Build clean; 292 tests pass.
|
||||
|
||||
### O8 — Written-off invoices misstated in AR recompute — **RESOLVED**
|
||||
- `InvoicesController.WriteOff` already posts **DR Bad Debt / CR AR** *and creates a balanced posted
|
||||
JournalEntry* — so both recompute engines already pick the entry up via their JE-line sections. The real
|
||||
defect was narrower: `FinancialReportService` **excluded payments on written-off invoices** from AR credits
|
||||
and bank debits (4× `Status != InvoiceStatus.WrittenOff` filters). Because the write-off JE only credits the
|
||||
*unpaid balance*, excluding the earlier payments left the **paid** portion dangling as open AR (and
|
||||
understated the bank). `LedgerService` had no such filter, so the two engines disagreed.
|
||||
- **Fix applied:** removed all four `WrittenOff` exclusion filters in `FinancialReportService` (Balance Sheet
|
||||
+ Trial Balance, both the bank-deposit and AR-credit queries). Now: invoice `Total` (debit) − payments
|
||||
(credit) − write-off JE (credit) nets AR to zero, the payment counts in the bank, and bad debt is the JE
|
||||
debit — trial balance balances, and the two engines agree. No backfill needed (the write-off JE already
|
||||
exists for historical write-offs). AR Aging was already correct.
|
||||
|
||||
**Architectural note:** O2/O6/O7/O8 all stem from reports re-deriving balances from source documents in
|
||||
parallel with the live postings. The durable fix is to make **every** financial event post `JournalEntry`
|
||||
lines and drive all reports/recompute from those lines alone (single source of truth) — O8's write-off already
|
||||
does exactly this, which is why it was the cleanest. Worth considering before the ledger grows further.
|
||||
|
||||
### O9 — Inventory accounting policy — **RESOLVED (policy decision: expense at purchase / periodic)**
|
||||
- **Decision (owner, 2026-06-19):** materials (powder, consumables) are **expensed at purchase**, not
|
||||
capitalized as inventory-for-resale — this is a service business that *uses* materials to deliver a
|
||||
service, it does not sell inventory. So the **periodic** model is correct.
|
||||
- **Implication — no code change needed:**
|
||||
- The Balance Sheet correctly does **not** capitalize inventory (cost hits the P&L at purchase via the
|
||||
bill line categorized to a COGS/expense account). The earlier decision to leave the BS untouched stands.
|
||||
- Purchase receiving creates a `Purchase`-type `InventoryTransaction` that posts **no** GL — correct.
|
||||
- The **perpetual** consumption-COGS path (O6: `DR COGS / CR Inventory` on JobUsage/Waste) is **opt-in**:
|
||||
it only fires when an item has *both* `CogsAccountId` and `InventoryAccountId`, which are set **only via
|
||||
CSV import** (never by normal item creation). Under this policy those mappings should be left empty, so
|
||||
the path stays dormant. O6's recompute fix remains correct and harmless when unused.
|
||||
- **⚠ Footgun to avoid:** do **not** both expense powder at purchase (bill → COGS account) *and* map an
|
||||
item's `CogsAccountId` + `InventoryAccountId` — that would record the cost twice (once at purchase, once at
|
||||
consumption). Keep item COGS/Inventory account mappings empty under the expense-at-purchase policy.
|
||||
|
||||
#### O9 update (2026-06-20) — default GL accounts feature widened this surface
|
||||
- The original O9 note assumed item `CogsAccountId`/`InventoryAccountId` are "set only via CSV import, never
|
||||
by normal item creation." **That assumption no longer holds.** A new **Default Accounts** feature
|
||||
(Chart of Accounts → "Set Defaults", stored on `CompanyPreferences.Default{Revenue,Cogs,Inventory}AccountId`)
|
||||
pre-fills these fields on **normal** Inventory/Catalog item creation. A company that sets *both* a default
|
||||
COGS and a default Inventory account will have new items post `DR COGS / CR Inventory` on consumption —
|
||||
i.e. it opts the shop into the perpetual path.
|
||||
- **Why it's still safe:** the defaults are **null by default**, so nothing changes until a company
|
||||
deliberately sets them. The footgun (double-counting under expense-at-purchase) is surfaced with an inline
|
||||
warning on the Default Accounts card and in the Settings help article. Posting and balance-recompute both
|
||||
read the item's *stored* accounts, so there is **no recompute drift** — verified during the 2026-06-20 audit.
|
||||
- **Revenue default fallback:** invoice lines now fall back to `DefaultRevenueAccountId` (active only), then
|
||||
to account 4000 (`InvoicesController.Create`). Resolved at invoice-create time and stored on the
|
||||
`InvoiceItem`, so recompute stays consistent.
|
||||
|
||||
### 2026-06-20 audit — dropdown sub-type→type broadening + deposit account picker
|
||||
Reviewed after broadening account dropdowns from sub-type to parent `AccountType` and adding a user-selectable
|
||||
deposit account. **No ledger-drift bugs found.** Notes:
|
||||
- **Deposit account picker ↔ recompute:** consistent. Live posting debits the chosen `DepositAccountId`, and
|
||||
`LedgerService` reproduces the debit by `DepositAccountId == accountId` (lines ~78/724). Picking a non-default
|
||||
deposit account recomputes correctly.
|
||||
- **Bank / "pay-from" / bank-rec pickers — server-side guard added (2026-06-20):** the submitted account is
|
||||
now validated via `AccountGuard.IsValidMoneyAccountAsync` (active, company-owned, AccountType Asset or
|
||||
Liability) before any posting, at bill `RecordPayment` / `Create(payNow)` / `EditPayment`,
|
||||
`BankReconciliation.Create`, and deposit `Record`. Defense in depth against tampered/stale POSTs. Per the
|
||||
"trust the operator" decision this still allows e.g. A/R (an Asset) as a source — it only rejects
|
||||
non-money types (Revenue/Expense/Equity/COGS).
|
||||
- **Latent deposit imbalance — RESOLVED (2026-06-20):** a deposit saved with a null `DepositAccountId` posted
|
||||
`CR 2300` with no offsetting debit → unbalanced. `DepositsController.Record` now blocks recording when the
|
||||
`2300` Customer Deposits account exists but no deposit/bank account resolves (user must pick one). When `2300`
|
||||
doesn't exist (company not using accounting), no GL posts at all, so the deposit is still allowed through.
|
||||
- **Type/sub-type mismatch risk — RESOLVED (2026-06-20):** account `AccountType` is now **derived** from the
|
||||
chosen `AccountSubType` on create/edit via `AccountClassification.TypeForSubType` (single source of truth,
|
||||
also used by the Create pre-select), so the two can never disagree and the sub-type-based sign convention is
|
||||
always consistent with the displayed type. A read-only sweep of the dev DB (109 accounts) found **0** existing
|
||||
mismatches, so no repair tool was needed.
|
||||
|
||||
### 2026-06-20 — GL trial-balance integrity check (audit "#2")
|
||||
Ran an empirical per-company trial-balance net on stored `Account.CurrentBalance`
|
||||
(`SUM(debit-normal) − SUM(credit-normal)`, which must net to 0 for balanced books). **Both tenants are
|
||||
imbalanced**, but the cause is pre-existing data, **not** this session's changes.
|
||||
|
||||
- **Demo Company:** net Dr **$92,653.63** = **$89,500 opening-balance** entry without an offsetting equity
|
||||
line (normal demo-data artifact) + **$3,153.63** from postings.
|
||||
- **SCP Powder Coating** (opening balance $0, clean start): net Dr **$3,079.52**, entirely from postings.
|
||||
|
||||
**Root cause (forensics on SCP):** AR reconciles correctly (~invoices $21,496 − payments $18,314 ≈ stored
|
||||
$3,079), so AR posted on both sides. But **Revenue has $0 GL movement** (the 24 invoices are header-only —
|
||||
**0 line items** — so the per-`InvoiceItem` revenue credit never fires) and the **payment-side bank debit
|
||||
never posted** ($0 bank delta from 22 payments). This is the classic one-sided posting from
|
||||
(a) imported/header-only invoices and (b) postings to unconfigured (null) offset accounts, which
|
||||
`AccountBalanceService` silently skips. Same architectural class as O2/O6/O7/O8.
|
||||
|
||||
**Conclusion:** this session's work (default GL accounts, deposit guard, money-account guards, tenant sweep)
|
||||
**did not introduce the imbalance** — those changes are read-path + validation + defaults and actually
|
||||
*prevent* this bug class going forward (new invoices now fall back to a default revenue account; deposits/
|
||||
payments are guarded against null money accounts). The existing imbalance is legacy/imported data.
|
||||
|
||||
**Remediation options (owner's call — not auto-applied; SCP is live company data):**
|
||||
- `Recalculate Balances` re-derives from source docs but will **not** conjure revenue for header-only
|
||||
invoices (no line items to credit), so it won't fully fix SCP on its own.
|
||||
- Durable fix: the **JournalEntry single-source** refactor (`docs/ACCOUNTING_LEDGER_REFACTOR.md`) forces every
|
||||
event to post balanced lines.
|
||||
- Historical cleanup: correcting journal entries (or backfilling revenue/bank accounts on the imported docs
|
||||
then recomputing) — a deliberate data-remediation task.
|
||||
- Worth considering: surfacing this trial-balance net as a built-in "GL Health" indicator so drift is visible.
|
||||
|
||||
## Status
|
||||
**All findings O1–O9 + the read-path sweep are resolved** on `dev` (O9 by policy decision — expense at
|
||||
purchase — needing no code change). The optional structural follow-up is the JournalEntry single-source
|
||||
refactor (`docs/ACCOUNTING_LEDGER_REFACTOR.md`), which would prevent the O2/O6/O7/O8 bug class from recurring.
|
||||
The 2026-06-20 trial-balance check confirmed a pre-existing GL imbalance from legacy/imported one-sided
|
||||
postings (not from recent changes) — see above for remediation options.
|
||||
Original audit numbering #1–3/#5/#6/#8 remains unrecoverable (see top). Nothing merged to `master` yet.
|
||||
@@ -1,86 +0,0 @@
|
||||
# Accounting Deploy & Verification Checklist
|
||||
|
||||
> Repeatable steps to run whenever accounting changes ship (and a good periodic health check).
|
||||
> Goal: prove the books are internally consistent with **real data**, not just code review.
|
||||
> Companions: `docs/ACCOUNTING_AUDIT.md` (findings/decisions), `docs/ACCOUNTING_LEDGER_REFACTOR.md` (future).
|
||||
|
||||
The decisive invariant: **a Trial Balance whose total debits == total credits, with no drift on the
|
||||
Balance Reconciliation report, for every company.** If that holds, the ledger is sound by construction.
|
||||
|
||||
---
|
||||
|
||||
## 1. Pre-deploy (against the production DB, read-only)
|
||||
|
||||
Migrations are applied **manually** at deploy (the app does not auto-migrate). Two are pending from the
|
||||
2026-06 audit; apply in this order:
|
||||
|
||||
1. `RenameDepositsAccountAddPayroll` (O1 — renames 2300 → "Customer Deposits", adds 2400 Payroll)
|
||||
2. `FixGiftCertificateLiabilityAccount` (O5 — relabels mislabeled 2500 → GC Liability, adds 2900)
|
||||
|
||||
Both are non-destructive (no account Id / number / balance is changed; only relabels + additive inserts).
|
||||
Preview exactly what they'll touch (read-only — swap account numbers as needed):
|
||||
|
||||
```sql
|
||||
-- O1: 2300 rows that will be renamed (only those still named the default), and who gets a new 2400
|
||||
SELECT CompanyId, AccountNumber, Name, CurrentBalance,
|
||||
CASE WHEN Name = 'Payroll Liabilities' THEN 'WILL RENAME -> Customer Deposits' ELSE 'kept as-is' END AS Action
|
||||
FROM Accounts WHERE AccountNumber = '2300' AND IsDeleted = 0 ORDER BY CompanyId;
|
||||
|
||||
-- O5: 2500 rows that will be relabeled (only those still named "Long-Term Loan")
|
||||
SELECT CompanyId, AccountNumber, Name, CurrentBalance,
|
||||
CASE WHEN Name = 'Long-Term Loan' THEN 'WILL RELABEL -> Gift Certificate Liability' ELSE 'kept as-is' END AS Action
|
||||
FROM Accounts WHERE AccountNumber = '2500' AND IsDeleted = 0 ORDER BY CompanyId;
|
||||
```
|
||||
|
||||
## 2. Deploy
|
||||
|
||||
1. Merge `dev` → `master`, trigger the Jenkins production job.
|
||||
2. Apply the two migrations above (in order) to the production DB.
|
||||
|
||||
## 3. Post-deploy verification (per company — all of them)
|
||||
|
||||
Run for **every** company (there are ~7). Most is doable from the app UI under Reports / Finance.
|
||||
|
||||
- [ ] **Trial Balance** — open it. **Total Debits must equal Total Credits.** Any difference is a
|
||||
one-sided posting and must be investigated before trusting other reports.
|
||||
- [ ] **Balance Reconciliation report** (`/Reports/Reconciliation`) — every account's *stored* balance
|
||||
should match the *recomputed* balance (no drift highlighted). Also confirm:
|
||||
- AR control account == sum of customer balances (AR subledger).
|
||||
- AP control account == sum of vendor balances (AP subledger).
|
||||
- [ ] **Recalculate Balances**, then re-open the Trial Balance and Balance Reconciliation. This exercises
|
||||
the recompute paths the audit fixed (`LedgerService`). After a recalc:
|
||||
- Trial Balance still balances.
|
||||
- Reconciliation shows no drift (stored now == recomputed by definition; the point is TB stays balanced
|
||||
and the values look sane).
|
||||
|
||||
## 4. Spot-check the accounts the audit touched
|
||||
|
||||
For each company, glance at these on the Trial Balance / chart of accounts:
|
||||
|
||||
- [ ] **2300 Customer Deposits** — named correctly; balance == outstanding (un-applied) customer deposits.
|
||||
- [ ] **2400 Payroll Liabilities** — exists (likely 0 unless payroll is tracked).
|
||||
- [ ] **2500 Gift Certificate Liability** — named correctly; balance == outstanding GC value (issued − redeemed − voided).
|
||||
- [ ] **2900 Long-Term Loan** — present where the old 2500 was relabeled.
|
||||
- [ ] **4950 Sales Discounts / 4960 Sales Returns** — contra-revenue, show as debit-balance.
|
||||
- [ ] **AR** — for any company that uses gift certificates or has written off an invoice, confirm AR is not
|
||||
overstated (these were O7 / O8). Cross-check AR total against the AR Aging report.
|
||||
|
||||
## 5. If something is off
|
||||
|
||||
- A Trial Balance that doesn't balance → a posting hit only one side. Note the company + amount and check it
|
||||
against the findings in `docs/ACCOUNTING_AUDIT.md` (the resolved O2/O6/O7/O8 patterns) before assuming a new bug.
|
||||
- Drift on the Reconciliation report → run **Recalculate Balances**; if it persists, the recompute is missing a
|
||||
posting type (same class as the audit findings).
|
||||
- Do **not** treat a recalc as a fix for a real imbalance — it makes stored == recomputed, which can *hide* a
|
||||
one-sided posting if only one engine is wrong. The Trial Balance balancing is the real test.
|
||||
|
||||
## 6. Policy reminders (from the audit)
|
||||
|
||||
- **Inventory = expensed at purchase (periodic).** Do **not** map an item's `CogsAccountId` + `InventoryAccountId`
|
||||
(set only via CSV import) while also expensing powder at purchase — that double-counts COGS. Keep those empty.
|
||||
- Sales-tax remittance is capped at the outstanding 2200 balance (O4) — you cannot over-remit.
|
||||
|
||||
## When to repeat
|
||||
|
||||
- After any accounting feature change or import.
|
||||
- As a periodic health check (e.g. monthly), run Section 3 — it's cheap and catches drift early.
|
||||
@@ -1,118 +0,0 @@
|
||||
# Accounting Ledger Refactor — Single Source of Truth
|
||||
|
||||
> Status: **PROPOSED / not started.** This is a captured plan, not in-progress work.
|
||||
> Companion to `docs/ACCOUNTING_AUDIT.md`. Resolves the O2/O6/O7/O8 bug *class* and the open
|
||||
> finding **O9** (inventory capitalization). Do this when there's runway + a staging environment —
|
||||
> it is not an emergency; after audit fixes O1–O8 the current books are correct.
|
||||
|
||||
---
|
||||
|
||||
## Why
|
||||
|
||||
Today every financial event's GL effect is encoded in **three** places:
|
||||
|
||||
1. **`Account.CurrentBalance`** — updated live at each posting site via
|
||||
`AccountBalanceService.DebitAsync/CreditAsync`.
|
||||
2. **`LedgerService`** — re-derives each account's balance by reading *source documents* (Payments,
|
||||
Invoices, Bills, InventoryTransactions, GiftCertificates, CreditMemos, …). Drives
|
||||
`RecalculateAllAsync` and the Balance Reconciliation report.
|
||||
3. **`FinancialReportService`** — *independently* re-derives P&L, Balance Sheet, and Trial Balance from
|
||||
the same documents, with its own per-report logic (largely duplicated across the three reports).
|
||||
|
||||
Adding a transaction type means hand-mirroring its debit and credit into all three engines, in the right
|
||||
direction, in every report method. Missing one spot produced every finding in the audit:
|
||||
|
||||
| Finding | Missed mirror |
|
||||
|---|---|
|
||||
| O2 | 4950 Sales Discounts absent from `LedgerService` → recalc wiped it |
|
||||
| O6 | inventory-consumption COGS absent from both recompute engines |
|
||||
| O7 | GC redemption's AR credit absent → **Trial Balance did not balance** |
|
||||
| O8 | written-off payments filtered inconsistently between the two engines |
|
||||
|
||||
These are one design flaw with four faces: **reports re-interpret documents instead of reading a ledger.**
|
||||
|
||||
## Target
|
||||
|
||||
The journal becomes the only representation of GL effect. The tables already exist
|
||||
(`JournalEntry` + `JournalEntryLine`, with a `Posted` status).
|
||||
|
||||
- **Every** financial event posts a balanced `JournalEntry` (DR == CR enforced once, centrally).
|
||||
- `Account.CurrentBalance`, Trial Balance, Balance Sheet, P&L, and the recompute all derive from
|
||||
**`JournalEntryLine` only** (plus each account's opening balance).
|
||||
- Invoices/Payments/Bills remain *operational* records; they stop carrying implicit GL meaning that every
|
||||
report must re-decode.
|
||||
|
||||
Result: `LedgerService.GetAccountLedgerAsync` (~800 lines of per-document sections) collapses to
|
||||
"sum the JE lines for this account in range." Trial Balance / Balance Sheet become straight per-account JE
|
||||
aggregations. The special cases — `gcRedeemed`, `cmApplied`, refund splits, consumption COGS, discount
|
||||
contra-revenue — all disappear because they are simply journal lines.
|
||||
|
||||
## What changes in the code
|
||||
|
||||
1. **One posting service:** `PostAsync(date, description, lines[])` — validates balanced, assigns the
|
||||
`JE-YYMM-####` number, marks `Posted`, updates `CurrentBalance` from the lines. `AccountBalanceService`
|
||||
becomes a thin wrapper over it.
|
||||
2. **Convert every posting site** to build JE lines instead of ad-hoc Debit/Credit pairs: invoice create,
|
||||
payment, deposit record/apply, refund, credit memo issue/apply/void, GC issue/redeem/void, bill, bill
|
||||
payment, vendor credit, inventory consumption, write-off, sales-tax remittance, year-end close,
|
||||
depreciation, Stripe. Many already call `DebitAsync`; mechanical but touches **every accounting
|
||||
controller**. `InvoicesController.WriteOff` already posts a JE — copy that model everywhere.
|
||||
3. **Edits/voids become reversing entries** (post an opposite JE), never mutate in place.
|
||||
4. **Gut the re-derivation:** `LedgerService` → JE-only; `FinancialReportService` TB/BS/P&L → JE-only.
|
||||
AR/AP **aging** and customer/vendor statements stay document-driven (they answer "which documents are
|
||||
open," not "what is the GL balance"). The AR control account then auto-reconciles to the subledger
|
||||
because both come from the same postings.
|
||||
|
||||
## Migration of historical data
|
||||
|
||||
- **(a) Replay** — re-run every historical document through the new posting logic to emit JEs. Most
|
||||
faithful; must handle edits/voids in chronological order. Complex, higher risk.
|
||||
- **(b) Conversion balances (recommended)** — snapshot each account's current `CurrentBalance` as a single
|
||||
dated "opening balance" JE at a cutover date; only new events post JEs after that. This is how real
|
||||
systems migrate. With ~7 companies it is the pragmatic, low-risk path: pre-cutover per-transaction GL
|
||||
granularity is lost, but every balance is preserved exactly.
|
||||
|
||||
## Phased plan (verifiable, not big-bang)
|
||||
|
||||
- **Phase 0** — build `PostAsync` + balanced-entry invariant + unit tests.
|
||||
- **Phase 1** — route all posting sites through it, creating JEs *alongside* existing behavior (no report
|
||||
changes yet). The **Balance Reconciliation report is the regression harness** — it should show near-zero
|
||||
drift everywhere once every event posts a JE.
|
||||
- **Phase 2** — switch `LedgerService` to JE-only; confirm `RecalculateAllAsync` reproduces balances.
|
||||
- **Phase 3** — switch `FinancialReportService` (TB/BS/P&L) to JE-only; reconcile each report per company
|
||||
against the old numbers using the snapshot harness below.
|
||||
- **Phase 4** — post conversion-balance JEs at the cutover date; delete the dead re-derivation code
|
||||
(~1,000+ lines).
|
||||
- **Phase 5** — **O9 is already decided: expense at purchase (periodic).** Materials are used to deliver a
|
||||
service, not sold, so they are expensed when purchased (bill → COGS/expense account); inventory is **not**
|
||||
capitalized on the Balance Sheet. In the refactor, post material purchases as `DR COGS / CR AP` (expense
|
||||
immediately) and do **not** emit a separate consumption JE. The opt-in perpetual path (item
|
||||
`CogsAccountId` + `InventoryAccountId`) stays unused under this policy — keep those mappings empty to avoid
|
||||
double-counting. No perpetual inventory asset/relief postings.
|
||||
|
||||
## Prerequisites before starting
|
||||
|
||||
- A **report-snapshot/diff harness** to compare each company's P&L / Balance Sheet / Trial Balance before
|
||||
vs after each phase (the safety net for "numbers shifting").
|
||||
- A **staging copy** of production data to run the cutover against first.
|
||||
- A **decision on O9** (the inventory policy) — see `docs/ACCOUNTING_AUDIT.md`.
|
||||
|
||||
## Honest assessment
|
||||
|
||||
- **Effort:** the single biggest change in the accounting area — weeks, not hours, with careful per-company
|
||||
verification.
|
||||
- **Risk:** broad blast radius (every accounting controller); report numbers *will* shift slightly as they
|
||||
become consistent (the point), so it needs per-company sign-off.
|
||||
- **Optional:** after O1–O8 the current system is correct. The refactor buys *prevention* of this bug class,
|
||||
a real audit trail, and ~1,500 fewer lines of parallel logic. Higher ROI the more accounting features you
|
||||
keep adding.
|
||||
- **Safe stopping point:** Phases 0–1 alone (post JEs everywhere, watch the reconciliation report) are
|
||||
low-risk and immediately valuable. You can stop there and continue later.
|
||||
|
||||
## What it fixes
|
||||
|
||||
- Eliminates the O2/O6/O7/O8 bug class (one place to get each event right).
|
||||
- Trial Balance balances by construction.
|
||||
- Every balance is traceable to a journal entry (audit trail).
|
||||
- Resolves **O9** as part of Phase 5.
|
||||
- Turns the Balance Reconciliation report into a true integrity check rather than a drift detector.
|
||||
@@ -0,0 +1,8 @@
|
||||
# Build output
|
||||
bin/
|
||||
obj/
|
||||
|
||||
# Transient scrape artifacts
|
||||
*.tmp
|
||||
*.invalid-*.bak
|
||||
prismatic-sync.log
|
||||
@@ -0,0 +1,43 @@
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace PrismaticSync.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// A headless Chromium session with a realistic desktop fingerprint (UA, viewport, locale,
|
||||
/// timezone) — matching the original scraper's settings to look like a normal browser.
|
||||
/// </summary>
|
||||
public sealed class BrowserSession : IAsyncDisposable
|
||||
{
|
||||
private IPlaywright? _pw;
|
||||
private IBrowser? _browser;
|
||||
private IBrowserContext? _context;
|
||||
|
||||
public IPage Page { get; private set; } = null!;
|
||||
|
||||
public static async Task<BrowserSession> CreateAsync(bool headed)
|
||||
{
|
||||
var session = new BrowserSession();
|
||||
session._pw = await Playwright.CreateAsync();
|
||||
session._browser = await session._pw.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
|
||||
{
|
||||
Headless = !headed
|
||||
});
|
||||
session._context = await session._browser.NewContextAsync(new BrowserNewContextOptions
|
||||
{
|
||||
UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
|
||||
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||
ViewportSize = new ViewportSize { Width = 1365, Height = 900 },
|
||||
Locale = "en-US",
|
||||
TimezoneId = "America/New_York"
|
||||
});
|
||||
session.Page = await session._context.NewPageAsync();
|
||||
return session;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_context is not null) await _context.CloseAsync();
|
||||
if (_browser is not null) await _browser.CloseAsync();
|
||||
_pw?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Text.Json;
|
||||
using PrismaticSync.Models;
|
||||
|
||||
namespace PrismaticSync.Infrastructure;
|
||||
|
||||
/// <summary>Loads/saves the scrape output and the URL list, with atomic writes so a crash mid-save can't corrupt them.</summary>
|
||||
public static class JsonStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true };
|
||||
private static readonly JsonSerializerOptions ReadOptions = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
public static ScrapeOutput LoadOutput(string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
return new ScrapeOutput();
|
||||
|
||||
var json = File.ReadAllText(path);
|
||||
try
|
||||
{
|
||||
// Tolerate a bare array (older output format) as well as { results, errors }.
|
||||
if (json.TrimStart().StartsWith("["))
|
||||
{
|
||||
var results = JsonSerializer.Deserialize<List<ProductRecord>>(json, ReadOptions) ?? new();
|
||||
return new ScrapeOutput { Results = results };
|
||||
}
|
||||
return JsonSerializer.Deserialize<ScrapeOutput>(json, ReadOptions) ?? new ScrapeOutput();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var backup = $"{path}.invalid-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}.bak";
|
||||
File.Copy(path, backup, overwrite: true);
|
||||
throw new InvalidOperationException($"Could not parse {path}. Backed it up to {backup}. {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public static void SaveOutput(string path, ScrapeOutput data)
|
||||
{
|
||||
var tmp = path + ".tmp";
|
||||
File.WriteAllText(tmp, JsonSerializer.Serialize(data, WriteOptions));
|
||||
File.Move(tmp, path, overwrite: true);
|
||||
}
|
||||
|
||||
public static List<string> LoadUrls(string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
return new List<string>();
|
||||
|
||||
return File.ReadAllLines(path)
|
||||
.Select(CleanUrl)
|
||||
.Where(u => u.Length > 0 && !u.StartsWith("#"))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static void SaveUrls(string path, IEnumerable<string> urls)
|
||||
{
|
||||
var sorted = urls.Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(u => u, StringComparer.OrdinalIgnoreCase);
|
||||
var tmp = path + ".tmp";
|
||||
File.WriteAllText(tmp, string.Join(Environment.NewLine, sorted) + Environment.NewLine);
|
||||
File.Move(tmp, path, overwrite: true);
|
||||
}
|
||||
|
||||
public static string CleanUrl(string? url) =>
|
||||
(url ?? string.Empty).Split('?')[0].Split('#')[0].Trim();
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
namespace PrismaticSync.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal timestamped logger — writes to the console and appends to a rolling log file so an
|
||||
/// unattended (Task Scheduler) run leaves an audit trail. Intentionally dependency-free.
|
||||
/// </summary>
|
||||
public static class Log
|
||||
{
|
||||
private static string _logFile = "prismatic-sync.log";
|
||||
private static readonly object Gate = new();
|
||||
|
||||
public static void Configure(string logFile) => _logFile = logFile;
|
||||
|
||||
public static void Info(string message) => Write("INFO", message);
|
||||
public static void Warn(string message) => Write("WARN", message);
|
||||
public static void Error(string message) => Write("ERROR", message);
|
||||
|
||||
private static void Write(string level, string message)
|
||||
{
|
||||
var line = $"[{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ssZ}] {level,-5} {message}";
|
||||
|
||||
// Live console stream (visible on a manual run); color-code so warnings/errors stand out.
|
||||
lock (Gate)
|
||||
{
|
||||
var color = level switch
|
||||
{
|
||||
"WARN" => ConsoleColor.Yellow,
|
||||
"ERROR" => ConsoleColor.Red,
|
||||
_ => (ConsoleColor?)null
|
||||
};
|
||||
|
||||
if (color is { } c)
|
||||
{
|
||||
var previous = Console.ForegroundColor;
|
||||
Console.ForegroundColor = c;
|
||||
Console.WriteLine(line);
|
||||
Console.ForegroundColor = previous;
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(line);
|
||||
}
|
||||
|
||||
// File trail — never let logging break a run.
|
||||
try { File.AppendAllText(_logFile, line + Environment.NewLine); }
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
namespace PrismaticSync.Infrastructure;
|
||||
|
||||
/// <summary>Strongly-typed config bound from the "Sync" section of appsettings.json.</summary>
|
||||
public class SyncConfig
|
||||
{
|
||||
public string BaseUrl { get; set; } = "https://www.prismaticpowders.com";
|
||||
public string ColorsPath { get; set; } = "/shop/powder-coating-colors";
|
||||
|
||||
public string ProductUrlsFile { get; set; } = "product-urls.txt";
|
||||
public string OutputJsonFile { get; set; } = "prismatic_powders.json";
|
||||
public string LogFile { get; set; } = "prismatic-sync.log";
|
||||
|
||||
/// <summary>Politeness delay between product scrapes (randomized within the range).</summary>
|
||||
public int MinDelaySeconds { get; set; } = 6;
|
||||
public int MaxDelaySeconds { get; set; } = 14;
|
||||
|
||||
/// <summary>On a 403/block, cool down this many seconds × the consecutive-block count, then retry.</summary>
|
||||
public int BlockedCooldownSeconds { get; set; } = 120;
|
||||
|
||||
/// <summary>Upper bound on a single cooldown so escalation can't run away.</summary>
|
||||
public int BlockedCooldownMaxSeconds { get; set; } = 600;
|
||||
|
||||
/// <summary>How many times to cool-down-and-retry a blocked product before recording it as an error.</summary>
|
||||
public int BlockedMaxRetries { get; set; } = 3;
|
||||
|
||||
/// <summary>Take a longer rest after this many products (0 disables). Eases load and looks less robotic.</summary>
|
||||
public int LongRestEveryProducts { get; set; } = 150;
|
||||
|
||||
/// <summary>Length of the periodic long rest, in seconds.</summary>
|
||||
public int LongRestSeconds { get; set; } = 45;
|
||||
|
||||
/// <summary>Extra settle time after a product page loads before reading it.</summary>
|
||||
public int PageSettleSeconds { get; set; } = 4;
|
||||
|
||||
/// <summary>Pause after each scroll while a listing lazy-loads more items.</summary>
|
||||
public int ScrollWaitMs { get; set; } = 1500;
|
||||
|
||||
/// <summary>Hard cap on scrolls per listing, as a safety stop.</summary>
|
||||
public int MaxScrolls { get; set; } = 400;
|
||||
|
||||
/// <summary>Full discovery: stop a listing after this many scrolls add no new links.</summary>
|
||||
public int StopAfterNoNewScrolls { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Incremental discovery: stop the newest-first listing after this many consecutive scrolls
|
||||
/// that surfaced only already-known URLs — i.e. we've scrolled past the new products.
|
||||
/// </summary>
|
||||
public int StopAfterKnownScrolls { get; set; } = 8;
|
||||
|
||||
/// <summary>Color filter params used by full discovery.</summary>
|
||||
public string[] ColorParams { get; set; } = Array.Empty<string>();
|
||||
|
||||
public ImportConfig Import { get; set; } = new();
|
||||
|
||||
public string ColorsUrl => $"{BaseUrl.TrimEnd('/')}{ColorsPath}";
|
||||
}
|
||||
|
||||
/// <summary>Where and how to push the scraped catalog into the app.</summary>
|
||||
public class ImportConfig
|
||||
{
|
||||
/// <summary>Full URL of the app's token-authenticated catalog import endpoint.</summary>
|
||||
public string EndpointUrl { get; set; } = "";
|
||||
|
||||
/// <summary>Shared secret sent in the X-Import-Token header. Must match the app's config.</summary>
|
||||
public string Token { get; set; } = "";
|
||||
|
||||
/// <summary>Vendor name applied to every record on import.</summary>
|
||||
public string VendorName { get; set; } = "Prismatic Powders";
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PrismaticSync.Models;
|
||||
|
||||
/// <summary>
|
||||
/// On-disk scrape output. Shape matches the app's catalog import (a top-level "results" array of
|
||||
/// snake_case product records), so the JSON drops straight into the import endpoint. "errors" tracks
|
||||
/// failed URLs for resumable re-runs.
|
||||
/// </summary>
|
||||
public class ScrapeOutput
|
||||
{
|
||||
[JsonPropertyName("results")] public List<ProductRecord> Results { get; set; } = new();
|
||||
[JsonPropertyName("errors")] public List<ScrapeError> Errors { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>One scraped product, in the import's expected field shape.</summary>
|
||||
public class ProductRecord
|
||||
{
|
||||
[JsonPropertyName("sku")] public string Sku { get; set; } = "";
|
||||
[JsonPropertyName("color_name")] public string ColorName { get; set; } = "";
|
||||
[JsonPropertyName("description")] public string Description { get; set; } = "";
|
||||
[JsonPropertyName("price_tiers")] public List<PriceTier> PriceTiers { get; set; } = new();
|
||||
[JsonPropertyName("safety_data_sheet_url")] public string SafetyDataSheetUrl { get; set; } = "";
|
||||
[JsonPropertyName("technical_data_sheet_url")] public string TechnicalDataSheetUrl { get; set; } = "";
|
||||
[JsonPropertyName("application_guide_url")] public string ApplicationGuideUrl { get; set; } = "";
|
||||
[JsonPropertyName("sample_image_url")] public string SampleImageUrl { get; set; } = "";
|
||||
[JsonPropertyName("product_url")] public string ProductUrl { get; set; } = "";
|
||||
[JsonPropertyName("scraped_at")] public DateTime ScrapedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A quantity-break price tier — {min, max, price}. max is null for an open-ended top tier.</summary>
|
||||
public class PriceTier
|
||||
{
|
||||
[JsonPropertyName("min")] public int? Min { get; set; }
|
||||
[JsonPropertyName("max")] public int? Max { get; set; }
|
||||
[JsonPropertyName("price")] public decimal Price { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A URL that failed to scrape, kept so resumable runs can skip or retry it.</summary>
|
||||
public class ScrapeError
|
||||
{
|
||||
[JsonPropertyName("product_url")] public string ProductUrl { get; set; } = "";
|
||||
[JsonPropertyName("error")] public string Error { get; set; } = "";
|
||||
[JsonPropertyName("scraped_at")] public DateTime ScrapedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Standalone workstation tool — deliberately NOT part of PowderCoating.sln.
|
||||
Build/publish independently and run on a machine you control (Task Scheduler),
|
||||
never on the deployed app server. Scrapes Prismatic Powders and pushes the
|
||||
result into the app's catalog import endpoint.
|
||||
|
||||
First-time setup on a workstation:
|
||||
dotnet build
|
||||
pwsh bin/Debug/net8.0/playwright.ps1 install chromium
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AssemblyName>PrismaticSync</AssemblyName>
|
||||
<RootNamespace>PrismaticSync</RootNamespace>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Playwright" Version="1.49.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,106 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using PrismaticSync.Infrastructure;
|
||||
using PrismaticSync.Services;
|
||||
|
||||
// ── Load config ───────────────────────────────────────────────────────────────
|
||||
var configRoot = new ConfigurationBuilder()
|
||||
.SetBasePath(AppContext.BaseDirectory)
|
||||
.AddJsonFile("appsettings.json", optional: false)
|
||||
.Build();
|
||||
|
||||
var config = configRoot.GetSection("Sync").Get<SyncConfig>() ?? new SyncConfig();
|
||||
Log.Configure(config.LogFile);
|
||||
|
||||
// ── Parse args ────────────────────────────────────────────────────────────────
|
||||
var command = args.Length > 0 && !args[0].StartsWith("--") ? args[0].ToLowerInvariant() : "run";
|
||||
var headed = args.Contains("--headed");
|
||||
var retryErrors = args.Contains("--retry-errors");
|
||||
var maxProducts = GetIntArg("--max-products", 0);
|
||||
// "run" refreshes products older than 30 days by default; explicit commands default to new-only.
|
||||
var refreshOlderThanDays = GetIntArg("--refresh-older-than", command == "run" ? 30 : 0);
|
||||
|
||||
Log.Info($"PrismaticSync — command '{command}' (headed={headed}, refreshOlderThan={refreshOlderThanDays}d, maxProducts={maxProducts})");
|
||||
|
||||
try
|
||||
{
|
||||
switch (command)
|
||||
{
|
||||
case "discover-new":
|
||||
await WithBrowser(d => new PrismaticDiscoverer(d, config).DiscoverNewAsync());
|
||||
break;
|
||||
|
||||
case "discover-full":
|
||||
await WithBrowser(d => new PrismaticDiscoverer(d, config).DiscoverFullAsync());
|
||||
break;
|
||||
|
||||
case "scrape":
|
||||
await WithBrowser(d => new PrismaticScraper(d, config).ScrapeAsync(refreshOlderThanDays, maxProducts, retryErrors));
|
||||
break;
|
||||
|
||||
case "push":
|
||||
await new CatalogPusher(config).PushAsync();
|
||||
break;
|
||||
|
||||
case "run":
|
||||
// The scheduled default: find new colors, scrape new + stale, then push.
|
||||
await WithBrowser(async d =>
|
||||
{
|
||||
await new PrismaticDiscoverer(d, config).DiscoverNewAsync();
|
||||
await new PrismaticScraper(d, config).ScrapeAsync(refreshOlderThanDays, maxProducts, retryErrors);
|
||||
});
|
||||
await new CatalogPusher(config).PushAsync();
|
||||
break;
|
||||
|
||||
default:
|
||||
PrintUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
Log.Info("Done.");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error($"Fatal: {ex}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
async Task WithBrowser(Func<BrowserSession, Task> action)
|
||||
{
|
||||
await using var session = await BrowserSession.CreateAsync(headed);
|
||||
await action(session);
|
||||
}
|
||||
|
||||
int GetIntArg(string name, int fallback)
|
||||
{
|
||||
var prefix = name + "=";
|
||||
var found = args.FirstOrDefault(a => a.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
|
||||
return found is not null && int.TryParse(found[prefix.Length..], out var value) ? value : fallback;
|
||||
}
|
||||
|
||||
void PrintUsage()
|
||||
{
|
||||
Console.WriteLine(
|
||||
"""
|
||||
PrismaticSync — scrape Prismatic Powders and push to the app catalog.
|
||||
|
||||
Usage: PrismaticSync [command] [options]
|
||||
|
||||
Commands:
|
||||
run (default) discover-new + scrape (new + stale) + push
|
||||
discover-new Incremental discovery via newest-first sort (cheap; finds new colors)
|
||||
discover-full Full discovery across all color filters (heavy; reconciles the whole set)
|
||||
scrape Scrape product pages from the URL list (resumable)
|
||||
push Push the scraped JSON to the import endpoint
|
||||
|
||||
Options:
|
||||
--refresh-older-than=N Re-scrape products whose data is older than N days (default 30 for 'run')
|
||||
--max-products=N Cap products scraped this run (0 = no cap)
|
||||
--retry-errors Retry URLs previously recorded as errors
|
||||
--headed Show the browser window (debugging)
|
||||
|
||||
Config: appsettings.json (delays, file paths, import endpoint + token).
|
||||
First run on a new machine: dotnet build, then `pwsh bin/Debug/net8.0/playwright.ps1 install chromium`.
|
||||
""");
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
# PrismaticSync
|
||||
|
||||
A standalone .NET console tool that scrapes the Prismatic Powders catalog and pushes it into the
|
||||
Powder Coating Logix catalog import endpoint. It exists because Prismatic has **no API** (unlike
|
||||
Columbia Coatings) — so the data has to be scraped via browser automation.
|
||||
|
||||
> **Runs on a workstation you control — never on the deployed app server.** Scraping from the cloud
|
||||
> app's IP would get blocked and isn't appropriate. This tool is deliberately *not* part of
|
||||
> `PowderCoating.sln`; build and run it independently.
|
||||
|
||||
## First-time setup (per machine)
|
||||
|
||||
```powershell
|
||||
cd "scripts/Prismatic Data Scraper"
|
||||
dotnet build
|
||||
pwsh bin/Debug/net8.0/playwright.ps1 install chromium # one-time browser download
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```powershell
|
||||
dotnet run -- run # default: discover-new + scrape (new + stale >30d) + push
|
||||
dotnet run -- discover-new # cheap: find newly-added colors (newest-first, stops at known)
|
||||
dotnet run -- discover-full # heavy: crawl all color filters (reconcile whole set / removals)
|
||||
dotnet run -- scrape # scrape product pages from product-urls.txt (resumable)
|
||||
dotnet run -- scrape --refresh-older-than=30 # also re-scrape products older than 30 days (price changes)
|
||||
dotnet run -- push # push prismatic_powders.json to the import endpoint
|
||||
```
|
||||
|
||||
Options: `--max-products=N`, `--retry-errors`, `--headed` (show the browser for debugging).
|
||||
|
||||
Everything streams to the console live (warnings/errors in color) **and** to `prismatic-sync.log`.
|
||||
|
||||
## Operating model (suggested cadence)
|
||||
|
||||
| Run | Command | Cadence | Why |
|
||||
|-----|---------|---------|-----|
|
||||
| Find new colors | `run` (does discover-new + scrape-new) | Weekly | Cheap; Prismatic adds colors often |
|
||||
| Price refresh | `scrape --refresh-older-than=30` then `push` | Monthly | Re-scrapes stale products to catch price changes (slow, ~hours) |
|
||||
| Full reconcile | `discover-full` then `scrape` | Quarterly | Catches removed/discontinued colors |
|
||||
|
||||
A full scrape of ~5,000 products takes hours (polite delays). It saves after every product and is
|
||||
fully resumable, so stop/restart any time.
|
||||
|
||||
## Politeness / anti-block
|
||||
|
||||
Configurable in `appsettings.json`: randomized 6–14s base delay, an escalating **cooldown + retry on
|
||||
403** (so a temporary block doesn't get you hard-banned mid-run), and a periodic long rest. Leave
|
||||
these conservative — getting blocked is worse than being slow, and Prismatic is a partner.
|
||||
|
||||
## Pushing into the app
|
||||
|
||||
Set in `appsettings.json`:
|
||||
- `Sync.Import.EndpointUrl` → `https://<your-app>/PowderCatalog/ImportApi`
|
||||
- `Sync.Import.Token` → the same secret as the app's `CatalogImport:Token` config
|
||||
|
||||
The tool POSTs the JSON with an `X-Import-Token` header (and `X-Vendor-Name: Prismatic Powders`) to
|
||||
that endpoint, which authenticates the token and runs the records through the same upsert as the
|
||||
Columbia sync. If the endpoint/token isn't configured here, `push` is skipped and you upload
|
||||
`prismatic_powders.json` manually via the Powder Catalog admin page instead.
|
||||
|
||||
> **App side:** set `CatalogImport:Token` in the web app's config (Azure App Setting in prod). The
|
||||
> endpoint returns 401 until a token is set, so it's inert by default.
|
||||
|
||||
## Scheduling (Windows Task Scheduler)
|
||||
|
||||
Point a scheduled task at the published exe (or `dotnet run`). Example weekly task command:
|
||||
|
||||
```
|
||||
Program/script: C:\Tools\PrismaticSync\PrismaticSync.exe
|
||||
Arguments: run
|
||||
Start in: C:\Tools\PrismaticSync
|
||||
```
|
||||
|
||||
Publish a self-contained build to drop on the workstation:
|
||||
|
||||
```powershell
|
||||
dotnet publish -c Release -r win-x64 --self-contained false -o C:\Tools\PrismaticSync
|
||||
pwsh C:\Tools\PrismaticSync\playwright.ps1 install chromium
|
||||
```
|
||||
|
||||
## The long game
|
||||
|
||||
This is the interim path. The durable endgame is a real Prismatic **API** (the partnership), at which
|
||||
point this tool is replaced by a clean in-app sync like Columbia's — reusing the same upsert,
|
||||
propagation, and discontinued handling.
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Text;
|
||||
using PrismaticSync.Infrastructure;
|
||||
|
||||
namespace PrismaticSync.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Pushes the scraped JSON to the app's token-authenticated catalog import endpoint. When no
|
||||
/// endpoint is configured it no-ops (the JSON is still on disk for a manual upload), so the tool is
|
||||
/// useful before the endpoint exists.
|
||||
/// </summary>
|
||||
public class CatalogPusher
|
||||
{
|
||||
private readonly SyncConfig _config;
|
||||
|
||||
public CatalogPusher(SyncConfig config) => _config = config;
|
||||
|
||||
public async Task<bool> PushAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_config.Import.EndpointUrl))
|
||||
{
|
||||
Log.Warn($"No import endpoint configured (Sync.Import.EndpointUrl) — skipping push. " +
|
||||
$"Upload {_config.OutputJsonFile} manually via the Powder Catalog admin instead.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!File.Exists(_config.OutputJsonFile))
|
||||
{
|
||||
Log.Warn($"Output file {_config.OutputJsonFile} not found — nothing to push.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var json = await File.ReadAllTextAsync(_config.OutputJsonFile);
|
||||
Log.Info($"Pushing {_config.OutputJsonFile} to {_config.Import.EndpointUrl} (vendor: {_config.Import.VendorName})...");
|
||||
|
||||
using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, _config.Import.EndpointUrl);
|
||||
request.Headers.Add("X-Import-Token", _config.Import.Token);
|
||||
request.Headers.Add("X-Vendor-Name", _config.Import.VendorName);
|
||||
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await http.SendAsync(request);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
Log.Info($"Push succeeded ({(int)response.StatusCode}): {Trim(body)}");
|
||||
return true;
|
||||
}
|
||||
|
||||
Log.Error($"Push failed ({(int)response.StatusCode}): {Trim(body)}");
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error($"Push error: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Trim(string s) => s.Length > 500 ? s[..500] + "…" : s;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Playwright;
|
||||
using PrismaticSync.Infrastructure;
|
||||
|
||||
namespace PrismaticSync.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Discovers product URLs from the Prismatic color listing (infinite-scroll). Two modes:
|
||||
/// incremental (newest-first via <c>?category=created_at</c>, stop once we reach already-known
|
||||
/// URLs) for cheap frequent runs, and full (every color filter to the bottom) for occasional
|
||||
/// reconciliation. Both append to the URL list file.
|
||||
/// </summary>
|
||||
public class PrismaticDiscoverer
|
||||
{
|
||||
private static readonly Regex ProductUrlRegex =
|
||||
new(@"/shop/powder-coating-colors/[A-Z0-9-]+/", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
private readonly BrowserSession _session;
|
||||
private readonly SyncConfig _config;
|
||||
|
||||
public PrismaticDiscoverer(BrowserSession session, SyncConfig config)
|
||||
{
|
||||
_session = session;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Incremental discovery: crawl the newest-first listing and stop once a run of consecutive
|
||||
/// scrolls surfaces only already-known URLs — meaning we've scrolled past the new products.
|
||||
/// Returns the count of newly found URLs.
|
||||
/// </summary>
|
||||
public async Task<int> DiscoverNewAsync()
|
||||
{
|
||||
var known = new HashSet<string>(JsonStore.LoadUrls(_config.ProductUrlsFile), StringComparer.OrdinalIgnoreCase);
|
||||
var startCount = known.Count;
|
||||
Log.Info($"Incremental discovery (newest first). Known URLs: {startCount}");
|
||||
|
||||
await GotoAsync($"{_config.ColorsUrl}?category=created_at");
|
||||
|
||||
var knownStreak = 0;
|
||||
for (var i = 0; i < _config.MaxScrolls; i++)
|
||||
{
|
||||
var addedNew = 0;
|
||||
foreach (var link in await CollectProductLinksAsync())
|
||||
if (known.Add(link)) addedNew++;
|
||||
|
||||
JsonStore.SaveUrls(_config.ProductUrlsFile, known);
|
||||
knownStreak = addedNew == 0 ? knownStreak + 1 : 0;
|
||||
Log.Info($"Scroll {i + 1}: +{addedNew} new, total {known.Count}, known-streak {knownStreak}");
|
||||
|
||||
if (knownStreak >= _config.StopAfterKnownScrolls)
|
||||
{
|
||||
Log.Info("Reached known territory — stopping incremental discovery.");
|
||||
break;
|
||||
}
|
||||
|
||||
await ScrollAsync();
|
||||
}
|
||||
|
||||
var newCount = known.Count - startCount;
|
||||
Log.Info($"Incremental discovery done. New URLs: {newCount}; total {known.Count}");
|
||||
return newCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full discovery: crawl every color filter to the bottom. Heavier — use occasionally to
|
||||
/// reconcile the whole set (e.g. to notice colors that have been removed). Returns new URL count.
|
||||
/// </summary>
|
||||
public async Task<int> DiscoverFullAsync()
|
||||
{
|
||||
var known = new HashSet<string>(JsonStore.LoadUrls(_config.ProductUrlsFile), StringComparer.OrdinalIgnoreCase);
|
||||
var startCount = known.Count;
|
||||
Log.Info($"Full discovery across {_config.ColorParams.Length} color filters. Known URLs: {startCount}");
|
||||
|
||||
foreach (var color in _config.ColorParams)
|
||||
{
|
||||
Log.Info($"Color filter: {color}");
|
||||
try
|
||||
{
|
||||
await GotoAsync($"{_config.ColorsUrl}?color={Uri.EscapeDataString(color)}");
|
||||
|
||||
var noNew = 0;
|
||||
for (var i = 0; i < _config.MaxScrolls; i++)
|
||||
{
|
||||
var added = 0;
|
||||
foreach (var link in await CollectProductLinksAsync())
|
||||
if (known.Add(link)) added++;
|
||||
|
||||
JsonStore.SaveUrls(_config.ProductUrlsFile, known);
|
||||
noNew = added == 0 ? noNew + 1 : 0;
|
||||
if (noNew >= _config.StopAfterNoNewScrolls)
|
||||
break;
|
||||
|
||||
await ScrollAsync();
|
||||
}
|
||||
|
||||
Log.Info($"Color {color} done. Total {known.Count}");
|
||||
await _session.Page.WaitForTimeoutAsync(3000);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"Color {color} failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
var newCount = known.Count - startCount;
|
||||
Log.Info($"Full discovery done. New this run: {newCount}; total {known.Count}");
|
||||
return newCount;
|
||||
}
|
||||
|
||||
private async Task GotoAsync(string url)
|
||||
{
|
||||
await _session.Page.GotoAsync(url, new PageGotoOptions
|
||||
{
|
||||
WaitUntil = WaitUntilState.DOMContentLoaded,
|
||||
Timeout = 60000
|
||||
});
|
||||
await _session.Page.WaitForTimeoutAsync(_config.PageSettleSeconds * 1000);
|
||||
}
|
||||
|
||||
private async Task ScrollAsync()
|
||||
{
|
||||
await _session.Page.Mouse.WheelAsync(0, 2500);
|
||||
await _session.Page.WaitForTimeoutAsync(_config.ScrollWaitMs);
|
||||
}
|
||||
|
||||
private async Task<List<string>> CollectProductLinksAsync()
|
||||
{
|
||||
var hrefs = await _session.Page.EvalOnSelectorAllAsync<string[]>(
|
||||
"a", "els => els.map(a => a.href).filter(Boolean)");
|
||||
|
||||
return hrefs
|
||||
.Where(h => ProductUrlRegex.IsMatch(h))
|
||||
.Select(JsonStore.CleanUrl)
|
||||
.Where(u => u.Length > 0)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Playwright;
|
||||
using PrismaticSync.Infrastructure;
|
||||
using PrismaticSync.Models;
|
||||
|
||||
namespace PrismaticSync.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Scrapes individual Prismatic product pages into <see cref="ProductRecord"/>s. Resumable (skips
|
||||
/// already-scraped URLs, optionally retries past errors) and supports a refresh window so stale
|
||||
/// records get re-scraped to catch price changes. Saves after every product so a long run can be
|
||||
/// stopped and resumed safely, and logs continuously — including the delay between products — so a
|
||||
/// manual run always shows it's alive.
|
||||
/// </summary>
|
||||
public class PrismaticScraper
|
||||
{
|
||||
private static readonly Regex ProductUrlRegex =
|
||||
new(@"/shop/powder-coating-colors/[A-Z0-9-]+/", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly Regex SkuRegex =
|
||||
new(@"Item:\s*([A-Z0-9-]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly Regex DescRegex =
|
||||
new(@"Description:\s*(.*?)(WARNING:|What does this match\?|PRODUCT SUPPORT|PRODUCT COLLECTIONS|CUSTOMER SERVICE|$)",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
|
||||
private static readonly Regex PriceTierRegex =
|
||||
new(@"(\d+\s*-\s*\d+\s*lbs|\d+\s*\+\s*lbs)\s*\$([\d.]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly Regex RangeRegex = new(@"(\d+)\s*-\s*(\d+)", RegexOptions.Compiled);
|
||||
private static readonly Regex PlusRegex = new(@"(\d+)\s*\+", RegexOptions.Compiled);
|
||||
private static readonly Regex WhitespaceRegex = new(@"\s+", RegexOptions.Compiled);
|
||||
|
||||
private readonly BrowserSession _session;
|
||||
private readonly SyncConfig _config;
|
||||
private readonly Random _random = new();
|
||||
|
||||
public PrismaticScraper(BrowserSession session, SyncConfig config)
|
||||
{
|
||||
_session = session;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scrapes products needing work: those not yet scraped, plus (when <paramref name="refreshOlderThanDays"/>
|
||||
/// > 0) any whose data is older than that window. Returns (scraped, errors).
|
||||
/// </summary>
|
||||
public async Task<(int Scraped, int Errors)> ScrapeAsync(int refreshOlderThanDays, int maxProducts, bool retryErrors)
|
||||
{
|
||||
var allUrls = JsonStore.LoadUrls(_config.ProductUrlsFile)
|
||||
.Where(u => ProductUrlRegex.IsMatch(u))
|
||||
.ToList();
|
||||
|
||||
var data = JsonStore.LoadOutput(_config.OutputJsonFile);
|
||||
|
||||
// Index existing results by URL (keep the most recent if the file has dupes).
|
||||
var resultByUrl = data.Results
|
||||
.GroupBy(r => JsonStore.CleanUrl(r.ProductUrl), StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.OrderByDescending(r => r.ScrapedAt).First(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var errorUrls = new HashSet<string>(
|
||||
data.Errors.Select(e => JsonStore.CleanUrl(e.ProductUrl)), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var staleCutoff = DateTime.UtcNow.AddDays(-Math.Max(0, refreshOlderThanDays));
|
||||
|
||||
var toScrape = new List<string>();
|
||||
foreach (var url in allUrls)
|
||||
{
|
||||
if (resultByUrl.TryGetValue(url, out var existing))
|
||||
{
|
||||
if (refreshOlderThanDays > 0 && existing.ScrapedAt < staleCutoff)
|
||||
toScrape.Add(url); // stale → refresh for price changes
|
||||
}
|
||||
else
|
||||
{
|
||||
if (retryErrors || !errorUrls.Contains(url))
|
||||
toScrape.Add(url); // never scraped (skip known errors unless retrying)
|
||||
}
|
||||
}
|
||||
|
||||
if (maxProducts > 0)
|
||||
toScrape = toScrape.Take(maxProducts).ToList();
|
||||
|
||||
var total = toScrape.Count;
|
||||
Log.Info($"URLs: {allUrls.Count}; already scraped: {resultByUrl.Count}; errors on file: {errorUrls.Count}");
|
||||
Log.Info($"To scrape this run: {total} (refresh older than {refreshOlderThanDays}d, retry errors: {retryErrors})");
|
||||
|
||||
if (total == 0)
|
||||
{
|
||||
Log.Info("Nothing to scrape. Done.");
|
||||
return (0, 0);
|
||||
}
|
||||
|
||||
var avgDelaySec = (_config.MinDelaySeconds + _config.MaxDelaySeconds) / 2.0;
|
||||
var etaMinutes = total * (avgDelaySec + _config.PageSettleSeconds + 2) / 60.0;
|
||||
Log.Info($"Estimated run time: ~{FormatDuration(TimeSpan.FromMinutes(etaMinutes))} " +
|
||||
$"(grab a coffee if that's a while — it saves after every product and is resumable).");
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
int scraped = 0, errors = 0, index = 0, consecutiveBlocks = 0;
|
||||
|
||||
foreach (var url in toScrape)
|
||||
{
|
||||
index++;
|
||||
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var row = await ParseProductAsync(url, index, total);
|
||||
|
||||
if (resultByUrl.TryGetValue(url, out var existing))
|
||||
data.Results[data.Results.IndexOf(existing)] = row;
|
||||
else
|
||||
data.Results.Add(row);
|
||||
|
||||
resultByUrl[url] = row;
|
||||
data.Errors.RemoveAll(e => JsonStore.CleanUrl(e.ProductUrl).Equals(url, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
scraped++;
|
||||
consecutiveBlocks = 0;
|
||||
JsonStore.SaveOutput(_config.OutputJsonFile, data);
|
||||
|
||||
var basePrice = row.PriceTiers.Count > 0 ? row.PriceTiers.Min(t => t.Price) : 0m;
|
||||
Log.Info($"[{index}/{total}] Saved {row.Sku} \"{row.ColorName}\" " +
|
||||
$"({row.PriceTiers.Count} tier(s), base ${basePrice:0.00}) | elapsed {FormatDuration(stopwatch.Elapsed)}");
|
||||
break;
|
||||
}
|
||||
catch (Exception ex) when (IsBlocked(ex) && attempt <= _config.BlockedMaxRetries)
|
||||
{
|
||||
// Site pushed back — back off (escalating) and retry the SAME product rather
|
||||
// than barreling on, which is how an unattended run gets hard-banned.
|
||||
consecutiveBlocks++;
|
||||
var cooldown = Math.Min(_config.BlockedCooldownSeconds * consecutiveBlocks, _config.BlockedCooldownMaxSeconds);
|
||||
Log.Warn($"[{index}/{total}] Blocked (403), attempt {attempt}. Cooling down {cooldown}s, then retrying this product...");
|
||||
await Task.Delay(cooldown * 1000);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
data.Errors.Add(new ScrapeError { ProductUrl = url, Error = ex.Message, ScrapedAt = DateTime.UtcNow });
|
||||
JsonStore.SaveOutput(_config.OutputJsonFile, data);
|
||||
errors++;
|
||||
Log.Error($"[{index}/{total}] {url} -> {ex.Message}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic longer rest — eases server load and avoids a robotic, evenly-spaced cadence.
|
||||
if (_config.LongRestEveryProducts > 0 && index % _config.LongRestEveryProducts == 0 && index < total)
|
||||
{
|
||||
Log.Info($"Resting {_config.LongRestSeconds}s after {index} products...");
|
||||
await Task.Delay(_config.LongRestSeconds * 1000);
|
||||
}
|
||||
|
||||
if (index < total)
|
||||
{
|
||||
var delayMs = RandomDelayMs();
|
||||
Log.Info($"[{index}/{total}] Waiting {delayMs / 1000.0:0.0}s before next product...");
|
||||
await Task.Delay(delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
Log.Info($"Scrape complete. Scraped {scraped}, errors {errors}. Total results on file: {data.Results.Count}. " +
|
||||
$"Took {FormatDuration(stopwatch.Elapsed)}.");
|
||||
return (scraped, errors);
|
||||
}
|
||||
|
||||
private async Task<ProductRecord> ParseProductAsync(string url, int index, int total)
|
||||
{
|
||||
Log.Info($"[{index}/{total}] Scraping {url}");
|
||||
|
||||
var response = await _session.Page.GotoAsync(url, new PageGotoOptions
|
||||
{
|
||||
WaitUntil = WaitUntilState.DOMContentLoaded,
|
||||
Timeout = 60000
|
||||
});
|
||||
await _session.Page.WaitForTimeoutAsync(_config.PageSettleSeconds * 1000);
|
||||
|
||||
var status = response?.Status ?? 0;
|
||||
var title = Clean(await SafeTextAsync(() => _session.Page.TitleAsync()));
|
||||
var plainText = Clean(await SafeTextAsync(() => _session.Page.Locator("body").InnerTextAsync()));
|
||||
|
||||
if (status == 403 || Regex.IsMatch(title, @"^403 Forbidden$", RegexOptions.IgnoreCase))
|
||||
throw new Exception("403 Forbidden returned by site.");
|
||||
if (status == 404 || Regex.IsMatch(title, @"404|Page Not Found", RegexOptions.IgnoreCase))
|
||||
throw new Exception("404 Not Found returned by site.");
|
||||
|
||||
var colorName = Clean(await SafeTextAsync(() => _session.Page.Locator("h1").First.InnerTextAsync()));
|
||||
|
||||
var skuMatch = SkuRegex.Match(plainText);
|
||||
var sku = skuMatch.Success ? skuMatch.Groups[1].Value : "";
|
||||
if (string.IsNullOrEmpty(sku) && string.IsNullOrEmpty(colorName))
|
||||
throw new Exception("Could not find SKU or title on product page.");
|
||||
|
||||
var descMatch = DescRegex.Match(plainText);
|
||||
var description = descMatch.Success ? Clean(descMatch.Groups[1].Value) : "";
|
||||
|
||||
return new ProductRecord
|
||||
{
|
||||
Sku = sku,
|
||||
ColorName = colorName,
|
||||
Description = description,
|
||||
PriceTiers = ParsePriceTiers(plainText),
|
||||
SafetyDataSheetUrl = await GetLinkByTextAsync(new[] { "Safety Data Sheet", @"\bSDS\b" }),
|
||||
TechnicalDataSheetUrl = await GetLinkByTextAsync(new[] { "Tech Data Sheet", "Technical Data Sheet", @"\bTDS\b" }),
|
||||
ApplicationGuideUrl = await GetLinkByTextAsync(new[] { "Application Guide" }),
|
||||
SampleImageUrl = await GetSampleImageUrlAsync(),
|
||||
ProductUrl = url,
|
||||
ScrapedAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static List<PriceTier> ParsePriceTiers(string text)
|
||||
{
|
||||
var tiers = new List<PriceTier>();
|
||||
foreach (Match m in PriceTierRegex.Matches(text))
|
||||
{
|
||||
if (!decimal.TryParse(m.Groups[2].Value, NumberStyles.Any, CultureInfo.InvariantCulture, out var price))
|
||||
continue;
|
||||
|
||||
var rangeText = Clean(m.Groups[1].Value);
|
||||
int? min = null, max = null;
|
||||
|
||||
var range = RangeRegex.Match(rangeText);
|
||||
if (range.Success)
|
||||
{
|
||||
min = int.Parse(range.Groups[1].Value);
|
||||
max = int.Parse(range.Groups[2].Value);
|
||||
}
|
||||
|
||||
var plus = PlusRegex.Match(rangeText);
|
||||
if (plus.Success)
|
||||
{
|
||||
min = int.Parse(plus.Groups[1].Value);
|
||||
max = null;
|
||||
}
|
||||
|
||||
tiers.Add(new PriceTier { Min = min, Max = max, Price = price });
|
||||
}
|
||||
return tiers;
|
||||
}
|
||||
|
||||
/// <summary>Returns the href of the first link whose text matches any pattern. Uses a single eval
|
||||
/// returning "texthref" pairs to avoid object deserialization quirks.</summary>
|
||||
private async Task<string> GetLinkByTextAsync(string[] patterns)
|
||||
{
|
||||
var combined = await _session.Page.EvalOnSelectorAllAsync<string[]>(
|
||||
"a",
|
||||
"els => els.map(a => ((a.innerText || a.textContent || '').replace(/\\s+/g, ' ').trim()) " +
|
||||
"+ String.fromCharCode(1) + (a.href || ''))");
|
||||
|
||||
foreach (var entry in combined)
|
||||
{
|
||||
var parts = entry.Split('');
|
||||
var text = parts.Length > 0 ? parts[0] : "";
|
||||
var href = parts.Length > 1 ? parts[1] : "";
|
||||
// Require the link to point at an actual document, not a generic /documents nav page.
|
||||
if (href.Length > 0
|
||||
&& IsDocumentUrl(href)
|
||||
&& patterns.Any(p => Regex.IsMatch(text, p, RegexOptions.IgnoreCase)))
|
||||
return href;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/// <summary>True when an href looks like a real document (hosted on the NIC CDN or a direct PDF).</summary>
|
||||
private static bool IsDocumentUrl(string href)
|
||||
{
|
||||
var path = href.Split('?')[0];
|
||||
return href.Contains("nicindustries.com", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private async Task<string> GetSampleImageUrlAsync()
|
||||
{
|
||||
var srcs = await _session.Page.EvalOnSelectorAllAsync<string[]>(
|
||||
"img",
|
||||
"els => els.map(i => i.currentSrc || i.src || i.getAttribute('src') || i.getAttribute('data-src') || '')" +
|
||||
".filter(Boolean)");
|
||||
|
||||
// Only accept real product images on the NIC CDN (prefer full-size over thumbnail). Do NOT
|
||||
// fall back to any "prismatic"-ish URL — that catches the site logo on products with no image.
|
||||
return srcs.FirstOrDefault(s => Regex.IsMatch(s, @"images\.nicindustries\.com/prismatic/products", RegexOptions.IgnoreCase)
|
||||
&& !Regex.IsMatch(s, "thumbnail", RegexOptions.IgnoreCase))
|
||||
?? srcs.FirstOrDefault(s => Regex.IsMatch(s, @"images\.nicindustries\.com/prismatic/products", RegexOptions.IgnoreCase))
|
||||
?? "";
|
||||
}
|
||||
|
||||
private static bool IsBlocked(Exception ex) =>
|
||||
ex.Message.Contains("403", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static async Task<string> SafeTextAsync(Func<Task<string>> fn)
|
||||
{
|
||||
try { return await fn(); } catch { return ""; }
|
||||
}
|
||||
|
||||
private static string Clean(string? text) => WhitespaceRegex.Replace(text ?? "", " ").Trim();
|
||||
|
||||
private int RandomDelayMs()
|
||||
{
|
||||
var min = Math.Max(0, _config.MinDelaySeconds * 1000);
|
||||
var max = Math.Max(min, _config.MaxDelaySeconds * 1000);
|
||||
return _random.Next(min, max + 1);
|
||||
}
|
||||
|
||||
private static string FormatDuration(TimeSpan t) =>
|
||||
t.TotalHours >= 1 ? $"{(int)t.TotalHours}h {t.Minutes}m" :
|
||||
t.TotalMinutes >= 1 ? $"{(int)t.TotalMinutes}m {t.Seconds}s" :
|
||||
$"{t.Seconds}s";
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"Sync": {
|
||||
"BaseUrl": "https://www.prismaticpowders.com",
|
||||
"ColorsPath": "/shop/powder-coating-colors",
|
||||
|
||||
"ProductUrlsFile": "product-urls.txt",
|
||||
"OutputJsonFile": "prismatic_powders.json",
|
||||
"LogFile": "prismatic-sync.log",
|
||||
|
||||
"MinDelaySeconds": 6,
|
||||
"MaxDelaySeconds": 14,
|
||||
"PageSettleSeconds": 4,
|
||||
|
||||
"BlockedCooldownSeconds": 120,
|
||||
"BlockedCooldownMaxSeconds": 600,
|
||||
"BlockedMaxRetries": 3,
|
||||
"LongRestEveryProducts": 150,
|
||||
"LongRestSeconds": 45,
|
||||
|
||||
"ScrollWaitMs": 1500,
|
||||
"MaxScrolls": 400,
|
||||
"StopAfterNoNewScrolls": 10,
|
||||
"StopAfterKnownScrolls": 8,
|
||||
|
||||
"ColorParams": [
|
||||
"pris_black", "pris_blue", "pris_bronze", "pris_brown", "pris_clear",
|
||||
"pris_copper", "pris_gold", "pris_gray", "pris_green", "pris_orange",
|
||||
"pris_pink", "pris_purple", "pris_red", "pris_silver", "pris_tan",
|
||||
"pris_white", "pris_yellow"
|
||||
],
|
||||
|
||||
"Import": {
|
||||
"EndpointUrl": "",
|
||||
"Token": "",
|
||||
"VendorName": "Prismatic Powders"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
BEGIN TRANSACTION;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260620134918_AddCompanyDefaultGlAccounts'
|
||||
)
|
||||
BEGIN
|
||||
ALTER TABLE [CompanyPreferences] ADD [DefaultCogsAccountId] int NULL;
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260620134918_AddCompanyDefaultGlAccounts'
|
||||
)
|
||||
BEGIN
|
||||
ALTER TABLE [CompanyPreferences] ADD [DefaultInventoryAccountId] int NULL;
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260620134918_AddCompanyDefaultGlAccounts'
|
||||
)
|
||||
BEGIN
|
||||
ALTER TABLE [CompanyPreferences] ADD [DefaultRevenueAccountId] int NULL;
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260620134918_AddCompanyDefaultGlAccounts'
|
||||
)
|
||||
BEGIN
|
||||
EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-06-20T13:49:14.5644507Z''
|
||||
WHERE [Id] = 1;
|
||||
SELECT @@ROWCOUNT');
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260620134918_AddCompanyDefaultGlAccounts'
|
||||
)
|
||||
BEGIN
|
||||
EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-06-20T13:49:14.5644514Z''
|
||||
WHERE [Id] = 2;
|
||||
SELECT @@ROWCOUNT');
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260620134918_AddCompanyDefaultGlAccounts'
|
||||
)
|
||||
BEGIN
|
||||
EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-06-20T13:49:14.5644515Z''
|
||||
WHERE [Id] = 3;
|
||||
SELECT @@ROWCOUNT');
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260620134918_AddCompanyDefaultGlAccounts'
|
||||
)
|
||||
BEGIN
|
||||
CREATE INDEX [IX_CompanyPreferences_DefaultCogsAccountId] ON [CompanyPreferences] ([DefaultCogsAccountId]);
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260620134918_AddCompanyDefaultGlAccounts'
|
||||
)
|
||||
BEGIN
|
||||
CREATE INDEX [IX_CompanyPreferences_DefaultInventoryAccountId] ON [CompanyPreferences] ([DefaultInventoryAccountId]);
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260620134918_AddCompanyDefaultGlAccounts'
|
||||
)
|
||||
BEGIN
|
||||
CREATE INDEX [IX_CompanyPreferences_DefaultRevenueAccountId] ON [CompanyPreferences] ([DefaultRevenueAccountId]);
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260620134918_AddCompanyDefaultGlAccounts'
|
||||
)
|
||||
BEGIN
|
||||
ALTER TABLE [CompanyPreferences] ADD CONSTRAINT [FK_CompanyPreferences_Accounts_DefaultCogsAccountId] FOREIGN KEY ([DefaultCogsAccountId]) REFERENCES [Accounts] ([Id]);
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260620134918_AddCompanyDefaultGlAccounts'
|
||||
)
|
||||
BEGIN
|
||||
ALTER TABLE [CompanyPreferences] ADD CONSTRAINT [FK_CompanyPreferences_Accounts_DefaultInventoryAccountId] FOREIGN KEY ([DefaultInventoryAccountId]) REFERENCES [Accounts] ([Id]);
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260620134918_AddCompanyDefaultGlAccounts'
|
||||
)
|
||||
BEGIN
|
||||
ALTER TABLE [CompanyPreferences] ADD CONSTRAINT [FK_CompanyPreferences_Accounts_DefaultRevenueAccountId] FOREIGN KEY ([DefaultRevenueAccountId]) REFERENCES [Accounts] ([Id]);
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260620134918_AddCompanyDefaultGlAccounts'
|
||||
)
|
||||
BEGIN
|
||||
INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
|
||||
VALUES (N'20260620134918_AddCompanyDefaultGlAccounts', N'8.0.11');
|
||||
END;
|
||||
GO
|
||||
|
||||
COMMIT;
|
||||
GO
|
||||
|
||||
@@ -154,49 +154,6 @@ public class TrialBalanceLine
|
||||
public decimal CreditBalance { get; set; }
|
||||
}
|
||||
|
||||
// ── Balance Reconciliation ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic that surfaces drift in the denormalized balances: each account's stored
|
||||
/// <c>CurrentBalance</c> vs its recomputed ledger balance, plus the AR/AP subledger totals
|
||||
/// (sum of Customer/Vendor CurrentBalance) vs their GL control account balances. Read-only.
|
||||
/// </summary>
|
||||
public class BalanceReconciliationDto
|
||||
{
|
||||
public DateTime AsOf { get; set; }
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
|
||||
public List<BalanceReconciliationLine> AccountLines { get; set; } = new();
|
||||
|
||||
public decimal ArControlBalance { get; set; }
|
||||
public decimal ArSubledgerTotal { get; set; }
|
||||
public decimal ArDifference => ArControlBalance - ArSubledgerTotal;
|
||||
|
||||
public decimal ApControlBalance { get; set; }
|
||||
public decimal ApSubledgerTotal { get; set; }
|
||||
public decimal ApDifference => ApControlBalance - ApSubledgerTotal;
|
||||
|
||||
public IEnumerable<BalanceReconciliationLine> DriftedAccounts => AccountLines.Where(l => !l.IsReconciled);
|
||||
public bool AccountsReconciled => AccountLines.All(l => l.IsReconciled);
|
||||
public bool ArReconciled => Math.Abs(ArDifference) < 0.01m;
|
||||
public bool ApReconciled => Math.Abs(ApDifference) < 0.01m;
|
||||
public bool AllReconciled => AccountsReconciled && ArReconciled && ApReconciled;
|
||||
}
|
||||
|
||||
public class BalanceReconciliationLine
|
||||
{
|
||||
public int AccountId { get; set; }
|
||||
public string AccountNumber { get; set; } = string.Empty;
|
||||
public string AccountName { get; set; } = string.Empty;
|
||||
public AccountType AccountType { get; set; }
|
||||
/// <summary>The denormalized Account.CurrentBalance (what most UI reads).</summary>
|
||||
public decimal StoredBalance { get; set; }
|
||||
/// <summary>The balance recomputed from source documents (what RecalculateBalances would set).</summary>
|
||||
public decimal LedgerBalance { get; set; }
|
||||
public decimal Difference => StoredBalance - LedgerBalance;
|
||||
public bool IsReconciled => Math.Abs(Difference) < 0.01m;
|
||||
}
|
||||
|
||||
// ── Profit & Loss ─────────────────────────────────────────────────────────────
|
||||
|
||||
public class ProfitAndLossDto
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
using CsvHelper.Configuration.Attributes;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.Import;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for importing vendor bill headers from CSV. Column names match the native bills export
|
||||
/// (ExportBillsCsv) for round-trip compatibility. The vendor is resolved by name and the AP account
|
||||
/// by number so accounting linkages survive. Line items import separately via BillLineItemImportDto.
|
||||
/// </summary>
|
||||
public class BillImportDto
|
||||
{
|
||||
[Name("BillNumber")]
|
||||
public string? BillNumber { get; set; }
|
||||
|
||||
[Name("VendorInvoiceNumber")]
|
||||
public string? VendorInvoiceNumber { get; set; }
|
||||
|
||||
/// <summary>Vendor company name, matched against Vendor.CompanyName.</summary>
|
||||
[Name("VendorName")]
|
||||
public string? VendorName { get; set; }
|
||||
|
||||
/// <summary>AP account number (Chart of Accounts) this bill posts to.</summary>
|
||||
[Name("APAccountNumber")]
|
||||
public string? APAccountNumber { get; set; }
|
||||
|
||||
[Name("BillDate")]
|
||||
public DateTime BillDate { get; set; }
|
||||
|
||||
[Name("DueDate")]
|
||||
public DateTime? DueDate { get; set; }
|
||||
|
||||
[Name("Status")]
|
||||
public string Status { get; set; } = "Open";
|
||||
|
||||
[Name("Terms")]
|
||||
public string? Terms { get; set; }
|
||||
|
||||
[Name("Memo")]
|
||||
public string? Memo { get; set; }
|
||||
|
||||
[Name("SubTotal")]
|
||||
public decimal SubTotal { get; set; }
|
||||
|
||||
[Name("TaxPercent")]
|
||||
public decimal TaxPercent { get; set; }
|
||||
|
||||
[Name("TaxAmount")]
|
||||
public decimal TaxAmount { get; set; }
|
||||
|
||||
[Name("Total")]
|
||||
public decimal Total { get; set; }
|
||||
|
||||
[Name("AmountPaid")]
|
||||
public decimal AmountPaid { get; set; }
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
using CsvHelper.Configuration.Attributes;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.Import;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for importing vendor bill line items from CSV. Column names match the native bill-items export
|
||||
/// (ExportBillLineItemsCsv). Lines are matched to their parent bill by BillNumber; the expense/asset
|
||||
/// account is resolved (optional) from AccountNumber so each line's GL attribution round-trips.
|
||||
/// </summary>
|
||||
public class BillLineItemImportDto
|
||||
{
|
||||
[Name("BillNumber")]
|
||||
public string? BillNumber { get; set; }
|
||||
|
||||
/// <summary>Expense/asset account number this line is categorized under. Optional.</summary>
|
||||
[Name("AccountNumber")]
|
||||
public string? AccountNumber { get; set; }
|
||||
|
||||
/// <summary>Optional job-costing link, matched against Job.JobNumber.</summary>
|
||||
[Name("JobNumber")]
|
||||
public string? JobNumber { get; set; }
|
||||
|
||||
[Name("Description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[Name("Quantity")]
|
||||
public decimal Quantity { get; set; }
|
||||
|
||||
[Name("UnitPrice")]
|
||||
public decimal UnitPrice { get; set; }
|
||||
|
||||
[Name("Amount")]
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
[Name("DisplayOrder")]
|
||||
public int DisplayOrder { get; set; }
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
using CsvHelper.Configuration.Attributes;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.Import;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for importing customer deposits from CSV. Column names match the native deposits export
|
||||
/// (ExportDepositsCsv). The customer is resolved by name, the bank account by number
|
||||
/// (DepositAccountNumber), and the optional applied invoice by number so the deposit's linkages
|
||||
/// survive an export/import round-trip.
|
||||
/// </summary>
|
||||
public class DepositImportDto
|
||||
{
|
||||
[Name("ReceiptNumber")]
|
||||
public string? ReceiptNumber { get; set; }
|
||||
|
||||
/// <summary>Customer name (company name, or contact full name), matched against the customer record.</summary>
|
||||
[Name("CustomerName")]
|
||||
public string? CustomerName { get; set; }
|
||||
|
||||
[Name("Amount")]
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>Valid values: Cash, Check, CreditDebitCard, BankTransferACH, DigitalPayment</summary>
|
||||
[Name("PaymentMethod")]
|
||||
public string PaymentMethod { get; set; } = "Cash";
|
||||
|
||||
[Name("ReceivedDate")]
|
||||
public DateTime ReceivedDate { get; set; }
|
||||
|
||||
/// <summary>Bank/cash account number (Chart of Accounts) the deposit landed in. Optional.</summary>
|
||||
[Name("DepositAccountNumber")]
|
||||
public string? DepositAccountNumber { get; set; }
|
||||
|
||||
/// <summary>Invoice number this deposit has been applied to, if any. Optional.</summary>
|
||||
[Name("AppliedToInvoiceNumber")]
|
||||
public string? AppliedToInvoiceNumber { get; set; }
|
||||
|
||||
[Name("AppliedDate")]
|
||||
public DateTime? AppliedDate { get; set; }
|
||||
|
||||
[Name("Reference")]
|
||||
public string? Reference { get; set; }
|
||||
|
||||
[Name("Notes")]
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
using CsvHelper.Configuration.Attributes;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.Import;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for importing invoice line items from CSV. Column names match the native
|
||||
/// invoice-items export (ExportInvoiceItemsCsv) for round-trip compatibility.
|
||||
/// Line items are matched to their parent invoice by <c>InvoiceNumber</c>; the revenue
|
||||
/// account is resolved from <c>RevenueAccountNumber</c> against Account.AccountNumber so the
|
||||
/// invoice's revenue attribution survives an export/import round-trip.
|
||||
/// </summary>
|
||||
public class InvoiceItemImportDto
|
||||
{
|
||||
[Name("InvoiceNumber")]
|
||||
public string? InvoiceNumber { get; set; }
|
||||
|
||||
[Name("Description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[Name("Quantity")]
|
||||
public decimal Quantity { get; set; }
|
||||
|
||||
[Name("UnitPrice")]
|
||||
public decimal UnitPrice { get; set; }
|
||||
|
||||
[Name("TotalPrice")]
|
||||
public decimal TotalPrice { get; set; }
|
||||
|
||||
[Name("ColorName")]
|
||||
public string? ColorName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Account number (Chart of Accounts) of the revenue account this line posts to. Optional —
|
||||
/// a blank value means the line falls back to the company's default revenue account.
|
||||
/// </summary>
|
||||
[Name("RevenueAccountNumber")]
|
||||
public string? RevenueAccountNumber { get; set; }
|
||||
|
||||
[Name("DisplayOrder")]
|
||||
public int DisplayOrder { get; set; }
|
||||
|
||||
[Name("Notes")]
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
using CsvHelper.Configuration.Attributes;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.Import;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for importing journal entry headers from CSV. Column names match the native journal-entries
|
||||
/// export (ExportJournalEntriesCsv). The debit/credit lines import separately via
|
||||
/// JournalEntryLineImportDto and must balance per entry.
|
||||
/// </summary>
|
||||
public class JournalEntryImportDto
|
||||
{
|
||||
[Name("EntryNumber")]
|
||||
public string? EntryNumber { get; set; }
|
||||
|
||||
[Name("EntryDate")]
|
||||
public DateTime EntryDate { get; set; }
|
||||
|
||||
[Name("Reference")]
|
||||
public string? Reference { get; set; }
|
||||
|
||||
[Name("Description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>Valid values: Draft, Posted, Reversed</summary>
|
||||
[Name("Status")]
|
||||
public string Status { get; set; } = "Draft";
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
using CsvHelper.Configuration.Attributes;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.Import;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for importing journal entry lines from CSV. Column names match the native journal-entry-lines
|
||||
/// export (ExportJournalEntryLinesCsv). Lines are matched to their parent entry by EntryNumber and the
|
||||
/// account is resolved from AccountNumber (required — a JE line is meaningless without its account).
|
||||
/// Either DebitAmount or CreditAmount is non-zero per line, not both.
|
||||
/// </summary>
|
||||
public class JournalEntryLineImportDto
|
||||
{
|
||||
[Name("EntryNumber")]
|
||||
public string? EntryNumber { get; set; }
|
||||
|
||||
/// <summary>Account number (Chart of Accounts) this line debits or credits. Required.</summary>
|
||||
[Name("AccountNumber")]
|
||||
public string? AccountNumber { get; set; }
|
||||
|
||||
[Name("DebitAmount")]
|
||||
public decimal DebitAmount { get; set; }
|
||||
|
||||
[Name("CreditAmount")]
|
||||
public decimal CreditAmount { get; set; }
|
||||
|
||||
[Name("Description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[Name("LineOrder")]
|
||||
public int LineOrder { get; set; }
|
||||
}
|
||||
@@ -24,14 +24,6 @@ public class PaymentImportDto
|
||||
[Name("PaymentMethod")]
|
||||
public string PaymentMethod { get; set; } = "Cash";
|
||||
|
||||
/// <summary>
|
||||
/// Account number (Chart of Accounts) of the bank/cash account the payment was deposited into.
|
||||
/// Resolved back to <c>DepositAccountId</c> on import so the balance recalc can post it to the
|
||||
/// right bank account. Optional — a blank value means no deposit account was recorded.
|
||||
/// </summary>
|
||||
[Name("DepositAccountNumber")]
|
||||
public string? DepositAccountNumber { get; set; }
|
||||
|
||||
[Name("Reference")]
|
||||
public string? Reference { get; set; }
|
||||
|
||||
|
||||
@@ -225,12 +225,6 @@ public class CreateInventoryItemDto
|
||||
[Display(Name = "Incoming / On Order")]
|
||||
public bool IsIncoming { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Existing inventory record the user explicitly chose to bypass when creating a separate
|
||||
/// powder lot or location. SKU duplicates can never be bypassed.
|
||||
/// </summary>
|
||||
public int? DuplicateOverrideInventoryItemId { get; set; }
|
||||
|
||||
}
|
||||
|
||||
public class UpdateInventoryItemDto : CreateInventoryItemDto
|
||||
|
||||
@@ -179,36 +179,10 @@ public interface ICsvImportService
|
||||
/// <summary>
|
||||
/// Import invoice headers from a CSV stream. Customers are resolved by CustomerEmail then
|
||||
/// CustomerName. Duplicate detection uses InvoiceNumber as the unique key. Existing invoices
|
||||
/// are updated; new ones are created. Line items are imported separately via
|
||||
/// <see cref="ImportInvoiceItemsAsync"/>.
|
||||
/// are updated; new ones are created. Line items are not part of the CSV format.
|
||||
/// </summary>
|
||||
Task<CsvImportResultDto> ImportInvoicesAsync(Stream csvStream, int companyId);
|
||||
|
||||
/// <summary>
|
||||
/// Import invoice line items from a CSV stream. Each line is matched to its parent invoice by
|
||||
/// InvoiceNumber and its revenue account resolved (optional) from RevenueAccountNumber. Idempotent
|
||||
/// by description + total + display order. Run after invoices have been imported.
|
||||
/// </summary>
|
||||
Task<CsvImportResultDto> ImportInvoiceItemsAsync(Stream csvStream, int companyId);
|
||||
|
||||
/// <summary>Import vendor bill headers. Vendor by name, AP account by number. Dedup by BillNumber.</summary>
|
||||
Task<CsvImportResultDto> ImportBillsAsync(Stream csvStream, int companyId);
|
||||
|
||||
/// <summary>Import vendor bill line items. Matched to bills by BillNumber; account/job by number.</summary>
|
||||
Task<CsvImportResultDto> ImportBillLineItemsAsync(Stream csvStream, int companyId);
|
||||
|
||||
/// <summary>Import customer deposits. Customer by name, bank account by number, applied invoice by number.</summary>
|
||||
Task<CsvImportResultDto> ImportDepositsAsync(Stream csvStream, int companyId);
|
||||
|
||||
/// <summary>Import journal entry headers. Dedup by EntryNumber. Lines import separately.</summary>
|
||||
Task<CsvImportResultDto> ImportJournalEntriesAsync(Stream csvStream, int companyId);
|
||||
|
||||
/// <summary>Import journal entry lines. Matched to entries by EntryNumber; account by number (required).</summary>
|
||||
Task<CsvImportResultDto> ImportJournalEntryLinesAsync(Stream csvStream, int companyId);
|
||||
|
||||
/// <summary>Generate a CSV template file for invoice line-item imports.</summary>
|
||||
byte[] GenerateInvoiceItemTemplate();
|
||||
|
||||
/// <summary>Generate a CSV template file for payment imports.</summary>
|
||||
byte[] GeneratePaymentTemplate();
|
||||
|
||||
|
||||
@@ -33,12 +33,6 @@ public interface IFinancialReportService
|
||||
/// <summary>Returns a Trial Balance using current account balances as of the given date.</summary>
|
||||
Task<TrialBalanceDto> GetTrialBalanceAsync(int companyId, DateTime asOf);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a balance reconciliation: each account's stored CurrentBalance vs its recomputed ledger
|
||||
/// balance, plus AR/AP subledger totals vs their control accounts. Read-only drift diagnostic.
|
||||
/// </summary>
|
||||
Task<BalanceReconciliationDto> GetBalanceReconciliationAsync(int companyId);
|
||||
|
||||
/// <summary>Looks up the accounting method configured for the given company. Returns Accrual if not found.</summary>
|
||||
Task<AccountingMethod> GetCompanyAccountingMethodAsync(int companyId);
|
||||
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Application.Services;
|
||||
|
||||
public enum InventoryDuplicateMatchType
|
||||
{
|
||||
Sku,
|
||||
ManufacturerPartNumber,
|
||||
ManufacturerColor
|
||||
}
|
||||
|
||||
public sealed record InventoryDuplicateMatch(
|
||||
InventoryItem Item,
|
||||
InventoryDuplicateMatchType MatchType);
|
||||
|
||||
/// <summary>
|
||||
/// Shared inventory duplicate rules used by manual creation and powder-label scanning.
|
||||
/// Callers are responsible for supplying inventory already restricted to the current tenant.
|
||||
/// </summary>
|
||||
public static class InventoryDuplicateMatcher
|
||||
{
|
||||
public static InventoryDuplicateMatch? Find(
|
||||
IEnumerable<InventoryItem> inventoryItems,
|
||||
int companyId,
|
||||
string? sku,
|
||||
string? manufacturer,
|
||||
string? manufacturerPartNumber,
|
||||
string? colorName,
|
||||
bool isCoating,
|
||||
int? excludeId = null)
|
||||
{
|
||||
var candidates = inventoryItems
|
||||
.Where(i => i.CompanyId == companyId && i.Id != excludeId)
|
||||
.ToList();
|
||||
|
||||
var normalizedSku = Normalize(sku);
|
||||
if (normalizedSku.Length > 0)
|
||||
{
|
||||
var skuMatch = candidates.FirstOrDefault(i => Normalize(i.SKU) == normalizedSku);
|
||||
if (skuMatch != null)
|
||||
return new InventoryDuplicateMatch(skuMatch, InventoryDuplicateMatchType.Sku);
|
||||
}
|
||||
|
||||
if (!isCoating)
|
||||
return null;
|
||||
|
||||
var coatingCandidates = candidates
|
||||
.Where(i => i.InventoryCategory?.IsCoating == true)
|
||||
.ToList();
|
||||
|
||||
var normalizedManufacturer = Normalize(manufacturer);
|
||||
var normalizedPartNumber = Normalize(manufacturerPartNumber);
|
||||
if (normalizedPartNumber.Length > 0)
|
||||
{
|
||||
var partNumberMatch = coatingCandidates.FirstOrDefault(i =>
|
||||
Normalize(i.ManufacturerPartNumber) == normalizedPartNumber &&
|
||||
(normalizedManufacturer.Length == 0 ||
|
||||
Normalize(i.Manufacturer) == normalizedManufacturer));
|
||||
|
||||
if (partNumberMatch != null)
|
||||
return new InventoryDuplicateMatch(
|
||||
partNumberMatch,
|
||||
InventoryDuplicateMatchType.ManufacturerPartNumber);
|
||||
}
|
||||
|
||||
var normalizedColorName = Normalize(colorName);
|
||||
if (normalizedManufacturer.Length == 0 || normalizedColorName.Length == 0)
|
||||
return null;
|
||||
|
||||
var manufacturerColorMatch = coatingCandidates.FirstOrDefault(i =>
|
||||
Normalize(i.Manufacturer) == normalizedManufacturer &&
|
||||
Normalize(i.ColorName ?? i.Name) == normalizedColorName);
|
||||
|
||||
return manufacturerColorMatch == null
|
||||
? null
|
||||
: new InventoryDuplicateMatch(
|
||||
manufacturerColorMatch,
|
||||
InventoryDuplicateMatchType.ManufacturerColor);
|
||||
}
|
||||
|
||||
private static string Normalize(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return string.Empty;
|
||||
|
||||
return string.Join(
|
||||
' ',
|
||||
value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries))
|
||||
.ToUpperInvariant();
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
namespace PowderCoating.Core.Accounting;
|
||||
|
||||
/// <summary>
|
||||
/// Single source of truth for splitting a customer refund into its revenue (returns) portion and
|
||||
/// its sales-tax portion, under the "reverse the sale" model. A refund of a paid invoice reverses
|
||||
/// the original sale: the revenue portion is debited to Sales Returns (contra-revenue) and the tax
|
||||
/// portion is debited to Sales Tax Payable (reducing the liability), with cash credited out.
|
||||
///
|
||||
/// The split is proportional to the parent invoice's tax ratio so a partial refund reverses the
|
||||
/// right amount of tax. Centralised here so the posting (<c>InvoicesController</c>) and the two
|
||||
/// reporting recomputes (<c>LedgerService</c>, <c>FinancialReportService</c>) always agree — if they
|
||||
/// computed it independently the trial balance could drift.
|
||||
/// </summary>
|
||||
public static class RefundAllocation
|
||||
{
|
||||
/// <summary>
|
||||
/// Splits <paramref name="refundAmount"/> (tax-inclusive) into (returnsPortion, taxPortion) using
|
||||
/// the parent invoice's tax ratio. When the invoice has no total or no tax, the whole refund is
|
||||
/// the returns portion and the tax portion is zero.
|
||||
/// </summary>
|
||||
public static (decimal ReturnsPortion, decimal TaxPortion) Split(
|
||||
decimal refundAmount, decimal invoiceTaxAmount, decimal invoiceTotal)
|
||||
{
|
||||
var taxPortion = invoiceTotal > 0m && invoiceTaxAmount > 0m
|
||||
? Math.Round(refundAmount * invoiceTaxAmount / invoiceTotal, 2, MidpointRounding.AwayFromZero)
|
||||
: 0m;
|
||||
return (refundAmount - taxPortion, taxPortion);
|
||||
}
|
||||
}
|
||||
@@ -18,18 +18,6 @@ public class CompanyPreferences : BaseEntity
|
||||
public string InvoiceNumberPrefix { get; set; } = "INV";
|
||||
public bool UseMetricSystem { get; set; } = false; // False = Imperial (ft, lb), True = Metric (m, kg)
|
||||
|
||||
// Default GL Accounts — used as the fallback when an item leaves its account field blank.
|
||||
// Null means "no default": revenue falls back to account 4000, and inventory-consumption
|
||||
// COGS simply isn't posted (consistent with expensing materials at purchase). A company
|
||||
// only opts into perpetual-inventory COGS posting by setting both the COGS and Inventory
|
||||
// defaults (or the per-item accounts). FKs are nullable with no cascade — accounts soft-delete.
|
||||
/// <summary>Default Revenue account for invoice lines that don't specify one (fallback before account 4000).</summary>
|
||||
public int? DefaultRevenueAccountId { get; set; }
|
||||
/// <summary>Default COGS account pre-filled on new inventory/catalog items. Drives inventory-consumption COGS posting when paired with an inventory account.</summary>
|
||||
public int? DefaultCogsAccountId { get; set; }
|
||||
/// <summary>Default Inventory asset account pre-filled on new inventory items. Drives inventory-consumption COGS posting when paired with a COGS account.</summary>
|
||||
public int? DefaultInventoryAccountId { get; set; }
|
||||
|
||||
// Job / Workflow Defaults
|
||||
public string DefaultJobPriority { get; set; } = "Normal";
|
||||
public bool RequireCustomerPO { get; set; } = false;
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
namespace PowderCoating.Core.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Single source of truth mapping an <see cref="AccountSubType"/> to its parent
|
||||
/// <see cref="AccountType"/>. Each sub-type belongs to exactly one type, so the type can always
|
||||
/// be derived from the sub-type. Used on account create/edit to keep the two fields consistent
|
||||
/// (a mismatched pair would post with the wrong debit/credit sign, since the sign convention keys
|
||||
/// off the sub-type) and anywhere else that needs the canonical pairing.
|
||||
/// </summary>
|
||||
public static class AccountClassification
|
||||
{
|
||||
/// <summary>Returns the parent <see cref="AccountType"/> for a given <see cref="AccountSubType"/>.</summary>
|
||||
public static AccountType TypeForSubType(AccountSubType subType) => subType switch
|
||||
{
|
||||
AccountSubType.Cash or AccountSubType.Checking or AccountSubType.Savings
|
||||
or AccountSubType.AccountsReceivable or AccountSubType.Inventory or AccountSubType.FixedAsset
|
||||
or AccountSubType.OtherCurrentAsset or AccountSubType.OtherAsset => AccountType.Asset,
|
||||
|
||||
AccountSubType.AccountsPayable or AccountSubType.CreditCard
|
||||
or AccountSubType.OtherCurrentLiability or AccountSubType.LongTermLiability => AccountType.Liability,
|
||||
|
||||
AccountSubType.OwnersEquity or AccountSubType.RetainedEarnings => AccountType.Equity,
|
||||
|
||||
AccountSubType.Sales or AccountSubType.ServiceRevenue or AccountSubType.OtherIncome => AccountType.Revenue,
|
||||
|
||||
AccountSubType.CostOfGoodsSold => AccountType.CostOfGoods,
|
||||
|
||||
// All expense sub-types (enum values >= 50) and any future additions default to Expense.
|
||||
_ => AccountType.Expense
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Returns a sensible generic <see cref="AccountSubType"/> for a given <see cref="AccountType"/>.
|
||||
/// Used by importers (e.g. QuickBooks) to reconcile a sub-type back to its parent type when the
|
||||
/// source's detail-type couldn't be mapped to a specific sub-type — without this, an unmapped
|
||||
/// liability/equity/revenue account would fall back to <c>Other</c> (an expense-range sub-type)
|
||||
/// and post with the wrong debit/credit sign, since the sign convention keys off sub-type.
|
||||
/// </summary>
|
||||
public static AccountSubType DefaultSubTypeForType(AccountType type) => type switch
|
||||
{
|
||||
AccountType.Asset => AccountSubType.OtherCurrentAsset,
|
||||
AccountType.Liability => AccountSubType.OtherCurrentLiability,
|
||||
AccountType.Equity => AccountSubType.OwnersEquity,
|
||||
AccountType.Revenue => AccountSubType.OtherIncome,
|
||||
AccountType.CostOfGoods => AccountSubType.CostOfGoodsSold,
|
||||
_ => AccountSubType.Other
|
||||
};
|
||||
}
|
||||
@@ -946,26 +946,6 @@ modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
||||
.HasForeignKey(i => i.CogsAccountId)
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
|
||||
// CompanyPreferences → Default Revenue / COGS / Inventory accounts (nullable, no cascade,
|
||||
// no navigation property — accounts use soft delete and these are config pointers only).
|
||||
modelBuilder.Entity<CompanyPreferences>()
|
||||
.HasOne<Account>()
|
||||
.WithMany()
|
||||
.HasForeignKey(p => p.DefaultRevenueAccountId)
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
|
||||
modelBuilder.Entity<CompanyPreferences>()
|
||||
.HasOne<Account>()
|
||||
.WithMany()
|
||||
.HasForeignKey(p => p.DefaultCogsAccountId)
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
|
||||
modelBuilder.Entity<CompanyPreferences>()
|
||||
.HasOne<Account>()
|
||||
.WithMany()
|
||||
.HasForeignKey(p => p.DefaultInventoryAccountId)
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
|
||||
// CatalogItem → RevenueAccount / CogsAccount (nullable, no cascade — accounts use soft delete)
|
||||
modelBuilder.Entity<CatalogItem>()
|
||||
.HasOne(ci => ci.RevenueAccount)
|
||||
|
||||
@@ -1359,12 +1359,8 @@ New accounts walk through an 18-step setup wizard to configure company informati
|
||||
new() { AccountNumber = "2000", Name = "Accounts Payable", AccountType = AccountType.Liability, AccountSubType = AccountSubType.AccountsPayable, IsSystem = true, IsActive = true, CompanyId = company.Id, CreatedAt = now },
|
||||
new() { AccountNumber = "2100", Name = "Credit Card", AccountType = AccountType.Liability, AccountSubType = AccountSubType.CreditCard, IsSystem = true, IsActive = true, CompanyId = company.Id, CreatedAt = now },
|
||||
new() { AccountNumber = "2200", Name = "Sales Tax Payable", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = false, IsActive = true, CompanyId = company.Id, CreatedAt = now },
|
||||
// 2300 = Customer Deposits liability (resolved by number in the deposit GL posting code); payroll is at 2400.
|
||||
new() { AccountNumber = "2300", Name = "Customer Deposits", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = true, IsActive = true, CompanyId = company.Id, CreatedAt = now },
|
||||
new() { AccountNumber = "2400", Name = "Payroll Liabilities", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = false, IsActive = true, CompanyId = company.Id, CreatedAt = now },
|
||||
// 2500 = Gift Certificate Liability (resolved by number in the GC GL posting code); long-term loan moved to 2900.
|
||||
new() { AccountNumber = "2500", Name = "Gift Certificate Liability", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = true, IsActive = true, CompanyId = company.Id, CreatedAt = now },
|
||||
new() { AccountNumber = "2900", Name = "Long-Term Loan", AccountType = AccountType.Liability, AccountSubType = AccountSubType.LongTermLiability, IsSystem = false, IsActive = true, CompanyId = company.Id, CreatedAt = now },
|
||||
new() { AccountNumber = "2300", Name = "Payroll Liabilities", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = false, IsActive = true, CompanyId = company.Id, CreatedAt = now },
|
||||
new() { AccountNumber = "2500", Name = "Long-Term Loan", AccountType = AccountType.Liability, AccountSubType = AccountSubType.LongTermLiability, IsSystem = false, IsActive = true, CompanyId = company.Id, CreatedAt = now },
|
||||
|
||||
// ── Equity ──────────────────────────────────────────────────────
|
||||
new() { AccountNumber = "3000", Name = "Owner's Equity", AccountType = AccountType.Equity, AccountSubType = AccountSubType.OwnersEquity, IsSystem = true, IsActive = true, CompanyId = company.Id, CreatedAt = now },
|
||||
|
||||
-11382
File diff suppressed because it is too large
Load Diff
-119
@@ -1,119 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RenameDepositsAccountAddPayroll : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// O1 remediation. Account 2300 has always been used by the deposit GL posting code as the
|
||||
// Customer Deposits liability (resolved by number), but pre-migration tenants still had it
|
||||
// seeded/named "Payroll Liabilities" — so the liability was mislabeled on the balance sheet.
|
||||
// Rename it to "Customer Deposits" and mark it system. Only touch accounts still carrying the
|
||||
// old default name, so a tenant's own rename is preserved.
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE Accounts
|
||||
SET Name = 'Customer Deposits',
|
||||
Description = 'Deposits received from customers before an invoice is created; cleared when applied to an invoice',
|
||||
IsSystem = 1
|
||||
WHERE AccountNumber = '2300'
|
||||
AND IsDeleted = 0
|
||||
AND Name = 'Payroll Liabilities';
|
||||
");
|
||||
|
||||
// Re-home payroll to a dedicated 2400 account for every company that lacks one, so the chart
|
||||
// still offers a payroll liability without colliding with Customer Deposits at 2300.
|
||||
migrationBuilder.Sql(@"
|
||||
INSERT INTO Accounts
|
||||
(AccountNumber, Name, AccountType, AccountSubType,
|
||||
IsSystem, IsActive, Description,
|
||||
CompanyId, CreatedAt, IsDeleted,
|
||||
CurrentBalance, OpeningBalance)
|
||||
SELECT
|
||||
'2400',
|
||||
'Payroll Liabilities',
|
||||
2, -- AccountType.Liability
|
||||
12, -- AccountSubType.OtherCurrentLiability
|
||||
0, -- IsSystem = false
|
||||
1, -- IsActive = true
|
||||
'Payroll taxes and withholdings owed',
|
||||
c.Id,
|
||||
GETUTCDATE(),
|
||||
0, -- IsDeleted = false
|
||||
0, -- CurrentBalance
|
||||
0 -- OpeningBalance
|
||||
FROM Companies c
|
||||
WHERE c.IsDeleted = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM Accounts a
|
||||
WHERE a.CompanyId = c.Id
|
||||
AND a.AccountNumber = '2400'
|
||||
AND a.IsDeleted = 0
|
||||
);
|
||||
");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 19, 23, 31, 4, 905, DateTimeKind.Utc).AddTicks(7611));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 19, 23, 31, 4, 905, DateTimeKind.Utc).AddTicks(7618));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 19, 23, 31, 4, 905, DateTimeKind.Utc).AddTicks(7619));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Best-effort reversal. The 2300 rename is intentionally NOT undone: it corrected a mislabeled
|
||||
// account and reverting would re-introduce the bug. Only the empty 2400 accounts this migration
|
||||
// added are soft-deleted (skip any that already carry a balance, i.e. are in use).
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE Accounts
|
||||
SET IsDeleted = 1
|
||||
WHERE AccountNumber = '2400'
|
||||
AND IsDeleted = 0
|
||||
AND Name = 'Payroll Liabilities'
|
||||
AND CurrentBalance = 0;
|
||||
");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2051));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2056));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2057));
|
||||
}
|
||||
}
|
||||
}
|
||||
-11382
File diff suppressed because it is too large
Load Diff
-123
@@ -1,123 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class FixGiftCertificateLiabilityAccount : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// O5 remediation. Account 2500 is resolved by number as the Gift Certificate Liability
|
||||
// (GiftCertificatesController), but the default-company chart seeded it as "Long-Term Loan",
|
||||
// so GC obligations were mislabeled there and the AccountingGapsPhase2 GC-liability seed was
|
||||
// skipped by its NOT EXISTS guard.
|
||||
|
||||
// 1) Preserve the long-term loan account: move it to 2900 for any company whose 2500 is still
|
||||
// named "Long-Term Loan" and that lacks a 2900. (Companies onboarded via the per-tenant
|
||||
// seeder already have a 2900 "Business Loan", so the NOT EXISTS guard leaves them alone.)
|
||||
migrationBuilder.Sql(@"
|
||||
INSERT INTO Accounts
|
||||
(AccountNumber, Name, AccountType, AccountSubType,
|
||||
IsSystem, IsActive, Description,
|
||||
CompanyId, CreatedAt, IsDeleted, CurrentBalance, OpeningBalance)
|
||||
SELECT '2900', 'Long-Term Loan',
|
||||
2, -- AccountType.Liability
|
||||
11, -- AccountSubType.LongTermLiability
|
||||
0, 1, 'Long-term equipment or business loan',
|
||||
c.Id, GETUTCDATE(), 0, 0, 0
|
||||
FROM Companies c
|
||||
WHERE c.IsDeleted = 0
|
||||
AND EXISTS (SELECT 1 FROM Accounts a WHERE a.CompanyId = c.Id
|
||||
AND a.AccountNumber = '2500' AND a.IsDeleted = 0 AND a.Name = 'Long-Term Loan')
|
||||
AND NOT EXISTS (SELECT 1 FROM Accounts a WHERE a.CompanyId = c.Id
|
||||
AND a.AccountNumber = '2900' AND a.IsDeleted = 0);
|
||||
");
|
||||
|
||||
// 2) Relabel the mislabeled 2500 to Gift Certificate Liability (only where it still carries the
|
||||
// old default name, so a user's own rename is preserved). Id / number / balance untouched.
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE Accounts
|
||||
SET Name = 'Gift Certificate Liability',
|
||||
Description = 'Outstanding gift certificate obligations owed to certificate holders',
|
||||
IsSystem = 1
|
||||
WHERE AccountNumber = '2500' AND IsDeleted = 0 AND Name = 'Long-Term Loan';
|
||||
");
|
||||
|
||||
// 3) Safety net: ensure every company has a 2500 Gift Certificate Liability (covers any tenant
|
||||
// onboarded after AccountingGapsPhase2 ran that never received one — without it GC GL no-ops).
|
||||
migrationBuilder.Sql(@"
|
||||
INSERT INTO Accounts
|
||||
(AccountNumber, Name, AccountType, AccountSubType,
|
||||
IsSystem, IsActive, Description,
|
||||
CompanyId, CreatedAt, IsDeleted, CurrentBalance, OpeningBalance)
|
||||
SELECT '2500', 'Gift Certificate Liability',
|
||||
2, -- AccountType.Liability
|
||||
12, -- AccountSubType.OtherCurrentLiability
|
||||
1, 1, 'Outstanding gift certificate obligations owed to certificate holders',
|
||||
c.Id, GETUTCDATE(), 0, 0, 0
|
||||
FROM Companies c
|
||||
WHERE c.IsDeleted = 0
|
||||
AND NOT EXISTS (SELECT 1 FROM Accounts a WHERE a.CompanyId = c.Id
|
||||
AND a.AccountNumber = '2500' AND a.IsDeleted = 0);
|
||||
");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 20, 0, 29, 46, 909, DateTimeKind.Utc).AddTicks(3976));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 20, 0, 29, 46, 909, DateTimeKind.Utc).AddTicks(3981));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 20, 0, 29, 46, 909, DateTimeKind.Utc).AddTicks(3982));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Best-effort reversal: the 2500 relabel is intentionally NOT undone (reverting would
|
||||
// re-introduce the mislabel), and 2500 rows are left in place since most pre-date this
|
||||
// migration. Only soft-delete the empty 2900 accounts this migration added.
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE Accounts SET IsDeleted = 1
|
||||
WHERE AccountNumber = '2900' AND IsDeleted = 0 AND Name = 'Long-Term Loan' AND CurrentBalance = 0;
|
||||
");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 19, 23, 31, 4, 905, DateTimeKind.Utc).AddTicks(7611));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 19, 23, 31, 4, 905, DateTimeKind.Utc).AddTicks(7618));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 19, 23, 31, 4, 905, DateTimeKind.Utc).AddTicks(7619));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
-11412
File diff suppressed because it is too large
Load Diff
-151
@@ -1,151 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCompanyDefaultGlAccounts : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "DefaultCogsAccountId",
|
||||
table: "CompanyPreferences",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "DefaultInventoryAccountId",
|
||||
table: "CompanyPreferences",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "DefaultRevenueAccountId",
|
||||
table: "CompanyPreferences",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4507));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4514));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4515));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CompanyPreferences_DefaultCogsAccountId",
|
||||
table: "CompanyPreferences",
|
||||
column: "DefaultCogsAccountId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CompanyPreferences_DefaultInventoryAccountId",
|
||||
table: "CompanyPreferences",
|
||||
column: "DefaultInventoryAccountId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CompanyPreferences_DefaultRevenueAccountId",
|
||||
table: "CompanyPreferences",
|
||||
column: "DefaultRevenueAccountId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_CompanyPreferences_Accounts_DefaultCogsAccountId",
|
||||
table: "CompanyPreferences",
|
||||
column: "DefaultCogsAccountId",
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_CompanyPreferences_Accounts_DefaultInventoryAccountId",
|
||||
table: "CompanyPreferences",
|
||||
column: "DefaultInventoryAccountId",
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_CompanyPreferences_Accounts_DefaultRevenueAccountId",
|
||||
table: "CompanyPreferences",
|
||||
column: "DefaultRevenueAccountId",
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_CompanyPreferences_Accounts_DefaultCogsAccountId",
|
||||
table: "CompanyPreferences");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_CompanyPreferences_Accounts_DefaultInventoryAccountId",
|
||||
table: "CompanyPreferences");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_CompanyPreferences_Accounts_DefaultRevenueAccountId",
|
||||
table: "CompanyPreferences");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_CompanyPreferences_DefaultCogsAccountId",
|
||||
table: "CompanyPreferences");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_CompanyPreferences_DefaultInventoryAccountId",
|
||||
table: "CompanyPreferences");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_CompanyPreferences_DefaultRevenueAccountId",
|
||||
table: "CompanyPreferences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DefaultCogsAccountId",
|
||||
table: "CompanyPreferences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DefaultInventoryAccountId",
|
||||
table: "CompanyPreferences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DefaultRevenueAccountId",
|
||||
table: "CompanyPreferences");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 20, 0, 29, 46, 909, DateTimeKind.Utc).AddTicks(3976));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 20, 0, 29, 46, 909, DateTimeKind.Utc).AddTicks(3981));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 20, 0, 29, 46, 909, DateTimeKind.Utc).AddTicks(3982));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2185,9 +2185,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("DefaultCogsAccountId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("DefaultCurrency")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
@@ -2196,9 +2193,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("DefaultInventoryAccountId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("DefaultJobPriority")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
@@ -2210,9 +2204,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<int>("DefaultQuoteValidityDays")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int?>("DefaultRevenueAccountId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("DefaultTimeFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
@@ -2389,12 +2380,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.HasIndex("CompanyId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("DefaultCogsAccountId");
|
||||
|
||||
b.HasIndex("DefaultInventoryAccountId");
|
||||
|
||||
b.HasIndex("DefaultRevenueAccountId");
|
||||
|
||||
b.ToTable("CompanyPreferences");
|
||||
});
|
||||
|
||||
@@ -7256,7 +7241,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4507),
|
||||
CreatedAt = new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2051),
|
||||
Description = "Standard pricing for regular customers",
|
||||
DiscountPercent = 0m,
|
||||
IsActive = true,
|
||||
@@ -7267,7 +7252,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 2,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4514),
|
||||
CreatedAt = new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2056),
|
||||
Description = "5% discount for preferred customers",
|
||||
DiscountPercent = 5m,
|
||||
IsActive = true,
|
||||
@@ -7278,7 +7263,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 3,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4515),
|
||||
CreatedAt = new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2057),
|
||||
Description = "10% discount for premium customers",
|
||||
DiscountPercent = 10m,
|
||||
IsActive = true,
|
||||
@@ -9595,21 +9580,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("PowderCoating.Core.Entities.Account", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("DefaultCogsAccountId")
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
|
||||
b.HasOne("PowderCoating.Core.Entities.Account", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("DefaultInventoryAccountId")
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
|
||||
b.HasOne("PowderCoating.Core.Entities.Account", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("DefaultRevenueAccountId")
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
|
||||
b.Navigation("Company");
|
||||
});
|
||||
|
||||
|
||||
@@ -2866,10 +2866,6 @@ public class CsvImportService : ICsvImportService
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sub-type is authoritative (matches account create/edit): derive the parent type
|
||||
// from it so a mismatched CSV pair can't post with the wrong debit/credit sign.
|
||||
accountType = AccountClassification.TypeForSubType(accountSubType);
|
||||
|
||||
DateTime? openingBalanceDate = null;
|
||||
if (!string.IsNullOrWhiteSpace(record.OpeningBalanceDate)
|
||||
&& DateTime.TryParse(record.OpeningBalanceDate, out var parsedDate))
|
||||
@@ -3212,33 +3208,6 @@ public class CsvImportService : ICsvImportService
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] GenerateInvoiceItemTemplate()
|
||||
{
|
||||
using var memoryStream = new MemoryStream();
|
||||
using var writer = new StreamWriter(memoryStream);
|
||||
using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture));
|
||||
|
||||
csv.WriteHeader<InvoiceItemImportDto>();
|
||||
csv.NextRecord();
|
||||
|
||||
csv.WriteRecord(new InvoiceItemImportDto
|
||||
{
|
||||
InvoiceNumber = "INV-2601-0001",
|
||||
Description = "Powder coating - 4 wheels",
|
||||
Quantity = 4,
|
||||
UnitPrice = 75.00m,
|
||||
TotalPrice = 300.00m,
|
||||
ColorName = "Gloss Black",
|
||||
RevenueAccountNumber = "47905",
|
||||
DisplayOrder = 0,
|
||||
Notes = ""
|
||||
});
|
||||
csv.NextRecord();
|
||||
|
||||
writer.Flush();
|
||||
return memoryStream.ToArray();
|
||||
}
|
||||
|
||||
public byte[] GeneratePaymentTemplate()
|
||||
{
|
||||
using var memoryStream = new MemoryStream();
|
||||
@@ -3250,13 +3219,12 @@ public class CsvImportService : ICsvImportService
|
||||
|
||||
csv.WriteRecord(new PaymentImportDto
|
||||
{
|
||||
InvoiceNumber = "INV-2601-0001",
|
||||
Amount = 250.00m,
|
||||
PaymentDate = DateTime.Today,
|
||||
PaymentMethod = "Check",
|
||||
DepositAccountNumber = "10100",
|
||||
Reference = "CHK-1234",
|
||||
Notes = ""
|
||||
InvoiceNumber = "INV-2601-0001",
|
||||
Amount = 250.00m,
|
||||
PaymentDate = DateTime.Today,
|
||||
PaymentMethod = "Check",
|
||||
Reference = "CHK-1234",
|
||||
Notes = ""
|
||||
});
|
||||
csv.NextRecord();
|
||||
|
||||
@@ -3264,651 +3232,6 @@ public class CsvImportService : ICsvImportService
|
||||
return memoryStream.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imports invoice line items from CSV. Each row is matched to its parent invoice by InvoiceNumber;
|
||||
/// the revenue account is resolved (optional) from RevenueAccountNumber against the Chart of Accounts.
|
||||
/// Idempotent — an invoice line with the same description + total + display order is skipped, so the
|
||||
/// import can be safely re-run. Run AFTER invoices have been imported (the parents must exist).
|
||||
/// </summary>
|
||||
public async Task<CsvImportResultDto> ImportInvoiceItemsAsync(Stream csvStream, int companyId)
|
||||
{
|
||||
var result = new CsvImportResultDto();
|
||||
var rowNumber = 0;
|
||||
|
||||
try
|
||||
{
|
||||
using var reader = new StreamReader(csvStream);
|
||||
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
|
||||
{
|
||||
HeaderValidated = null,
|
||||
MissingFieldFound = null
|
||||
});
|
||||
|
||||
var records = csv.GetRecords<InvoiceItemImportDto>().ToList();
|
||||
result.TotalRows = records.Count;
|
||||
|
||||
_logger.LogInformation("Starting import of {Count} invoice line items for company {CompanyId}", records.Count, companyId);
|
||||
|
||||
var invoices = await _unitOfWork.Invoices.GetAllAsync(false, i => i.InvoiceItems);
|
||||
var invoiceByNumber = invoices.Where(i => !string.IsNullOrEmpty(i.InvoiceNumber))
|
||||
.ToDictionary(i => i.InvoiceNumber.Trim(), i => i, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var accounts = await _unitOfWork.Accounts.GetAllAsync();
|
||||
var accountByNumber = accounts
|
||||
.Where(a => !string.IsNullOrEmpty(a.AccountNumber))
|
||||
.GroupBy(a => a.AccountNumber.Trim())
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var record in records)
|
||||
{
|
||||
rowNumber++;
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(record.InvoiceNumber))
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: InvoiceNumber is required.");
|
||||
result.ErrorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!invoiceByNumber.TryGetValue(record.InvoiceNumber.Trim(), out var invoice))
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: Invoice '{record.InvoiceNumber}' not found.");
|
||||
result.ErrorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var description = StripQuotes(record.Description)?.Trim() ?? "";
|
||||
|
||||
// Idempotency: skip a line that already exists on this invoice.
|
||||
var isDuplicate = invoice.InvoiceItems.Any(it =>
|
||||
string.Equals(it.Description, description, StringComparison.OrdinalIgnoreCase)
|
||||
&& it.TotalPrice == record.TotalPrice
|
||||
&& it.DisplayOrder == record.DisplayOrder);
|
||||
if (isDuplicate)
|
||||
{
|
||||
result.Warnings.Add($"Row {rowNumber}: Line '{description}' already exists on invoice '{record.InvoiceNumber}' — skipped.");
|
||||
result.SkippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve the optional revenue account by number so revenue attribution is preserved.
|
||||
int? revenueAccountId = null;
|
||||
var cleanRevenueAccount = StripQuotes(record.RevenueAccountNumber)?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(cleanRevenueAccount))
|
||||
{
|
||||
if (accountByNumber.TryGetValue(cleanRevenueAccount, out var revenueAccount))
|
||||
revenueAccountId = revenueAccount.Id;
|
||||
else
|
||||
result.Warnings.Add($"Row {rowNumber}: Revenue account '{cleanRevenueAccount}' not found in Chart of Accounts — line imported without a revenue account.");
|
||||
}
|
||||
|
||||
var item = new Core.Entities.InvoiceItem
|
||||
{
|
||||
InvoiceId = invoice.Id,
|
||||
CompanyId = companyId,
|
||||
Description = description,
|
||||
Quantity = record.Quantity,
|
||||
UnitPrice = record.UnitPrice,
|
||||
TotalPrice = record.TotalPrice,
|
||||
ColorName = string.IsNullOrWhiteSpace(record.ColorName) ? null : record.ColorName.Trim(),
|
||||
RevenueAccountId = revenueAccountId,
|
||||
DisplayOrder = record.DisplayOrder,
|
||||
Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim()
|
||||
};
|
||||
|
||||
await _unitOfWork.InvoiceItems.AddAsync(item);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Keep the in-memory invoice current so later rows dedup against it correctly.
|
||||
invoice.InvoiceItems.Add(item);
|
||||
|
||||
result.SuccessCount++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: {ex.Message}");
|
||||
result.ErrorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
result.Success = result.ErrorCount == 0 || result.SuccessCount > 0;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Fatal error during invoice item CSV import for company {CompanyId}", companyId);
|
||||
result.Errors.Add($"Fatal error: {ex.Message}");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imports vendor bill headers from CSV. Vendor is resolved by name and the AP account by number.
|
||||
/// Dedup by BillNumber. Line items import separately via <see cref="ImportBillLineItemsAsync"/>.
|
||||
/// </summary>
|
||||
public async Task<CsvImportResultDto> ImportBillsAsync(Stream csvStream, int companyId)
|
||||
{
|
||||
var result = new CsvImportResultDto();
|
||||
var rowNumber = 0;
|
||||
|
||||
try
|
||||
{
|
||||
using var reader = new StreamReader(csvStream);
|
||||
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
|
||||
{
|
||||
HeaderValidated = null,
|
||||
MissingFieldFound = null
|
||||
});
|
||||
|
||||
var records = csv.GetRecords<BillImportDto>().ToList();
|
||||
result.TotalRows = records.Count;
|
||||
|
||||
var accounts = await _unitOfWork.Accounts.GetAllAsync();
|
||||
var accountByNumber = accounts.Where(a => !string.IsNullOrEmpty(a.AccountNumber))
|
||||
.GroupBy(a => a.AccountNumber.Trim())
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var vendors = await _unitOfWork.Vendors.GetAllAsync();
|
||||
var vendorByName = vendors.Where(v => !string.IsNullOrEmpty(v.CompanyName))
|
||||
.GroupBy(v => v.CompanyName.Trim())
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var existingBills = await _unitOfWork.Bills.GetAllAsync();
|
||||
var existingBillNumbers = existingBills.Where(b => !string.IsNullOrEmpty(b.BillNumber))
|
||||
.Select(b => b.BillNumber.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var record in records)
|
||||
{
|
||||
rowNumber++;
|
||||
try
|
||||
{
|
||||
var billNumber = StripQuotes(record.BillNumber)?.Trim() ?? "";
|
||||
if (string.IsNullOrWhiteSpace(billNumber))
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: BillNumber is required.");
|
||||
result.ErrorCount++;
|
||||
continue;
|
||||
}
|
||||
if (existingBillNumbers.Contains(billNumber))
|
||||
{
|
||||
result.Warnings.Add($"Row {rowNumber}: Bill '{billNumber}' already exists — skipped.");
|
||||
result.SkippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var cleanVendor = StripQuotes(record.VendorName)?.Trim() ?? "";
|
||||
if (!vendorByName.TryGetValue(cleanVendor, out var vendor))
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: Vendor '{cleanVendor}' not found.");
|
||||
result.ErrorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var cleanApAccount = StripQuotes(record.APAccountNumber)?.Trim() ?? "";
|
||||
if (!accountByNumber.TryGetValue(cleanApAccount, out var apAccount))
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: AP account '{cleanApAccount}' not found in Chart of Accounts.");
|
||||
result.ErrorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<BillStatus>(record.Status?.Trim(), true, out var status))
|
||||
status = BillStatus.Open;
|
||||
|
||||
var bill = new Core.Entities.Bill
|
||||
{
|
||||
CompanyId = companyId,
|
||||
BillNumber = billNumber,
|
||||
VendorInvoiceNumber = string.IsNullOrWhiteSpace(record.VendorInvoiceNumber) ? null : record.VendorInvoiceNumber.Trim(),
|
||||
VendorId = vendor.Id,
|
||||
APAccountId = apAccount.Id,
|
||||
BillDate = record.BillDate == default ? DateTime.UtcNow.Date : record.BillDate,
|
||||
DueDate = record.DueDate,
|
||||
Status = status,
|
||||
Terms = string.IsNullOrWhiteSpace(record.Terms) ? null : record.Terms.Trim(),
|
||||
Memo = string.IsNullOrWhiteSpace(record.Memo) ? null : record.Memo.Trim(),
|
||||
SubTotal = record.SubTotal,
|
||||
TaxPercent = record.TaxPercent,
|
||||
TaxAmount = record.TaxAmount,
|
||||
Total = record.Total,
|
||||
AmountPaid = record.AmountPaid
|
||||
};
|
||||
|
||||
await _unitOfWork.Bills.AddAsync(bill);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
existingBillNumbers.Add(billNumber);
|
||||
result.SuccessCount++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: {ex.Message}");
|
||||
result.ErrorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
result.Success = result.ErrorCount == 0 || result.SuccessCount > 0;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Fatal error during bill CSV import for company {CompanyId}", companyId);
|
||||
result.Errors.Add($"Fatal error: {ex.Message}");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imports vendor bill line items from CSV. Each line is matched to its parent bill by BillNumber;
|
||||
/// the expense/asset account (optional) and job (optional) are resolved by number. Idempotent by
|
||||
/// bill + description + amount + display order. Run after bills have been imported.
|
||||
/// </summary>
|
||||
public async Task<CsvImportResultDto> ImportBillLineItemsAsync(Stream csvStream, int companyId)
|
||||
{
|
||||
var result = new CsvImportResultDto();
|
||||
var rowNumber = 0;
|
||||
|
||||
try
|
||||
{
|
||||
using var reader = new StreamReader(csvStream);
|
||||
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
|
||||
{
|
||||
HeaderValidated = null,
|
||||
MissingFieldFound = null
|
||||
});
|
||||
|
||||
var records = csv.GetRecords<BillLineItemImportDto>().ToList();
|
||||
result.TotalRows = records.Count;
|
||||
|
||||
var bills = await _unitOfWork.Bills.GetAllAsync(false, b => b.LineItems);
|
||||
var billByNumber = bills.Where(b => !string.IsNullOrEmpty(b.BillNumber))
|
||||
.ToDictionary(b => b.BillNumber.Trim(), b => b, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var accounts = await _unitOfWork.Accounts.GetAllAsync();
|
||||
var accountByNumber = accounts.Where(a => !string.IsNullOrEmpty(a.AccountNumber))
|
||||
.GroupBy(a => a.AccountNumber.Trim())
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var jobs = await _unitOfWork.Jobs.GetAllAsync();
|
||||
var jobByNumber = jobs.Where(j => !string.IsNullOrEmpty(j.JobNumber))
|
||||
.ToDictionary(j => j.JobNumber.Trim(), j => j, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var record in records)
|
||||
{
|
||||
rowNumber++;
|
||||
try
|
||||
{
|
||||
var billNumber = StripQuotes(record.BillNumber)?.Trim() ?? "";
|
||||
if (!billByNumber.TryGetValue(billNumber, out var bill))
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: Bill '{record.BillNumber}' not found.");
|
||||
result.ErrorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var description = StripQuotes(record.Description)?.Trim() ?? "";
|
||||
var isDuplicate = bill.LineItems.Any(li =>
|
||||
string.Equals(li.Description, description, StringComparison.OrdinalIgnoreCase)
|
||||
&& li.Amount == record.Amount && li.DisplayOrder == record.DisplayOrder);
|
||||
if (isDuplicate)
|
||||
{
|
||||
result.Warnings.Add($"Row {rowNumber}: Line '{description}' already exists on bill '{billNumber}' — skipped.");
|
||||
result.SkippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
int? accountId = null;
|
||||
var cleanAccount = StripQuotes(record.AccountNumber)?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(cleanAccount))
|
||||
{
|
||||
if (accountByNumber.TryGetValue(cleanAccount, out var account))
|
||||
accountId = account.Id;
|
||||
else
|
||||
result.Warnings.Add($"Row {rowNumber}: Account '{cleanAccount}' not found — line imported without an account.");
|
||||
}
|
||||
|
||||
int? jobId = null;
|
||||
var cleanJob = StripQuotes(record.JobNumber)?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(cleanJob) && jobByNumber.TryGetValue(cleanJob, out var job))
|
||||
jobId = job.Id;
|
||||
|
||||
var lineItem = new Core.Entities.BillLineItem
|
||||
{
|
||||
CompanyId = companyId,
|
||||
BillId = bill.Id,
|
||||
AccountId = accountId,
|
||||
JobId = jobId,
|
||||
Description = description,
|
||||
Quantity = record.Quantity,
|
||||
UnitPrice = record.UnitPrice,
|
||||
Amount = record.Amount,
|
||||
DisplayOrder = record.DisplayOrder
|
||||
};
|
||||
|
||||
await _unitOfWork.BillLineItems.AddAsync(lineItem);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
bill.LineItems.Add(lineItem);
|
||||
result.SuccessCount++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: {ex.Message}");
|
||||
result.ErrorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
result.Success = result.ErrorCount == 0 || result.SuccessCount > 0;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Fatal error during bill line item CSV import for company {CompanyId}", companyId);
|
||||
result.Errors.Add($"Fatal error: {ex.Message}");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imports customer deposits from CSV. Customer is resolved by name, the bank account by number,
|
||||
/// and the optional applied invoice by number. Dedup by ReceiptNumber.
|
||||
/// </summary>
|
||||
public async Task<CsvImportResultDto> ImportDepositsAsync(Stream csvStream, int companyId)
|
||||
{
|
||||
var result = new CsvImportResultDto();
|
||||
var rowNumber = 0;
|
||||
|
||||
try
|
||||
{
|
||||
using var reader = new StreamReader(csvStream);
|
||||
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
|
||||
{
|
||||
HeaderValidated = null,
|
||||
MissingFieldFound = null
|
||||
});
|
||||
|
||||
var records = csv.GetRecords<DepositImportDto>().ToList();
|
||||
result.TotalRows = records.Count;
|
||||
|
||||
var accounts = await _unitOfWork.Accounts.GetAllAsync();
|
||||
var accountByNumber = accounts.Where(a => !string.IsNullOrEmpty(a.AccountNumber))
|
||||
.GroupBy(a => a.AccountNumber.Trim())
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var customers = await _unitOfWork.Customers.GetAllAsync();
|
||||
var customerByName = new Dictionary<string, Core.Entities.Customer>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var c in customers)
|
||||
{
|
||||
var name = !string.IsNullOrWhiteSpace(c.CompanyName)
|
||||
? c.CompanyName.Trim()
|
||||
: $"{c.ContactFirstName} {c.ContactLastName}".Trim();
|
||||
if (!string.IsNullOrWhiteSpace(name)) customerByName[name] = c;
|
||||
}
|
||||
|
||||
var invoices = await _unitOfWork.Invoices.GetAllAsync();
|
||||
var invoiceByNumber = invoices.Where(i => !string.IsNullOrEmpty(i.InvoiceNumber))
|
||||
.ToDictionary(i => i.InvoiceNumber.Trim(), i => i, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var validMethods = Enum.GetNames<PaymentMethod>()
|
||||
.ToDictionary(n => n.ToLower(), n => Enum.Parse<PaymentMethod>(n), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var existingReceipts = (await _unitOfWork.Deposits.GetAllAsync())
|
||||
.Where(d => !string.IsNullOrEmpty(d.ReceiptNumber))
|
||||
.Select(d => d.ReceiptNumber.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var record in records)
|
||||
{
|
||||
rowNumber++;
|
||||
try
|
||||
{
|
||||
var receiptNumber = StripQuotes(record.ReceiptNumber)?.Trim() ?? "";
|
||||
if (!string.IsNullOrWhiteSpace(receiptNumber) && existingReceipts.Contains(receiptNumber))
|
||||
{
|
||||
result.Warnings.Add($"Row {rowNumber}: Deposit '{receiptNumber}' already exists — skipped.");
|
||||
result.SkippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var cleanCustomer = StripQuotes(record.CustomerName)?.Trim() ?? "";
|
||||
if (!customerByName.TryGetValue(cleanCustomer, out var customer))
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: Customer '{cleanCustomer}' not found.");
|
||||
result.ErrorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!validMethods.TryGetValue(record.PaymentMethod?.Trim() ?? "", out var method))
|
||||
method = PaymentMethod.Cash;
|
||||
|
||||
int? depositAccountId = null;
|
||||
var cleanDepositAccount = StripQuotes(record.DepositAccountNumber)?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(cleanDepositAccount))
|
||||
{
|
||||
if (accountByNumber.TryGetValue(cleanDepositAccount, out var depositAccount))
|
||||
depositAccountId = depositAccount.Id;
|
||||
else
|
||||
result.Warnings.Add($"Row {rowNumber}: Deposit account '{cleanDepositAccount}' not found — deposit imported without a bank account.");
|
||||
}
|
||||
|
||||
int? appliedInvoiceId = null;
|
||||
var cleanInvoice = StripQuotes(record.AppliedToInvoiceNumber)?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(cleanInvoice) && invoiceByNumber.TryGetValue(cleanInvoice, out var appliedInvoice))
|
||||
appliedInvoiceId = appliedInvoice.Id;
|
||||
|
||||
var deposit = new Core.Entities.Deposit
|
||||
{
|
||||
CompanyId = companyId,
|
||||
ReceiptNumber = receiptNumber,
|
||||
CustomerId = customer.Id,
|
||||
Amount = record.Amount,
|
||||
PaymentMethod = method,
|
||||
ReceivedDate = record.ReceivedDate == default ? DateTime.UtcNow.Date : record.ReceivedDate,
|
||||
DepositAccountId = depositAccountId,
|
||||
AppliedToInvoiceId = appliedInvoiceId,
|
||||
AppliedDate = record.AppliedDate,
|
||||
Reference = string.IsNullOrWhiteSpace(record.Reference) ? null : record.Reference.Trim(),
|
||||
Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim()
|
||||
};
|
||||
|
||||
await _unitOfWork.Deposits.AddAsync(deposit);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
if (!string.IsNullOrWhiteSpace(receiptNumber)) existingReceipts.Add(receiptNumber);
|
||||
result.SuccessCount++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: {ex.Message}");
|
||||
result.ErrorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
result.Success = result.ErrorCount == 0 || result.SuccessCount > 0;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Fatal error during deposit CSV import for company {CompanyId}", companyId);
|
||||
result.Errors.Add($"Fatal error: {ex.Message}");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imports journal entry headers from CSV. Dedup by EntryNumber. The debit/credit lines import
|
||||
/// separately via <see cref="ImportJournalEntryLinesAsync"/>.
|
||||
/// </summary>
|
||||
public async Task<CsvImportResultDto> ImportJournalEntriesAsync(Stream csvStream, int companyId)
|
||||
{
|
||||
var result = new CsvImportResultDto();
|
||||
var rowNumber = 0;
|
||||
|
||||
try
|
||||
{
|
||||
using var reader = new StreamReader(csvStream);
|
||||
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
|
||||
{
|
||||
HeaderValidated = null,
|
||||
MissingFieldFound = null
|
||||
});
|
||||
|
||||
var records = csv.GetRecords<JournalEntryImportDto>().ToList();
|
||||
result.TotalRows = records.Count;
|
||||
|
||||
var existingNumbers = (await _unitOfWork.JournalEntries.GetAllAsync())
|
||||
.Where(j => !string.IsNullOrEmpty(j.EntryNumber))
|
||||
.Select(j => j.EntryNumber.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var record in records)
|
||||
{
|
||||
rowNumber++;
|
||||
try
|
||||
{
|
||||
var entryNumber = StripQuotes(record.EntryNumber)?.Trim() ?? "";
|
||||
if (string.IsNullOrWhiteSpace(entryNumber))
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: EntryNumber is required.");
|
||||
result.ErrorCount++;
|
||||
continue;
|
||||
}
|
||||
if (existingNumbers.Contains(entryNumber))
|
||||
{
|
||||
result.Warnings.Add($"Row {rowNumber}: Journal entry '{entryNumber}' already exists — skipped.");
|
||||
result.SkippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<JournalEntryStatus>(record.Status?.Trim(), true, out var status))
|
||||
status = JournalEntryStatus.Draft;
|
||||
|
||||
var entry = new Core.Entities.JournalEntry
|
||||
{
|
||||
CompanyId = companyId,
|
||||
EntryNumber = entryNumber,
|
||||
EntryDate = record.EntryDate == default ? DateTime.UtcNow.Date : record.EntryDate,
|
||||
Reference = string.IsNullOrWhiteSpace(record.Reference) ? null : record.Reference.Trim(),
|
||||
Description = string.IsNullOrWhiteSpace(record.Description) ? null : record.Description.Trim(),
|
||||
Status = status,
|
||||
PostedAt = status == JournalEntryStatus.Posted ? DateTime.UtcNow : null
|
||||
};
|
||||
|
||||
await _unitOfWork.JournalEntries.AddAsync(entry);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
existingNumbers.Add(entryNumber);
|
||||
result.SuccessCount++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: {ex.Message}");
|
||||
result.ErrorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
result.Success = result.ErrorCount == 0 || result.SuccessCount > 0;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Fatal error during journal entry CSV import for company {CompanyId}", companyId);
|
||||
result.Errors.Add($"Fatal error: {ex.Message}");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imports journal entry lines from CSV. Each line is matched to its parent entry by EntryNumber
|
||||
/// and the account resolved (required) from AccountNumber. Idempotent by entry + account + amounts
|
||||
/// + line order. Run after journal entry headers have been imported.
|
||||
/// </summary>
|
||||
public async Task<CsvImportResultDto> ImportJournalEntryLinesAsync(Stream csvStream, int companyId)
|
||||
{
|
||||
var result = new CsvImportResultDto();
|
||||
var rowNumber = 0;
|
||||
|
||||
try
|
||||
{
|
||||
using var reader = new StreamReader(csvStream);
|
||||
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
|
||||
{
|
||||
HeaderValidated = null,
|
||||
MissingFieldFound = null
|
||||
});
|
||||
|
||||
var records = csv.GetRecords<JournalEntryLineImportDto>().ToList();
|
||||
result.TotalRows = records.Count;
|
||||
|
||||
var entries = await _unitOfWork.JournalEntries.GetAllAsync(false, j => j.Lines);
|
||||
var entryByNumber = entries.Where(j => !string.IsNullOrEmpty(j.EntryNumber))
|
||||
.ToDictionary(j => j.EntryNumber.Trim(), j => j, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var accounts = await _unitOfWork.Accounts.GetAllAsync();
|
||||
var accountByNumber = accounts.Where(a => !string.IsNullOrEmpty(a.AccountNumber))
|
||||
.GroupBy(a => a.AccountNumber.Trim())
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var record in records)
|
||||
{
|
||||
rowNumber++;
|
||||
try
|
||||
{
|
||||
var entryNumber = StripQuotes(record.EntryNumber)?.Trim() ?? "";
|
||||
if (!entryByNumber.TryGetValue(entryNumber, out var entry))
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: Journal entry '{record.EntryNumber}' not found.");
|
||||
result.ErrorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var cleanAccount = StripQuotes(record.AccountNumber)?.Trim() ?? "";
|
||||
if (!accountByNumber.TryGetValue(cleanAccount, out var account))
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: Account '{cleanAccount}' not found in Chart of Accounts.");
|
||||
result.ErrorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var isDuplicate = entry.Lines.Any(l =>
|
||||
l.AccountId == account.Id && l.DebitAmount == record.DebitAmount
|
||||
&& l.CreditAmount == record.CreditAmount && l.LineOrder == record.LineOrder);
|
||||
if (isDuplicate)
|
||||
{
|
||||
result.Warnings.Add($"Row {rowNumber}: Line for account '{cleanAccount}' already exists on entry '{entryNumber}' — skipped.");
|
||||
result.SkippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var line = new Core.Entities.JournalEntryLine
|
||||
{
|
||||
CompanyId = companyId,
|
||||
JournalEntryId = entry.Id,
|
||||
AccountId = account.Id,
|
||||
DebitAmount = record.DebitAmount,
|
||||
CreditAmount = record.CreditAmount,
|
||||
Description = string.IsNullOrWhiteSpace(record.Description) ? null : record.Description.Trim(),
|
||||
LineOrder = record.LineOrder
|
||||
};
|
||||
|
||||
await _unitOfWork.JournalEntryLines.AddAsync(line);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
entry.Lines.Add(line);
|
||||
result.SuccessCount++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: {ex.Message}");
|
||||
result.ErrorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
result.Success = result.ErrorCount == 0 || result.SuccessCount > 0;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Fatal error during journal entry line CSV import for company {CompanyId}", companyId);
|
||||
result.Errors.Add($"Fatal error: {ex.Message}");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CsvImportResultDto> ImportPaymentsAsync(Stream csvStream, int companyId)
|
||||
{
|
||||
var result = new CsvImportResultDto();
|
||||
@@ -3933,14 +3256,6 @@ public class CsvImportService : ICsvImportService
|
||||
var invoiceByNumber = invoices.Where(i => !string.IsNullOrEmpty(i.InvoiceNumber))
|
||||
.ToDictionary(i => i.InvoiceNumber.Trim(), i => i, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Account lookup for resolving the deposit (bank) account by number — optional per row.
|
||||
// Mirrors the expense import so payments round-trip with their bank-account linkage intact.
|
||||
var accounts = await _unitOfWork.Accounts.GetAllAsync();
|
||||
var accountByNumber = accounts
|
||||
.Where(a => !string.IsNullOrEmpty(a.AccountNumber))
|
||||
.GroupBy(a => a.AccountNumber.Trim())
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var validMethods = Enum.GetNames<PaymentMethod>()
|
||||
.ToDictionary(n => n.ToLower(), n => Enum.Parse<PaymentMethod>(n), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -3986,28 +3301,15 @@ public class CsvImportService : ICsvImportService
|
||||
method = PaymentMethod.Cash;
|
||||
}
|
||||
|
||||
// Resolve the optional deposit (bank) account by number so the balance recalc can
|
||||
// post this payment. A blank value is fine; an unknown number warns but still imports.
|
||||
int? depositAccountId = null;
|
||||
var cleanDepositAccount = StripQuotes(record.DepositAccountNumber)?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(cleanDepositAccount))
|
||||
{
|
||||
if (accountByNumber.TryGetValue(cleanDepositAccount, out var depositAccount))
|
||||
depositAccountId = depositAccount.Id;
|
||||
else
|
||||
result.Warnings.Add($"Row {rowNumber}: Deposit account '{cleanDepositAccount}' not found in Chart of Accounts — payment imported without a deposit account.");
|
||||
}
|
||||
|
||||
var payment = new Core.Entities.Payment
|
||||
{
|
||||
InvoiceId = invoice.Id,
|
||||
CompanyId = companyId,
|
||||
Amount = record.Amount,
|
||||
PaymentDate = new DateTime(paymentDate.Year, paymentDate.Month, paymentDate.Day, 0, 0, 0, DateTimeKind.Utc),
|
||||
PaymentMethod = method,
|
||||
DepositAccountId = depositAccountId,
|
||||
Reference = string.IsNullOrWhiteSpace(record.Reference) ? null : record.Reference.Trim(),
|
||||
Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim()
|
||||
InvoiceId = invoice.Id,
|
||||
CompanyId = companyId,
|
||||
Amount = record.Amount,
|
||||
PaymentDate = new DateTime(paymentDate.Year, paymentDate.Month, paymentDate.Day, 0, 0, 0, DateTimeKind.Utc),
|
||||
PaymentMethod = method,
|
||||
Reference = string.IsNullOrWhiteSpace(record.Reference) ? null : record.Reference.Trim(),
|
||||
Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim()
|
||||
};
|
||||
|
||||
await _unitOfWork.Payments.AddAsync(payment);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.DTOs.Accounting;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Accounting;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
@@ -19,65 +18,10 @@ namespace PowderCoating.Infrastructure.Services;
|
||||
public class FinancialReportService : IFinancialReportService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly ILedgerService _ledger;
|
||||
|
||||
public FinancialReportService(ApplicationDbContext context, ILedgerService ledger)
|
||||
public FinancialReportService(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
_ledger = ledger;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<BalanceReconciliationDto> GetBalanceReconciliationAsync(int companyId)
|
||||
{
|
||||
var companyName = await GetCompanyNameAsync(companyId);
|
||||
|
||||
var accounts = await _context.Accounts
|
||||
.Where(a => a.CompanyId == companyId && a.IsActive && !a.IsDeleted)
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.ToListAsync();
|
||||
|
||||
// Epoch start so LedgerService treats OpeningBalance as prior and all activity falls in-window —
|
||||
// identical to how AccountBalanceService.RecalculateAllAsync derives the authoritative balance.
|
||||
var epoch = new DateTime(2000, 1, 1);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var lines = new List<BalanceReconciliationLine>();
|
||||
decimal arControl = 0m, apControl = 0m;
|
||||
foreach (var a in accounts)
|
||||
{
|
||||
var ledger = await _ledger.GetAccountLedgerAsync(a.Id, epoch, now);
|
||||
var ledgerBalance = ledger?.ClosingBalance ?? 0m;
|
||||
lines.Add(new BalanceReconciliationLine
|
||||
{
|
||||
AccountId = a.Id,
|
||||
AccountNumber = a.AccountNumber,
|
||||
AccountName = a.Name,
|
||||
AccountType = a.AccountType,
|
||||
StoredBalance = a.CurrentBalance,
|
||||
LedgerBalance = ledgerBalance
|
||||
});
|
||||
if (a.AccountSubType == AccountSubType.AccountsReceivable) arControl += ledgerBalance;
|
||||
if (a.AccountSubType == AccountSubType.AccountsPayable) apControl += ledgerBalance;
|
||||
}
|
||||
|
||||
var arSubledger = await _context.Customers
|
||||
.Where(c => c.CompanyId == companyId && !c.IsDeleted)
|
||||
.SumAsync(c => (decimal?)c.CurrentBalance) ?? 0m;
|
||||
var apSubledger = await _context.Vendors
|
||||
.Where(v => v.CompanyId == companyId && !v.IsDeleted)
|
||||
.SumAsync(v => (decimal?)v.CurrentBalance) ?? 0m;
|
||||
|
||||
return new BalanceReconciliationDto
|
||||
{
|
||||
AsOf = now,
|
||||
CompanyName = companyName,
|
||||
AccountLines = lines,
|
||||
ArControlBalance = arControl,
|
||||
ArSubledgerTotal = arSubledger,
|
||||
ApControlBalance = apControl,
|
||||
ApSubledgerTotal = apSubledger
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -89,7 +33,7 @@ public class FinancialReportService : IFinancialReportService
|
||||
var isCash = accountingMethod == AccountingMethod.Cash;
|
||||
|
||||
var revenueAccounts = await _context.Accounts
|
||||
.Where(a => a.CompanyId == companyId && a.AccountType == AccountType.Revenue && a.IsActive)
|
||||
.Where(a => a.AccountType == AccountType.Revenue && a.IsActive)
|
||||
.ToDictionaryAsync(a => a.Id);
|
||||
|
||||
var revenueLines = new List<FinancialReportLine>();
|
||||
@@ -98,26 +42,17 @@ public class FinancialReportService : IFinancialReportService
|
||||
{
|
||||
// Cash basis: total payments received in period (not split by revenue account)
|
||||
var cashRevenue = await _context.Payments
|
||||
.Where(p => p.CompanyId == companyId && p.PaymentDate >= from && p.PaymentDate <= toEnd
|
||||
.Where(p => p.PaymentDate >= from && p.PaymentDate <= toEnd
|
||||
&& p.Invoice.Status != InvoiceStatus.Voided)
|
||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
if (cashRevenue > 0)
|
||||
revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Cash Receipts", Amount = cashRevenue });
|
||||
|
||||
// Cash refunds are cash paid back out — they reduce cash-basis revenue.
|
||||
var cashRefunds = await _context.Refunds
|
||||
.Where(r => r.CompanyId == companyId && !r.IsDeleted && r.RefundMethod != PaymentMethod.StoreCredit
|
||||
&& r.RefundDate >= from && r.RefundDate <= toEnd)
|
||||
.SumAsync(r => (decimal?)r.Amount) ?? 0m;
|
||||
if (cashRefunds > 0)
|
||||
revenueLines.Add(new FinancialReportLine { AccountNumber = "4960", AccountName = "Less: Refunds Paid", Amount = -cashRefunds });
|
||||
}
|
||||
else
|
||||
{
|
||||
// Accrual basis: revenue = invoice item amounts by invoice date
|
||||
var accrualRevenue = await _context.InvoiceItems
|
||||
.Where(ii => ii.CompanyId == companyId
|
||||
&& ii.RevenueAccountId != null
|
||||
.Where(ii => ii.RevenueAccountId != null
|
||||
&& ii.Invoice.Status != InvoiceStatus.Draft
|
||||
&& ii.Invoice.Status != InvoiceStatus.Voided
|
||||
&& ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd)
|
||||
@@ -137,8 +72,7 @@ public class FinancialReportService : IFinancialReportService
|
||||
.OrderBy(l => l.AccountNumber));
|
||||
|
||||
var unlinkedRevenue = await _context.InvoiceItems
|
||||
.Where(ii => ii.CompanyId == companyId
|
||||
&& ii.RevenueAccountId == null
|
||||
.Where(ii => ii.RevenueAccountId == null
|
||||
&& ii.Invoice.Status != InvoiceStatus.Draft
|
||||
&& ii.Invoice.Status != InvoiceStatus.Voided
|
||||
&& ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd)
|
||||
@@ -148,19 +82,13 @@ public class FinancialReportService : IFinancialReportService
|
||||
|
||||
// Contra-revenue: discounts granted and credit memos applied reduce gross revenue.
|
||||
var periodDiscounts = await _context.Invoices
|
||||
.Where(i => i.CompanyId == companyId && i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||
&& i.DiscountAmount > 0 && i.InvoiceDate >= from && i.InvoiceDate <= toEnd)
|
||||
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m;
|
||||
// Credit-memo contra-revenue is recognized at issue (DR Sales Discounts). Net for the period =
|
||||
// memos issued in the period minus the unapplied remainder of memos voided in the period.
|
||||
var periodCmIssued = await _context.CreditMemos
|
||||
.Where(m => m.CompanyId == companyId && m.IssueDate >= from && m.IssueDate <= toEnd)
|
||||
.SumAsync(m => (decimal?)m.Amount) ?? 0m;
|
||||
var periodCmVoided = await _context.CreditMemos
|
||||
.Where(m => m.CompanyId == companyId && m.Status == CreditMemoStatus.Voided
|
||||
&& m.UpdatedAt >= from && m.UpdatedAt <= toEnd)
|
||||
.SumAsync(m => (decimal?)(m.Amount - m.AmountApplied)) ?? 0m;
|
||||
var periodCredits = periodCmIssued - periodCmVoided;
|
||||
var periodCredits = await _context.CreditMemoApplications
|
||||
.Where(a => a.AppliedDate >= from && a.AppliedDate <= toEnd
|
||||
&& a.Invoice.Status != InvoiceStatus.Voided)
|
||||
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0m;
|
||||
var totalDeductions = periodDiscounts + periodCredits;
|
||||
if (totalDeductions > 0)
|
||||
revenueLines.Add(new FinancialReportLine
|
||||
@@ -170,26 +98,9 @@ public class FinancialReportService : IFinancialReportService
|
||||
Amount = -totalDeductions
|
||||
});
|
||||
|
||||
// Cash refunds reverse the sale — the revenue portion is contra-revenue (the tax portion
|
||||
// relieves Sales Tax Payable, not revenue). Store-credit refunds are excluded (no GL posting).
|
||||
var periodRefunds = await _context.Refunds
|
||||
.Where(r => r.CompanyId == companyId && !r.IsDeleted && r.Invoice != null && r.RefundMethod != PaymentMethod.StoreCredit
|
||||
&& r.RefundDate >= from && r.RefundDate <= toEnd)
|
||||
.Select(r => new { r.Amount, r.Invoice!.TaxAmount, r.Invoice.Total })
|
||||
.ToListAsync();
|
||||
var periodRefundReturns = periodRefunds.Sum(r => RefundAllocation.Split(r.Amount, r.TaxAmount, r.Total).ReturnsPortion);
|
||||
if (periodRefundReturns > 0)
|
||||
revenueLines.Add(new FinancialReportLine
|
||||
{
|
||||
AccountNumber = "4960",
|
||||
AccountName = "Less: Sales Returns",
|
||||
Amount = -periodRefundReturns
|
||||
});
|
||||
|
||||
// GC sales are deferred to GC Liability at issuance; revenue is recognized on redemption.
|
||||
var periodGcReclassified = await _context.InvoiceItems
|
||||
.Where(ii => ii.CompanyId == companyId
|
||||
&& ii.IsGiftCertificate
|
||||
.Where(ii => ii.IsGiftCertificate
|
||||
&& ii.Invoice.Status != InvoiceStatus.Draft
|
||||
&& ii.Invoice.Status != InvoiceStatus.Voided
|
||||
&& ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd)
|
||||
@@ -204,7 +115,7 @@ public class FinancialReportService : IFinancialReportService
|
||||
|
||||
// Voided GCs with remaining balance are breakage income (liability extinguished).
|
||||
var periodGcBreakage = await _context.GiftCertificates
|
||||
.Where(gc => gc.CompanyId == companyId && !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
||||
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
||||
&& gc.UpdatedAt >= from && gc.UpdatedAt <= toEnd
|
||||
&& gc.OriginalAmount > gc.RedeemedAmount)
|
||||
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m;
|
||||
@@ -223,7 +134,7 @@ public class FinancialReportService : IFinancialReportService
|
||||
if (isCash)
|
||||
{
|
||||
var cashExpenses = await _context.Expenses
|
||||
.Where(e => e.CompanyId == companyId && e.Date >= from && e.Date <= toEnd)
|
||||
.Where(e => e.Date >= from && e.Date <= toEnd)
|
||||
.GroupBy(e => e.ExpenseAccountId)
|
||||
.Select(g => new { AccountId = g.Key, Amount = g.Sum(e => e.Amount) })
|
||||
.ToListAsync();
|
||||
@@ -232,7 +143,7 @@ public class FinancialReportService : IFinancialReportService
|
||||
|
||||
// Pro-rate paid bill line items by payment fraction (bill total may be partial)
|
||||
var paidBillLines = await _context.BillPayments
|
||||
.Where(bp => bp.CompanyId == companyId && bp.PaymentDate >= from && bp.PaymentDate <= toEnd)
|
||||
.Where(bp => bp.PaymentDate >= from && bp.PaymentDate <= toEnd)
|
||||
.Include(bp => bp.Bill).ThenInclude(b => b.LineItems)
|
||||
.ToListAsync();
|
||||
foreach (var bp in paidBillLines)
|
||||
@@ -245,7 +156,7 @@ public class FinancialReportService : IFinancialReportService
|
||||
else
|
||||
{
|
||||
var accrualExpenses = await _context.Expenses
|
||||
.Where(e => e.CompanyId == companyId && e.Date >= from && e.Date <= toEnd)
|
||||
.Where(e => e.Date >= from && e.Date <= toEnd)
|
||||
.GroupBy(e => e.ExpenseAccountId)
|
||||
.Select(g => new { AccountId = g.Key, Amount = g.Sum(e => e.Amount) })
|
||||
.ToListAsync();
|
||||
@@ -253,8 +164,7 @@ public class FinancialReportService : IFinancialReportService
|
||||
expenseAmounts[e.AccountId] = expenseAmounts.GetValueOrDefault(e.AccountId) + e.Amount;
|
||||
|
||||
var accrualBillLines = await _context.BillLineItems
|
||||
.Where(bli => bli.CompanyId == companyId
|
||||
&& bli.AccountId != null
|
||||
.Where(bli => bli.AccountId != null
|
||||
&& bli.Bill.Status != BillStatus.Draft
|
||||
&& bli.Bill.Status != BillStatus.Voided
|
||||
&& bli.Bill.BillDate >= from && bli.Bill.BillDate <= toEnd)
|
||||
@@ -263,23 +173,10 @@ public class FinancialReportService : IFinancialReportService
|
||||
.ToListAsync();
|
||||
foreach (var b in accrualBillLines)
|
||||
expenseAmounts[b.AccountId] = expenseAmounts.GetValueOrDefault(b.AccountId) + b.Amount;
|
||||
|
||||
// Inventory consumed on jobs posts DR COGS / CR Inventory — recognise the COGS in the period.
|
||||
// (Cash basis recognises inventory cost when purchased, so this applies to accrual only.)
|
||||
var consumptionCogs = await _context.InventoryTransactions
|
||||
.Where(t => t.CompanyId == companyId
|
||||
&& (t.TransactionType == InventoryTransactionType.JobUsage || t.TransactionType == InventoryTransactionType.Waste)
|
||||
&& t.InventoryItem.CogsAccountId != null && t.InventoryItem.InventoryAccountId != null
|
||||
&& t.TransactionDate >= from && t.TransactionDate <= toEnd)
|
||||
.GroupBy(t => t.InventoryItem.CogsAccountId!.Value)
|
||||
.Select(g => new { AccountId = g.Key, Amount = g.Sum(t => t.TotalCost) })
|
||||
.ToListAsync();
|
||||
foreach (var c in consumptionCogs)
|
||||
expenseAmounts[c.AccountId] = expenseAmounts.GetValueOrDefault(c.AccountId) + c.Amount;
|
||||
}
|
||||
|
||||
var expAccounts = await _context.Accounts
|
||||
.Where(a => a.CompanyId == companyId && (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods) && a.IsActive)
|
||||
.Where(a => (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods) && a.IsActive)
|
||||
.ToDictionaryAsync(a => a.Id);
|
||||
|
||||
var cogsLines = new List<FinancialReportLine>();
|
||||
@@ -319,45 +216,46 @@ public class FinancialReportService : IFinancialReportService
|
||||
// Pre-compute balance contributions per account (batch GROUP BY queries avoid N+1)
|
||||
|
||||
var depositsByAcct = await _context.Payments
|
||||
.Where(p => p.CompanyId == companyId && p.PaymentDate <= asOfEnd && p.DepositAccountId != null
|
||||
&& p.Invoice.Status != InvoiceStatus.Voided)
|
||||
.Where(p => p.PaymentDate <= asOfEnd && p.DepositAccountId != null
|
||||
&& p.Invoice.Status != InvoiceStatus.Voided
|
||||
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
|
||||
.GroupBy(p => p.DepositAccountId!.Value)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(p => p.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
var expFromByAcct = await _context.Expenses
|
||||
.Where(e => e.CompanyId == companyId && e.Date <= asOfEnd)
|
||||
.Where(e => e.Date <= asOfEnd)
|
||||
.GroupBy(e => e.PaymentAccountId)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(e => e.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
var bpFromByAcct = await _context.BillPayments
|
||||
.Where(bp => bp.CompanyId == companyId && bp.PaymentDate <= asOfEnd)
|
||||
.Where(bp => bp.PaymentDate <= asOfEnd)
|
||||
.GroupBy(bp => bp.BankAccountId)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
var billsByApAcct = await _context.Bills
|
||||
.Where(b => b.CompanyId == companyId && b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate <= asOfEnd)
|
||||
.Where(b => b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate <= asOfEnd)
|
||||
.GroupBy(b => b.APAccountId)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(b => b.Total) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
var bpByApAcct = await _context.BillPayments
|
||||
.Where(bp => bp.CompanyId == companyId && bp.PaymentDate <= asOfEnd)
|
||||
.Where(bp => bp.PaymentDate <= asOfEnd)
|
||||
.GroupBy(bp => bp.Bill.APAccountId)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
// AP: vendor credit applications reduce AP (DR side) when matched against specific bills.
|
||||
var vcByApAcctBs = await _context.VendorCreditApplications
|
||||
.Where(vca => vca.CompanyId == companyId && vca.AppliedDate <= asOfEnd)
|
||||
.Where(vca => vca.AppliedDate <= asOfEnd)
|
||||
.GroupBy(vca => vca.VendorCredit.APAccountId)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(vca => vca.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
var taxByAcct = await _context.Invoices
|
||||
.Where(i => i.CompanyId == companyId && i.SalesTaxAccountId != null && i.TaxAmount > 0
|
||||
.Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0
|
||||
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||
&& i.InvoiceDate <= asOfEnd)
|
||||
.GroupBy(i => i.SalesTaxAccountId!.Value)
|
||||
@@ -365,67 +263,32 @@ public class FinancialReportService : IFinancialReportService
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
var arDebits = await _context.Invoices
|
||||
.Where(i => i.CompanyId == companyId && i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate <= asOfEnd)
|
||||
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate <= asOfEnd)
|
||||
.SumAsync(i => (decimal?)i.Total) ?? 0;
|
||||
var arCredits = await _context.Payments
|
||||
.Where(p => p.CompanyId == companyId && p.PaymentDate <= asOfEnd
|
||||
&& p.Invoice.Status != InvoiceStatus.Voided)
|
||||
.Where(p => p.PaymentDate <= asOfEnd
|
||||
&& p.Invoice.Status != InvoiceStatus.Voided
|
||||
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
|
||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
// Credit memo applications reduce open AR (CR AR when a credit is applied to an invoice).
|
||||
var cmAppliedBs = await _context.CreditMemoApplications
|
||||
.Where(a => a.CompanyId == companyId && a.AppliedDate <= asOfEnd && a.Invoice.Status != InvoiceStatus.Voided)
|
||||
arCredits += await _context.CreditMemoApplications
|
||||
.Where(a => a.AppliedDate <= asOfEnd && a.Invoice.Status != InvoiceStatus.Voided)
|
||||
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
|
||||
arCredits += cmAppliedBs;
|
||||
// Gift-certificate redemptions also credit AR (ApplyGiftCertificate posts DR 2500 / CR AR).
|
||||
// Mirror the posting here so AR is not overstated and the entry's two sides stay balanced.
|
||||
var gcRedeemedBs = await _context.GiftCertificateRedemptions
|
||||
.Where(r => r.CompanyId == companyId && !r.IsDeleted && r.RedeemedDate <= asOfEnd
|
||||
&& r.Invoice.Status != InvoiceStatus.Voided)
|
||||
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0;
|
||||
arCredits += gcRedeemedBs;
|
||||
|
||||
// Customer Credits (2350): a credit memo books DR Sales Discounts / CR Customer Credits on issue,
|
||||
// then DR Customer Credits / CR AR on apply. Contra-revenue (retained earnings) = issued amount
|
||||
// (active in full + applied portion of voided); the 2350 liability = unapplied balance on active memos.
|
||||
var cmIssuedNonVoidedBs = await _context.CreditMemos
|
||||
.Where(m => m.CompanyId == companyId && m.Status != CreditMemoStatus.Voided && m.IssueDate <= asOfEnd)
|
||||
.SumAsync(m => (decimal?)m.Amount) ?? 0m;
|
||||
var cmAppliedNonVoidedBs = await _context.CreditMemoApplications
|
||||
.Where(a => a.CompanyId == companyId && a.AppliedDate <= asOfEnd && a.Invoice.Status != InvoiceStatus.Voided
|
||||
&& a.CreditMemo.Status != CreditMemoStatus.Voided)
|
||||
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0m;
|
||||
var cmContraRevenueBs = cmIssuedNonVoidedBs + (cmAppliedBs - cmAppliedNonVoidedBs);
|
||||
var customerCreditsAcctIdBs = await _context.Accounts
|
||||
.Where(a => a.CompanyId == companyId && a.AccountNumber == "2350" && a.IsActive && !a.IsDeleted)
|
||||
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
|
||||
// Cash refunds reverse the sale: revenue portion reduces retained earnings (Sales Returns),
|
||||
// tax portion relieves Sales Tax Payable, cash leaves the bank (refundsByAcctBs). AR is untouched.
|
||||
// Store-credit refunds post via CreditMemo, not the GL, so are excluded.
|
||||
var saleReversingRefundsBs = await _context.Refunds
|
||||
.Where(r => r.CompanyId == companyId && r.RefundDate <= asOfEnd && !r.IsDeleted && r.Invoice != null
|
||||
&& r.RefundMethod != PaymentMethod.StoreCredit)
|
||||
.Select(r => new { r.Amount, r.Invoice!.TaxAmount, r.Invoice.Total, r.Invoice.SalesTaxAccountId })
|
||||
.ToListAsync();
|
||||
decimal refundReturnsTotalBs = 0m;
|
||||
var refundTaxByAcctBs = new Dictionary<int, decimal>();
|
||||
foreach (var r in saleReversingRefundsBs)
|
||||
{
|
||||
var (returnsPortion, taxPortion) = RefundAllocation.Split(r.Amount, r.TaxAmount, r.Total);
|
||||
refundReturnsTotalBs += returnsPortion;
|
||||
if (taxPortion != 0m && r.SalesTaxAccountId.HasValue)
|
||||
refundTaxByAcctBs[r.SalesTaxAccountId.Value] = refundTaxByAcctBs.GetValueOrDefault(r.SalesTaxAccountId.Value) + taxPortion;
|
||||
}
|
||||
// Refunds reverse collected payments — they re-open AR so reduce net AR credits.
|
||||
arCredits -= await _context.Refunds
|
||||
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted)
|
||||
.SumAsync(r => (decimal?)r.Amount) ?? 0m;
|
||||
|
||||
// Refunds by bank account: money that left the account (CR to checking/bank).
|
||||
var refundsByAcctBs = await _context.Refunds
|
||||
.Where(r => r.CompanyId == companyId && r.RefundDate <= asOfEnd && !r.IsDeleted && r.DepositAccountId != null)
|
||||
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted && r.DepositAccountId != null)
|
||||
.GroupBy(r => r.DepositAccountId!.Value)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(r => r.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
// Deposits by bank account: cash received at deposit recording time (DR bank).
|
||||
var depositsByAcctDepBs = await _context.Deposits
|
||||
.Where(d => d.CompanyId == companyId && !d.IsDeleted && d.DepositAccountId != null && d.ReceivedDate <= asOfEnd)
|
||||
.Where(d => !d.IsDeleted && d.DepositAccountId != null && d.ReceivedDate <= asOfEnd)
|
||||
.GroupBy(d => d.DepositAccountId!.Value)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(d => d.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
@@ -436,11 +299,11 @@ public class FinancialReportService : IFinancialReportService
|
||||
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
|
||||
var custDepositsCreditsBs = custDepositsAcctIdBs.HasValue
|
||||
? (await _context.Deposits
|
||||
.Where(d => d.CompanyId == companyId && !d.IsDeleted && d.ReceivedDate <= asOfEnd)
|
||||
.Where(d => !d.IsDeleted && d.ReceivedDate <= asOfEnd)
|
||||
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
|
||||
var custDepositsDebitsBs = custDepositsAcctIdBs.HasValue
|
||||
? (await _context.Deposits
|
||||
.Where(d => d.CompanyId == companyId && !d.IsDeleted && d.AppliedToInvoiceId != null && d.AppliedDate <= asOfEnd)
|
||||
.Where(d => !d.IsDeleted && d.AppliedToInvoiceId != null && d.AppliedDate <= asOfEnd)
|
||||
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
|
||||
|
||||
// Gift Certificate Liability (2500): balance driven by GC issuances, redemptions, and voids.
|
||||
@@ -449,14 +312,14 @@ public class FinancialReportService : IFinancialReportService
|
||||
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
|
||||
var gcLiabilityCreditsBs = gcLiabilityAcctIdBs.HasValue
|
||||
? (await _context.GiftCertificates
|
||||
.Where(gc => gc.CompanyId == companyId && !gc.IsDeleted && gc.IssueDate <= asOfEnd)
|
||||
.Where(gc => !gc.IsDeleted && gc.IssueDate <= asOfEnd)
|
||||
.SumAsync(gc => (decimal?)gc.OriginalAmount) ?? 0m) : 0m;
|
||||
var gcLiabilityDebitsBs = gcLiabilityAcctIdBs.HasValue
|
||||
? ((await _context.GiftCertificateRedemptions
|
||||
.Where(r => r.CompanyId == companyId && !r.IsDeleted && r.RedeemedDate <= asOfEnd)
|
||||
.Where(r => !r.IsDeleted && r.RedeemedDate <= asOfEnd)
|
||||
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0m)
|
||||
+ (await _context.GiftCertificates
|
||||
.Where(gc => gc.CompanyId == companyId && !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
||||
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
||||
&& gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount)
|
||||
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m)) : 0m;
|
||||
|
||||
@@ -465,21 +328,23 @@ public class FinancialReportService : IFinancialReportService
|
||||
// plus (5) the net effect of any posted journal entries on revenue/expense/COGS accounts
|
||||
// (accruals, depreciation, year-end closes, and other adjustments not in the tables above).
|
||||
var lifetimeRevenue = await _context.InvoiceItems
|
||||
.Where(ii => ii.CompanyId == companyId && ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate <= asOfEnd)
|
||||
.Where(ii => ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate <= asOfEnd)
|
||||
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
|
||||
var lifetimeDiscounts = isCash ? 0m
|
||||
: (await _context.Invoices
|
||||
.Where(i => i.CompanyId == companyId && i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||
&& i.DiscountAmount > 0 && i.InvoiceDate <= asOfEnd)
|
||||
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m);
|
||||
// Credit memos are contra-revenue recognized at issue (DR Sales Discounts). Net revenue is
|
||||
// reduced by the issued amount (active memos in full + applied portion of voided memos).
|
||||
var lifetimeCreditMemos = isCash ? 0m : cmContraRevenueBs;
|
||||
// Credit memos applied to invoices reduce net revenue (contra-revenue, same as discounts).
|
||||
var lifetimeCreditMemos = isCash ? 0m
|
||||
: (await _context.CreditMemoApplications
|
||||
.Where(a => a.AppliedDate <= asOfEnd && a.Invoice.Status != InvoiceStatus.Voided)
|
||||
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0m);
|
||||
var lifetimeDirectExp = await _context.Expenses
|
||||
.Where(e => e.CompanyId == companyId && e.Date <= asOfEnd)
|
||||
.Where(e => e.Date <= asOfEnd)
|
||||
.SumAsync(e => (decimal?)e.Amount) ?? 0;
|
||||
var lifetimeBillCosts = await _context.BillLineItems
|
||||
.Where(bli => bli.CompanyId == companyId && bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate <= asOfEnd)
|
||||
.Where(bli => bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate <= asOfEnd)
|
||||
.SumAsync(bli => (decimal?)bli.Amount) ?? 0;
|
||||
|
||||
// JE net effect on revenue accounts (positive = additional revenue recognised via manual JE)
|
||||
@@ -511,21 +376,20 @@ public class FinancialReportService : IFinancialReportService
|
||||
|
||||
// GC items sold via invoices are reclassified to GC Liability and not yet earned income.
|
||||
var lifetimeGcReclassified = await _context.InvoiceItems
|
||||
.Where(ii => ii.CompanyId == companyId && ii.IsGiftCertificate
|
||||
.Where(ii => ii.IsGiftCertificate
|
||||
&& ii.Invoice.Status != InvoiceStatus.Draft
|
||||
&& ii.Invoice.Status != InvoiceStatus.Voided
|
||||
&& ii.Invoice.InvoiceDate <= asOfEnd)
|
||||
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0m;
|
||||
// Voided GCs with remaining balance become breakage income (the liability is extinguished).
|
||||
var lifetimeGcBreakage = await _context.GiftCertificates
|
||||
.Where(gc => gc.CompanyId == companyId && !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
||||
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
||||
&& gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount)
|
||||
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m;
|
||||
|
||||
var retainedEarnings = lifetimeRevenue + jeRevNet
|
||||
- lifetimeDiscounts
|
||||
- lifetimeCreditMemos
|
||||
- refundReturnsTotalBs // revenue portion of cash refunds (reversed sales)
|
||||
- lifetimeGcReclassified // deferred to GC Liability, not earned yet
|
||||
+ lifetimeGcBreakage // breakage income when GC voided with balance
|
||||
- lifetimeDirectExp
|
||||
@@ -533,7 +397,7 @@ public class FinancialReportService : IFinancialReportService
|
||||
- jeExpNet;
|
||||
|
||||
var accounts = await _context.Accounts
|
||||
.Where(a => a.CompanyId == companyId && a.IsActive)
|
||||
.Where(a => a.IsActive)
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.ToListAsync();
|
||||
|
||||
@@ -561,17 +425,11 @@ public class FinancialReportService : IFinancialReportService
|
||||
credits += taxByAcct.GetValueOrDefault(a.Id);
|
||||
credits += refundsByAcctBs.GetValueOrDefault(a.Id); // refunds reduce bank balance
|
||||
debits += depositsByAcctDepBs.GetValueOrDefault(a.Id); // deposits increase bank balance
|
||||
debits += refundTaxByAcctBs.GetValueOrDefault(a.Id); // refund tax portion relieves the tax liability
|
||||
if (gcLiabilityAcctIdBs.HasValue && a.Id == gcLiabilityAcctIdBs.Value)
|
||||
{
|
||||
credits += gcLiabilityCreditsBs; // GC issued → CR liability
|
||||
debits += gcLiabilityDebitsBs; // redeemed/voided → DR liability
|
||||
}
|
||||
if (customerCreditsAcctIdBs.HasValue && a.Id == customerCreditsAcctIdBs.Value)
|
||||
{
|
||||
credits += cmIssuedNonVoidedBs; // credit memos issued → CR liability
|
||||
debits += cmAppliedNonVoidedBs; // applied → DR liability
|
||||
}
|
||||
if (custDepositsAcctIdBs.HasValue && a.Id == custDepositsAcctIdBs.Value)
|
||||
{
|
||||
credits += custDepositsCreditsBs; // deposits taken → CR liability
|
||||
@@ -641,8 +499,7 @@ public class FinancialReportService : IFinancialReportService
|
||||
|
||||
var openInvoices = await _context.Invoices
|
||||
.Include(i => i.Customer)
|
||||
.Where(i => i.CompanyId == companyId
|
||||
&& i.Status != InvoiceStatus.Draft
|
||||
.Where(i => i.Status != InvoiceStatus.Draft
|
||||
&& i.Status != InvoiceStatus.Voided
|
||||
&& i.Status != InvoiceStatus.Paid
|
||||
&& i.InvoiceDate <= asOfEnd
|
||||
@@ -722,15 +579,14 @@ public class FinancialReportService : IFinancialReportService
|
||||
var invoices = await _context.Invoices
|
||||
.Include(i => i.Customer)
|
||||
.Include(i => i.Payments)
|
||||
.Where(i => i.CompanyId == companyId
|
||||
&& i.Status != InvoiceStatus.Draft
|
||||
.Where(i => i.Status != InvoiceStatus.Draft
|
||||
&& i.Status != InvoiceStatus.Voided
|
||||
&& i.InvoiceDate >= from && i.InvoiceDate <= toEnd)
|
||||
.OrderBy(i => i.InvoiceDate)
|
||||
.ToListAsync();
|
||||
|
||||
var collectedInPeriod = await _context.Payments
|
||||
.Where(p => p.CompanyId == companyId && p.PaymentDate >= from && p.PaymentDate <= toEnd)
|
||||
.Where(p => p.PaymentDate >= from && p.PaymentDate <= toEnd)
|
||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
|
||||
var byCustomer = invoices
|
||||
@@ -995,8 +851,9 @@ public class FinancialReportService : IFinancialReportService
|
||||
|
||||
// Bank/cash: customer payments deposited here (DR)
|
||||
var depositsByAcct = await _context.Payments
|
||||
.Where(p => p.CompanyId == companyId && p.PaymentDate <= asOfEnd && p.DepositAccountId != null
|
||||
&& p.Invoice.Status != InvoiceStatus.Voided)
|
||||
.Where(p => p.PaymentDate <= asOfEnd && p.DepositAccountId != null
|
||||
&& p.Invoice.Status != InvoiceStatus.Voided
|
||||
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
|
||||
.GroupBy(p => p.DepositAccountId!.Value)
|
||||
.Select(g => new { Id = g.Key, Amt = g.Sum(p => p.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||
@@ -1004,42 +861,42 @@ public class FinancialReportService : IFinancialReportService
|
||||
// AP: vendor credit applications reduce AP (DR) — credits are applied when a vendor
|
||||
// issues a credit note and it is matched against a specific bill.
|
||||
var vcByApAcct = await _context.VendorCreditApplications
|
||||
.Where(vca => vca.CompanyId == companyId && vca.AppliedDate <= asOfEnd)
|
||||
.Where(vca => vca.AppliedDate <= asOfEnd)
|
||||
.GroupBy(vca => vca.VendorCredit.APAccountId)
|
||||
.Select(g => new { Id = g.Key, Amt = g.Sum(vca => vca.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||
|
||||
// Bank/cash: expenses paid from here (CR)
|
||||
var expFromByAcct = await _context.Expenses
|
||||
.Where(e => e.CompanyId == companyId && e.Date <= asOfEnd)
|
||||
.Where(e => e.Date <= asOfEnd)
|
||||
.GroupBy(e => e.PaymentAccountId)
|
||||
.Select(g => new { Id = g.Key, Amt = g.Sum(e => e.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||
|
||||
// Bank/cash: bill payments made from here (CR)
|
||||
var bpFromByAcct = await _context.BillPayments
|
||||
.Where(bp => bp.CompanyId == companyId && bp.PaymentDate <= asOfEnd)
|
||||
.Where(bp => bp.PaymentDate <= asOfEnd)
|
||||
.GroupBy(bp => bp.BankAccountId)
|
||||
.Select(g => new { Id = g.Key, Amt = g.Sum(bp => bp.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||
|
||||
// AP: bills increase AP (CR)
|
||||
var billsByApAcct = await _context.Bills
|
||||
.Where(b => b.CompanyId == companyId && b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate <= asOfEnd)
|
||||
.Where(b => b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate <= asOfEnd)
|
||||
.GroupBy(b => b.APAccountId)
|
||||
.Select(g => new { Id = g.Key, Amt = g.Sum(b => b.Total) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||
|
||||
// AP: bill payments reduce AP (DR)
|
||||
var bpByApAcct = await _context.BillPayments
|
||||
.Where(bp => bp.CompanyId == companyId && bp.PaymentDate <= asOfEnd)
|
||||
.Where(bp => bp.PaymentDate <= asOfEnd)
|
||||
.GroupBy(bp => bp.Bill.APAccountId)
|
||||
.Select(g => new { Id = g.Key, Amt = g.Sum(bp => bp.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||
|
||||
// Tax liability: sales tax collected (CR)
|
||||
var taxByAcct = await _context.Invoices
|
||||
.Where(i => i.CompanyId == companyId && i.SalesTaxAccountId != null && i.TaxAmount > 0
|
||||
.Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0
|
||||
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||
&& i.InvoiceDate <= asOfEnd)
|
||||
.GroupBy(i => i.SalesTaxAccountId!.Value)
|
||||
@@ -1048,7 +905,7 @@ public class FinancialReportService : IFinancialReportService
|
||||
|
||||
// Revenue accounts: invoice line items (CR)
|
||||
var revenueByAcct = await _context.InvoiceItems
|
||||
.Where(ii => ii.CompanyId == companyId && ii.RevenueAccountId != null
|
||||
.Where(ii => ii.RevenueAccountId != null
|
||||
&& ii.Invoice.Status != InvoiceStatus.Draft
|
||||
&& ii.Invoice.Status != InvoiceStatus.Voided
|
||||
&& ii.Invoice.InvoiceDate <= asOfEnd)
|
||||
@@ -1058,14 +915,14 @@ public class FinancialReportService : IFinancialReportService
|
||||
|
||||
// Expense accounts: direct expenses (DR)
|
||||
var expenseByAcct = await _context.Expenses
|
||||
.Where(e => e.CompanyId == companyId && e.Date <= asOfEnd)
|
||||
.Where(e => e.Date <= asOfEnd)
|
||||
.GroupBy(e => e.ExpenseAccountId)
|
||||
.Select(g => new { Id = g.Key, Amt = g.Sum(e => e.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||
|
||||
// Expense/COGS accounts: vendor bill line items (DR)
|
||||
var billLinesByAcct = await _context.BillLineItems
|
||||
.Where(bli => bli.CompanyId == companyId && bli.AccountId != null
|
||||
.Where(bli => bli.AccountId != null
|
||||
&& bli.Bill.Status != BillStatus.Draft
|
||||
&& bli.Bill.Status != BillStatus.Voided
|
||||
&& bli.Bill.BillDate <= asOfEnd)
|
||||
@@ -1073,25 +930,6 @@ public class FinancialReportService : IFinancialReportService
|
||||
.Select(g => new { Id = g.Key, Amt = g.Sum(bli => bli.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||
|
||||
// Inventory consumption: COGS account (DR) and Inventory asset account (CR) for JobUsage/Waste
|
||||
// transactions on items with both accounts mapped — mirrors the DR COGS / CR Inventory posting.
|
||||
var cogsConsumptionByAcct = await _context.InventoryTransactions
|
||||
.Where(t => t.CompanyId == companyId
|
||||
&& (t.TransactionType == InventoryTransactionType.JobUsage || t.TransactionType == InventoryTransactionType.Waste)
|
||||
&& t.InventoryItem.CogsAccountId != null && t.InventoryItem.InventoryAccountId != null
|
||||
&& t.TransactionDate <= asOfEnd)
|
||||
.GroupBy(t => t.InventoryItem.CogsAccountId!.Value)
|
||||
.Select(g => new { Id = g.Key, Amt = g.Sum(t => t.TotalCost) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||
var invConsumptionByAcct = await _context.InventoryTransactions
|
||||
.Where(t => t.CompanyId == companyId
|
||||
&& (t.TransactionType == InventoryTransactionType.JobUsage || t.TransactionType == InventoryTransactionType.Waste)
|
||||
&& t.InventoryItem.CogsAccountId != null && t.InventoryItem.InventoryAccountId != null
|
||||
&& t.TransactionDate <= asOfEnd)
|
||||
.GroupBy(t => t.InventoryItem.InventoryAccountId!.Value)
|
||||
.Select(g => new { Id = g.Key, Amt = g.Sum(t => t.TotalCost) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||
|
||||
// Sales Discounts contra-revenue account: invoice discounts and credit memo applications (DR).
|
||||
// Both reduce net revenue and are attributed to account 4950 as contra-revenue debits.
|
||||
// Credit memo applications are also added to AR credits below so the double-entry balances.
|
||||
@@ -1106,50 +944,33 @@ public class FinancialReportService : IFinancialReportService
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
var cmApplied = await _context.CreditMemoApplications
|
||||
.Where(a => a.CompanyId == companyId && a.AppliedDate <= asOfEnd
|
||||
.Where(a => a.AppliedDate <= asOfEnd
|
||||
&& a.Invoice.Status != InvoiceStatus.Voided)
|
||||
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0m;
|
||||
|
||||
// Customer Credits (2350) model: a credit memo books DR Sales Discounts / CR Customer Credits on
|
||||
// issue, then DR Customer Credits / CR AR on apply. So the 4950 contra-revenue is the *issued*
|
||||
// amount (active memos in full + the applied portion of voided memos), and the 2350 liability is
|
||||
// the unapplied balance on active memos. AR is still credited by applications (cmApplied).
|
||||
var cmIssuedNonVoided = await _context.CreditMemos
|
||||
.Where(m => m.CompanyId == companyId && m.Status != CreditMemoStatus.Voided && m.IssueDate <= asOfEnd)
|
||||
.SumAsync(m => (decimal?)m.Amount) ?? 0m;
|
||||
var cmAppliedNonVoided = await _context.CreditMemoApplications
|
||||
.Where(a => a.CompanyId == companyId && a.AppliedDate <= asOfEnd
|
||||
&& a.Invoice.Status != InvoiceStatus.Voided
|
||||
&& a.CreditMemo.Status != CreditMemoStatus.Voided)
|
||||
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0m;
|
||||
var cmContraRevenue = cmIssuedNonVoided + (cmApplied - cmAppliedNonVoided); // DR 4950
|
||||
var customerCreditsAcctId = await _context.Accounts
|
||||
.Where(a => a.CompanyId == companyId && a.AccountNumber == "2350" && a.IsActive && !a.IsDeleted)
|
||||
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
|
||||
|
||||
var discountsByAcct = new Dictionary<int, decimal>();
|
||||
if (discountAcctId.HasValue)
|
||||
{
|
||||
var totalDiscounts = await _context.Invoices
|
||||
.Where(i => i.CompanyId == companyId && i.DiscountAmount > 0
|
||||
.Where(i => i.DiscountAmount > 0
|
||||
&& i.Status != InvoiceStatus.Draft
|
||||
&& i.Status != InvoiceStatus.Voided
|
||||
&& i.InvoiceDate <= asOfEnd)
|
||||
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m;
|
||||
if (totalDiscounts + cmContraRevenue > 0)
|
||||
discountsByAcct[discountAcctId.Value] = totalDiscounts + cmContraRevenue;
|
||||
if (totalDiscounts + cmApplied > 0)
|
||||
discountsByAcct[discountAcctId.Value] = totalDiscounts + cmApplied;
|
||||
}
|
||||
|
||||
// JE lines: posted entries debit/credit all account types
|
||||
var jeDebitsByAcct = await _context.JournalEntryLines
|
||||
.Where(l => l.CompanyId == companyId && l.JournalEntry.Status == JournalEntryStatus.Posted
|
||||
.Where(l => l.JournalEntry.Status == JournalEntryStatus.Posted
|
||||
&& l.JournalEntry.EntryDate <= asOfEnd)
|
||||
.GroupBy(l => l.AccountId)
|
||||
.Select(g => new { Id = g.Key, Amt = g.Sum(l => l.DebitAmount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||
|
||||
var jeCreditsByAcct = await _context.JournalEntryLines
|
||||
.Where(l => l.CompanyId == companyId && l.JournalEntry.Status == JournalEntryStatus.Posted
|
||||
.Where(l => l.JournalEntry.Status == JournalEntryStatus.Posted
|
||||
&& l.JournalEntry.EntryDate <= asOfEnd)
|
||||
.GroupBy(l => l.AccountId)
|
||||
.Select(g => new { Id = g.Key, Amt = g.Sum(l => l.CreditAmount) })
|
||||
@@ -1159,48 +980,25 @@ public class FinancialReportService : IFinancialReportService
|
||||
// Credits include both cash payments and credit memo applications (which reduce open AR
|
||||
// when a customer credit is applied against a specific invoice).
|
||||
var arTotalDebits = await _context.Invoices
|
||||
.Where(i => i.CompanyId == companyId && i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||
&& i.InvoiceDate <= asOfEnd)
|
||||
.SumAsync(i => (decimal?)i.Total) ?? 0m;
|
||||
var arTotalCredits = await _context.Payments
|
||||
.Where(p => p.CompanyId == companyId && p.PaymentDate <= asOfEnd
|
||||
&& p.Invoice.Status != InvoiceStatus.Voided)
|
||||
.Where(p => p.PaymentDate <= asOfEnd
|
||||
&& p.Invoice.Status != InvoiceStatus.Voided
|
||||
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
|
||||
.SumAsync(p => (decimal?)p.Amount) ?? 0m;
|
||||
arTotalCredits += cmApplied; // credit memo applications reduce AR balance
|
||||
// Gift-certificate redemptions credit AR too (DR 2500 / CR AR). Without this the redemption's
|
||||
// 2500 debit is recomputed but its AR credit is not, leaving the trial balance out of balance.
|
||||
var gcRedeemedTb = await _context.GiftCertificateRedemptions
|
||||
.Where(r => r.CompanyId == companyId && !r.IsDeleted && r.RedeemedDate <= asOfEnd
|
||||
&& r.Invoice.Status != InvoiceStatus.Voided)
|
||||
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0m;
|
||||
arTotalCredits += gcRedeemedTb;
|
||||
|
||||
// Cash refunds reverse the sale: revenue portion → DR Sales Returns (4960), tax portion →
|
||||
// DR Sales Tax Payable (relieves the liability), cash → CR bank (refundsByAcct below). They no
|
||||
// longer touch AR. Store-credit refunds post via CreditMemo, not the GL, so are excluded.
|
||||
var saleReversingRefunds = await _context.Refunds
|
||||
.Where(r => r.CompanyId == companyId && r.RefundDate <= asOfEnd && !r.IsDeleted && r.Invoice != null
|
||||
&& r.RefundMethod != PaymentMethod.StoreCredit)
|
||||
.Select(r => new { r.Amount, r.Invoice!.TaxAmount, r.Invoice.Total, r.Invoice.SalesTaxAccountId })
|
||||
.ToListAsync();
|
||||
|
||||
decimal refundReturnsTotal = 0m;
|
||||
var refundTaxByAcct = new Dictionary<int, decimal>();
|
||||
foreach (var r in saleReversingRefunds)
|
||||
{
|
||||
var (returnsPortion, taxPortion) = RefundAllocation.Split(r.Amount, r.TaxAmount, r.Total);
|
||||
refundReturnsTotal += returnsPortion;
|
||||
if (taxPortion != 0m && r.SalesTaxAccountId.HasValue)
|
||||
refundTaxByAcct[r.SalesTaxAccountId.Value] = refundTaxByAcct.GetValueOrDefault(r.SalesTaxAccountId.Value) + taxPortion;
|
||||
}
|
||||
|
||||
var salesReturnsAcctId = await _context.Accounts
|
||||
.Where(a => a.CompanyId == companyId && a.AccountNumber == "4960" && a.IsActive && !a.IsDeleted)
|
||||
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
|
||||
// Refunds reverse collected payments — reduce net AR credits (re-opens the receivable).
|
||||
var refundTotal = await _context.Refunds
|
||||
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted)
|
||||
.SumAsync(r => (decimal?)r.Amount) ?? 0m;
|
||||
arTotalCredits -= refundTotal;
|
||||
|
||||
// Refunds by bank account: money leaving the account (CR to checking/bank).
|
||||
var refundsByAcct = await _context.Refunds
|
||||
.Where(r => r.CompanyId == companyId && r.RefundDate <= asOfEnd && !r.IsDeleted && r.DepositAccountId != null)
|
||||
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted && r.DepositAccountId != null)
|
||||
.GroupBy(r => r.DepositAccountId!.Value)
|
||||
.Select(g => new { Id = g.Key, Amt = g.Sum(r => r.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||
@@ -1208,7 +1006,7 @@ public class FinancialReportService : IFinancialReportService
|
||||
// Deposits by bank account: cash received at deposit recording time (DR bank).
|
||||
// Deposit-sourced Payments have DepositAccountId = null, so there is no double-count with depositsByAcct.
|
||||
var depositsByAcctDep = await _context.Deposits
|
||||
.Where(d => d.CompanyId == companyId && !d.IsDeleted && d.DepositAccountId != null && d.ReceivedDate <= asOfEnd)
|
||||
.Where(d => !d.IsDeleted && d.DepositAccountId != null && d.ReceivedDate <= asOfEnd)
|
||||
.GroupBy(d => d.DepositAccountId!.Value)
|
||||
.Select(g => new { Id = g.Key, Amt = g.Sum(d => d.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||
@@ -1219,11 +1017,11 @@ public class FinancialReportService : IFinancialReportService
|
||||
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
|
||||
var custDepositsCredits = custDepositsAcctId.HasValue
|
||||
? (await _context.Deposits
|
||||
.Where(d => d.CompanyId == companyId && !d.IsDeleted && d.ReceivedDate <= asOfEnd)
|
||||
.Where(d => !d.IsDeleted && d.ReceivedDate <= asOfEnd)
|
||||
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
|
||||
var custDepositsDebits = custDepositsAcctId.HasValue
|
||||
? (await _context.Deposits
|
||||
.Where(d => d.CompanyId == companyId && !d.IsDeleted && d.AppliedToInvoiceId != null && d.AppliedDate <= asOfEnd)
|
||||
.Where(d => !d.IsDeleted && d.AppliedToInvoiceId != null && d.AppliedDate <= asOfEnd)
|
||||
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
|
||||
|
||||
// Gift Certificate Liability (2500): balance driven by GC issuances, redemptions, and voids.
|
||||
@@ -1232,14 +1030,14 @@ public class FinancialReportService : IFinancialReportService
|
||||
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
|
||||
var gcLiabilityCredits = gcLiabilityAcctId.HasValue
|
||||
? (await _context.GiftCertificates
|
||||
.Where(gc => gc.CompanyId == companyId && !gc.IsDeleted && gc.IssueDate <= asOfEnd)
|
||||
.Where(gc => !gc.IsDeleted && gc.IssueDate <= asOfEnd)
|
||||
.SumAsync(gc => (decimal?)gc.OriginalAmount) ?? 0m) : 0m;
|
||||
var gcLiabilityDebits = gcLiabilityAcctId.HasValue
|
||||
? ((await _context.GiftCertificateRedemptions
|
||||
.Where(r => r.CompanyId == companyId && !r.IsDeleted && r.RedeemedDate <= asOfEnd)
|
||||
.Where(r => !r.IsDeleted && r.RedeemedDate <= asOfEnd)
|
||||
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0m)
|
||||
+ (await _context.GiftCertificates
|
||||
.Where(gc => gc.CompanyId == companyId && !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
||||
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
||||
&& gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount)
|
||||
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m)) : 0m;
|
||||
|
||||
@@ -1279,13 +1077,8 @@ public class FinancialReportService : IFinancialReportService
|
||||
debits += expenseByAcct.GetValueOrDefault(a.Id);
|
||||
debits += billLinesByAcct.GetValueOrDefault(a.Id);
|
||||
debits += discountsByAcct.GetValueOrDefault(a.Id);
|
||||
debits += cogsConsumptionByAcct.GetValueOrDefault(a.Id); // inventory consumption → DR COGS
|
||||
credits += invConsumptionByAcct.GetValueOrDefault(a.Id); // inventory consumption → CR Inventory
|
||||
credits += refundsByAcct.GetValueOrDefault(a.Id); // refunds reduce bank balance
|
||||
debits += depositsByAcctDep.GetValueOrDefault(a.Id); // deposits increase bank balance
|
||||
if (salesReturnsAcctId.HasValue && a.Id == salesReturnsAcctId.Value)
|
||||
debits += refundReturnsTotal; // revenue portion of cash refunds
|
||||
debits += refundTaxByAcct.GetValueOrDefault(a.Id); // tax portion relieves the tax liability
|
||||
if (gcLiabilityAcctId.HasValue && a.Id == gcLiabilityAcctId.Value)
|
||||
{
|
||||
credits += gcLiabilityCredits; // GC issued → CR liability
|
||||
@@ -1296,11 +1089,6 @@ public class FinancialReportService : IFinancialReportService
|
||||
credits += custDepositsCredits; // deposits taken → CR liability
|
||||
debits += custDepositsDebits; // deposits applied → DR liability
|
||||
}
|
||||
if (customerCreditsAcctId.HasValue && a.Id == customerCreditsAcctId.Value)
|
||||
{
|
||||
credits += cmIssuedNonVoided; // credit memos issued → CR liability
|
||||
debits += cmAppliedNonVoided; // applied → DR liability
|
||||
}
|
||||
}
|
||||
|
||||
// Manual JEs apply to all account types (including AR/AP for unusual adjustments)
|
||||
@@ -1387,17 +1175,17 @@ public class FinancialReportService : IFinancialReportService
|
||||
|
||||
// Opening balance: invoiced − paid before period start
|
||||
var preInvoiced = await _context.Invoices
|
||||
.Where(i => i.CompanyId == companyId && i.CustomerId == customerId
|
||||
.Where(i => i.CustomerId == customerId
|
||||
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||
&& i.InvoiceDate < from)
|
||||
.SumAsync(i => (decimal?)i.Total) ?? 0;
|
||||
var prePaid = await _context.Payments
|
||||
.Where(p => p.CompanyId == companyId && p.Invoice.CustomerId == customerId
|
||||
.Where(p => p.Invoice.CustomerId == customerId
|
||||
&& p.Invoice.Status != InvoiceStatus.Voided
|
||||
&& p.PaymentDate < from)
|
||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
var preCredits = await _context.CreditMemoApplications
|
||||
.Where(a => a.CompanyId == companyId && a.Invoice.CustomerId == customerId && a.AppliedDate < from)
|
||||
.Where(a => a.Invoice.CustomerId == customerId && a.AppliedDate < from)
|
||||
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
|
||||
|
||||
var openingBalance = preInvoiced - prePaid - preCredits;
|
||||
@@ -1406,7 +1194,7 @@ public class FinancialReportService : IFinancialReportService
|
||||
var lines = new List<StatementLineDto>();
|
||||
|
||||
var periodInvoices = await _context.Invoices
|
||||
.Where(i => i.CompanyId == companyId && i.CustomerId == customerId
|
||||
.Where(i => i.CustomerId == customerId
|
||||
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||
&& i.InvoiceDate >= from && i.InvoiceDate <= toEnd)
|
||||
.AsNoTracking().ToListAsync();
|
||||
@@ -1423,7 +1211,7 @@ public class FinancialReportService : IFinancialReportService
|
||||
|
||||
var periodPayments = await _context.Payments
|
||||
.Include(p => p.Invoice)
|
||||
.Where(p => p.CompanyId == companyId && p.Invoice.CustomerId == customerId
|
||||
.Where(p => p.Invoice.CustomerId == customerId
|
||||
&& p.Invoice.Status != InvoiceStatus.Voided
|
||||
&& p.PaymentDate >= from && p.PaymentDate <= toEnd)
|
||||
.AsNoTracking().ToListAsync();
|
||||
@@ -1441,7 +1229,7 @@ public class FinancialReportService : IFinancialReportService
|
||||
var periodCredits = await _context.CreditMemoApplications
|
||||
.Include(a => a.Invoice)
|
||||
.Include(a => a.CreditMemo)
|
||||
.Where(a => a.CompanyId == companyId && a.Invoice.CustomerId == customerId
|
||||
.Where(a => a.Invoice.CustomerId == customerId
|
||||
&& a.AppliedDate >= from && a.AppliedDate <= toEnd)
|
||||
.AsNoTracking().ToListAsync();
|
||||
|
||||
@@ -1492,15 +1280,15 @@ public class FinancialReportService : IFinancialReportService
|
||||
|
||||
// Opening balance: bills − payments − credits before period start
|
||||
var preBills = await _context.Bills
|
||||
.Where(b => b.CompanyId == companyId && b.VendorId == vendorId
|
||||
.Where(b => b.VendorId == vendorId
|
||||
&& b.Status != BillStatus.Draft && b.Status != BillStatus.Voided
|
||||
&& b.BillDate < from)
|
||||
.SumAsync(b => (decimal?)b.Total) ?? 0;
|
||||
var prePayments = await _context.BillPayments
|
||||
.Where(bp => bp.CompanyId == companyId && bp.Bill.VendorId == vendorId && bp.PaymentDate < from)
|
||||
.Where(bp => bp.Bill.VendorId == vendorId && bp.PaymentDate < from)
|
||||
.SumAsync(bp => (decimal?)bp.Amount) ?? 0;
|
||||
var preVcApplied = await _context.VendorCreditApplications
|
||||
.Where(vca => vca.CompanyId == companyId && vca.Bill.VendorId == vendorId && vca.AppliedDate < from)
|
||||
.Where(vca => vca.Bill.VendorId == vendorId && vca.AppliedDate < from)
|
||||
.SumAsync(vca => (decimal?)vca.Amount) ?? 0;
|
||||
|
||||
var openingBalance = preBills - prePayments - preVcApplied;
|
||||
@@ -1508,7 +1296,7 @@ public class FinancialReportService : IFinancialReportService
|
||||
var lines = new List<StatementLineDto>();
|
||||
|
||||
var periodBills = await _context.Bills
|
||||
.Where(b => b.CompanyId == companyId && b.VendorId == vendorId
|
||||
.Where(b => b.VendorId == vendorId
|
||||
&& b.Status != BillStatus.Draft && b.Status != BillStatus.Voided
|
||||
&& b.BillDate >= from && b.BillDate <= toEnd)
|
||||
.AsNoTracking().ToListAsync();
|
||||
@@ -1525,7 +1313,7 @@ public class FinancialReportService : IFinancialReportService
|
||||
|
||||
var periodPayments = await _context.BillPayments
|
||||
.Include(bp => bp.Bill)
|
||||
.Where(bp => bp.CompanyId == companyId && bp.Bill.VendorId == vendorId
|
||||
.Where(bp => bp.Bill.VendorId == vendorId
|
||||
&& bp.PaymentDate >= from && bp.PaymentDate <= toEnd)
|
||||
.AsNoTracking().ToListAsync();
|
||||
|
||||
@@ -1542,7 +1330,7 @@ public class FinancialReportService : IFinancialReportService
|
||||
var periodVcApplied = await _context.VendorCreditApplications
|
||||
.Include(vca => vca.VendorCredit)
|
||||
.Include(vca => vca.Bill)
|
||||
.Where(vca => vca.CompanyId == companyId && vca.Bill.VendorId == vendorId
|
||||
.Where(vca => vca.Bill.VendorId == vendorId
|
||||
&& vca.AppliedDate >= from && vca.AppliedDate <= toEnd)
|
||||
.AsNoTracking().ToListAsync();
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PowderCoating.Application.DTOs.Accounting;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Accounting;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
@@ -200,43 +199,6 @@ public class LedgerService : ILedgerService
|
||||
LinkId = inv.Id
|
||||
});
|
||||
|
||||
// ── 5b. Cash refunds reverse the sale (DR Sales Returns 4960 + DR Sales Tax Payable) ──
|
||||
// The revenue portion debits Sales Returns; the tax portion debits the invoice's sales-tax
|
||||
// account (relieving the liability). Cash leaving the bank is handled in the bank section above.
|
||||
// Store-credit refunds are excluded — they post via CreditMemo, not the GL (see CancelRefund).
|
||||
if (account.AccountNumber == "4960" || account.AccountType == AccountType.Liability)
|
||||
{
|
||||
var saleReversingRefunds = await _context.Refunds
|
||||
.Include(r => r.Invoice)
|
||||
.Where(r => !r.IsDeleted && r.Invoice != null
|
||||
&& r.RefundMethod != PaymentMethod.StoreCredit
|
||||
&& r.RefundDate >= fromDate && r.RefundDate <= toDate)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var r in saleReversingRefunds)
|
||||
{
|
||||
var (returnsPortion, taxPortion) = RefundAllocation.Split(r.Amount, r.Invoice.TaxAmount, r.Invoice.Total);
|
||||
|
||||
if (account.AccountNumber == "4960" && returnsPortion != 0)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = r.RefundDate, Reference = r.Reference ?? $"REF-{r.Id}", Source = "Refund",
|
||||
Description = $"Sales return — {r.Invoice.InvoiceNumber}",
|
||||
Debit = returnsPortion, Credit = 0,
|
||||
LinkController = "Invoices", LinkId = r.InvoiceId
|
||||
});
|
||||
|
||||
if (r.Invoice.SalesTaxAccountId == accountId && taxPortion != 0)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = r.RefundDate, Reference = r.Reference ?? $"REF-{r.Id}", Source = "Refund",
|
||||
Description = $"Tax refunded — {r.Invoice.InvoiceNumber}",
|
||||
Debit = taxPortion, Credit = 0,
|
||||
LinkController = "Invoices", LinkId = r.InvoiceId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── 6. Direct expenses categorized to this account (DEBIT) ────────────
|
||||
// e.g. Expense account 6200 receives direct expense entries
|
||||
var expensesTo = await _context.Expenses
|
||||
@@ -350,29 +312,24 @@ public class LedgerService : ILedgerService
|
||||
LinkId = cm.InvoiceId
|
||||
});
|
||||
|
||||
// Gift-certificate redemptions reduce open AR (CREDIT) — ApplyGiftCertificate posts DR 2500 / CR AR.
|
||||
var arGcRedemptions = await _context.GiftCertificateRedemptions
|
||||
// Refunds re-open AR (DEBIT — customer owes again after refund)
|
||||
var arRefunds = await _context.Refunds
|
||||
.Include(r => r.Invoice)
|
||||
.Include(r => r.GiftCertificate)
|
||||
.Where(r => !r.IsDeleted && r.RedeemedDate >= fromDate && r.RedeemedDate <= toDate
|
||||
&& r.Invoice.Status != InvoiceStatus.Voided)
|
||||
.Where(r => r.RefundDate >= fromDate && r.RefundDate <= toDate && !r.IsDeleted)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var r in arGcRedemptions)
|
||||
foreach (var r in arRefunds)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = r.RedeemedDate,
|
||||
Reference = r.GiftCertificate?.CertificateCode ?? $"GC-{r.GiftCertificateId}",
|
||||
Source = "Gift Certificate",
|
||||
Description = $"GC redeemed on {r.Invoice?.InvoiceNumber}",
|
||||
Debit = 0,
|
||||
Credit = r.AmountRedeemed,
|
||||
Date = r.RefundDate,
|
||||
Reference = r.Reference ?? $"REF-{r.Id}",
|
||||
Source = "Refund",
|
||||
Description = r.Reason,
|
||||
Debit = r.Amount,
|
||||
Credit = 0,
|
||||
LinkController = "Invoices",
|
||||
LinkId = r.InvoiceId
|
||||
});
|
||||
|
||||
// NOTE: cash refunds no longer touch AR. Under the "reverse the sale" model they debit
|
||||
// Sales Returns + Sales Tax Payable and credit the bank (see section 5b above).
|
||||
}
|
||||
|
||||
// ── 9. Accounts Payable ────────────────────────────────────────────────
|
||||
@@ -516,125 +473,6 @@ public class LedgerService : ILedgerService
|
||||
});
|
||||
}
|
||||
|
||||
// ── 12b. Customer Credits liability (account 2350) ────────────────────
|
||||
// CR when a credit memo (incl. store-credit refund) is issued; DR when applied to an invoice.
|
||||
// Voided memos are excluded (their issue/void net to zero).
|
||||
if (account.AccountNumber == "2350")
|
||||
{
|
||||
var memosIssued = await _context.CreditMemos
|
||||
.Where(m => m.Status != CreditMemoStatus.Voided
|
||||
&& m.IssueDate >= fromDate && m.IssueDate <= toDate)
|
||||
.ToListAsync();
|
||||
foreach (var m in memosIssued)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = m.IssueDate, Reference = m.MemoNumber,
|
||||
Source = "Credit Memo", Description = "Store credit issued",
|
||||
Debit = 0, Credit = m.Amount,
|
||||
LinkController = "CreditMemos", LinkId = m.Id
|
||||
});
|
||||
|
||||
var memosApplied = await _context.CreditMemoApplications
|
||||
.Include(a => a.CreditMemo).Include(a => a.Invoice)
|
||||
.Where(a => a.CreditMemo.Status != CreditMemoStatus.Voided
|
||||
&& a.AppliedDate >= fromDate && a.AppliedDate <= toDate)
|
||||
.ToListAsync();
|
||||
foreach (var a in memosApplied)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = a.AppliedDate, Reference = a.CreditMemo?.MemoNumber ?? $"CM-{a.CreditMemoId}",
|
||||
Source = "Credit Applied", Description = $"Applied to {a.Invoice?.InvoiceNumber}",
|
||||
Debit = a.AmountApplied, Credit = 0,
|
||||
LinkController = "Invoices", LinkId = a.InvoiceId
|
||||
});
|
||||
}
|
||||
|
||||
// ── 12c. Sales Discounts contra-revenue (account 4950) ────────────────
|
||||
// Mirrors the actual postings made by AccountBalanceService so a balance recompute reproduces
|
||||
// the stored CurrentBalance (otherwise "Recalculate Balances" would wipe 4950 down to JE-only):
|
||||
// • Invoice discounts → DR 4950 at invoice date (InvoicesController invoice create/edit).
|
||||
// • Credit memo issuance → DR 4950 = full memo amount at issue (CreditMemosController.Create
|
||||
// and the store-credit refund path, which both create a CreditMemo row).
|
||||
// • Credit memo void → CR 4950 = unapplied remainder at void (reverses the unused part).
|
||||
// Keep this in step with FinancialReportService's 4950 computation (discountsByAcct + cmContraRevenue).
|
||||
if (account.AccountNumber == "4950")
|
||||
{
|
||||
var discountInvoices = await _context.Invoices
|
||||
.Where(i => i.DiscountAmount > 0
|
||||
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||
&& i.InvoiceDate >= fromDate && i.InvoiceDate <= toDate)
|
||||
.ToListAsync();
|
||||
foreach (var inv in discountInvoices)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = inv.InvoiceDate, Reference = inv.InvoiceNumber,
|
||||
Source = "Invoice", Description = $"Discount on {inv.InvoiceNumber}",
|
||||
Debit = inv.DiscountAmount, Credit = 0,
|
||||
LinkController = "Invoices", LinkId = inv.Id
|
||||
});
|
||||
|
||||
var discountMemosIssued = await _context.CreditMemos
|
||||
.Where(m => m.IssueDate >= fromDate && m.IssueDate <= toDate)
|
||||
.ToListAsync();
|
||||
foreach (var m in discountMemosIssued)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = m.IssueDate, Reference = m.MemoNumber,
|
||||
Source = "Credit Memo", Description = "Store credit issued (contra-revenue)",
|
||||
Debit = m.Amount, Credit = 0,
|
||||
LinkController = "CreditMemos", LinkId = m.Id
|
||||
});
|
||||
|
||||
var discountMemosVoided = await _context.CreditMemos
|
||||
.Where(m => m.Status == CreditMemoStatus.Voided
|
||||
&& m.UpdatedAt >= fromDate && m.UpdatedAt <= toDate
|
||||
&& m.Amount > m.AmountApplied)
|
||||
.ToListAsync();
|
||||
foreach (var m in discountMemosVoided)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = m.UpdatedAt.GetValueOrDefault(), Reference = m.MemoNumber,
|
||||
Source = "Credit Memo Voided", Description = "Reversed unapplied store credit",
|
||||
Debit = 0, Credit = m.Amount - m.AmountApplied,
|
||||
LinkController = "CreditMemos", LinkId = m.Id
|
||||
});
|
||||
}
|
||||
|
||||
// ── 12d. Inventory consumption COGS (DR COGS / CR Inventory) ──────────
|
||||
// When an item with both a COGS and an Inventory account is consumed (JobUsage/Waste — the only
|
||||
// two transaction types created at the COGS-posting sites), JobsController/InventoryController post
|
||||
// DR COGS / CR Inventory at the transaction's TotalCost. Reproduce it here so a balance recompute
|
||||
// matches the posting and the trial balance stays balanced. TotalCost is stored positive.
|
||||
if (account.AccountType == AccountType.CostOfGoods || account.AccountSubType == AccountSubType.Inventory)
|
||||
{
|
||||
var consumption = await _context.InventoryTransactions
|
||||
.Include(t => t.InventoryItem)
|
||||
.Where(t => (t.TransactionType == InventoryTransactionType.JobUsage || t.TransactionType == InventoryTransactionType.Waste)
|
||||
&& t.InventoryItem.CogsAccountId != null && t.InventoryItem.InventoryAccountId != null
|
||||
&& (t.InventoryItem.CogsAccountId == accountId || t.InventoryItem.InventoryAccountId == accountId)
|
||||
&& t.TransactionDate >= fromDate && t.TransactionDate <= toDate)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var t in consumption)
|
||||
{
|
||||
var amount = Math.Abs(t.TotalCost);
|
||||
if (t.InventoryItem.CogsAccountId == accountId)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = t.TransactionDate, Reference = t.Reference ?? $"INV-{t.Id}",
|
||||
Source = "Inventory Usage", Description = $"COGS — {t.InventoryItem.Name}",
|
||||
Debit = amount, Credit = 0, LinkController = "Inventory", LinkId = t.InventoryItemId
|
||||
});
|
||||
if (t.InventoryItem.InventoryAccountId == accountId)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = t.TransactionDate, Reference = t.Reference ?? $"INV-{t.Id}",
|
||||
Source = "Inventory Usage", Description = $"Inventory relieved — {t.InventoryItem.Name}",
|
||||
Debit = 0, Credit = amount, LinkController = "Inventory", LinkId = t.InventoryItemId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── 10. Journal Entry lines touching this account ──────────────────
|
||||
var jeLines = await _context.JournalEntryLines
|
||||
.Include(l => l.JournalEntry)
|
||||
@@ -756,27 +594,6 @@ public class LedgerService : ILedgerService
|
||||
&& i.InvoiceDate < beforeDate)
|
||||
.SumAsync(i => (decimal?)i.TaxAmount) ?? 0;
|
||||
|
||||
// 5b. Cash refunds reverse the sale (DR Sales Returns 4960 + DR Sales Tax Payable). Store-credit
|
||||
// refunds are excluded (no GL posting). Mirrors section 5b in GetAccountLedgerAsync.
|
||||
if (account.AccountNumber == "4960" || account.AccountType == AccountType.Liability)
|
||||
{
|
||||
var priorRefunds = await _context.Refunds
|
||||
.Include(r => r.Invoice)
|
||||
.Where(r => !r.IsDeleted && r.Invoice != null
|
||||
&& r.RefundMethod != PaymentMethod.StoreCredit
|
||||
&& r.RefundDate < beforeDate)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var r in priorRefunds)
|
||||
{
|
||||
var (returnsPortion, taxPortion) = RefundAllocation.Split(r.Amount, r.Invoice.TaxAmount, r.Invoice.Total);
|
||||
if (account.AccountNumber == "4960")
|
||||
debits += returnsPortion;
|
||||
if (r.Invoice.SalesTaxAccountId == accountId)
|
||||
debits += taxPortion;
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Direct expenses categorized to this account (DEBIT)
|
||||
debits += await _context.Expenses
|
||||
.Where(e => e.ExpenseAccountId == accountId && e.Date < beforeDate)
|
||||
@@ -807,13 +624,9 @@ public class LedgerService : ILedgerService
|
||||
.Where(a => a.AppliedDate < beforeDate && a.Invoice.Status != InvoiceStatus.Voided)
|
||||
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
|
||||
|
||||
// Gift-certificate redemptions credit AR (DR 2500 / CR AR), same as in GetAccountLedgerAsync.
|
||||
credits += await _context.GiftCertificateRedemptions
|
||||
.Where(r => !r.IsDeleted && r.RedeemedDate < beforeDate && r.Invoice.Status != InvoiceStatus.Voided)
|
||||
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0;
|
||||
|
||||
// NOTE: cash refunds no longer debit AR — they reverse the sale (Sales Returns + Sales Tax),
|
||||
// handled in section 5b above.
|
||||
debits += await _context.Refunds
|
||||
.Where(r => !r.IsDeleted && r.RefundDate < beforeDate)
|
||||
.SumAsync(r => (decimal?)r.Amount) ?? 0;
|
||||
}
|
||||
|
||||
// 9. Accounts Payable
|
||||
@@ -861,55 +674,6 @@ public class LedgerService : ILedgerService
|
||||
.SumAsync(d => (decimal?)d.Amount) ?? 0;
|
||||
}
|
||||
|
||||
// 12b. Customer Credits liability (account 2350)
|
||||
if (account.AccountNumber == "2350")
|
||||
{
|
||||
credits += await _context.CreditMemos
|
||||
.Where(m => m.Status != CreditMemoStatus.Voided && m.IssueDate < beforeDate)
|
||||
.SumAsync(m => (decimal?)m.Amount) ?? 0;
|
||||
debits += await _context.CreditMemoApplications
|
||||
.Where(a => a.CreditMemo.Status != CreditMemoStatus.Voided && a.AppliedDate < beforeDate)
|
||||
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
|
||||
}
|
||||
|
||||
// 12c. Sales Discounts contra-revenue (account 4950). Mirrors section 12c in GetAccountLedgerAsync
|
||||
// so the prior-period opening balance matches the actual postings (invoice discounts + memo issues,
|
||||
// less the unapplied remainder of voided memos).
|
||||
if (account.AccountNumber == "4950")
|
||||
{
|
||||
debits += await _context.Invoices
|
||||
.Where(i => i.DiscountAmount > 0
|
||||
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||
&& i.InvoiceDate < beforeDate)
|
||||
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0;
|
||||
debits += await _context.CreditMemos
|
||||
.Where(m => m.IssueDate < beforeDate)
|
||||
.SumAsync(m => (decimal?)m.Amount) ?? 0;
|
||||
credits += await _context.CreditMemos
|
||||
.Where(m => m.Status == CreditMemoStatus.Voided && m.UpdatedAt < beforeDate && m.Amount > m.AmountApplied)
|
||||
.SumAsync(m => (decimal?)(m.Amount - m.AmountApplied)) ?? 0;
|
||||
}
|
||||
|
||||
// 12d. Inventory consumption COGS (DR COGS / CR Inventory). Mirrors section 12d in
|
||||
// GetAccountLedgerAsync so the prior-period opening balance matches the posting.
|
||||
if (account.AccountType == AccountType.CostOfGoods || account.AccountSubType == AccountSubType.Inventory)
|
||||
{
|
||||
var priorConsumption = await _context.InventoryTransactions
|
||||
.Include(t => t.InventoryItem)
|
||||
.Where(t => (t.TransactionType == InventoryTransactionType.JobUsage || t.TransactionType == InventoryTransactionType.Waste)
|
||||
&& t.InventoryItem.CogsAccountId != null && t.InventoryItem.InventoryAccountId != null
|
||||
&& (t.InventoryItem.CogsAccountId == accountId || t.InventoryItem.InventoryAccountId == accountId)
|
||||
&& t.TransactionDate < beforeDate)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var t in priorConsumption)
|
||||
{
|
||||
var amount = Math.Abs(t.TotalCost);
|
||||
if (t.InventoryItem.CogsAccountId == accountId) debits += amount;
|
||||
if (t.InventoryItem.InventoryAccountId == accountId) credits += amount;
|
||||
}
|
||||
}
|
||||
|
||||
// 10. Posted journal entry lines touching this account (prior to period)
|
||||
debits += await _context.JournalEntryLines
|
||||
.Where(l => l.AccountId == accountId
|
||||
|
||||
@@ -60,15 +60,7 @@ public partial class SeedDataService
|
||||
new Account { AccountNumber = "2000", Name = "Accounts Payable", AccountType = AccountType.Liability, AccountSubType = AccountSubType.AccountsPayable, IsSystem = true, IsActive = true, Description = "Amounts owed to suppliers and vendors", CompanyId = company.Id, CreatedAt = now },
|
||||
new Account { AccountNumber = "2100", Name = "Credit Card Payable", AccountType = AccountType.Liability, AccountSubType = AccountSubType.CreditCard, IsSystem = false, IsActive = true, Description = "Business credit card balance", CompanyId = company.Id, CreatedAt = now },
|
||||
new Account { AccountNumber = "2200", Name = "Sales Tax Payable", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = false, IsActive = true, Description = "Sales tax collected and owed to government", CompanyId = company.Id, CreatedAt = now },
|
||||
// 2300 is the Customer Deposits liability — credited when a deposit is taken, debited when it is
|
||||
// applied to an invoice (see DepositsController / InvoicesController, which resolve it by number).
|
||||
// IsSystem because the GL posting code depends on it existing. Payroll lives at 2400 below.
|
||||
new Account { AccountNumber = "2300", Name = "Customer Deposits", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = true, IsActive = true, Description = "Deposits received from customers before an invoice is created; cleared when applied to an invoice", CompanyId = company.Id, CreatedAt = now },
|
||||
new Account { AccountNumber = "2350", Name = "Customer Credits", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = true, IsActive = true, Description = "Store credit owed to customers (credit memos not yet applied)", CompanyId = company.Id, CreatedAt = now },
|
||||
new Account { AccountNumber = "2400", Name = "Payroll Liabilities", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = false, IsActive = true, Description = "Payroll taxes and withholdings owed", CompanyId = company.Id, CreatedAt = now },
|
||||
// 2500 Gift Certificate Liability — credited when a GC is issued, debited when redeemed/voided
|
||||
// (resolved by number in GiftCertificatesController). IsSystem because the GL posting depends on it.
|
||||
new Account { AccountNumber = "2500", Name = "Gift Certificate Liability", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = true, IsActive = true, Description = "Outstanding gift certificate obligations owed to certificate holders", CompanyId = company.Id, CreatedAt = now },
|
||||
new Account { AccountNumber = "2300", Name = "Payroll Liabilities", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = false, IsActive = true, Description = "Payroll taxes and withholdings owed", CompanyId = company.Id, CreatedAt = now },
|
||||
new Account { AccountNumber = "2900", Name = "Business Loan", AccountType = AccountType.Liability, AccountSubType = AccountSubType.LongTermLiability, IsSystem = false, IsActive = true, Description = "Long-term equipment or business loan", CompanyId = company.Id, CreatedAt = now },
|
||||
|
||||
// ── EQUITY ────────────────────────────────────────────────────────
|
||||
@@ -85,7 +77,6 @@ public partial class SeedDataService
|
||||
// A credit-normal account with a debit balance appears in the Trial Balance debit column,
|
||||
// reducing net revenue to match the discounted AR amount that was posted.
|
||||
new Account { AccountNumber = "4950", Name = "Sales Discounts", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.OtherIncome, IsSystem = true, IsActive = true, Description = "Contra-revenue for invoice discounts granted to customers", CompanyId = company.Id, CreatedAt = now },
|
||||
new Account { AccountNumber = "4960", Name = "Sales Returns & Allowances", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.OtherIncome, IsSystem = true, IsActive = true, Description = "Contra-revenue for the revenue portion of customer refunds (reversed sales)", CompanyId = company.Id, CreatedAt = now },
|
||||
|
||||
// ── COST OF GOODS SOLD ────────────────────────────────────────────
|
||||
new Account { AccountNumber = "5000", Name = "Cost of Goods Sold", AccountType = AccountType.CostOfGoods, AccountSubType = AccountSubType.CostOfGoodsSold, IsSystem = false, IsActive = true, Description = "Direct cost of services delivered", CompanyId = company.Id, CreatedAt = now },
|
||||
@@ -150,134 +141,6 @@ public partial class SeedDataService
|
||||
added++;
|
||||
}
|
||||
|
||||
// 4960 Sales Returns & Allowances — contra-revenue account that receives the revenue portion
|
||||
// of customer refunds under the "reverse the sale" model (DR Sales Returns + DR Sales Tax / CR Bank).
|
||||
var has4960 = await _context.Set<Account>()
|
||||
.IgnoreQueryFilters()
|
||||
.AnyAsync(a => a.CompanyId == company.Id && a.AccountNumber == "4960" && !a.IsDeleted);
|
||||
|
||||
if (!has4960)
|
||||
{
|
||||
_context.Set<Account>().Add(new Account
|
||||
{
|
||||
AccountNumber = "4960",
|
||||
Name = "Sales Returns & Allowances",
|
||||
AccountType = AccountType.Revenue,
|
||||
AccountSubType = AccountSubType.OtherIncome,
|
||||
IsSystem = true,
|
||||
IsActive = true,
|
||||
Description = "Contra-revenue for the revenue portion of customer refunds (reversed sales)",
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = now
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
added++;
|
||||
}
|
||||
|
||||
// 2350 Customer Credits — liability for store credit owed to customers. Credited when a credit
|
||||
// memo (incl. store-credit refunds) is issued; debited when applied to an invoice.
|
||||
var has2350 = await _context.Set<Account>()
|
||||
.IgnoreQueryFilters()
|
||||
.AnyAsync(a => a.CompanyId == company.Id && a.AccountNumber == "2350" && !a.IsDeleted);
|
||||
|
||||
if (!has2350)
|
||||
{
|
||||
_context.Set<Account>().Add(new Account
|
||||
{
|
||||
AccountNumber = "2350",
|
||||
Name = "Customer Credits",
|
||||
AccountType = AccountType.Liability,
|
||||
AccountSubType = AccountSubType.OtherCurrentLiability,
|
||||
IsSystem = true,
|
||||
IsActive = true,
|
||||
Description = "Store credit owed to customers (credit memos not yet applied)",
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = now
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
added++;
|
||||
}
|
||||
|
||||
// 2300 used to be seeded as "Payroll Liabilities" but the deposit GL posting code has always
|
||||
// resolved 2300 by number and used it as the Customer Deposits liability — so the account was
|
||||
// mislabeled on the balance sheet. Rename it to "Customer Deposits" and mark it system. Only
|
||||
// touch accounts still carrying the old default name so a user's own rename is preserved.
|
||||
var legacyDepositsAcct = await _context.Set<Account>()
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && a.AccountNumber == "2300"
|
||||
&& !a.IsDeleted && a.Name == "Payroll Liabilities");
|
||||
if (legacyDepositsAcct != null)
|
||||
{
|
||||
legacyDepositsAcct.Name = "Customer Deposits";
|
||||
legacyDepositsAcct.Description = "Deposits received from customers before an invoice is created; cleared when applied to an invoice";
|
||||
legacyDepositsAcct.IsSystem = true;
|
||||
legacyDepositsAcct.UpdatedAt = now;
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// 2400 Payroll Liabilities — the payroll account displaced from 2300 (now Customer Deposits).
|
||||
var has2400 = await _context.Set<Account>()
|
||||
.IgnoreQueryFilters()
|
||||
.AnyAsync(a => a.CompanyId == company.Id && a.AccountNumber == "2400" && !a.IsDeleted);
|
||||
|
||||
if (!has2400)
|
||||
{
|
||||
_context.Set<Account>().Add(new Account
|
||||
{
|
||||
AccountNumber = "2400",
|
||||
Name = "Payroll Liabilities",
|
||||
AccountType = AccountType.Liability,
|
||||
AccountSubType = AccountSubType.OtherCurrentLiability,
|
||||
IsSystem = false,
|
||||
IsActive = true,
|
||||
Description = "Payroll taxes and withholdings owed",
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = now
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
added++;
|
||||
}
|
||||
|
||||
// 2500 has always been resolved by number as the Gift Certificate Liability (GiftCertificatesController),
|
||||
// but the default-company seed created it as "Long-Term Loan" — so GC obligations were mislabeled there.
|
||||
// Rename it (only where it still carries the old default name) and mark it system.
|
||||
var legacyGcAcct = await _context.Set<Account>()
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && a.AccountNumber == "2500"
|
||||
&& !a.IsDeleted && a.Name == "Long-Term Loan");
|
||||
if (legacyGcAcct != null)
|
||||
{
|
||||
legacyGcAcct.Name = "Gift Certificate Liability";
|
||||
legacyGcAcct.Description = "Outstanding gift certificate obligations owed to certificate holders";
|
||||
legacyGcAcct.IsSystem = true;
|
||||
legacyGcAcct.UpdatedAt = now;
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// 2500 Gift Certificate Liability — ensure it exists for companies that never got one (e.g. tenants
|
||||
// onboarded after the AccountingGapsPhase2 migration ran). Without it, GC GL postings silently no-op.
|
||||
var has2500 = await _context.Set<Account>()
|
||||
.IgnoreQueryFilters()
|
||||
.AnyAsync(a => a.CompanyId == company.Id && a.AccountNumber == "2500" && !a.IsDeleted);
|
||||
|
||||
if (!has2500)
|
||||
{
|
||||
_context.Set<Account>().Add(new Account
|
||||
{
|
||||
AccountNumber = "2500",
|
||||
Name = "Gift Certificate Liability",
|
||||
AccountType = AccountType.Liability,
|
||||
AccountSubType = AccountSubType.OtherCurrentLiability,
|
||||
IsSystem = true,
|
||||
IsActive = true,
|
||||
Description = "Outstanding gift certificate obligations owed to certificate holders",
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = now
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
added++;
|
||||
}
|
||||
|
||||
return added;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,8 +55,7 @@ public class AccountsController : Controller
|
||||
// GET: /Accounts
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId, false, a => a.ParentAccount);
|
||||
var accounts = await _unitOfWork.Accounts.GetAllAsync(false, a => a.ParentAccount);
|
||||
|
||||
var dtos = _mapper.Map<List<AccountListDto>>(accounts.OrderBy(a => a.AccountNumber).ToList());
|
||||
|
||||
@@ -66,17 +65,6 @@ public class AccountsController : Controller
|
||||
.OrderBy(g => (int)g.Key)
|
||||
.ToList();
|
||||
|
||||
// Default-account pickers (Revenue / COGS / Inventory) — see SaveDefaultAccounts.
|
||||
await PopulateDefaultAccountViewDataAsync(companyId, accounts);
|
||||
|
||||
// GL health: trial-balance net. Debit-normal (Asset/COGS/Expense) minus credit-normal
|
||||
// (Liability/Equity/Revenue) should net to ~0 for balanced books. A non-zero value flags
|
||||
// drift or one-sided postings (often opening balances entered without an offsetting entry).
|
||||
ViewBag.TrialBalanceNet = accounts.Sum(a =>
|
||||
(a.AccountType == AccountType.Asset || a.AccountType == AccountType.CostOfGoods
|
||||
|| a.AccountType == AccountType.Expense)
|
||||
? a.CurrentBalance : -a.CurrentBalance);
|
||||
|
||||
return View(grouped);
|
||||
}
|
||||
|
||||
@@ -99,7 +87,18 @@ public class AccountsController : Controller
|
||||
if (preSubType.HasValue)
|
||||
{
|
||||
dto.AccountSubType = preSubType.Value;
|
||||
dto.AccountType = AccountClassification.TypeForSubType(preSubType.Value);
|
||||
dto.AccountType = preSubType.Value switch
|
||||
{
|
||||
AccountSubType.Cash or AccountSubType.Checking or AccountSubType.Savings
|
||||
or AccountSubType.AccountsReceivable or AccountSubType.Inventory or AccountSubType.FixedAsset
|
||||
or AccountSubType.OtherCurrentAsset or AccountSubType.OtherAsset => AccountType.Asset,
|
||||
AccountSubType.AccountsPayable or AccountSubType.CreditCard
|
||||
or AccountSubType.OtherCurrentLiability or AccountSubType.LongTermLiability => AccountType.Liability,
|
||||
AccountSubType.OwnersEquity or AccountSubType.RetainedEarnings => AccountType.Equity,
|
||||
AccountSubType.Sales or AccountSubType.ServiceRevenue or AccountSubType.OtherIncome => AccountType.Revenue,
|
||||
AccountSubType.CostOfGoodsSold => AccountType.CostOfGoods,
|
||||
_ => AccountType.Expense
|
||||
};
|
||||
}
|
||||
ViewBag.Inline = inline;
|
||||
if (inline)
|
||||
@@ -135,7 +134,7 @@ public class AccountsController : Controller
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
|
||||
// Check for duplicate account number
|
||||
var existing = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == currentUser!.CompanyId && a.AccountNumber == dto.AccountNumber);
|
||||
var existing = await _unitOfWork.Accounts.FindAsync(a => a.AccountNumber == dto.AccountNumber);
|
||||
if (existing.Any())
|
||||
{
|
||||
ModelState.AddModelError(nameof(dto.AccountNumber), "An account with this number already exists.");
|
||||
@@ -148,9 +147,6 @@ public class AccountsController : Controller
|
||||
var account = _mapper.Map<Account>(dto);
|
||||
account.CompanyId = currentUser!.CompanyId;
|
||||
account.CreatedBy = currentUser.Email;
|
||||
// Derive the parent type from the chosen sub-type so the two can never disagree —
|
||||
// a mismatch would post with the wrong debit/credit sign (sign keys off sub-type).
|
||||
account.AccountType = AccountClassification.TypeForSubType(account.AccountSubType);
|
||||
|
||||
await _unitOfWork.Accounts.AddAsync(account);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
@@ -217,7 +213,7 @@ public class AccountsController : Controller
|
||||
|
||||
// Check duplicate number (excluding self)
|
||||
var existing = await _unitOfWork.Accounts.FindAsync(
|
||||
a => a.CompanyId == account.CompanyId && a.AccountNumber == dto.AccountNumber && a.Id != id);
|
||||
a => a.AccountNumber == dto.AccountNumber && a.Id != id);
|
||||
if (existing.Any())
|
||||
{
|
||||
ModelState.AddModelError(nameof(dto.AccountNumber), "An account with this number already exists.");
|
||||
@@ -226,9 +222,6 @@ public class AccountsController : Controller
|
||||
}
|
||||
|
||||
_mapper.Map(dto, account);
|
||||
// Keep type consistent with the chosen sub-type (see Create) so the sign convention,
|
||||
// which keys off sub-type, can never be at odds with the displayed account type.
|
||||
account.AccountType = AccountClassification.TypeForSubType(account.AccountSubType);
|
||||
account.UpdatedAt = DateTime.UtcNow;
|
||||
account.UpdatedBy = (await _userManager.GetUserAsync(User))?.Email;
|
||||
|
||||
@@ -328,49 +321,20 @@ public class AccountsController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the Revenue / COGS / Inventory account dropdowns and the company's currently-selected
|
||||
/// default account IDs for the "Default Accounts" card on the Chart of Accounts page. Revenue and
|
||||
/// COGS are filtered by their top-level AccountType; the inventory-asset list shows all Asset
|
||||
/// accounts (Inventory sub-type first) so a company that classified its inventory account
|
||||
/// differently can still pick it. Reuses the already-loaded <paramref name="accounts"/> list.
|
||||
/// One-time data repair for companies whose chart of accounts was imported from QuickBooks
|
||||
/// IIF files. QuickBooks IIF exports store credit-normal account opening balances as negative
|
||||
/// numbers (e.g. Revenue accounts), but the application's convention is to store all opening
|
||||
/// balances as positive amounts with the credit/debit nature implied by account type. This
|
||||
/// action flips negative opening balances on Revenue, Liability, and Equity accounts to their
|
||||
/// absolute values. After running this, <see cref="RecalculateBalances"/> should be called to
|
||||
/// propagate the corrected opening balances into <c>CurrentBalance</c>.
|
||||
/// </summary>
|
||||
private async Task PopulateDefaultAccountViewDataAsync(int companyId, IEnumerable<Account> accounts)
|
||||
{
|
||||
SelectListItem Item(Account a) => new($"{a.AccountNumber} – {a.Name}", a.Id.ToString());
|
||||
|
||||
ViewBag.DefaultRevenueAccounts = accounts
|
||||
.Where(a => a.IsActive && a.AccountType == AccountType.Revenue)
|
||||
.OrderBy(a => a.AccountNumber).Select(Item).ToList();
|
||||
|
||||
ViewBag.DefaultCogsAccounts = accounts
|
||||
.Where(a => a.IsActive && a.AccountType == AccountType.CostOfGoods)
|
||||
.OrderBy(a => a.AccountNumber).Select(Item).ToList();
|
||||
|
||||
ViewBag.DefaultInventoryAccounts = accounts
|
||||
.Where(a => a.IsActive && a.AccountType == AccountType.Asset)
|
||||
.OrderByDescending(a => a.AccountSubType == AccountSubType.Inventory)
|
||||
.ThenBy(a => a.AccountNumber).Select(Item).ToList();
|
||||
|
||||
var prefs = await _unitOfWork.CompanyPreferences
|
||||
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
|
||||
ViewBag.DefaultRevenueAccountId = prefs?.DefaultRevenueAccountId;
|
||||
ViewBag.DefaultCogsAccountId = prefs?.DefaultCogsAccountId;
|
||||
ViewBag.DefaultInventoryAccountId = prefs?.DefaultInventoryAccountId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves the company's default Revenue, COGS, and Inventory accounts to <c>CompanyPreferences</c>.
|
||||
/// These are used as the fallback when an item leaves its account field blank: invoice lines fall
|
||||
/// back to the default Revenue account (then 4000), and new inventory/catalog items are pre-filled
|
||||
/// with the default COGS/Inventory accounts. Each submitted id is validated to belong to the
|
||||
/// company and to be of the expected account type before it is stored; an invalid or cleared
|
||||
/// selection saves as null. CompanyAdmin-only because it affects GL routing for the whole company.
|
||||
/// </summary>
|
||||
// POST: /Accounts/SaveDefaultAccounts
|
||||
// POST: /Accounts/FixOpeningBalanceSigns
|
||||
// One-time fix: QB IIF imports store credit-normal accounts with negative opening balances.
|
||||
// This flips them to positive so the chart of accounts displays correctly.
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||
public async Task<IActionResult> SaveDefaultAccounts(
|
||||
int? defaultRevenueAccountId, int? defaultCogsAccountId, int? defaultInventoryAccountId)
|
||||
public async Task<IActionResult> FixOpeningBalanceSigns()
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (companyId == null)
|
||||
@@ -381,37 +345,30 @@ public class AccountsController : Controller
|
||||
|
||||
try
|
||||
{
|
||||
var prefs = await _unitOfWork.CompanyPreferences
|
||||
.FirstOrDefaultAsync(p => p.CompanyId == companyId.Value && !p.IsDeleted);
|
||||
if (prefs == null)
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
|
||||
int fixed_ = 0;
|
||||
foreach (var acct in accounts)
|
||||
{
|
||||
TempData["Error"] = "Company preferences not found.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
if (acct.OpeningBalance < 0 &&
|
||||
acct.AccountType is Core.Enums.AccountType.Revenue
|
||||
or Core.Enums.AccountType.Liability
|
||||
or Core.Enums.AccountType.Equity)
|
||||
{
|
||||
acct.OpeningBalance = Math.Abs(acct.OpeningBalance);
|
||||
acct.CurrentBalance = Math.Abs(acct.CurrentBalance);
|
||||
await _unitOfWork.Accounts.UpdateAsync(acct);
|
||||
fixed_++;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate each pick belongs to this company, is active, and is of the right type.
|
||||
// Explicit CompanyId predicate (defense in depth) alongside the global tenant filter.
|
||||
async Task<int?> Validate(int? id, params AccountType[] allowed)
|
||||
{
|
||||
if (id == null) return null;
|
||||
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.Id == id.Value && a.CompanyId == companyId.Value && a.IsActive);
|
||||
return acct != null && allowed.Contains(acct.AccountType) ? acct.Id : null;
|
||||
}
|
||||
|
||||
prefs.DefaultRevenueAccountId = await Validate(defaultRevenueAccountId, AccountType.Revenue);
|
||||
prefs.DefaultCogsAccountId = await Validate(defaultCogsAccountId, AccountType.CostOfGoods);
|
||||
prefs.DefaultInventoryAccountId = await Validate(defaultInventoryAccountId, AccountType.Asset);
|
||||
|
||||
await _unitOfWork.CompanyPreferences.UpdateAsync(prefs);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = "Default accounts saved. New items and invoice lines will use these when no account is chosen.";
|
||||
TempData["Success"] = fixed_ > 0
|
||||
? $"Fixed {fixed_} account(s) with negative opening balances. Run Recalculate Balances to update CurrentBalance."
|
||||
: "No accounts needed fixing — all opening balances already have the correct sign.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error saving default accounts for company {CompanyId}", companyId);
|
||||
TempData["Error"] = "An error occurred while saving the default accounts.";
|
||||
_logger.LogError(ex, "Error fixing opening balance signs for company {CompanyId}", companyId);
|
||||
TempData["Error"] = "An error occurred while fixing opening balances.";
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Index));
|
||||
@@ -482,7 +439,7 @@ public class AccountsController : Controller
|
||||
public async Task<IActionResult> YearEndClose()
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var history = (await _unitOfWork.YearEndCloses.FindAsync(y => y.CompanyId == companyId, false, y => y.JournalEntry))
|
||||
var history = (await _unitOfWork.YearEndCloses.FindAsync(y => true, false, y => y.JournalEntry))
|
||||
.OrderByDescending(y => y.ClosedYear)
|
||||
.ToList();
|
||||
|
||||
@@ -507,7 +464,7 @@ public class AccountsController : Controller
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
// Idempotency check
|
||||
var existing = (await _unitOfWork.YearEndCloses.FindAsync(y => y.CompanyId == companyId && y.ClosedYear == year)).FirstOrDefault();
|
||||
var existing = (await _unitOfWork.YearEndCloses.FindAsync(y => y.ClosedYear == year)).FirstOrDefault();
|
||||
if (existing != null)
|
||||
{
|
||||
TempData["Error"] = $"{year} has already been closed (JE {existing.JournalEntryId}).";
|
||||
@@ -515,7 +472,7 @@ public class AccountsController : Controller
|
||||
}
|
||||
|
||||
// Load all active accounts with balances
|
||||
var accounts = (await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive)).ToList();
|
||||
var accounts = (await _unitOfWork.Accounts.FindAsync(a => a.IsActive)).ToList();
|
||||
|
||||
var revenueAccounts = accounts.Where(a => a.AccountType == AccountType.Revenue).ToList();
|
||||
var expenseAccounts = accounts.Where(a =>
|
||||
@@ -659,8 +616,7 @@ public class AccountsController : Controller
|
||||
/// </summary>
|
||||
private async Task PopulateDropdownsAsync(int? excludeId = null)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && (excludeId == null || a.Id != excludeId.Value));
|
||||
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => excludeId == null || a.Id != excludeId.Value);
|
||||
|
||||
ViewBag.ParentAccounts = allAccounts
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
|
||||
@@ -65,7 +65,7 @@ public class AiQuickQuoteController : Controller
|
||||
try
|
||||
{
|
||||
var powders = await _unitOfWork.InventoryItems.FindAsync(i =>
|
||||
i.CompanyId == currentUser.CompanyId && i.Category != null && i.Category.ToLower().Contains("powder") && i.UnitCost > 0);
|
||||
i.Category != null && i.Category.ToLower().Contains("powder") && i.UnitCost > 0);
|
||||
if (powders.Any())
|
||||
avgPowderCost = powders.Average(p => p.UnitCost);
|
||||
}
|
||||
@@ -180,7 +180,7 @@ public class AiQuickQuoteController : Controller
|
||||
var context = new CompanyAiContext { ProfileText = costs.AiContextProfile };
|
||||
|
||||
var predictions = await _unitOfWork.AiItemPredictions.FindAsync(
|
||||
p => p.CompanyId == companyId && !p.UserOverrodeEstimate && p.PredictedSurfaceAreaSqFt > 0 && p.PredictedUnitPrice > 0);
|
||||
p => !p.UserOverrodeEstimate && p.PredictedSurfaceAreaSqFt > 0 && p.PredictedUnitPrice > 0);
|
||||
|
||||
context.AcceptedExamples = predictions
|
||||
.OrderByDescending(p => p.CreatedAt)
|
||||
@@ -213,9 +213,8 @@ public class AiQuickQuoteController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
var companyId = (await _userManager.GetUserAsync(User))?.CompanyId ?? 0;
|
||||
var inventory = await _unitOfWork.InventoryItems.FindAsync(
|
||||
i => i.CompanyId == companyId && i.IsActive,
|
||||
i => i.IsActive,
|
||||
false,
|
||||
i => i.InventoryCategory);
|
||||
|
||||
@@ -268,7 +267,7 @@ public class AiQuickQuoteController : Controller
|
||||
private async Task<Customer> GetOrCreateWalkInCustomerAsync(int companyId)
|
||||
{
|
||||
var existing = (await _unitOfWork.Customers.FindAsync(
|
||||
c => c.CompanyId == companyId && c.CompanyName == "Walk-In / Phone" && c.IsActive))
|
||||
c => c.CompanyName == "Walk-In / Phone" && c.IsActive))
|
||||
.FirstOrDefault();
|
||||
|
||||
if (existing != null) return existing;
|
||||
|
||||
@@ -8,7 +8,6 @@ using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using PowderCoating.Web.Helpers;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
@@ -74,13 +73,6 @@ public class BankReconciliationsController : Controller
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
// The account being reconciled must be a real money account (Asset/Liability).
|
||||
if (!await AccountGuard.IsValidMoneyAccountAsync(_unitOfWork, model.AccountId, companyId))
|
||||
{
|
||||
TempData["Error"] = "Select a valid bank, cash, or credit account to reconcile.";
|
||||
return RedirectToAction(nameof(Create));
|
||||
}
|
||||
|
||||
// Set beginning balance from last completed reconciliation for this account, or 0
|
||||
var lastCompleted = (await _unitOfWork.BankReconciliations.FindAsync(
|
||||
br => br.CompanyId == companyId
|
||||
@@ -373,14 +365,11 @@ public class BankReconciliationsController : Controller
|
||||
|
||||
private async Task PopulateAccountDropdownAsync()
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
// Reconcilable accounts: any Asset (bank/cash) or Liability (credit card, line of
|
||||
// credit) account. Filter by parent AccountType, not sub-type, so an account the
|
||||
// company classified differently still shows up for reconciliation.
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(
|
||||
a => a.CompanyId == companyId && a.IsActive
|
||||
&& (a.AccountType == AccountType.Asset
|
||||
|| a.AccountType == AccountType.Liability));
|
||||
a => a.IsActive
|
||||
&& (a.AccountSubType == AccountSubType.Checking
|
||||
|| a.AccountSubType == AccountSubType.Savings
|
||||
|| a.AccountSubType == AccountSubType.Cash));
|
||||
|
||||
ViewBag.AccountSelectList = accounts
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
|
||||
@@ -202,14 +202,14 @@ public class BillsController : Controller
|
||||
}
|
||||
|
||||
var apAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.CompanyId == po.CompanyId && a.AccountSubType == AccountSubType.AccountsPayable);
|
||||
a => a.AccountSubType == AccountSubType.AccountsPayable);
|
||||
|
||||
// Vendor default expense account, fall back to first expense/COGS account
|
||||
int? defaultExpenseAccountId = po.Vendor?.DefaultExpenseAccountId;
|
||||
if (!defaultExpenseAccountId.HasValue)
|
||||
{
|
||||
var fallbackAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.CompanyId == po.CompanyId && a.IsActive && (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods));
|
||||
a => a.IsActive && (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods));
|
||||
defaultExpenseAccountId = fallbackAccount?.Id;
|
||||
}
|
||||
|
||||
@@ -272,9 +272,8 @@ public class BillsController : Controller
|
||||
};
|
||||
|
||||
// Pre-fill AP account
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var apAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.CompanyId == companyId && a.AccountSubType == AccountSubType.AccountsPayable);
|
||||
a => a.AccountSubType == AccountSubType.AccountsPayable);
|
||||
dto.APAccountId = apAccount?.Id ?? 0;
|
||||
|
||||
// Pre-fill default expense account for vendor
|
||||
@@ -340,16 +339,6 @@ public class BillsController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the pay-from account before entering the transaction so an invalid
|
||||
// selection rejects the whole request rather than saving a bill with no payment.
|
||||
if (payNow && paymentMethod.HasValue && bankAccountId.HasValue && currentUser != null
|
||||
&& !await AccountGuard.IsValidMoneyAccountAsync(_unitOfWork, bankAccountId, currentUser.CompanyId))
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, "Choose a valid bank or credit account to record the payment.");
|
||||
await PopulateDropdownsAsync();
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
Bill? bill = null;
|
||||
|
||||
// Bill entity, PO back-reference, and optional immediate payment all commit
|
||||
@@ -462,12 +451,11 @@ public class BillsController : Controller
|
||||
var dto = _mapper.Map<BillDto>(bill);
|
||||
|
||||
// Payment form defaults
|
||||
// Payment sources: filter by parent AccountType (Asset or Liability), not sub-type,
|
||||
// so accounts a company classified under a different sub-type still appear.
|
||||
var bankAccounts = (await _unitOfWork.Accounts.FindAsync(
|
||||
a => a.CompanyId == bill.CompanyId &&
|
||||
(a.AccountType == AccountType.Asset ||
|
||||
a.AccountType == AccountType.Liability)))
|
||||
a => a.AccountSubType == AccountSubType.Cash ||
|
||||
a.AccountSubType == AccountSubType.Checking ||
|
||||
a.AccountSubType == AccountSubType.Savings ||
|
||||
a.AccountSubType == AccountSubType.CreditCard))
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.ToList();
|
||||
|
||||
@@ -728,14 +716,6 @@ public class BillsController : Controller
|
||||
return RedirectToAction(nameof(Details), new { id = dto.BillId });
|
||||
}
|
||||
|
||||
// The pay-from account must be a real money account (Asset/Liability) — defense in depth
|
||||
// against a tampered or stale selection before we post to it.
|
||||
if (!await AccountGuard.IsValidMoneyAccountAsync(_unitOfWork, dto.BankAccountId, bill.CompanyId))
|
||||
{
|
||||
TempData["Error"] = "Select a valid bank or credit account to pay from.";
|
||||
return RedirectToAction(nameof(Details), new { id = dto.BillId });
|
||||
}
|
||||
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
|
||||
var payment = _mapper.Map<BillPayment>(dto);
|
||||
@@ -861,13 +841,6 @@ public class BillsController : Controller
|
||||
var payment = await _unitOfWork.BillPayments.GetByIdAsync(dto.PaymentId);
|
||||
if (payment == null) return NotFound();
|
||||
|
||||
// Reject an invalid new pay-from account before we move the balance to it.
|
||||
if (!await AccountGuard.IsValidMoneyAccountAsync(_unitOfWork, dto.BankAccountId, payment.CompanyId))
|
||||
{
|
||||
TempData["Error"] = "Select a valid bank or credit account.";
|
||||
return RedirectToAction(nameof(Details), new { id = dto.BillId });
|
||||
}
|
||||
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
|
||||
// If the bank account changed, reverse the old balance entry and apply the new one
|
||||
@@ -1103,8 +1076,7 @@ public class BillsController : Controller
|
||||
return Json(new { success = false, error = "File must be under 10 MB." });
|
||||
|
||||
// Load expense accounts for matching
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
|
||||
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
|
||||
var expenseAccounts = allAccounts
|
||||
.Where(a => a.AccountType == AccountType.Expense ||
|
||||
a.AccountType == AccountType.CostOfGoods ||
|
||||
@@ -1124,6 +1096,7 @@ public class BillsController : Controller
|
||||
var imageBytes = ms.ToArray();
|
||||
|
||||
var result = await _accountingAi.ScanReceiptAsync(imageBytes, receiptImage.ContentType, expenseAccounts);
|
||||
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
|
||||
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
|
||||
await _usageLogger.LogAsync(companyId, userId, AppConstants.AiFeatures.ReceiptScan, inputLength: (int)receiptImage.Length);
|
||||
return Json(result);
|
||||
@@ -1150,8 +1123,7 @@ public class BillsController : Controller
|
||||
// Load expense accounts if not supplied
|
||||
if (!request.AvailableAccounts.Any())
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
|
||||
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
|
||||
request.AvailableAccounts = allAccounts
|
||||
.Where(a => a.AccountType == AccountType.Expense ||
|
||||
a.AccountType == AccountType.CostOfGoods ||
|
||||
@@ -1198,7 +1170,7 @@ public class BillsController : Controller
|
||||
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
|
||||
var cutoff = DateTime.Today.AddMonths(-12);
|
||||
|
||||
var bills = (await _unitOfWork.Bills.FindAsync(b => b.CompanyId == companyId, false, b => b.Vendor))
|
||||
var bills = (await _unitOfWork.Bills.GetAllAsync(false, b => b.Vendor))
|
||||
.Where(b => b.Status != BillStatus.Voided && b.BillDate >= cutoff)
|
||||
.ToList();
|
||||
|
||||
|
||||
@@ -30,8 +30,7 @@ public class BudgetsController : Controller
|
||||
/// <summary>Lists all budgets for the current company ordered by fiscal year descending.</summary>
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var budgets = (await _unitOfWork.Budgets.FindAsync(b => b.CompanyId == companyId, false, b => b.Lines))
|
||||
var budgets = (await _unitOfWork.Budgets.FindAsync(b => true, false, b => b.Lines))
|
||||
.OrderByDescending(b => b.FiscalYear)
|
||||
.ThenBy(b => b.Name)
|
||||
.ToList();
|
||||
@@ -247,16 +246,15 @@ public class BudgetsController : Controller
|
||||
|
||||
private async Task<List<Account>> GetBudgetableAccountsAsync()
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(
|
||||
a => a.CompanyId == companyId && a.IsActive && (a.AccountType == AccountType.Revenue || a.AccountType == AccountType.Expense));
|
||||
a => a.IsActive && (a.AccountType == AccountType.Revenue || a.AccountType == AccountType.Expense));
|
||||
return accounts.OrderBy(a => a.AccountNumber).ToList();
|
||||
}
|
||||
|
||||
private async Task ClearDefaultFlagAsync(int companyId, int fiscalYear, int? excludeId)
|
||||
{
|
||||
var others = await _unitOfWork.Budgets.FindAsync(
|
||||
b => b.CompanyId == companyId && b.IsDefault && b.FiscalYear == fiscalYear && b.Id != (excludeId ?? 0));
|
||||
b => b.IsDefault && b.FiscalYear == fiscalYear && b.Id != (excludeId ?? 0));
|
||||
foreach (var b in others)
|
||||
{
|
||||
b.IsDefault = false;
|
||||
|
||||
@@ -208,16 +208,10 @@ namespace PowderCoating.Web.Controllers
|
||||
var useMetric = await _tenantContext.UseMetricSystemAsync();
|
||||
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
|
||||
|
||||
// Pre-fill the GL account dropdowns from the company's configured defaults.
|
||||
var prefs = await _unitOfWork.CompanyPreferences
|
||||
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
|
||||
|
||||
var model = new CreateCatalogItemDto
|
||||
{
|
||||
CategoryId = categoryId ?? 0,
|
||||
DisplayOrder = 0,
|
||||
RevenueAccountId = prefs?.DefaultRevenueAccountId,
|
||||
CogsAccountId = prefs?.DefaultCogsAccountId
|
||||
DisplayOrder = 0
|
||||
};
|
||||
|
||||
return View(model);
|
||||
@@ -500,8 +494,7 @@ namespace PowderCoating.Web.Controllers
|
||||
{
|
||||
try
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var items = await _unitOfWork.CatalogItems.FindAsync(i => i.CompanyId == companyId && i.CategoryId == categoryId && i.IsActive);
|
||||
var items = await _unitOfWork.CatalogItems.FindAsync(i => i.CategoryId == categoryId && i.IsActive);
|
||||
|
||||
var itemDtos = items
|
||||
.OrderBy(i => i.DisplayOrder)
|
||||
@@ -542,9 +535,8 @@ namespace PowderCoating.Web.Controllers
|
||||
{
|
||||
try
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var items = await _unitOfWork.CatalogItems.FindAsync(
|
||||
i => i.CompanyId == companyId && i.IsMerchandise && i.IsActive, false, i => i.Category);
|
||||
i => i.IsMerchandise && i.IsActive, false, i => i.Category);
|
||||
|
||||
var result = items
|
||||
.OrderBy(i => i.Category.Name)
|
||||
@@ -678,8 +670,7 @@ namespace PowderCoating.Web.Controllers
|
||||
return;
|
||||
}
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
|
||||
|
||||
var revenueAccounts = accounts
|
||||
.Where(a => a.AccountType == PowderCoating.Core.Enums.AccountType.Revenue)
|
||||
@@ -695,13 +686,6 @@ namespace PowderCoating.Web.Controllers
|
||||
|
||||
ViewBag.RevenueAccounts = revenueAccounts;
|
||||
ViewBag.CogsAccounts = cogsAccounts;
|
||||
|
||||
// Whether the company has configured default accounts — the views use this to label the
|
||||
// blank dropdown option "(Default …)" vs "(None)".
|
||||
var prefs = await _unitOfWork.CompanyPreferences
|
||||
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
|
||||
ViewBag.HasDefaultRevenueAccount = prefs?.DefaultRevenueAccountId != null;
|
||||
ViewBag.HasDefaultCogsAccount = prefs?.DefaultCogsAccountId != null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -914,7 +898,7 @@ namespace PowderCoating.Web.Controllers
|
||||
|
||||
// Get all active catalog items with their categories
|
||||
var items = await _unitOfWork.CatalogItems.FindAsync(
|
||||
ci => ci.CompanyId == currentUser.CompanyId && ci.IsActive,
|
||||
ci => ci.IsActive,
|
||||
false,
|
||||
ci => ci.Category
|
||||
);
|
||||
@@ -969,7 +953,7 @@ namespace PowderCoating.Web.Controllers
|
||||
r => r.CompanyId == currentUser.CompanyId);
|
||||
var report = existing.OrderByDescending(r => r.RunAt).FirstOrDefault();
|
||||
|
||||
var pricedItems = await _unitOfWork.CatalogItems.FindAsync(ci => ci.CompanyId == currentUser.CompanyId && ci.IsActive && ci.DefaultPrice > 0);
|
||||
var pricedItems = await _unitOfWork.CatalogItems.FindAsync(ci => ci.IsActive && ci.DefaultPrice > 0);
|
||||
ViewBag.ActiveItemCount = pricedItems.Count();
|
||||
|
||||
if (report != null)
|
||||
@@ -1053,7 +1037,7 @@ namespace PowderCoating.Web.Controllers
|
||||
// Load active catalog items with a real price — skip $0 items (placeholders,
|
||||
// category headers, etc.) since there's no pricing to evaluate.
|
||||
var items = (await _unitOfWork.CatalogItems.FindAsync(
|
||||
ci => ci.CompanyId == currentUser.CompanyId && ci.IsActive && ci.DefaultPrice > 0, false, ci => ci.Category)).ToList();
|
||||
ci => ci.IsActive && ci.DefaultPrice > 0, false, ci => ci.Category)).ToList();
|
||||
|
||||
if (items.Count == 0)
|
||||
{
|
||||
|
||||
@@ -754,69 +754,6 @@ public class CompaniesController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One-time data repair for a company whose chart of accounts was imported from QuickBooks
|
||||
/// IIF files. QuickBooks stores credit-normal account opening balances as negative numbers
|
||||
/// (e.g. Revenue, Liability, Equity), but this app's convention is positive opening balances
|
||||
/// with the debit/credit nature implied by account type. This flips negative opening balances
|
||||
/// on Revenue/Liability/Equity accounts to their absolute values so the chart of accounts
|
||||
/// reads correctly. Afterward, Recalculate Balances (on the Chart of Accounts page) should be
|
||||
/// run to propagate the corrected opening balances into CurrentBalance.
|
||||
/// <para>
|
||||
/// This is a SuperAdmin-only platform tool — it operates on the target company identified by
|
||||
/// <paramref name="id"/> (not the caller's tenant), so it uses <c>ignoreQueryFilters</c> to
|
||||
/// reach across the multi-tenancy boundary. It was deliberately moved here from the company
|
||||
/// Chart of Accounts page so normal company admins can't see or trigger it.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
// POST: Companies/FixOpeningBalanceSigns/5
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> FixOpeningBalanceSigns(int id)
|
||||
{
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(id, ignoreQueryFilters: true);
|
||||
if (company == null)
|
||||
{
|
||||
TempData["Error"] = "Company not found.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Explicit CompanyId predicate + ignoreQueryFilters: SuperAdmin acts on another
|
||||
// tenant, so the global multi-tenancy filter must be bypassed but scoping kept tight.
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(
|
||||
a => a.CompanyId == id && a.IsActive, ignoreQueryFilters: true);
|
||||
|
||||
int fixedCount = 0;
|
||||
foreach (var acct in accounts)
|
||||
{
|
||||
if (acct.OpeningBalance < 0 &&
|
||||
acct.AccountType is Core.Enums.AccountType.Revenue
|
||||
or Core.Enums.AccountType.Liability
|
||||
or Core.Enums.AccountType.Equity)
|
||||
{
|
||||
acct.OpeningBalance = Math.Abs(acct.OpeningBalance);
|
||||
acct.CurrentBalance = Math.Abs(acct.CurrentBalance);
|
||||
await _unitOfWork.Accounts.UpdateAsync(acct);
|
||||
fixedCount++;
|
||||
}
|
||||
}
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData[fixedCount > 0 ? "Success" : "Info"] = fixedCount > 0
|
||||
? $"Fixed {fixedCount} account(s) with negative opening balances for '{company.CompanyName}'. Run Recalculate Balances on the company's Chart of Accounts to update CurrentBalance."
|
||||
: $"No accounts needed fixing for '{company.CompanyName}' — all opening balances already have the correct sign.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fixing opening balance signs for company {CompanyId}", id);
|
||||
TempData["Error"] = "An error occurred while fixing opening balances.";
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the form for adding an additional CompanyAdmin user to an existing company.
|
||||
/// Used when a company needs more than one admin or when the original admin's account must
|
||||
|
||||
@@ -990,7 +990,7 @@ public class CompanySettingsController : Controller
|
||||
// Add job counts
|
||||
foreach (var dto in dtos)
|
||||
{
|
||||
dto.JobCount = await _unitOfWork.Jobs.CountAsync(j => j.CompanyId == companyId && j.JobStatusId == dto.Id);
|
||||
dto.JobCount = await _unitOfWork.Jobs.CountAsync(j => j.JobStatusId == dto.Id);
|
||||
}
|
||||
|
||||
return Json(dtos);
|
||||
@@ -1023,7 +1023,7 @@ public class CompanySettingsController : Controller
|
||||
|
||||
// Check if status code already exists for this company
|
||||
var exists = await _unitOfWork.JobStatusLookups
|
||||
.AnyAsync(s => s.CompanyId == companyId.Value && s.StatusCode == dto.StatusCode);
|
||||
.AnyAsync(s => s.StatusCode == dto.StatusCode);
|
||||
if (exists)
|
||||
return Json(new { success = false, message = "Status code already exists" });
|
||||
|
||||
@@ -1100,7 +1100,7 @@ public class CompanySettingsController : Controller
|
||||
return Json(new { success = false, message = "Cannot delete system-defined status" });
|
||||
|
||||
// Check if status is in use
|
||||
var inUse = await _unitOfWork.Jobs.AnyAsync(j => j.CompanyId == status.CompanyId && j.JobStatusId == id);
|
||||
var inUse = await _unitOfWork.Jobs.AnyAsync(j => j.JobStatusId == id);
|
||||
if (inUse)
|
||||
return Json(new { success = false, message = "Status is in use and cannot be deleted" });
|
||||
|
||||
@@ -1184,7 +1184,7 @@ public class CompanySettingsController : Controller
|
||||
// Add job counts
|
||||
foreach (var dto in dtos)
|
||||
{
|
||||
dto.JobCount = await _unitOfWork.Jobs.CountAsync(j => j.CompanyId == companyId && j.JobPriorityId == dto.Id);
|
||||
dto.JobCount = await _unitOfWork.Jobs.CountAsync(j => j.JobPriorityId == dto.Id);
|
||||
}
|
||||
|
||||
return Json(dtos);
|
||||
@@ -1216,7 +1216,7 @@ public class CompanySettingsController : Controller
|
||||
|
||||
// Check if priority code already exists for this company
|
||||
var exists = await _unitOfWork.JobPriorityLookups
|
||||
.AnyAsync(p => p.CompanyId == companyId.Value && p.PriorityCode == dto.PriorityCode);
|
||||
.AnyAsync(p => p.PriorityCode == dto.PriorityCode);
|
||||
if (exists)
|
||||
return Json(new { success = false, message = "Priority code already exists" });
|
||||
|
||||
@@ -1290,7 +1290,7 @@ public class CompanySettingsController : Controller
|
||||
return Json(new { success = false, message = "Cannot delete system-defined priority" });
|
||||
|
||||
// Check if priority is in use
|
||||
var inUse = await _unitOfWork.Jobs.AnyAsync(j => j.CompanyId == priority.CompanyId && j.JobPriorityId == id);
|
||||
var inUse = await _unitOfWork.Jobs.AnyAsync(j => j.JobPriorityId == id);
|
||||
if (inUse)
|
||||
return Json(new { success = false, message = "Priority is in use and cannot be deleted" });
|
||||
|
||||
@@ -1370,7 +1370,7 @@ public class CompanySettingsController : Controller
|
||||
// Add quote counts
|
||||
foreach (var dto in dtos)
|
||||
{
|
||||
dto.QuoteCount = await _unitOfWork.Quotes.CountAsync(q => q.CompanyId == companyId && q.QuoteStatusId == dto.Id);
|
||||
dto.QuoteCount = await _unitOfWork.Quotes.CountAsync(q => q.QuoteStatusId == dto.Id);
|
||||
}
|
||||
|
||||
return Json(dtos);
|
||||
@@ -1403,7 +1403,7 @@ public class CompanySettingsController : Controller
|
||||
|
||||
// Check if status code already exists for this company
|
||||
var exists = await _unitOfWork.QuoteStatusLookups
|
||||
.AnyAsync(s => s.CompanyId == companyId.Value && s.StatusCode == dto.StatusCode);
|
||||
.AnyAsync(s => s.StatusCode == dto.StatusCode);
|
||||
if (exists)
|
||||
return Json(new { success = false, message = "Status code already exists" });
|
||||
|
||||
@@ -1411,7 +1411,7 @@ public class CompanySettingsController : Controller
|
||||
if (dto.IsApprovedStatus)
|
||||
{
|
||||
var hasApproved = await _unitOfWork.QuoteStatusLookups
|
||||
.AnyAsync(s => s.CompanyId == companyId.Value && s.IsApprovedStatus);
|
||||
.AnyAsync(s => s.IsApprovedStatus);
|
||||
if (hasApproved)
|
||||
return Json(new { success = false, message = "Only one status can be marked as 'Approved'" });
|
||||
}
|
||||
@@ -1419,7 +1419,7 @@ public class CompanySettingsController : Controller
|
||||
if (dto.IsConvertedStatus)
|
||||
{
|
||||
var hasConverted = await _unitOfWork.QuoteStatusLookups
|
||||
.AnyAsync(s => s.CompanyId == companyId.Value && s.IsConvertedStatus);
|
||||
.AnyAsync(s => s.IsConvertedStatus);
|
||||
if (hasConverted)
|
||||
return Json(new { success = false, message = "Only one status can be marked as 'Converted'" });
|
||||
}
|
||||
@@ -1466,7 +1466,7 @@ public class CompanySettingsController : Controller
|
||||
if (dto.IsApprovedStatus && !status.IsApprovedStatus)
|
||||
{
|
||||
var hasApproved = await _unitOfWork.QuoteStatusLookups
|
||||
.AnyAsync(s => s.CompanyId == status.CompanyId && s.Id != dto.Id && s.IsApprovedStatus);
|
||||
.AnyAsync(s => s.Id != dto.Id && s.IsApprovedStatus);
|
||||
if (hasApproved)
|
||||
return Json(new { success = false, message = "Only one status can be marked as 'Approved'" });
|
||||
}
|
||||
@@ -1474,7 +1474,7 @@ public class CompanySettingsController : Controller
|
||||
if (dto.IsConvertedStatus && !status.IsConvertedStatus)
|
||||
{
|
||||
var hasConverted = await _unitOfWork.QuoteStatusLookups
|
||||
.AnyAsync(s => s.CompanyId == status.CompanyId && s.Id != dto.Id && s.IsConvertedStatus);
|
||||
.AnyAsync(s => s.Id != dto.Id && s.IsConvertedStatus);
|
||||
if (hasConverted)
|
||||
return Json(new { success = false, message = "Only one status can be marked as 'Converted'" });
|
||||
}
|
||||
@@ -1512,7 +1512,7 @@ public class CompanySettingsController : Controller
|
||||
return Json(new { success = false, message = "Cannot delete system-defined status" });
|
||||
|
||||
// Check if status is in use
|
||||
var inUse = await _unitOfWork.Quotes.AnyAsync(q => q.CompanyId == status.CompanyId && q.QuoteStatusId == id);
|
||||
var inUse = await _unitOfWork.Quotes.AnyAsync(q => q.QuoteStatusId == id);
|
||||
if (inUse)
|
||||
return Json(new { success = false, message = "Status is in use and cannot be deleted" });
|
||||
|
||||
@@ -1909,7 +1909,7 @@ public class CompanySettingsController : Controller
|
||||
// Add appointment counts
|
||||
foreach (var dto in dtos)
|
||||
{
|
||||
dto.AppointmentCount = await _unitOfWork.Appointments.CountAsync(a => a.CompanyId == companyId && a.AppointmentTypeId == dto.Id);
|
||||
dto.AppointmentCount = await _unitOfWork.Appointments.CountAsync(a => a.AppointmentTypeId == dto.Id);
|
||||
}
|
||||
|
||||
return Json(dtos);
|
||||
@@ -1941,7 +1941,7 @@ public class CompanySettingsController : Controller
|
||||
|
||||
// Check if type code already exists for this company
|
||||
var exists = await _unitOfWork.AppointmentTypeLookups
|
||||
.AnyAsync(t => t.CompanyId == companyId.Value && t.TypeCode == dto.TypeCode);
|
||||
.AnyAsync(t => t.TypeCode == dto.TypeCode);
|
||||
if (exists)
|
||||
return Json(new { success = false, message = "Type code already exists" });
|
||||
|
||||
@@ -2015,7 +2015,7 @@ public class CompanySettingsController : Controller
|
||||
return Json(new { success = false, message = "Cannot delete system-defined type" });
|
||||
|
||||
// Check if type is in use
|
||||
var inUse = await _unitOfWork.Appointments.AnyAsync(a => a.CompanyId == type.CompanyId && a.AppointmentTypeId == id);
|
||||
var inUse = await _unitOfWork.Appointments.AnyAsync(a => a.AppointmentTypeId == id);
|
||||
if (inUse)
|
||||
return Json(new { success = false, message = "Type is in use and cannot be deleted" });
|
||||
|
||||
@@ -2095,7 +2095,7 @@ public class CompanySettingsController : Controller
|
||||
// Add item counts
|
||||
foreach (var dto in dtos)
|
||||
{
|
||||
dto.ItemCount = await _unitOfWork.InventoryItems.CountAsync(i => i.CompanyId == companyId && i.InventoryCategoryId == dto.Id);
|
||||
dto.ItemCount = await _unitOfWork.InventoryItems.CountAsync(i => i.InventoryCategoryId == dto.Id);
|
||||
}
|
||||
|
||||
return Json(dtos);
|
||||
@@ -2127,7 +2127,7 @@ public class CompanySettingsController : Controller
|
||||
|
||||
// Check if category code already exists for this company
|
||||
var exists = await _unitOfWork.InventoryCategoryLookups
|
||||
.AnyAsync(c => c.CompanyId == companyId.Value && c.CategoryCode == dto.CategoryCode);
|
||||
.AnyAsync(c => c.CategoryCode == dto.CategoryCode);
|
||||
if (exists)
|
||||
return Json(new { success = false, message = "Category code already exists" });
|
||||
|
||||
@@ -2193,7 +2193,7 @@ public class CompanySettingsController : Controller
|
||||
return Json(new { success = false, message = "Category not found" });
|
||||
|
||||
// Check if category is in use
|
||||
var inUse = await _unitOfWork.InventoryItems.AnyAsync(i => i.CompanyId == category.CompanyId && i.InventoryCategoryId == id);
|
||||
var inUse = await _unitOfWork.InventoryItems.AnyAsync(i => i.InventoryCategoryId == id);
|
||||
if (inUse)
|
||||
return Json(new { success = false, message = "Category is in use and cannot be deleted" });
|
||||
|
||||
@@ -2404,7 +2404,7 @@ public class CompanySettingsController : Controller
|
||||
return Json(new { success = false, message = "Oven not found." });
|
||||
|
||||
// Check if any quotes reference this oven
|
||||
var usageCount = await _unitOfWork.Quotes.CountAsync(q => q.CompanyId == companyId.Value && q.OvenCostId == id);
|
||||
var usageCount = await _unitOfWork.Quotes.CountAsync(q => q.OvenCostId == id);
|
||||
if (usageCount > 0)
|
||||
return Json(new { success = false, message = $"Cannot delete: {usageCount} quote(s) reference this oven. Deactivate it instead." });
|
||||
|
||||
|
||||
@@ -47,9 +47,8 @@ public class CreditMemosController : Controller
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Index(string? status, string? search)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var memos = await _unitOfWork.CreditMemos.FindAsync(
|
||||
m => m.CompanyId == companyId, false,
|
||||
m => true, false,
|
||||
m => m.Customer);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
@@ -178,13 +177,6 @@ public class CreditMemosController : Controller
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// GL: the credit is a liability owed to the customer — DR Sales Discounts (contra-revenue)
|
||||
// / CR Customer Credits (2350). Relieved when the memo is applied to an invoice.
|
||||
var discountAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(a => a.CompanyId == customer.CompanyId && a.AccountNumber == "4950" && a.IsActive);
|
||||
var customerCreditsAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(a => a.CompanyId == customer.CompanyId && a.AccountNumber == "2350" && a.IsActive);
|
||||
await _accountBalanceService.DebitAsync(discountAcct?.Id, vm.Amount);
|
||||
await _accountBalanceService.CreditAsync(customerCreditsAcct?.Id, vm.Amount);
|
||||
|
||||
TempData["Success"] = $"Credit memo {memoNumber} for {vm.Amount:C} issued to {DisplayName(customer)}.";
|
||||
return RedirectToAction(nameof(Details), new { id = memo.Id });
|
||||
}
|
||||
@@ -260,14 +252,18 @@ public class CreditMemosController : Controller
|
||||
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
||||
}
|
||||
|
||||
// GL: applying the credit relieves the liability — DR Customer Credits (2350) / CR AR.
|
||||
// The contra-revenue (Sales Discounts) was recognized when the credit was issued.
|
||||
// Keeps Account.CurrentBalance in sync for RecalculateAllAsync and direct readers.
|
||||
// GL: DR 4950 Sales Discounts (contra-revenue) / CR AR.
|
||||
// The dynamic report computation attributes credit memo applications to both
|
||||
// accounts already; this call keeps Account.CurrentBalance in sync for
|
||||
// RecalculateAllAsync and any tools that read it directly.
|
||||
var arAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.CompanyId == invoice.CompanyId && a.AccountSubType == AccountSubType.AccountsReceivable && a.IsActive);
|
||||
var customerCreditsAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.CompanyId == invoice.CompanyId && a.AccountNumber == "2350" && a.IsActive);
|
||||
await _accountBalanceService.DebitAsync(customerCreditsAcct?.Id, applyAmount);
|
||||
a => a.AccountSubType == AccountSubType.AccountsReceivable && a.IsActive);
|
||||
var discountAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.AccountNumber == "4950" && a.IsActive)
|
||||
?? await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.AccountType == AccountType.Revenue && a.IsActive
|
||||
&& a.Name.ToLower().Contains("discount"));
|
||||
await _accountBalanceService.DebitAsync(discountAcct?.Id, applyAmount);
|
||||
await _accountBalanceService.CreditAsync(arAcct?.Id, applyAmount);
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
@@ -312,15 +308,6 @@ public class CreditMemosController : Controller
|
||||
await _unitOfWork.Customers.UpdateAsync(memo.Customer);
|
||||
}
|
||||
|
||||
// GL: reverse the unapplied issuance — DR Customer Credits (2350) / CR Sales Discounts.
|
||||
if (remaining > 0)
|
||||
{
|
||||
var ccAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(a => a.CompanyId == memo.CompanyId && a.AccountNumber == "2350" && a.IsActive);
|
||||
var sdAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(a => a.CompanyId == memo.CompanyId && a.AccountNumber == "4950" && a.IsActive);
|
||||
await _accountBalanceService.DebitAsync(ccAcct?.Id, remaining);
|
||||
await _accountBalanceService.CreditAsync(sdAcct?.Id, remaining);
|
||||
}
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
TempData["Success"] = "Credit memo voided. Unapplied balance reversed from customer credit.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
|
||||
@@ -1411,8 +1411,7 @@ public class CustomersController : Controller
|
||||
/// </summary>
|
||||
private async Task PopulatePricingTiersAsync()
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var tiers = await _unitOfWork.PricingTiers.FindAsync(t => t.CompanyId == companyId && t.IsActive);
|
||||
var tiers = await _unitOfWork.PricingTiers.FindAsync(t => t.IsActive);
|
||||
ViewBag.PricingTiers = tiers
|
||||
.OrderBy(t => t.TierName)
|
||||
.Select(t => new SelectListItem
|
||||
|
||||
@@ -499,7 +499,7 @@ public class DashboardController : Controller
|
||||
return null;
|
||||
|
||||
// These share the same scoped DbContext so must run sequentially
|
||||
var hasStatusHistory = await _unitOfWork.JobStatusHistory.AnyAsync(h => h.CompanyId == companyId);
|
||||
var hasStatusHistory = await _unitOfWork.JobStatusHistory.AnyAsync(_ => true);
|
||||
// ignoreQueryFilters so soft-deleted lookups (UpdatedAt set on delete) are also visible
|
||||
var hasCustomizedLookups = await _unitOfWork.JobStatusLookups.AnyAsync(
|
||||
j => j.CompanyId == companyId && j.UpdatedAt != null,
|
||||
|
||||
@@ -9,7 +9,6 @@ using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using PowderCoating.Web.Helpers;
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Helpers;
|
||||
using QuestPDF.Infrastructure;
|
||||
@@ -64,8 +63,7 @@ public class DepositsController : Controller
|
||||
string paymentMethod,
|
||||
DateTime receivedDate,
|
||||
string? reference,
|
||||
string? notes,
|
||||
int? depositAccountId = null)
|
||||
string? notes)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -82,32 +80,7 @@ public class DepositsController : Controller
|
||||
if (currentUser == null) return Unauthorized();
|
||||
|
||||
var receiptNumber = await GenerateReceiptNumberAsync(currentUser.CompanyId);
|
||||
|
||||
// Resolve the bank/asset account the deposit lands in. The user now picks this on
|
||||
// the form; if they didn't (or the value is stale), fall back to the legacy
|
||||
// auto-pick of the first Checking/Cash account. Validate any user-supplied id
|
||||
// belongs to this company (defense in depth — the global filter alone isn't enough).
|
||||
int? depositAcctId = null;
|
||||
if (depositAccountId.HasValue &&
|
||||
await AccountGuard.IsValidMoneyAccountAsync(_unitOfWork, depositAccountId, currentUser.CompanyId))
|
||||
{
|
||||
depositAcctId = depositAccountId;
|
||||
}
|
||||
depositAcctId ??= await GetCheckingAccountIdAsync(currentUser.CompanyId);
|
||||
|
||||
// Guard against an unbalanced GL posting: this deposit credits the Customer Deposits
|
||||
// liability (2300). If that account exists but we have no bank/asset account to debit,
|
||||
// the entry would be one-sided. Block it so the user picks a deposit account first.
|
||||
// (When 2300 doesn't exist — e.g. a company not using accounting — no GL posts at all,
|
||||
// so a missing bank account is harmless and the deposit is allowed through.)
|
||||
var custDepositsAcctId = await GetCustomerDepositsAccountIdAsync(currentUser.CompanyId);
|
||||
if (custDepositsAcctId != null && depositAcctId == null)
|
||||
return Json(new
|
||||
{
|
||||
success = false,
|
||||
message = "Select a deposit account (the bank/asset account this payment lands in) " +
|
||||
"before recording. None is configured for your company yet."
|
||||
});
|
||||
var checkingAcctId = await GetCheckingAccountIdAsync(currentUser.CompanyId);
|
||||
|
||||
var deposit = new Deposit
|
||||
{
|
||||
@@ -120,7 +93,7 @@ public class DepositsController : Controller
|
||||
ReceivedDate = receivedDate,
|
||||
Reference = reference,
|
||||
Notes = notes,
|
||||
DepositAccountId = depositAcctId,
|
||||
DepositAccountId = checkingAcctId,
|
||||
RecordedById = currentUser.Id,
|
||||
CompanyId = currentUser.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
@@ -131,7 +104,8 @@ public class DepositsController : Controller
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// GL: DR Checking (cash received) / CR Customer Deposits 2300 (liability until applied to invoice).
|
||||
await _accountBalanceService.DebitAsync(depositAcctId, deposit.Amount);
|
||||
var custDepositsAcctId = await GetCustomerDepositsAccountIdAsync(currentUser.CompanyId);
|
||||
await _accountBalanceService.DebitAsync(checkingAcctId, deposit.Amount);
|
||||
await _accountBalanceService.CreditAsync(custDepositsAcctId, deposit.Amount);
|
||||
|
||||
return Json(new
|
||||
|
||||
@@ -105,9 +105,8 @@ public class ExpensesController : Controller
|
||||
ViewBag.To = to?.ToString("yyyy-MM-dd");
|
||||
ViewBag.TotalAmount = dtos.Sum(e => e.Amount);
|
||||
|
||||
var legacyUser = await _userManager.GetUserAsync(User);
|
||||
var expenseAccounts = (await _unitOfWork.Accounts.FindAsync(
|
||||
a => a.CompanyId == legacyUser!.CompanyId && a.IsActive &&
|
||||
a => a.IsActive &&
|
||||
(a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods)))
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.ToList();
|
||||
@@ -480,8 +479,7 @@ public class ExpensesController : Controller
|
||||
|
||||
if (!request.AvailableAccounts.Any())
|
||||
{
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == currentUser!.CompanyId && a.IsActive);
|
||||
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
|
||||
request.AvailableAccounts = allAccounts
|
||||
.Where(a => a.AccountType == AccountType.Expense ||
|
||||
a.AccountType == AccountType.CostOfGoods)
|
||||
|
||||
@@ -44,9 +44,8 @@ public class FixedAssetsController : Controller
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var assets = await _unitOfWork.FixedAssets.FindAsync(
|
||||
fa => fa.CompanyId == companyId, false,
|
||||
fa => true, false,
|
||||
fa => fa.AssetAccount,
|
||||
fa => fa.DepreciationExpenseAccount,
|
||||
fa => fa.AccumDepreciationAccount);
|
||||
@@ -193,7 +192,7 @@ public class FixedAssetsController : Controller
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
|
||||
var assets = await _unitOfWork.FixedAssets.FindAsync(
|
||||
fa => fa.CompanyId == companyId && !fa.IsDisposed, false,
|
||||
fa => !fa.IsDisposed, false,
|
||||
fa => fa.DepreciationEntries);
|
||||
|
||||
int posted = 0, skipped = 0;
|
||||
@@ -314,8 +313,7 @@ public class FixedAssetsController : Controller
|
||||
|
||||
private async Task PopulateAccountsAsync()
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
|
||||
var list = accounts.OrderBy(a => a.AccountNumber).ThenBy(a => a.Name).ToList();
|
||||
|
||||
ViewBag.AssetAccounts = list
|
||||
|
||||
@@ -62,9 +62,8 @@ public class GiftCertificatesController : Controller
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Index(string? searchTerm, string? statusFilter)
|
||||
{
|
||||
var companyId = (await _userManager.GetUserAsync(User))?.CompanyId ?? 0;
|
||||
var certs = await _unitOfWork.GiftCertificates.FindAsync(
|
||||
gc => gc.CompanyId == companyId, false,
|
||||
gc => true, false,
|
||||
gc => gc.RecipientCustomer,
|
||||
gc => gc.PurchasingCustomer);
|
||||
|
||||
@@ -255,14 +254,14 @@ public class GiftCertificatesController : Controller
|
||||
if (dto.IssuedReason == GiftCertificateIssuedReason.Sold)
|
||||
{
|
||||
var checkingAcctId = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.CompanyId == companyId && a.IsActive && (a.AccountSubType == AccountSubTypeEnum.Checking
|
||||
a => a.IsActive && (a.AccountSubType == AccountSubTypeEnum.Checking
|
||||
|| a.AccountSubType == AccountSubTypeEnum.Cash));
|
||||
await _accountBalanceService.DebitAsync(checkingAcctId?.Id, cert.OriginalAmount);
|
||||
}
|
||||
else
|
||||
{
|
||||
var discountAcctId = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "4950");
|
||||
a => a.IsActive && a.AccountNumber == "4950");
|
||||
await _accountBalanceService.DebitAsync(discountAcctId?.Id, cert.OriginalAmount);
|
||||
}
|
||||
|
||||
@@ -311,7 +310,7 @@ public class GiftCertificatesController : Controller
|
||||
var companyId = currentUser?.CompanyId ?? 0;
|
||||
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(companyId);
|
||||
var otherIncomeAcctId = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.CompanyId == companyId && a.IsActive && a.AccountSubType == AccountSubTypeEnum.OtherIncome);
|
||||
a => a.IsActive && a.AccountSubType == AccountSubTypeEnum.OtherIncome);
|
||||
await _accountBalanceService.DebitAsync(gcLiabilityAcctId, remaining);
|
||||
await _accountBalanceService.CreditAsync(otherIncomeAcctId?.Id, remaining);
|
||||
}
|
||||
@@ -421,8 +420,7 @@ public class GiftCertificatesController : Controller
|
||||
/// </summary>
|
||||
private async Task PopulateCustomersAsync()
|
||||
{
|
||||
var companyId = (await _userManager.GetUserAsync(User))?.CompanyId ?? 0;
|
||||
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId && c.IsActive);
|
||||
var customers = await _unitOfWork.Customers.FindAsync(c => c.IsActive);
|
||||
var list = customers
|
||||
.OrderBy(c => c.CompanyName ?? c.ContactFirstName)
|
||||
.Select(c => new SelectListItem
|
||||
@@ -439,7 +437,7 @@ public class GiftCertificatesController : Controller
|
||||
private async Task<int?> GetGcLiabilityAccountIdAsync(int companyId)
|
||||
{
|
||||
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "2500");
|
||||
a => a.IsActive && a.AccountNumber == "2500");
|
||||
return acct?.Id;
|
||||
}
|
||||
|
||||
@@ -479,14 +477,14 @@ public class GiftCertificatesController : Controller
|
||||
if (dto.IssuedReason == GiftCertificateIssuedReason.Sold)
|
||||
{
|
||||
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.CompanyId == companyId && a.IsActive && (a.AccountSubType == AccountSubTypeEnum.Checking
|
||||
a => a.IsActive && (a.AccountSubType == AccountSubTypeEnum.Checking
|
||||
|| a.AccountSubType == AccountSubTypeEnum.Cash));
|
||||
checkingAcctId = acct?.Id;
|
||||
}
|
||||
else
|
||||
{
|
||||
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "4950");
|
||||
a => a.IsActive && a.AccountNumber == "4950");
|
||||
discountAcctId = acct?.Id;
|
||||
}
|
||||
|
||||
|
||||
@@ -126,12 +126,11 @@ public class InAppNotificationsController : Controller
|
||||
public async Task<IActionResult> MarkAllRead()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var companyId = _tenant.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
var unread = _tenant.IsPlatformAdmin()
|
||||
? (await _unitOfWork.InAppNotifications.FindAsync(
|
||||
n => !n.IsDeleted && n.CompanyId == 0 && !n.IsRead, ignoreQueryFilters: true)).ToList()
|
||||
: (await _unitOfWork.InAppNotifications.FindAsync(n => n.CompanyId == companyId && !n.IsRead)).ToList();
|
||||
: (await _unitOfWork.InAppNotifications.FindAsync(n => !n.IsRead)).ToList();
|
||||
|
||||
foreach (var n in unread)
|
||||
{
|
||||
|
||||
@@ -193,9 +193,8 @@ public class InventoryController : Controller
|
||||
return RedirectToAction(nameof(Index));
|
||||
|
||||
var loc = location.Trim();
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var items = await _unitOfWork.InventoryItems.FindAsync(
|
||||
i => i.CompanyId == companyId && i.Location != null && i.Location.ToLower() == loc.ToLower());
|
||||
i => i.Location != null && i.Location.ToLower() == loc.ToLower());
|
||||
|
||||
var dtos = _mapper.Map<List<InventoryListDto>>(items.OrderBy(i => i.Name).ToList());
|
||||
ViewBag.Location = loc;
|
||||
@@ -276,18 +275,10 @@ public class InventoryController : Controller
|
||||
ViewBag.UseMetric = useMetric;
|
||||
ViewBag.CoverageUnit = _measurementService.GetCoverageUnitLabel(useMetric);
|
||||
|
||||
// Pre-fill the GL account dropdowns from the company's configured defaults so new items
|
||||
// inherit them (the user can still change or clear them on the form).
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var prefs = await _unitOfWork.CompanyPreferences
|
||||
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
|
||||
|
||||
return View(new CreateInventoryItemDto
|
||||
{
|
||||
CoverageSqFtPerLb = 30,
|
||||
TransferEfficiency = 65,
|
||||
InventoryAccountId = prefs?.DefaultInventoryAccountId,
|
||||
CogsAccountId = prefs?.DefaultCogsAccountId
|
||||
TransferEfficiency = 65
|
||||
});
|
||||
}
|
||||
|
||||
@@ -307,27 +298,6 @@ public class InventoryController : Controller
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
var category = dto.InventoryCategoryId.HasValue
|
||||
? await _unitOfWork.InventoryCategoryLookups.GetByIdAsync(dto.InventoryCategoryId.Value)
|
||||
: null;
|
||||
var duplicate = await FindInventoryDuplicateAsync(
|
||||
dto.SKU,
|
||||
dto.Manufacturer,
|
||||
dto.ManufacturerPartNumber,
|
||||
dto.ColorName,
|
||||
category?.IsCoating == true);
|
||||
|
||||
if (duplicate != null &&
|
||||
(duplicate.MatchType == InventoryDuplicateMatchType.Sku ||
|
||||
dto.DuplicateOverrideInventoryItemId != duplicate.Item.Id))
|
||||
{
|
||||
ModelState.AddModelError(
|
||||
duplicate.MatchType == InventoryDuplicateMatchType.Sku ? nameof(dto.SKU) : string.Empty,
|
||||
BuildDuplicateMessage(duplicate));
|
||||
await PopulateDropdowns();
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var item = _mapper.Map<InventoryItem>(dto);
|
||||
@@ -336,8 +306,12 @@ public class InventoryController : Controller
|
||||
item.Name = ToTitleCase(item.Name);
|
||||
|
||||
// Populate legacy Category field from lookup table
|
||||
if (category != null)
|
||||
item.Category = category.DisplayName;
|
||||
if (item.InventoryCategoryId.HasValue)
|
||||
{
|
||||
var category = await _unitOfWork.InventoryCategoryLookups.GetByIdAsync(item.InventoryCategoryId.Value);
|
||||
if (category != null)
|
||||
item.Category = category.DisplayName;
|
||||
}
|
||||
|
||||
// Link to the platform catalog row when this item's identity matches one, so the detail
|
||||
// screen can show manufacturer-level status (discontinued / cannot reorder) and quotes
|
||||
@@ -1068,12 +1042,45 @@ public class InventoryController : Controller
|
||||
// TDS cure fallback — same logic as AiLookup button
|
||||
await ApplyTdsCureFallbackAsync(aiResult, colorName);
|
||||
|
||||
var duplicate = await FindInventoryDuplicateAsync(
|
||||
null,
|
||||
manufacturer,
|
||||
sku,
|
||||
colorName,
|
||||
isCoating: true);
|
||||
// Check if this product already exists in the tenant's inventory.
|
||||
// Match by ManufacturerPartNumber first (most precise); fall back to color name + manufacturer.
|
||||
// Returns the first active match so the UI can prompt to add stock inline.
|
||||
int? existingInventoryId = null;
|
||||
string? existingInventoryName = null;
|
||||
decimal? existingQuantityOnHand = null;
|
||||
string? existingUnitOfMeasure = null;
|
||||
|
||||
InventoryItem? existingHit = null;
|
||||
|
||||
if (!string.IsNullOrEmpty(sku))
|
||||
{
|
||||
var skuLower = sku.ToLower();
|
||||
var byPart = await _unitOfWork.InventoryItems.FindAsync(i =>
|
||||
i.ManufacturerPartNumber != null &&
|
||||
i.ManufacturerPartNumber.ToLower() == skuLower);
|
||||
existingHit = byPart.FirstOrDefault();
|
||||
}
|
||||
|
||||
if (existingHit == null && !string.IsNullOrEmpty(colorName))
|
||||
{
|
||||
var nameLower = colorName.ToLower();
|
||||
var mfrLower = manufacturer?.ToLower() ?? "";
|
||||
var byName = await _unitOfWork.InventoryItems.FindAsync(i =>
|
||||
(i.ColorName != null && i.ColorName.ToLower() == nameLower) ||
|
||||
i.Name.ToLower() == nameLower);
|
||||
existingHit = byName.FirstOrDefault(i =>
|
||||
string.IsNullOrEmpty(mfrLower) ||
|
||||
(i.Manufacturer ?? "").ToLower().Contains(mfrLower) ||
|
||||
mfrLower.Contains((i.Manufacturer ?? "").ToLower().Trim()));
|
||||
}
|
||||
|
||||
if (existingHit != null)
|
||||
{
|
||||
existingInventoryId = existingHit.Id;
|
||||
existingInventoryName = existingHit.Name;
|
||||
existingQuantityOnHand = existingHit.QuantityOnHand;
|
||||
existingUnitOfMeasure = existingHit.UnitOfMeasure;
|
||||
}
|
||||
|
||||
return Json(new
|
||||
{
|
||||
@@ -1098,61 +1105,16 @@ public class InventoryController : Controller
|
||||
vendorName = manufacturer,
|
||||
wasInCatalog = wasInCatalog,
|
||||
addedToCatalog = addedToCatalog,
|
||||
existingInventoryId = duplicate?.Item.Id,
|
||||
existingInventoryName = duplicate?.Item.Name,
|
||||
existingQuantityOnHand = duplicate?.Item.QuantityOnHand,
|
||||
existingUnitOfMeasure = duplicate?.Item.UnitOfMeasure,
|
||||
duplicateMatchType = duplicate?.MatchType.ToString(),
|
||||
existingInventoryId = existingInventoryId,
|
||||
existingInventoryName = existingInventoryName,
|
||||
existingQuantityOnHand = existingQuantityOnHand,
|
||||
existingUnitOfMeasure = existingUnitOfMeasure,
|
||||
reasoning = aiResult.Reasoning,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks the current tenant's active inventory for an existing SKU or powder identity.
|
||||
/// Uses the same matcher as label scanning and repeats the tenant boundary explicitly.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> CheckDuplicate(
|
||||
string? sku,
|
||||
int? categoryId,
|
||||
string? manufacturer,
|
||||
string? manufacturerPartNumber,
|
||||
string? colorName,
|
||||
int? currentId = null)
|
||||
{
|
||||
var category = categoryId.HasValue
|
||||
? await _unitOfWork.InventoryCategoryLookups.GetByIdAsync(categoryId.Value)
|
||||
: null;
|
||||
|
||||
var duplicate = await FindInventoryDuplicateAsync(
|
||||
sku,
|
||||
manufacturer,
|
||||
manufacturerPartNumber,
|
||||
colorName,
|
||||
category?.IsCoating == true,
|
||||
currentId);
|
||||
|
||||
if (duplicate == null)
|
||||
return Json(new { hasDuplicate = false });
|
||||
|
||||
return Json(new
|
||||
{
|
||||
hasDuplicate = true,
|
||||
isBlocking = duplicate.MatchType == InventoryDuplicateMatchType.Sku,
|
||||
matchType = duplicate.MatchType.ToString(),
|
||||
message = BuildDuplicateMessage(duplicate),
|
||||
existingInventoryId = duplicate.Item.Id,
|
||||
existingInventoryName = duplicate.Item.Name,
|
||||
existingSku = duplicate.Item.SKU,
|
||||
existingManufacturer = duplicate.Item.Manufacturer,
|
||||
existingColorName = duplicate.Item.ColorName,
|
||||
existingQuantityOnHand = duplicate.Item.QuantityOnHand,
|
||||
existingUnitOfMeasure = duplicate.Item.UnitOfMeasure,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds stock to an existing inventory item from the shared duplicate prompt.
|
||||
/// Adds stock to an existing inventory item from the label scanner inline prompt.
|
||||
/// Creates a Purchase transaction and updates QuantityOnHand without navigating away.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
@@ -1398,48 +1360,6 @@ public class InventoryController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<InventoryDuplicateMatch?> FindInventoryDuplicateAsync(
|
||||
string? sku,
|
||||
string? manufacturer,
|
||||
string? manufacturerPartNumber,
|
||||
string? colorName,
|
||||
bool isCoating,
|
||||
int? excludeId = null)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (!companyId.HasValue || companyId.Value <= 0)
|
||||
return null;
|
||||
|
||||
// Explicit CompanyId predicate is intentional defense-in-depth on top of the global filter.
|
||||
var tenantInventory = await _unitOfWork.InventoryItems.FindAsync(
|
||||
i => i.CompanyId == companyId.Value,
|
||||
false,
|
||||
i => i.InventoryCategory!);
|
||||
|
||||
return InventoryDuplicateMatcher.Find(
|
||||
tenantInventory,
|
||||
companyId.Value,
|
||||
sku,
|
||||
manufacturer,
|
||||
manufacturerPartNumber,
|
||||
colorName,
|
||||
isCoating,
|
||||
excludeId);
|
||||
}
|
||||
|
||||
private static string BuildDuplicateMessage(InventoryDuplicateMatch duplicate)
|
||||
{
|
||||
return duplicate.MatchType switch
|
||||
{
|
||||
InventoryDuplicateMatchType.Sku =>
|
||||
$"SKU '{duplicate.Item.SKU}' is already used by '{duplicate.Item.Name}'.",
|
||||
InventoryDuplicateMatchType.ManufacturerPartNumber =>
|
||||
$"This manufacturer's part number is already recorded as '{duplicate.Item.Name}' ({duplicate.Item.SKU}).",
|
||||
_ =>
|
||||
$"{duplicate.Item.Manufacturer} {duplicate.Item.ColorName ?? duplicate.Item.Name} is already in inventory as '{duplicate.Item.Name}' ({duplicate.Item.SKU})."
|
||||
};
|
||||
}
|
||||
|
||||
private static decimal GetEffectiveTransferEfficiency(decimal? transferEfficiency)
|
||||
{
|
||||
return transferEfficiency ?? DefaultTransferEfficiency;
|
||||
@@ -1532,9 +1452,8 @@ public class InventoryController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var allCoatings = (await _unitOfWork.InventoryItems.FindAsync(
|
||||
i => i.CompanyId == companyId && i.InventoryCategory != null && i.InventoryCategory.IsCoating,
|
||||
i => i.InventoryCategory != null && i.InventoryCategory.IsCoating,
|
||||
false,
|
||||
i => i.InventoryCategory))
|
||||
.OrderBy(i => i.Manufacturer).ThenBy(i => i.ColorName).ThenBy(i => i.Name)
|
||||
@@ -1611,7 +1530,7 @@ public class InventoryController : Controller
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
ViewBag.AiInventoryAssistEnabled = await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId);
|
||||
|
||||
var vendors = (await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId && v.IsActive, false, v => v.Categories))
|
||||
var vendors = (await _unitOfWork.Vendors.FindAsync(v => v.IsActive, false, v => v.Categories))
|
||||
.OrderBy(v => v.CompanyName).ToList();
|
||||
ViewBag.Vendors = new SelectList(vendors, "Id", "CompanyName");
|
||||
|
||||
@@ -1650,17 +1569,12 @@ public class InventoryController : Controller
|
||||
new SelectListItem { Value = "rolls", Text = "Rolls" }
|
||||
};
|
||||
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
|
||||
|
||||
// Show ALL asset accounts, not just the Inventory sub-type. Companies that created
|
||||
// their inventory account manually often land on a different asset sub-type (e.g.
|
||||
// Other Current Asset), which previously left this dropdown empty. Listing every
|
||||
// asset account lets them pick whatever they actually use; Inventory sub-type
|
||||
// accounts are surfaced first as the recommended choice.
|
||||
ViewBag.InventoryAccounts = accounts
|
||||
.Where(a => a.AccountType == AccountType.Asset)
|
||||
.OrderByDescending(a => a.AccountSubType == AccountSubType.Inventory)
|
||||
.ThenBy(a => a.AccountNumber)
|
||||
.Where(a => a.AccountType == AccountType.Asset
|
||||
&& a.AccountSubType == AccountSubType.Inventory)
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
||||
.ToList();
|
||||
|
||||
@@ -1669,13 +1583,6 @@ public class InventoryController : Controller
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
||||
.ToList();
|
||||
|
||||
// Whether the company has configured default accounts — the views use this to label the
|
||||
// blank dropdown option "(Default …)" vs "(None)".
|
||||
var prefs = await _unitOfWork.CompanyPreferences
|
||||
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
|
||||
ViewBag.HasDefaultInventoryAccount = prefs?.DefaultInventoryAccountId != null;
|
||||
ViewBag.HasDefaultCogsAccount = prefs?.DefaultCogsAccountId != null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1853,16 +1760,13 @@ public class InventoryController : Controller
|
||||
item.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.InventoryItems.UpdateAsync(item);
|
||||
|
||||
// Record at the effective (weighted-average) unit cost so TotalCost equals the COGS actually
|
||||
// posted — the GL recompute reads TotalCost to reproduce the DR COGS / CR Inventory entry.
|
||||
var effectiveUnitCost = item.AverageCost > 0 ? item.AverageCost : item.UnitCost;
|
||||
var txn = new InventoryTransaction
|
||||
{
|
||||
InventoryItemId = item.Id,
|
||||
TransactionType = transactionType,
|
||||
Quantity = -quantityUsed,
|
||||
UnitCost = effectiveUnitCost,
|
||||
TotalCost = quantityUsed * effectiveUnitCost,
|
||||
UnitCost = item.UnitCost,
|
||||
TotalCost = quantityUsed * item.UnitCost,
|
||||
TransactionDate = DateTime.UtcNow,
|
||||
BalanceAfter = item.QuantityOnHand,
|
||||
JobId = jobId,
|
||||
@@ -1876,7 +1780,7 @@ public class InventoryController : Controller
|
||||
|
||||
if (item.CogsAccountId.HasValue && item.InventoryAccountId.HasValue)
|
||||
{
|
||||
var cost = txn.TotalCost;
|
||||
var cost = quantityUsed * (item.AverageCost > 0 ? item.AverageCost : item.UnitCost);
|
||||
await _accountBalanceService.DebitAsync(item.CogsAccountId, cost);
|
||||
await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost);
|
||||
}
|
||||
@@ -2227,7 +2131,7 @@ public class InventoryController : Controller
|
||||
return BadRequest("Only usage transactions can be edited here.");
|
||||
|
||||
var allJobs = await _unitOfWork.Jobs.FindAsync(
|
||||
j => j.CompanyId == txn.CompanyId && !j.JobStatus.IsTerminalStatus,
|
||||
j => !j.JobStatus.IsTerminalStatus,
|
||||
false,
|
||||
j => j.Customer,
|
||||
j => j.JobStatus);
|
||||
|
||||
@@ -7,7 +7,6 @@ using PowderCoating.Application.DTOs.Common;
|
||||
using PowderCoating.Application.DTOs.Invoice;
|
||||
using PowderCoating.Application.DTOs.Quote;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Accounting;
|
||||
using PowderCoating.Core.Entities;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using PowderCoating.Core.Enums;
|
||||
@@ -241,12 +240,11 @@ public class InvoicesController : Controller
|
||||
ViewBag.SortDirection = gridRequest.SortDirection;
|
||||
|
||||
// Pill badge counts — always global (not scoped to current filter/page)
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
ViewBag.UnpaidCount = await _unitOfWork.Invoices.CountAsync(i => i.CompanyId == companyId &&
|
||||
(i.Status == InvoiceStatus.Draft || i.Status == InvoiceStatus.Sent || i.Status == InvoiceStatus.Overdue));
|
||||
ViewBag.PartialCount = await _unitOfWork.Invoices.CountAsync(i => i.CompanyId == companyId && i.Status == InvoiceStatus.PartiallyPaid);
|
||||
ViewBag.PaidCount = await _unitOfWork.Invoices.CountAsync(i => i.CompanyId == companyId && i.Status == InvoiceStatus.Paid);
|
||||
ViewBag.AllCount = await _unitOfWork.Invoices.CountAsync(i => i.CompanyId == companyId);
|
||||
ViewBag.UnpaidCount = await _unitOfWork.Invoices.CountAsync(i =>
|
||||
i.Status == InvoiceStatus.Draft || i.Status == InvoiceStatus.Sent || i.Status == InvoiceStatus.Overdue);
|
||||
ViewBag.PartialCount = await _unitOfWork.Invoices.CountAsync(i => i.Status == InvoiceStatus.PartiallyPaid);
|
||||
ViewBag.PaidCount = await _unitOfWork.Invoices.CountAsync(i => i.Status == InvoiceStatus.Paid);
|
||||
ViewBag.AllCount = await _unitOfWork.Invoices.CountAsync();
|
||||
|
||||
return View(pagedResult);
|
||||
}
|
||||
@@ -306,9 +304,8 @@ public class InvoicesController : Controller
|
||||
&& company?.StripeConnectStatus == StripeConnectStatus.Active;
|
||||
|
||||
// Expense accounts for the write-off bad-debt modal
|
||||
var expenseCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var expenseAccounts = await _unitOfWork.Accounts.FindAsync(
|
||||
a => a.CompanyId == expenseCompanyId && a.IsActive && a.AccountType == AccountType.Expense);
|
||||
a => a.IsActive && a.AccountType == AccountType.Expense);
|
||||
ViewBag.ExpenseAccounts = expenseAccounts
|
||||
.OrderBy(a => a.AccountNumber).ThenBy(a => a.Name)
|
||||
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
||||
@@ -413,15 +410,8 @@ public class InvoicesController : Controller
|
||||
.ToDictionary(ci => ci.Id)
|
||||
: new Dictionary<int, CatalogItem>();
|
||||
|
||||
// Fall back to the company's configured default revenue account when a catalog item
|
||||
// has no specific account; if none is configured (or it has since been deactivated),
|
||||
// fall back to the seeded 4000 account. The IsActive check mirrors the 4000 lookup so a
|
||||
// deactivated default doesn't keep being posted to.
|
||||
Account? defaultRevenueAccount = null;
|
||||
if (prefs?.DefaultRevenueAccountId != null)
|
||||
defaultRevenueAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.Id == prefs.DefaultRevenueAccountId.Value && a.IsActive);
|
||||
defaultRevenueAccount ??= await _unitOfWork.Accounts
|
||||
// Fall back to the default revenue account (4000) if a catalog item has no specific account
|
||||
var defaultRevenueAccount = await _unitOfWork.Accounts
|
||||
.FirstOrDefaultAsync(a => a.AccountNumber == "4000" && a.IsActive);
|
||||
|
||||
// Deserialize the job's pricing snapshot up front — it is authoritative for discount,
|
||||
@@ -2414,7 +2404,7 @@ public class InvoicesController : Controller
|
||||
return Json(new { taxPercent = 0m, taxRateName = (string?)null });
|
||||
|
||||
var defaultRate = await _unitOfWork.TaxRates
|
||||
.FirstOrDefaultAsync(r => r.CompanyId == customer.CompanyId && r.IsDefault && r.IsActive && !r.IsDeleted);
|
||||
.FirstOrDefaultAsync(r => r.IsDefault && r.IsActive && !r.IsDeleted);
|
||||
|
||||
return Json(new
|
||||
{
|
||||
@@ -2451,7 +2441,7 @@ public class InvoicesController : Controller
|
||||
|
||||
// Merchandise items for the invoice merch picker (all active IsMerchandise items)
|
||||
var allMerchItems = await _unitOfWork.CatalogItems.FindAsync(
|
||||
i => i.CompanyId == companyId && i.IsMerchandise && i.IsActive, false, i => i.Category);
|
||||
i => i.IsMerchandise && i.IsActive, false, i => i.Category);
|
||||
var merchItems = allMerchItems
|
||||
.OrderBy(i => i.Category.Name).ThenBy(i => i.DisplayOrder).ThenBy(i => i.Name)
|
||||
.Select(i => new { i.Id, i.Name, i.SKU, CategoryName = i.Category.Name, i.DefaultPrice, i.RevenueAccountId })
|
||||
@@ -2467,8 +2457,7 @@ public class InvoicesController : Controller
|
||||
/// </summary>
|
||||
private async Task PopulateBankAccountsAsync()
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive
|
||||
&& (a.AccountSubType == AccountSubType.Cash ||
|
||||
a.AccountSubType == AccountSubType.Checking ||
|
||||
a.AccountSubType == AccountSubType.Savings));
|
||||
@@ -2483,7 +2472,7 @@ public class InvoicesController : Controller
|
||||
private async Task<int?> GetCheckingAccountIdAsync(int companyId)
|
||||
{
|
||||
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.CompanyId == companyId && a.IsActive && (a.AccountSubType == AccountSubType.Checking
|
||||
a => a.IsActive && (a.AccountSubType == AccountSubType.Checking
|
||||
|| a.AccountSubType == AccountSubType.Cash));
|
||||
return acct?.Id;
|
||||
}
|
||||
@@ -2492,23 +2481,7 @@ public class InvoicesController : Controller
|
||||
private async Task<int?> GetCustomerDepositsAccountIdAsync(int companyId)
|
||||
{
|
||||
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "2300");
|
||||
return acct?.Id;
|
||||
}
|
||||
|
||||
/// <summary>Returns the Sales Returns & Allowances contra-revenue account (4960) for refunds.</summary>
|
||||
private async Task<int?> GetSalesReturnsAccountIdAsync(int companyId)
|
||||
{
|
||||
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "4960");
|
||||
return acct?.Id;
|
||||
}
|
||||
|
||||
/// <summary>Returns the Customer Credits liability account (2350) for store credit / credit memos.</summary>
|
||||
private async Task<int?> GetCustomerCreditsAccountIdAsync(int companyId)
|
||||
{
|
||||
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "2350");
|
||||
a => a.IsActive && a.AccountNumber == "2300");
|
||||
return acct?.Id;
|
||||
}
|
||||
|
||||
@@ -2516,7 +2489,7 @@ public class InvoicesController : Controller
|
||||
private async Task<int?> GetArAccountIdAsync(int companyId)
|
||||
{
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(
|
||||
a => a.CompanyId == companyId && a.IsActive && a.AccountSubType == AccountSubType.AccountsReceivable);
|
||||
a => a.IsActive && a.AccountSubType == AccountSubType.AccountsReceivable);
|
||||
return accounts.FirstOrDefault()?.Id;
|
||||
}
|
||||
|
||||
@@ -2527,7 +2500,7 @@ public class InvoicesController : Controller
|
||||
private async Task<int?> GetBadDebtAccountIdAsync(int companyId)
|
||||
{
|
||||
var expenses = await _unitOfWork.Accounts.FindAsync(
|
||||
a => a.CompanyId == companyId && a.IsActive && a.AccountType == AccountType.Expense);
|
||||
a => a.IsActive && a.AccountType == AccountType.Expense);
|
||||
return expenses.FirstOrDefault(a => a.Name.Contains("bad", StringComparison.OrdinalIgnoreCase)
|
||||
|| a.Name.Contains("debt", StringComparison.OrdinalIgnoreCase))?.Id
|
||||
?? expenses.FirstOrDefault()?.Id;
|
||||
@@ -2558,9 +2531,9 @@ public class InvoicesController : Controller
|
||||
private async Task<int?> ResolveSalesTaxAccountIdAsync(int companyId)
|
||||
{
|
||||
var taxAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.CompanyId == companyId && a.AccountNumber == "2200" && a.IsActive);
|
||||
a => a.AccountNumber == "2200" && a.IsActive);
|
||||
taxAccount ??= await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.CompanyId == companyId && a.AccountType == AccountType.Liability && a.IsActive && a.Name.ToLower().Contains("tax"));
|
||||
a => a.AccountType == AccountType.Liability && a.IsActive && a.Name.ToLower().Contains("tax"));
|
||||
return taxAccount?.Id;
|
||||
}
|
||||
|
||||
@@ -2572,9 +2545,9 @@ public class InvoicesController : Controller
|
||||
private async Task<int?> GetSalesDiscountAccountIdAsync(int companyId)
|
||||
{
|
||||
var discountAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.CompanyId == companyId && a.AccountNumber == "4950" && a.IsActive);
|
||||
a => a.AccountNumber == "4950" && a.IsActive);
|
||||
discountAccount ??= await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.CompanyId == companyId && a.AccountType == AccountType.Revenue && a.IsActive && a.Name.ToLower().Contains("discount"));
|
||||
a => a.AccountType == AccountType.Revenue && a.IsActive && a.Name.ToLower().Contains("discount"));
|
||||
return discountAccount?.Id;
|
||||
}
|
||||
|
||||
@@ -2582,7 +2555,7 @@ public class InvoicesController : Controller
|
||||
private async Task<int?> GetGcLiabilityAccountIdAsync(int companyId)
|
||||
{
|
||||
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "2500");
|
||||
a => a.IsActive && a.AccountNumber == "2500");
|
||||
return acct?.Id;
|
||||
}
|
||||
|
||||
@@ -2690,32 +2663,24 @@ public class InvoicesController : Controller
|
||||
}
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// GL: store credit is a liability owed to the customer — DR Sales Discounts (contra-revenue)
|
||||
// / CR Customer Credits (2350). The liability is relieved when the credit memo is applied.
|
||||
var scDiscountAcctId = await GetSalesDiscountAccountIdAsync(companyId);
|
||||
var scCustomerCreditsAcctId = await GetCustomerCreditsAccountIdAsync(companyId);
|
||||
await _accountBalanceService.DebitAsync(scDiscountAcctId, dto.Amount);
|
||||
await _accountBalanceService.CreditAsync(scCustomerCreditsAcctId, dto.Amount);
|
||||
|
||||
TempData["Success"] = $"Refund of {dto.Amount:C} applied as store credit. Credit memo {memoNumber} created.";
|
||||
}
|
||||
else
|
||||
{
|
||||
// "Reverse the sale": a cash refund contra's the original sale instead of re-opening AR.
|
||||
// GL: DR Sales Returns (revenue portion) + DR Sales Tax Payable (tax portion) / CR Bank.
|
||||
// Customer AR balance is intentionally left unchanged — the invoice stays paid and the
|
||||
// sale is reversed via the contra accounts. The split is centralised in RefundAllocation
|
||||
// so LedgerService and FinancialReportService recompute the same way.
|
||||
// Adjust customer AR balance — they're owed money back
|
||||
if (invoice.Customer != null)
|
||||
{
|
||||
invoice.Customer.CurrentBalance -= dto.Amount;
|
||||
await _unitOfWork.Customers.UpdateAsync(invoice.Customer);
|
||||
}
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
var (returnsPortion, taxPortion) = RefundAllocation.Split(dto.Amount, invoice.TaxAmount, invoice.Total);
|
||||
var salesReturnsAccountId = await GetSalesReturnsAccountIdAsync(companyId);
|
||||
var salesTaxAccountId = invoice.SalesTaxAccountId ?? await ResolveSalesTaxAccountIdAsync(companyId);
|
||||
|
||||
await _accountBalanceService.DebitAsync(salesReturnsAccountId, returnsPortion);
|
||||
if (taxPortion > 0)
|
||||
await _accountBalanceService.DebitAsync(salesTaxAccountId, taxPortion);
|
||||
// GL: DR AR (un-collects the payment) / CR Bank (cash leaves).
|
||||
// Mirrors how FinancialReportService accounts for refunds:
|
||||
// arTotalCredits -= refundTotal; refundsByAcct credits the bank account.
|
||||
var arAccountId = await GetArAccountIdAsync(companyId);
|
||||
await _accountBalanceService.DebitAsync(arAccountId, dto.Amount);
|
||||
await _accountBalanceService.CreditAsync(dto.DepositAccountId, dto.Amount);
|
||||
|
||||
TempData["Success"] = $"Refund of {dto.Amount:C} recorded successfully. Please issue the refund manually.";
|
||||
@@ -2766,14 +2731,12 @@ public class InvoicesController : Controller
|
||||
|
||||
if (refund.RefundMethod == PaymentMethod.StoreCredit)
|
||||
{
|
||||
// Cancel the linked CreditMemo and reverse the unapplied store-credit remainder.
|
||||
decimal creditReversed = refund.Amount;
|
||||
// Cancel the linked CreditMemo and reverse the CreditBalance
|
||||
if (refund.CreditMemoId.HasValue)
|
||||
{
|
||||
var memo = await _unitOfWork.CreditMemos.GetByIdAsync(refund.CreditMemoId.Value);
|
||||
if (memo != null && memo.Status == CreditMemoStatus.Active)
|
||||
{
|
||||
creditReversed = memo.Amount - memo.AmountApplied; // only the unapplied remainder
|
||||
memo.Status = CreditMemoStatus.Voided;
|
||||
memo.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.CreditMemos.UpdateAsync(memo);
|
||||
@@ -2782,30 +2745,22 @@ public class InvoicesController : Controller
|
||||
|
||||
if (customer != null)
|
||||
{
|
||||
customer.CreditBalance = Math.Max(0, customer.CreditBalance - creditReversed);
|
||||
customer.CreditBalance -= refund.Amount;
|
||||
await _unitOfWork.Customers.UpdateAsync(customer);
|
||||
}
|
||||
|
||||
// GL: reverse the unapplied store-credit issuance — DR Customer Credits / CR Sales Discounts.
|
||||
if (creditReversed > 0)
|
||||
{
|
||||
var ccAcctId = await GetCustomerCreditsAccountIdAsync(refund.Invoice.CompanyId);
|
||||
var sdAcctId = await GetSalesDiscountAccountIdAsync(refund.Invoice.CompanyId);
|
||||
await _accountBalanceService.DebitAsync(ccAcctId, creditReversed);
|
||||
await _accountBalanceService.CreditAsync(sdAcctId, creditReversed);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Reverse the "reverse the sale" posting: CR Sales Returns + CR Sales Tax Payable / DR Bank.
|
||||
// The customer's AR balance was not touched when the refund was issued, so it is not touched here.
|
||||
var (returnsPortion, taxPortion) = RefundAllocation.Split(refund.Amount, refund.Invoice.TaxAmount, refund.Invoice.Total);
|
||||
var salesReturnsAccountId = await GetSalesReturnsAccountIdAsync(refund.Invoice.CompanyId);
|
||||
var salesTaxAccountId = refund.Invoice.SalesTaxAccountId ?? await ResolveSalesTaxAccountIdAsync(refund.Invoice.CompanyId);
|
||||
// Reverse the AR balance adjustment
|
||||
if (customer != null)
|
||||
{
|
||||
customer.CurrentBalance += refund.Amount;
|
||||
await _unitOfWork.Customers.UpdateAsync(customer);
|
||||
}
|
||||
|
||||
await _accountBalanceService.CreditAsync(salesReturnsAccountId, returnsPortion);
|
||||
if (taxPortion > 0)
|
||||
await _accountBalanceService.CreditAsync(salesTaxAccountId, taxPortion);
|
||||
// GL reversal: CR AR / DR Bank — mirrors the DR AR / CR Bank posted in IssueRefund.
|
||||
var arAccountId = await GetArAccountIdAsync(refund.Invoice.CompanyId);
|
||||
await _accountBalanceService.CreditAsync(arAccountId, refund.Amount);
|
||||
await _accountBalanceService.DebitAsync(refund.DepositAccountId, refund.Amount);
|
||||
}
|
||||
|
||||
@@ -2868,14 +2823,6 @@ public class InvoicesController : Controller
|
||||
}
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// GL: the credit is a liability owed to the customer — DR Sales Discounts (contra-revenue)
|
||||
// / CR Customer Credits (2350). Relieved when the memo is applied to an invoice.
|
||||
var cmDiscountAcctId = await GetSalesDiscountAccountIdAsync(companyId);
|
||||
var cmCustomerCreditsAcctId = await GetCustomerCreditsAccountIdAsync(companyId);
|
||||
await _accountBalanceService.DebitAsync(cmDiscountAcctId, dto.Amount);
|
||||
await _accountBalanceService.CreditAsync(cmCustomerCreditsAcctId, dto.Amount);
|
||||
|
||||
TempData["Success"] = $"Credit memo {memoNumber} for {dto.Amount:C} issued to customer.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -2962,11 +2909,9 @@ public class InvoicesController : Controller
|
||||
}
|
||||
|
||||
// GL: DR Sales Discounts 4950 / CR AR — same as CreditMemosController.Apply.
|
||||
// GL: applying the credit relieves the liability — DR Customer Credits (2350) / CR AR.
|
||||
// (The contra-revenue was already recognized as Sales Discounts when the credit was issued.)
|
||||
var arAccountId = await GetArAccountIdAsync(invoice.CompanyId);
|
||||
var customerCreditsAcctId = await GetCustomerCreditsAccountIdAsync(invoice.CompanyId);
|
||||
await _accountBalanceService.DebitAsync(customerCreditsAcctId, applyAmount);
|
||||
var discountAcctId = await GetSalesDiscountAccountIdAsync(invoice.CompanyId);
|
||||
await _accountBalanceService.DebitAsync(discountAcctId, applyAmount);
|
||||
await _accountBalanceService.CreditAsync(arAccountId, applyAmount);
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
@@ -3009,15 +2954,6 @@ public class InvoicesController : Controller
|
||||
await _unitOfWork.Customers.UpdateAsync(memo.Customer);
|
||||
}
|
||||
|
||||
// GL: reverse the unapplied issuance — DR Customer Credits (2350) / CR Sales Discounts.
|
||||
if (remaining > 0)
|
||||
{
|
||||
var ccAcctId = await GetCustomerCreditsAccountIdAsync(memo.CompanyId);
|
||||
var sdAcctId = await GetSalesDiscountAccountIdAsync(memo.CompanyId);
|
||||
await _accountBalanceService.DebitAsync(ccAcctId, remaining);
|
||||
await _accountBalanceService.CreditAsync(sdAcctId, remaining);
|
||||
}
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
TempData["Success"] = "Credit memo voided.";
|
||||
return RedirectToAction(nameof(Details), new { id = invoiceId });
|
||||
|
||||
@@ -213,29 +213,24 @@ public class JobsController : Controller
|
||||
|
||||
// Pill badge counts — always global (not scoped to current filter/page)
|
||||
var today = DateTime.Today;
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
ViewBag.AllJobCount = await _unitOfWork.Jobs.CountAsync(j => j.CompanyId == companyId);
|
||||
ViewBag.AllJobCount = await _unitOfWork.Jobs.CountAsync();
|
||||
ViewBag.ActiveCount = await _unitOfWork.Jobs.CountAsync(j =>
|
||||
j.CompanyId == companyId
|
||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Completed
|
||||
j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Completed
|
||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.ReadyForPickup
|
||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered
|
||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled);
|
||||
ViewBag.OverdueCount = await _unitOfWork.Jobs.CountAsync(j =>
|
||||
j.CompanyId == companyId
|
||||
&& j.DueDate < today
|
||||
j.DueDate < today
|
||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Completed
|
||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.ReadyForPickup
|
||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered
|
||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled);
|
||||
ViewBag.CompletedCount = await _unitOfWork.Jobs.CountAsync(j =>
|
||||
j.CompanyId == companyId &&
|
||||
(j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Completed
|
||||
j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Completed
|
||||
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup
|
||||
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Delivered));
|
||||
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Delivered);
|
||||
ViewBag.ReadyCount = await _unitOfWork.Jobs.CountAsync(j =>
|
||||
j.CompanyId == companyId
|
||||
&& j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup);
|
||||
j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup);
|
||||
|
||||
// Set ViewBag for sorting
|
||||
ViewBag.SearchTerm = searchTerm;
|
||||
@@ -451,9 +446,6 @@ public class JobsController : Controller
|
||||
ViewBag.JobInvoiceStatus = jobInvoice?.Status;
|
||||
ViewBag.JobVoidedInvoices = voidedInvoices;
|
||||
|
||||
// Bank/asset accounts the deposit can land in (deposit modal dropdown)
|
||||
ViewBag.DepositAccounts = await AccountingDropdownHelper.LoadDepositAccountsAsync(_unitOfWork, companyId);
|
||||
|
||||
// Workers dropdown for inline assignment
|
||||
await PopulateWorkersDropdown();
|
||||
|
||||
@@ -2176,12 +2168,10 @@ public class JobsController : Controller
|
||||
try
|
||||
{
|
||||
var today = date?.Date ?? DateTime.Today;
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
// Load all non-terminal statuses for the progress strip (excluding nav/hold/cancel)
|
||||
var allStatusesEnum = await _unitOfWork.JobStatusLookups.FindAsync(s =>
|
||||
s.CompanyId == companyId
|
||||
&& s.StatusCode != AppConstants.StatusCodes.Job.OnHold && s.StatusCode != AppConstants.StatusCodes.Job.Cancelled
|
||||
s.StatusCode != AppConstants.StatusCodes.Job.OnHold && s.StatusCode != AppConstants.StatusCodes.Job.Cancelled
|
||||
&& s.StatusCode != AppConstants.StatusCodes.Job.Delivered && s.StatusCode != AppConstants.StatusCodes.Job.Quoted
|
||||
&& s.StatusCode != AppConstants.StatusCodes.Job.Pending && s.StatusCode != AppConstants.StatusCodes.Job.Approved);
|
||||
var allStatuses = allStatusesEnum.OrderBy(s => s.DisplayOrder).ToList();
|
||||
@@ -2191,7 +2181,7 @@ public class JobsController : Controller
|
||||
|
||||
// Get existing priority records for today
|
||||
var existingPriorities = await _unitOfWork.JobDailyPriorities
|
||||
.FindAsync(p => p.CompanyId == companyId && p.ScheduledDate.Date == today);
|
||||
.FindAsync(p => p.ScheduledDate.Date == today);
|
||||
|
||||
var priorityDict = existingPriorities.ToDictionary(p => p.JobId);
|
||||
|
||||
@@ -2288,8 +2278,7 @@ public class JobsController : Controller
|
||||
if (!companyId.HasValue) return RedirectToAction(nameof(Index));
|
||||
|
||||
var allStatusesEnum = await _unitOfWork.JobStatusLookups.FindAsync(s =>
|
||||
s.CompanyId == companyId.Value
|
||||
&& !s.IsTerminalStatus
|
||||
!s.IsTerminalStatus
|
||||
&& s.StatusCode != AppConstants.StatusCodes.Job.OnHold && s.StatusCode != AppConstants.StatusCodes.Job.Cancelled
|
||||
&& s.StatusCode != AppConstants.StatusCodes.Job.Delivered);
|
||||
var allStatuses = allStatusesEnum.OrderBy(s => s.DisplayOrder).ToList();
|
||||
@@ -3008,17 +2997,13 @@ public class JobsController : Controller
|
||||
inventoryItem.QuantityOnHand -= deductNow;
|
||||
await _unitOfWork.InventoryItems.UpdateAsync(inventoryItem);
|
||||
|
||||
// Record the consumption at the effective (weighted-average) unit cost so the
|
||||
// transaction's TotalCost equals the COGS actually posted — the GL recompute
|
||||
// reads TotalCost to reproduce the DR COGS / CR Inventory entry.
|
||||
var effectiveUnitCost = inventoryItem.AverageCost > 0 ? inventoryItem.AverageCost : inventoryItem.UnitCost;
|
||||
var transaction = new InventoryTransaction
|
||||
{
|
||||
InventoryItemId = inventoryItem.Id,
|
||||
TransactionType = InventoryTransactionType.JobUsage,
|
||||
Quantity = -deductNow,
|
||||
UnitCost = effectiveUnitCost,
|
||||
TotalCost = effectiveUnitCost * deductNow,
|
||||
UnitCost = inventoryItem.UnitCost,
|
||||
TotalCost = inventoryItem.UnitCost * deductNow,
|
||||
TransactionDate = DateTime.UtcNow,
|
||||
JobId = job.Id,
|
||||
Reference = job.JobNumber,
|
||||
@@ -3030,7 +3015,7 @@ public class JobsController : Controller
|
||||
|
||||
if (inventoryItem.CogsAccountId.HasValue && inventoryItem.InventoryAccountId.HasValue)
|
||||
{
|
||||
var cost = transaction.TotalCost;
|
||||
var cost = deductNow * (inventoryItem.AverageCost > 0 ? inventoryItem.AverageCost : inventoryItem.UnitCost);
|
||||
await _accountBalanceService.DebitAsync(inventoryItem.CogsAccountId, cost);
|
||||
await _accountBalanceService.CreditAsync(inventoryItem.InventoryAccountId, cost);
|
||||
}
|
||||
|
||||
@@ -57,14 +57,13 @@ public class JobsPriorityController : Controller
|
||||
public async Task<IActionResult> Index(DateTime? date)
|
||||
{
|
||||
var today = date?.Date ?? DateTime.Today;
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
// Get all jobs scheduled for today with related data
|
||||
var jobs = await _unitOfWork.Jobs.GetScheduledJobsForDateAsync(today);
|
||||
|
||||
// Get existing priority records for today
|
||||
var existingPriorities = await _unitOfWork.JobDailyPriorities
|
||||
.FindAsync(p => p.CompanyId == companyId && p.ScheduledDate.Date == today);
|
||||
.FindAsync(p => p.ScheduledDate.Date == today);
|
||||
|
||||
var priorityDict = existingPriorities.ToDictionary(p => p.JobId);
|
||||
|
||||
@@ -91,6 +90,7 @@ public class JobsPriorityController : Controller
|
||||
.ToList();
|
||||
|
||||
// Get priorities and workers for modal options
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var priorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId);
|
||||
var workers = await _userManager.Users
|
||||
.Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null)
|
||||
@@ -99,7 +99,7 @@ public class JobsPriorityController : Controller
|
||||
|
||||
// Get maintenance records scheduled for today (Scheduled or InProgress)
|
||||
var maintenanceItems = (await _unitOfWork.MaintenanceRecords.FindAsync(
|
||||
m => m.CompanyId == companyId && m.ScheduledDate.Date == today &&
|
||||
m => m.ScheduledDate.Date == today &&
|
||||
(m.Status == MaintenanceStatus.Scheduled || m.Status == MaintenanceStatus.InProgress),
|
||||
false,
|
||||
m => m.Equipment, m => m.AssignedUser))
|
||||
@@ -169,11 +169,10 @@ public class JobsPriorityController : Controller
|
||||
}
|
||||
|
||||
var today = DateTime.Today;
|
||||
var cid = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
// Get all existing priority records for today
|
||||
var existingPriorities = await _unitOfWork.JobDailyPriorities
|
||||
.FindAsync(p => p.CompanyId == cid && p.ScheduledDate.Date == today);
|
||||
.FindAsync(p => p.ScheduledDate.Date == today);
|
||||
|
||||
var priorityDict = existingPriorities.ToDictionary(p => p.JobId);
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ public class JournalEntriesController : Controller
|
||||
|
||||
// Load account names for lines
|
||||
var accountIds = je.Lines.Select(l => l.AccountId).Distinct().ToList();
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == je.CompanyId && accountIds.Contains(a.Id));
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => accountIds.Contains(a.Id));
|
||||
ViewBag.AccountMap = accounts.ToDictionary(a => a.Id, a => $"{a.AccountNumber} – {a.Name}");
|
||||
|
||||
// Reversal metadata
|
||||
@@ -196,113 +196,6 @@ public class JournalEntriesController : Controller
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
// ── Sales Tax Remittance ───────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Form to record a sales tax payment to the tax authority. Shows the current Sales Tax Payable
|
||||
/// (2200) liability and a bank-account picker. Relieves the liability that invoices accumulate.
|
||||
/// </summary>
|
||||
// GET: /JournalEntries/SalesTaxPayment
|
||||
public async Task<IActionResult> SalesTaxPayment()
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
var taxAcct = (await _unitOfWork.Accounts.FindAsync(
|
||||
a => a.CompanyId == companyId && a.AccountNumber == "2200" && a.IsActive)).FirstOrDefault();
|
||||
ViewBag.TaxLiability = taxAcct?.CurrentBalance ?? 0m;
|
||||
ViewBag.TaxAccountFound = taxAcct != null;
|
||||
|
||||
var banks = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive
|
||||
&& (a.AccountSubType == AccountSubType.Checking
|
||||
|| a.AccountSubType == AccountSubType.Savings
|
||||
|| a.AccountSubType == AccountSubType.Cash));
|
||||
ViewBag.BankAccounts = banks.OrderBy(a => a.AccountNumber)
|
||||
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString())).ToList();
|
||||
|
||||
return View();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a sales tax remittance as a posted journal entry: DR Sales Tax Payable (2200) / CR the
|
||||
/// chosen bank account. Honors the period lock. The reporting already accounts for posted JE lines,
|
||||
/// so this is all that's needed to relieve the liability.
|
||||
/// </summary>
|
||||
// POST: /JournalEntries/SalesTaxPayment
|
||||
[HttpPost]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> SalesTaxPayment(decimal amount, DateTime paymentDate, int bankAccountId, string? reference)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
if (amount <= 0)
|
||||
{
|
||||
TempData["Error"] = "Enter a payment amount greater than zero.";
|
||||
return RedirectToAction(nameof(SalesTaxPayment));
|
||||
}
|
||||
|
||||
var taxAcct = (await _unitOfWork.Accounts.FindAsync(
|
||||
a => a.CompanyId == companyId && a.AccountNumber == "2200" && a.IsActive)).FirstOrDefault();
|
||||
if (taxAcct == null)
|
||||
{
|
||||
TempData["Error"] = "No active Sales Tax Payable (2200) account found in your chart of accounts.";
|
||||
return RedirectToAction(nameof(SalesTaxPayment));
|
||||
}
|
||||
|
||||
// Don't let a remittance exceed the outstanding liability — overpaying would push Sales Tax
|
||||
// Payable into an abnormal (debit) balance. The 0.005 tolerance absorbs decimal rounding.
|
||||
if (amount > taxAcct.CurrentBalance + 0.005m)
|
||||
{
|
||||
TempData["Error"] = $"Payment of {amount:C} exceeds the Sales Tax Payable balance of {taxAcct.CurrentBalance:C}. Enter an amount up to the outstanding liability.";
|
||||
return RedirectToAction(nameof(SalesTaxPayment));
|
||||
}
|
||||
|
||||
var bankAcct = await _unitOfWork.Accounts.GetByIdAsync(bankAccountId);
|
||||
if (bankAcct == null || bankAcct.CompanyId != companyId)
|
||||
{
|
||||
TempData["Error"] = "Select a valid bank account to pay from.";
|
||||
return RedirectToAction(nameof(SalesTaxPayment));
|
||||
}
|
||||
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
|
||||
if (Web.Helpers.AccountingPeriodValidator.IsLocked(paymentDate, company?.BookLockedThrough))
|
||||
{
|
||||
TempData["Error"] = Web.Helpers.AccountingPeriodValidator.LockedMessage(company!.BookLockedThrough);
|
||||
return RedirectToAction(nameof(SalesTaxPayment));
|
||||
}
|
||||
|
||||
var entryNumber = await GenerateEntryNumberAsync(companyId);
|
||||
var entry = new JournalEntry
|
||||
{
|
||||
EntryNumber = entryNumber,
|
||||
EntryDate = paymentDate,
|
||||
Reference = string.IsNullOrWhiteSpace(reference) ? "Sales tax remittance" : reference.Trim(),
|
||||
Description = $"Sales tax remittance — {amount:C}",
|
||||
Status = JournalEntryStatus.Posted,
|
||||
PostedAt = DateTime.UtcNow,
|
||||
PostedBy = User.Identity?.Name,
|
||||
CompanyId = companyId,
|
||||
Lines = new List<JournalEntryLine>
|
||||
{
|
||||
new() { AccountId = taxAcct.Id, DebitAmount = amount, CreditAmount = 0, Description = "Sales tax paid to authority", LineOrder = 0, CompanyId = companyId },
|
||||
new() { AccountId = bankAcct.Id, DebitAmount = 0, CreditAmount = amount, Description = $"Paid from {bankAcct.Name}", LineOrder = 1, CompanyId = companyId }
|
||||
}
|
||||
};
|
||||
|
||||
await _unitOfWork.ExecuteInTransactionAsync(async () =>
|
||||
{
|
||||
await _unitOfWork.JournalEntries.AddAsync(entry);
|
||||
await _accountBalanceService.DebitAsync(taxAcct.Id, amount); // reduce the liability
|
||||
await _accountBalanceService.CreditAsync(bankAcct.Id, amount); // cash leaves the bank
|
||||
await _unitOfWork.CompleteAsync();
|
||||
});
|
||||
|
||||
TempData["Success"] = $"Recorded sales tax remittance of {amount:C} ({entryNumber}).";
|
||||
return RedirectToAction(nameof(Details), new { id = entry.Id });
|
||||
}
|
||||
|
||||
// ── Reverse ──────────────────────────────────────────────────────────────
|
||||
|
||||
[HttpPost]
|
||||
@@ -474,8 +367,7 @@ public class JournalEntriesController : Controller
|
||||
|
||||
private async Task PopulateAccountDropdownAsync()
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
|
||||
ViewBag.AccountSelectList = accounts
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.Select(a => new SelectListItem
|
||||
|
||||
@@ -582,9 +582,7 @@ public class KioskController : Controller
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Intakes(string? filter)
|
||||
{
|
||||
var companyId = GetCurrentCompanyId();
|
||||
var sessions = await _unitOfWork.KioskSessions.FindAsync(
|
||||
s => s.CompanyId == companyId, false,
|
||||
var sessions = await _unitOfWork.KioskSessions.GetAllAsync(false,
|
||||
s => s.LinkedCustomer,
|
||||
s => s.LinkedJob);
|
||||
|
||||
|
||||
@@ -11,9 +11,7 @@ using PowderCoating.Web.Hubs;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
// Oven batch scheduling is shop-floor job management — gated to CanManageJobs so
|
||||
// low-privilege roles can't create/modify/delete batches. (Audit #3, 2026-06-20.)
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
|
||||
[Authorize]
|
||||
public class OvenSchedulerController : Controller
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
@@ -63,17 +61,16 @@ public class OvenSchedulerController : Controller
|
||||
public async Task<IActionResult> Index(DateTime? date, string goal = "maximize_throughput")
|
||||
{
|
||||
var scheduledDate = date?.Date ?? DateTime.Today;
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
// Load active Named Ovens — filter IsActive at database level
|
||||
var ovenCosts = (await _unitOfWork.OvenCosts.FindAsync(o => o.CompanyId == companyId && o.IsActive))
|
||||
var ovenCosts = (await _unitOfWork.OvenCosts.FindAsync(o => o.IsActive))
|
||||
.OrderBy(o => o.DisplayOrder).ThenBy(o => o.Label)
|
||||
.ToList();
|
||||
|
||||
// Load batches for the selected date — filter at database level with includes
|
||||
var scheduledDateEnd = scheduledDate.AddDays(1);
|
||||
var batches = (await _unitOfWork.OvenBatches.FindAsync(
|
||||
b => b.CompanyId == companyId && b.ScheduledDate >= scheduledDate && b.ScheduledDate < scheduledDateEnd
|
||||
b => b.ScheduledDate >= scheduledDate && b.ScheduledDate < scheduledDateEnd
|
||||
&& b.Status != OvenBatchStatus.Cancelled,
|
||||
false,
|
||||
b => b.OvenCost, b => b.Items))
|
||||
@@ -101,7 +98,7 @@ public class OvenSchedulerController : Controller
|
||||
|
||||
// Load jobs in the queue — filter by status at database level
|
||||
var queueJobs = (await _unitOfWork.Jobs.FindAsync(
|
||||
j => j.CompanyId == companyId && j.JobStatus != null && QueueableStatuses.Contains(j.JobStatus.StatusCode),
|
||||
j => j.JobStatus != null && QueueableStatuses.Contains(j.JobStatus.StatusCode),
|
||||
false,
|
||||
j => j.Customer, j => j.JobStatus, j => j.JobPriority, j => j.JobItems))
|
||||
.ToList();
|
||||
@@ -129,14 +126,14 @@ public class OvenSchedulerController : Controller
|
||||
|
||||
// Determine which coats are already scheduled — filter out removed/cancelled at database level
|
||||
var scheduledCoatIds = (await _unitOfWork.OvenBatchItems.FindAsync(
|
||||
i => i.CompanyId == companyId && i.Status != OvenBatchItemStatus.Removed && i.Batch.Status != OvenBatchStatus.Cancelled,
|
||||
i => i.Status != OvenBatchItemStatus.Removed && i.Batch.Status != OvenBatchStatus.Cancelled,
|
||||
false,
|
||||
i => i.Batch))
|
||||
.Select(i => i.JobItemCoatId)
|
||||
.ToHashSet();
|
||||
|
||||
// Get company defaults
|
||||
var companyCosts = await _unitOfWork.CompanyOperatingCosts.FirstOrDefaultAsync(c => c.CompanyId == companyId);
|
||||
var companyCosts = await _unitOfWork.CompanyOperatingCosts.FirstOrDefaultAsync(c => true);
|
||||
var defaultCycleMinutes = companyCosts?.DefaultOvenCycleMinutes ?? 45;
|
||||
|
||||
// Build the view model
|
||||
|
||||
@@ -25,6 +25,7 @@ public class PowderCatalogController : Controller
|
||||
private readonly IColumbiaCatalogSyncService _columbiaSyncService;
|
||||
private readonly IPowderCatalogUpsertService _upsertService;
|
||||
private readonly IPlatformSettingsService _platformSettings;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<PowderCatalogController> _logger;
|
||||
|
||||
public PowderCatalogController(
|
||||
@@ -33,6 +34,7 @@ public class PowderCatalogController : Controller
|
||||
IColumbiaCatalogSyncService columbiaSyncService,
|
||||
IPowderCatalogUpsertService upsertService,
|
||||
IPlatformSettingsService platformSettings,
|
||||
IConfiguration config,
|
||||
ILogger<PowderCatalogController> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
@@ -40,6 +42,7 @@ public class PowderCatalogController : Controller
|
||||
_columbiaSyncService = columbiaSyncService;
|
||||
_upsertService = upsertService;
|
||||
_platformSettings = platformSettings;
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -372,7 +375,8 @@ public class PowderCatalogController : Controller
|
||||
PowderCatalogImportResult result;
|
||||
try
|
||||
{
|
||||
result = await ImportJsonAsync(file, vendorName);
|
||||
using var stream = file.OpenReadStream();
|
||||
result = await ImportJsonAsync(stream, vendorName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -393,6 +397,67 @@ public class PowderCatalogController : Controller
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unattended catalog import for the offline scraper tool (e.g. PrismaticSync). Accepts the same
|
||||
/// JSON scrape format in the request body, authenticated by a shared secret in the
|
||||
/// <c>X-Import-Token</c> header (matched against <c>CatalogImport:Token</c>). The vendor name
|
||||
/// comes from the <c>X-Vendor-Name</c> header. Runs through the same upsert as the manual upload.
|
||||
/// Inert (401) until a token is configured.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[AllowAnonymous]
|
||||
[IgnoreAntiforgeryToken]
|
||||
[RequestSizeLimit(50 * 1024 * 1024)] // 50 MB
|
||||
public async Task<IActionResult> ImportApi()
|
||||
{
|
||||
var configuredToken = _config["CatalogImport:Token"];
|
||||
if (string.IsNullOrWhiteSpace(configuredToken))
|
||||
{
|
||||
_logger.LogWarning("ImportApi called but no CatalogImport:Token is configured — rejecting.");
|
||||
return Unauthorized(new { success = false, errorMessage = "Import API is not enabled." });
|
||||
}
|
||||
|
||||
var providedToken = Request.Headers["X-Import-Token"].ToString();
|
||||
if (!FixedTimeEquals(providedToken, configuredToken))
|
||||
return Unauthorized(new { success = false, errorMessage = "Invalid import token." });
|
||||
|
||||
var vendorName = Request.Headers["X-Vendor-Name"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(vendorName))
|
||||
vendorName = "Prismatic Powders";
|
||||
|
||||
try
|
||||
{
|
||||
var result = await ImportJsonAsync(Request.Body, vendorName);
|
||||
_logger.LogInformation(
|
||||
"ImportApi ({Vendor}): {Inserted} inserted, {Updated} updated, {Skipped} skipped, {Errors} errors.",
|
||||
vendorName, result.Inserted, result.Updated, result.Skipped, result.Errors);
|
||||
|
||||
return Json(new
|
||||
{
|
||||
success = result.Success,
|
||||
vendorName,
|
||||
result.Inserted,
|
||||
result.Updated,
|
||||
result.Skipped,
|
||||
result.Errors,
|
||||
result.ErrorMessage
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "ImportApi failed for vendor {Vendor}", vendorName);
|
||||
return StatusCode(500, new { success = false, errorMessage = "Import failed." });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Constant-time string comparison so token checks don't leak length/contents via timing.</summary>
|
||||
private static bool FixedTimeEquals(string a, string b)
|
||||
{
|
||||
var ba = System.Text.Encoding.UTF8.GetBytes(a ?? string.Empty);
|
||||
var bb = System.Text.Encoding.UTF8.GetBytes(b ?? string.Empty);
|
||||
return System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(ba, bb);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AJAX endpoint used by the inventory form to search the catalog by SKU or color name.
|
||||
/// SKU exact matches are ranked first; color name substring matches follow.
|
||||
@@ -527,9 +592,8 @@ public class PowderCatalogController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<PowderCatalogImportResult> ImportJsonAsync(IFormFile file, string vendorName)
|
||||
private async Task<PowderCatalogImportResult> ImportJsonAsync(Stream stream, string vendorName)
|
||||
{
|
||||
using var stream = file.OpenReadStream();
|
||||
using var doc = await JsonDocument.ParseAsync(stream);
|
||||
|
||||
if (!doc.RootElement.TryGetProperty("results", out var resultsEl) ||
|
||||
|
||||
@@ -68,8 +68,7 @@ public class PricingTiersController : Controller
|
||||
return View(dto);
|
||||
|
||||
// Check for duplicate name
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var existing = await _unitOfWork.PricingTiers.FindAsync(t => t.CompanyId == companyId && t.TierName == dto.TierName);
|
||||
var existing = await _unitOfWork.PricingTiers.FindAsync(t => t.TierName == dto.TierName);
|
||||
if (existing.Any())
|
||||
{
|
||||
ModelState.AddModelError(nameof(dto.TierName), "A tier with this name already exists.");
|
||||
@@ -112,9 +111,8 @@ public class PricingTiersController : Controller
|
||||
if (entity == null) return NotFound();
|
||||
|
||||
// Check for duplicate name (excluding this record)
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var duplicate = await _unitOfWork.PricingTiers.FindAsync(
|
||||
t => t.CompanyId == companyId && t.TierName == dto.TierName && t.Id != dto.Id);
|
||||
t => t.TierName == dto.TierName && t.Id != dto.Id);
|
||||
if (duplicate.Any())
|
||||
{
|
||||
ModelState.AddModelError(nameof(dto.TierName), "A tier with this name already exists.");
|
||||
@@ -140,8 +138,7 @@ public class PricingTiersController : Controller
|
||||
if (entity == null) return NotFound();
|
||||
|
||||
// Block delete if customers are assigned to this tier
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var assignedCustomers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId && c.PricingTierId == id);
|
||||
var assignedCustomers = await _unitOfWork.Customers.FindAsync(c => c.PricingTierId == id);
|
||||
if (assignedCustomers.Any())
|
||||
{
|
||||
TempData["ErrorMessage"] = $"Cannot delete '{entity.TierName}' — {assignedCustomers.Count()} customer(s) are assigned to it. Reassign them first.";
|
||||
|
||||
@@ -302,9 +302,6 @@ public class QuotesController : Controller
|
||||
|
||||
var quoteDto = _mapper.Map<QuoteDto>(quote);
|
||||
|
||||
// Bank/asset accounts the deposit can land in (deposit modal dropdown)
|
||||
ViewBag.DepositAccounts = await AccountingDropdownHelper.LoadDepositAccountsAsync(_unitOfWork, companyId);
|
||||
|
||||
// Get customer info if exists
|
||||
if (quote.CustomerId.HasValue)
|
||||
{
|
||||
@@ -3428,7 +3425,7 @@ public class QuotesController : Controller
|
||||
try
|
||||
{
|
||||
var powders = await _unitOfWork.InventoryItems.FindAsync(i =>
|
||||
i.CompanyId == companyId && i.Category != null && i.Category.ToLower().Contains("powder") && i.UnitCost > 0);
|
||||
i.Category != null && i.Category.ToLower().Contains("powder") && i.UnitCost > 0);
|
||||
avgPowderCost = powders.Any() ? powders.Average(p => p.UnitCost) : 8m;
|
||||
}
|
||||
catch
|
||||
@@ -3522,7 +3519,7 @@ public class QuotesController : Controller
|
||||
try
|
||||
{
|
||||
var powders = await _unitOfWork.InventoryItems.FindAsync(i =>
|
||||
i.CompanyId == companyId && i.Category != null && i.Category.ToLower().Contains("powder") && i.UnitCost > 0);
|
||||
i.Category != null && i.Category.ToLower().Contains("powder") && i.UnitCost > 0);
|
||||
avgPowderCost = powders.Any() ? powders.Average(p => p.UnitCost) : 8m;
|
||||
}
|
||||
catch { avgPowderCost = 8m; }
|
||||
@@ -3617,7 +3614,7 @@ public class QuotesController : Controller
|
||||
|
||||
// Pull recent accepted predictions (user didn't override) as few-shot calibration examples
|
||||
var allPredictions = await _unitOfWork.AiItemPredictions.FindAsync(
|
||||
p => p.CompanyId == companyId && !p.UserOverrodeEstimate && p.PredictedSurfaceAreaSqFt > 0 && p.PredictedUnitPrice > 0);
|
||||
p => !p.UserOverrodeEstimate && p.PredictedSurfaceAreaSqFt > 0 && p.PredictedUnitPrice > 0);
|
||||
|
||||
context.AcceptedExamples = allPredictions
|
||||
.OrderByDescending(p => p.CreatedAt)
|
||||
@@ -3660,11 +3657,9 @@ public class QuotesController : Controller
|
||||
{
|
||||
var sqFtMin = sqFt * 0.4m;
|
||||
var sqFtMax = sqFt * 2.5m;
|
||||
var companyId = (await _userManager.GetUserAsync(User))?.CompanyId ?? 0;
|
||||
|
||||
var matches = await _unitOfWork.JobItems.FindAsync(
|
||||
ji => ji.CompanyId == companyId
|
||||
&& ji.Complexity == complexity
|
||||
ji => ji.Complexity == complexity
|
||||
&& ji.SurfaceAreaSqFt >= sqFtMin
|
||||
&& ji.SurfaceAreaSqFt <= sqFtMax
|
||||
&& ji.UnitPrice > 0
|
||||
@@ -3672,7 +3667,7 @@ public class QuotesController : Controller
|
||||
|
||||
var jobIds = matches.Select(ji => ji.JobId).Distinct().ToList();
|
||||
var completedStatusIds = (await _unitOfWork.JobStatusLookups.FindAsync(
|
||||
s => s.CompanyId == companyId && (s.StatusCode == AppConstants.StatusCodes.Job.Completed || s.StatusCode == AppConstants.StatusCodes.Job.Delivered)))
|
||||
s => s.StatusCode == AppConstants.StatusCodes.Job.Completed || s.StatusCode == AppConstants.StatusCodes.Job.Delivered))
|
||||
.Select(s => s.Id).ToHashSet();
|
||||
var completedJobs = await _unitOfWork.Jobs.FindAsync(
|
||||
j => jobIds.Contains(j.Id) && completedStatusIds.Contains(j.JobStatusId));
|
||||
|
||||
@@ -590,8 +590,7 @@ public class ReportsController : Controller
|
||||
|
||||
// === POWDER USAGE ANALYTICS ===
|
||||
var powderTransactions = (await _unitOfWork.InventoryTransactions
|
||||
.FindAsync(t => t.CompanyId == companyId
|
||||
&& t.TransactionType == InventoryTransactionType.JobUsage
|
||||
.FindAsync(t => t.TransactionType == InventoryTransactionType.JobUsage
|
||||
&& t.TransactionDate >= startDate,
|
||||
false,
|
||||
t => t.InventoryItem))
|
||||
@@ -1251,20 +1250,6 @@ public class ReportsController : Controller
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Balance reconciliation diagnostic: each account's stored CurrentBalance vs its recomputed ledger
|
||||
/// balance, plus AR/AP subledger totals vs their control accounts. Read-only; surfaces drift in the
|
||||
/// denormalized balances without changing any posting. Gated behind <see cref="AllowAccounting"/>.
|
||||
/// </summary>
|
||||
// GET: /Reports/Reconciliation
|
||||
public async Task<IActionResult> Reconciliation()
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
||||
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
|
||||
var dto = await _financialReports.GetBalanceReconciliationAsync(companyId);
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PDF export of the Trial Balance report. Same inline/attachment pattern as other PDF actions.
|
||||
/// Gated behind <see cref="AllowAccounting"/>.
|
||||
@@ -2513,7 +2498,7 @@ public class ReportsController : Controller
|
||||
var reportYear = year ?? DateTime.Now.Year;
|
||||
|
||||
// Load all budgets for the year for the selector
|
||||
var allBudgets = (await _unitOfWork.Budgets.FindAsync(b => b.CompanyId == companyId && b.FiscalYear == reportYear))
|
||||
var allBudgets = (await _unitOfWork.Budgets.FindAsync(b => b.FiscalYear == reportYear))
|
||||
.OrderBy(b => b.Name).ToList();
|
||||
|
||||
Core.Entities.Budget? budget = null;
|
||||
@@ -2521,10 +2506,10 @@ public class ReportsController : Controller
|
||||
budget = await _unitOfWork.Budgets.GetByIdAsync(budgetId.Value, false, b => b.Lines);
|
||||
|
||||
budget ??= (await _unitOfWork.Budgets.FindAsync(
|
||||
b => b.CompanyId == companyId && b.FiscalYear == reportYear && b.IsDefault, false, b => b.Lines)).FirstOrDefault();
|
||||
b => b.FiscalYear == reportYear && b.IsDefault, false, b => b.Lines)).FirstOrDefault();
|
||||
|
||||
budget ??= (await _unitOfWork.Budgets.FindAsync(
|
||||
b => b.CompanyId == companyId && b.FiscalYear == reportYear, false, b => b.Lines)).FirstOrDefault();
|
||||
b => b.FiscalYear == reportYear, false, b => b.Lines)).FirstOrDefault();
|
||||
|
||||
ViewBag.ReportYear = reportYear;
|
||||
ViewBag.Budget = budget;
|
||||
@@ -2559,7 +2544,7 @@ public class ReportsController : Controller
|
||||
|
||||
// Load account metadata for budget lines
|
||||
var accountIds = budget.Lines.Select(l => l.AccountId).Distinct().ToList();
|
||||
var accounts = (await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == budget.CompanyId && accountIds.Contains(a.Id)))
|
||||
var accounts = (await _unitOfWork.Accounts.FindAsync(a => accountIds.Contains(a.Id)))
|
||||
.ToDictionary(a => a.Id);
|
||||
|
||||
var rows = new List<BudgetVsActualRow>();
|
||||
@@ -2600,7 +2585,7 @@ public class ReportsController : Controller
|
||||
var periodEnd = new DateTime(reportYear, 12, 31, 23, 59, 59, DateTimeKind.Utc);
|
||||
|
||||
// Load 1099-eligible vendors
|
||||
var vendors = (await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId && v.Is1099Vendor)).ToList();
|
||||
var vendors = (await _unitOfWork.Vendors.FindAsync(v => v.Is1099Vendor)).ToList();
|
||||
|
||||
var rows = new List<Vendor1099Row>();
|
||||
|
||||
|
||||
@@ -134,8 +134,7 @@ public class TaxRatesController : Controller
|
||||
/// </summary>
|
||||
private async Task ClearOtherDefaultsAsync(int exceptId)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var others = await _unitOfWork.TaxRates.FindAsync(r => r.CompanyId == companyId && r.IsDefault && r.Id != exceptId);
|
||||
var others = await _unitOfWork.TaxRates.FindAsync(r => r.IsDefault && r.Id != exceptId);
|
||||
foreach (var r in others)
|
||||
r.IsDefault = false;
|
||||
}
|
||||
|
||||
@@ -3,21 +3,16 @@ using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.DTOs.Import;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
// Bulk import/export + QuickBooks migration tools — gated to the financial-management
|
||||
// permission so low-privilege roles (ReadOnly/Employee/ShopFloor) can't export or
|
||||
// import company data. (Audit #3, 2026-06-20.)
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageInvoices)]
|
||||
[Authorize]
|
||||
public class ToolsController : Controller
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
@@ -1399,53 +1394,6 @@ public class ToolsController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulk-imports invoice line items from a native CSV file. Lines are matched to their parent
|
||||
/// invoice by InvoiceNumber and revenue accounts resolved by number. Run after the invoice import.
|
||||
/// </summary>
|
||||
// POST: Tools/CsvImportInvoiceItems
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> CsvImportInvoiceItems(IFormFile file)
|
||||
{
|
||||
try
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (companyId == null)
|
||||
return Json(new { success = false, message = "Your account is not associated with a company." });
|
||||
|
||||
if (file == null || file.Length == 0)
|
||||
return Json(new { success = false, message = "No file provided or file is empty." });
|
||||
|
||||
if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase))
|
||||
return Json(new { success = false, message = "Only CSV files are allowed." });
|
||||
|
||||
_logger.LogInformation("User {UserName} importing invoice items from CSV {FileName} for company {CompanyId}",
|
||||
User.Identity?.Name, file.FileName, companyId);
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
var result = await _csvImportService.ImportInvoiceItemsAsync(stream, companyId.Value);
|
||||
await LogCsvImportAsync("InvoiceItems", file.FileName, result);
|
||||
|
||||
return Json(new
|
||||
{
|
||||
success = result.Success,
|
||||
message = result.Summary,
|
||||
successCount = result.SuccessCount,
|
||||
skippedCount = result.SkippedCount,
|
||||
errorCount = result.ErrorCount,
|
||||
totalRows = result.TotalRows,
|
||||
errors = result.Errors,
|
||||
warnings = result.Warnings
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error importing invoice items from CSV");
|
||||
return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads a blank CSV template for the native payment bulk import.
|
||||
/// Columns match the native ExportPaymentsCsv output for round-trip compatibility.
|
||||
@@ -1458,90 +1406,6 @@ public class ToolsController : Controller
|
||||
return File(csvBytes, "text/csv", "payment_import_template.csv");
|
||||
}
|
||||
|
||||
/// <summary>Downloads a blank CSV template for the invoice line-item bulk import.</summary>
|
||||
// GET: Tools/DownloadInvoiceItemTemplate
|
||||
[HttpGet]
|
||||
public IActionResult DownloadInvoiceItemTemplate()
|
||||
{
|
||||
var csvBytes = _csvImportService.GenerateInvoiceItemTemplate();
|
||||
return File(csvBytes, "text/csv", "invoice_item_import_template.csv");
|
||||
}
|
||||
|
||||
// POST: Tools/CsvImportBills — vendor bill headers (vendor by name, AP account by number).
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public Task<IActionResult> CsvImportBills(IFormFile file)
|
||||
=> RunCsvImport(file, "Bills", _csvImportService.ImportBillsAsync);
|
||||
|
||||
// POST: Tools/CsvImportBillLineItems — bill lines (run after bills).
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public Task<IActionResult> CsvImportBillLineItems(IFormFile file)
|
||||
=> RunCsvImport(file, "BillLineItems", _csvImportService.ImportBillLineItemsAsync);
|
||||
|
||||
// POST: Tools/CsvImportDeposits — customer deposits (customer by name, bank account by number).
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public Task<IActionResult> CsvImportDeposits(IFormFile file)
|
||||
=> RunCsvImport(file, "Deposits", _csvImportService.ImportDepositsAsync);
|
||||
|
||||
// POST: Tools/CsvImportJournalEntries — journal entry headers.
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public Task<IActionResult> CsvImportJournalEntries(IFormFile file)
|
||||
=> RunCsvImport(file, "JournalEntries", _csvImportService.ImportJournalEntriesAsync);
|
||||
|
||||
// POST: Tools/CsvImportJournalEntryLines — journal entry lines (run after journal entries).
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public Task<IActionResult> CsvImportJournalEntryLines(IFormFile file)
|
||||
=> RunCsvImport(file, "JournalEntryLines", _csvImportService.ImportJournalEntryLinesAsync);
|
||||
|
||||
/// <summary>
|
||||
/// Shared plumbing for the accounting CSV imports: validates the upload, resolves the company,
|
||||
/// runs the given import function, logs it, and returns the standard JSON result shape.
|
||||
/// </summary>
|
||||
private async Task<IActionResult> RunCsvImport(IFormFile file, string label,
|
||||
Func<Stream, int, Task<CsvImportResultDto>> import)
|
||||
{
|
||||
try
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (companyId == null)
|
||||
return Json(new { success = false, message = "Your account is not associated with a company." });
|
||||
|
||||
if (file == null || file.Length == 0)
|
||||
return Json(new { success = false, message = "No file provided or file is empty." });
|
||||
|
||||
if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase))
|
||||
return Json(new { success = false, message = "Only CSV files are allowed." });
|
||||
|
||||
_logger.LogInformation("User {UserName} importing {Label} from CSV {FileName} for company {CompanyId}",
|
||||
User.Identity?.Name, label, file.FileName, companyId);
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
var result = await import(stream, companyId.Value);
|
||||
await LogCsvImportAsync(label, file.FileName, result);
|
||||
|
||||
return Json(new
|
||||
{
|
||||
success = result.Success,
|
||||
message = result.Summary,
|
||||
successCount = result.SuccessCount,
|
||||
skippedCount = result.SkippedCount,
|
||||
errorCount = result.ErrorCount,
|
||||
totalRows = result.TotalRows,
|
||||
errors = result.Errors,
|
||||
warnings = result.Warnings
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error importing {Label} from CSV", label);
|
||||
return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulk-imports payment records from a native CSV file. Invoices are resolved by InvoiceNumber.
|
||||
/// Duplicate payments (same invoice + date + amount) are skipped. Updates the invoice AmountPaid
|
||||
@@ -2180,7 +2044,7 @@ public class ToolsController : Controller
|
||||
}
|
||||
|
||||
// 11. Invoices
|
||||
var invoices = await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId.Value, false, i => i.Customer, i => i.Job, i => i.InvoiceItems);
|
||||
var invoices = await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId.Value, false, i => i.Customer, i => i.Job);
|
||||
var invoicesCsv = GenerateInvoicesCsv(invoices);
|
||||
var invoicesEntry = archive.CreateEntry($"invoices_{timestamp}.csv");
|
||||
using (var entryStream = invoicesEntry.Open())
|
||||
@@ -2199,17 +2063,6 @@ public class ToolsController : Controller
|
||||
await writer.WriteAsync(accountsCsv);
|
||||
}
|
||||
|
||||
// 12b. Invoice line items — one row per line, carrying the revenue account number so
|
||||
// the invoice's revenue attribution survives an export/import round-trip.
|
||||
var accountNumberById = accounts.ToDictionary(a => a.Id, a => a.AccountNumber ?? "");
|
||||
var invoiceItemsCsv = GenerateInvoiceItemsCsv(invoices, accountNumberById);
|
||||
var invoiceItemsEntry = archive.CreateEntry($"invoice_items_{timestamp}.csv");
|
||||
using (var entryStream = invoiceItemsEntry.Open())
|
||||
using (var writer = new System.IO.StreamWriter(entryStream))
|
||||
{
|
||||
await writer.WriteAsync(invoiceItemsCsv);
|
||||
}
|
||||
|
||||
// 13. Expenses
|
||||
var expenses = await _unitOfWork.Expenses.FindAsync(e => e.CompanyId == companyId.Value, false, e => e.ExpenseAccount, e => e.PaymentAccount, e => e.Vendor, e => e.Job);
|
||||
var expensesCsv = GenerateExpensesCsv(expenses);
|
||||
@@ -2221,7 +2074,7 @@ public class ToolsController : Controller
|
||||
}
|
||||
|
||||
// 14. Payments
|
||||
var payments = await _unitOfWork.Payments.FindAsync(p => p.CompanyId == companyId.Value, false, p => p.Invoice, p => p.DepositAccount);
|
||||
var payments = await _unitOfWork.Payments.FindAsync(p => p.CompanyId == companyId.Value, false, p => p.Invoice);
|
||||
var paymentsCsv = GeneratePaymentsCsv(payments);
|
||||
var paymentsEntry = archive.CreateEntry($"payments_{timestamp}.csv");
|
||||
using (var entryStream = paymentsEntry.Open())
|
||||
@@ -2230,48 +2083,8 @@ public class ToolsController : Controller
|
||||
await writer.WriteAsync(paymentsCsv);
|
||||
}
|
||||
|
||||
// 15. Bills + bill line items (account/job by number, AP account, vendor by name)
|
||||
var jobNumberById = jobs.Where(j => !string.IsNullOrEmpty(j.JobNumber)).ToDictionary(j => j.Id, j => j.JobNumber);
|
||||
var bills = await _unitOfWork.Bills.FindAsync(b => b.CompanyId == companyId.Value, false, b => b.Vendor, b => b.APAccount, b => b.LineItems);
|
||||
var billsEntry = archive.CreateEntry($"bills_{timestamp}.csv");
|
||||
using (var entryStream = billsEntry.Open())
|
||||
using (var writer = new System.IO.StreamWriter(entryStream))
|
||||
{
|
||||
await writer.WriteAsync(GenerateBillsCsv(bills));
|
||||
}
|
||||
var billLineItemsEntry = archive.CreateEntry($"bill_line_items_{timestamp}.csv");
|
||||
using (var entryStream = billLineItemsEntry.Open())
|
||||
using (var writer = new System.IO.StreamWriter(entryStream))
|
||||
{
|
||||
await writer.WriteAsync(GenerateBillLineItemsCsv(bills, accountNumberById, jobNumberById));
|
||||
}
|
||||
|
||||
// 16. Deposits (customer by name, bank account + applied invoice by number)
|
||||
var deposits = await _unitOfWork.Deposits.FindAsync(d => d.CompanyId == companyId.Value, false, d => d.Customer, d => d.AppliedToInvoice);
|
||||
var depositsEntry = archive.CreateEntry($"deposits_{timestamp}.csv");
|
||||
using (var entryStream = depositsEntry.Open())
|
||||
using (var writer = new System.IO.StreamWriter(entryStream))
|
||||
{
|
||||
await writer.WriteAsync(GenerateDepositsCsv(deposits, accountNumberById));
|
||||
}
|
||||
|
||||
// 17. Journal entries + lines (account by number, debit/credit)
|
||||
var journalEntries = await _unitOfWork.JournalEntries.FindAsync(j => j.CompanyId == companyId.Value, false, j => j.Lines);
|
||||
var journalEntriesEntry = archive.CreateEntry($"journal_entries_{timestamp}.csv");
|
||||
using (var entryStream = journalEntriesEntry.Open())
|
||||
using (var writer = new System.IO.StreamWriter(entryStream))
|
||||
{
|
||||
await writer.WriteAsync(GenerateJournalEntriesCsv(journalEntries));
|
||||
}
|
||||
var journalEntryLinesEntry = archive.CreateEntry($"journal_entry_lines_{timestamp}.csv");
|
||||
using (var entryStream = journalEntryLinesEntry.Open())
|
||||
using (var writer = new System.IO.StreamWriter(entryStream))
|
||||
{
|
||||
await writer.WriteAsync(GenerateJournalEntryLinesCsv(journalEntries, accountNumberById));
|
||||
}
|
||||
|
||||
// 15. Purchase Orders
|
||||
var purchaseOrders = await _unitOfWork.PurchaseOrders.FindAsync(po => po.CompanyId == companyId, false, po => po.Vendor);
|
||||
var purchaseOrders = await _unitOfWork.PurchaseOrders.GetAllAsync(false, po => po.Vendor);
|
||||
var purchaseOrdersCsv = GeneratePurchaseOrdersCsv(purchaseOrders);
|
||||
var purchaseOrdersEntry = archive.CreateEntry($"purchase_orders_{timestamp}.csv");
|
||||
using (var entryStream = purchaseOrdersEntry.Open())
|
||||
@@ -2334,7 +2147,7 @@ public class ToolsController : Controller
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId, false, c => c.PricingTier);
|
||||
var customers = await _unitOfWork.Customers.GetAllAsync(false, c => c.PricingTier);
|
||||
var csv = GenerateCustomersCsv(customers);
|
||||
var fileName = $"customers_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
||||
|
||||
@@ -2368,7 +2181,7 @@ public class ToolsController : Controller
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
var quotes = await _unitOfWork.Quotes.FindAsync(q => q.CompanyId == companyId, false, q => q.Customer, q => q.QuoteStatus);
|
||||
var quotes = await _unitOfWork.Quotes.GetAllAsync(false, q => q.Customer, q => q.QuoteStatus);
|
||||
var csv = GenerateQuotesCsv(quotes);
|
||||
var fileName = $"quotes_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
||||
|
||||
@@ -2401,7 +2214,7 @@ public class ToolsController : Controller
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
var jobs = await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority);
|
||||
var jobs = await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority);
|
||||
var csv = GenerateJobsCsv(jobs);
|
||||
var fileName = $"jobs_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
||||
|
||||
@@ -2433,7 +2246,7 @@ public class ToolsController : Controller
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
var appointments = await _unitOfWork.Appointments.FindAsync(a => a.CompanyId == companyId, false,
|
||||
var appointments = await _unitOfWork.Appointments.GetAllAsync(false,
|
||||
a => a.Customer, a => a.AppointmentType, a => a.AppointmentStatus);
|
||||
var csv = GenerateAppointmentsCsv(appointments);
|
||||
var fileName = $"appointments_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
||||
@@ -2503,7 +2316,7 @@ public class ToolsController : Controller
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
var inventoryItems = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId, false, i => i.PrimaryVendor);
|
||||
var inventoryItems = await _unitOfWork.InventoryItems.GetAllAsync(false, i => i.PrimaryVendor);
|
||||
var csv = GenerateInventoryCsv(inventoryItems);
|
||||
var fileName = $"inventory_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
||||
|
||||
@@ -2570,7 +2383,7 @@ public class ToolsController : Controller
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
var maintenance = await _unitOfWork.MaintenanceRecords.FindAsync(m => m.CompanyId == companyId, false, m => m.Equipment);
|
||||
var maintenance = await _unitOfWork.MaintenanceRecords.GetAllAsync(false, m => m.Equipment);
|
||||
var csv = GenerateMaintenanceCsv(maintenance);
|
||||
var fileName = $"maintenance_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
||||
|
||||
@@ -2658,7 +2471,7 @@ public class ToolsController : Controller
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
var invoices = await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Job);
|
||||
var invoices = await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Job);
|
||||
var csv = GenerateInvoicesCsv(invoices);
|
||||
var fileName = $"invoices_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
||||
|
||||
@@ -2691,7 +2504,7 @@ public class ToolsController : Controller
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
var payments = await _unitOfWork.Payments.FindAsync(p => p.CompanyId == companyId, false, p => p.Invoice, p => p.DepositAccount);
|
||||
var payments = await _unitOfWork.Payments.GetAllAsync(false, p => p.Invoice);
|
||||
var csv = GeneratePaymentsCsv(payments);
|
||||
var fileName = $"payments_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
||||
|
||||
@@ -2706,164 +2519,6 @@ public class ToolsController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports all invoice line items for the current company as a CSV, keyed by parent invoice number
|
||||
/// and carrying each line's revenue account number. Complements <see cref="ExportInvoicesCsv"/>
|
||||
/// (which is header-only) so invoice detail and revenue attribution round-trip on re-import.
|
||||
/// </summary>
|
||||
// GET: Tools/ExportInvoiceItemsCsv
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> ExportInvoiceItemsCsv()
|
||||
{
|
||||
try
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (companyId == null)
|
||||
{
|
||||
TempData["ErrorMessage"] = "Your account is not associated with a company.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
var invoices = await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId.Value, false, i => i.InvoiceItems);
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value);
|
||||
var accountNumberById = accounts.ToDictionary(a => a.Id, a => a.AccountNumber ?? "");
|
||||
var csv = GenerateInvoiceItemsCsv(invoices, accountNumberById);
|
||||
var fileName = $"invoice_items_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
||||
|
||||
await LogExportAsync("InvoiceItems", $"CSV export ({invoices.Sum(i => i.InvoiceItems.Count)} line items)");
|
||||
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", fileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error exporting invoice items to CSV");
|
||||
TempData["ErrorMessage"] = "An error occurred while exporting invoice items.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Exports vendor bill headers (vendor by name, AP account by number) as CSV.</summary>
|
||||
// GET: Tools/ExportBillsCsv
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> ExportBillsCsv()
|
||||
{
|
||||
try
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); }
|
||||
|
||||
var bills = await _unitOfWork.Bills.FindAsync(b => b.CompanyId == companyId.Value, false, b => b.Vendor, b => b.APAccount);
|
||||
var csv = GenerateBillsCsv(bills);
|
||||
await LogExportAsync("Bills", $"CSV export ({bills.Count()} records)");
|
||||
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", $"bills_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error exporting bills to CSV");
|
||||
TempData["ErrorMessage"] = "An error occurred while exporting bills.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Exports vendor bill line items (account/job by number) as CSV.</summary>
|
||||
// GET: Tools/ExportBillLineItemsCsv
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> ExportBillLineItemsCsv()
|
||||
{
|
||||
try
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); }
|
||||
|
||||
var bills = await _unitOfWork.Bills.FindAsync(b => b.CompanyId == companyId.Value, false, b => b.LineItems);
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value);
|
||||
var jobs = await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId.Value);
|
||||
var accountNumberById = accounts.ToDictionary(a => a.Id, a => a.AccountNumber ?? "");
|
||||
var jobNumberById = jobs.Where(j => !string.IsNullOrEmpty(j.JobNumber)).ToDictionary(j => j.Id, j => j.JobNumber);
|
||||
var csv = GenerateBillLineItemsCsv(bills, accountNumberById, jobNumberById);
|
||||
await LogExportAsync("BillLineItems", $"CSV export ({bills.Sum(b => b.LineItems.Count)} line items)");
|
||||
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", $"bill_line_items_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error exporting bill line items to CSV");
|
||||
TempData["ErrorMessage"] = "An error occurred while exporting bill line items.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Exports customer deposits (customer by name, bank account + applied invoice by number) as CSV.</summary>
|
||||
// GET: Tools/ExportDepositsCsv
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> ExportDepositsCsv()
|
||||
{
|
||||
try
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); }
|
||||
|
||||
var deposits = await _unitOfWork.Deposits.FindAsync(d => d.CompanyId == companyId.Value, false, d => d.Customer, d => d.AppliedToInvoice);
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value);
|
||||
var accountNumberById = accounts.ToDictionary(a => a.Id, a => a.AccountNumber ?? "");
|
||||
var csv = GenerateDepositsCsv(deposits, accountNumberById);
|
||||
await LogExportAsync("Deposits", $"CSV export ({deposits.Count()} records)");
|
||||
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", $"deposits_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error exporting deposits to CSV");
|
||||
TempData["ErrorMessage"] = "An error occurred while exporting deposits.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Exports journal entry headers as CSV. Lines export separately.</summary>
|
||||
// GET: Tools/ExportJournalEntriesCsv
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> ExportJournalEntriesCsv()
|
||||
{
|
||||
try
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); }
|
||||
|
||||
var entries = await _unitOfWork.JournalEntries.FindAsync(j => j.CompanyId == companyId.Value);
|
||||
var csv = GenerateJournalEntriesCsv(entries);
|
||||
await LogExportAsync("JournalEntries", $"CSV export ({entries.Count()} records)");
|
||||
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", $"journal_entries_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error exporting journal entries to CSV");
|
||||
TempData["ErrorMessage"] = "An error occurred while exporting journal entries.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Exports journal entry lines (account by number, debit/credit) as CSV.</summary>
|
||||
// GET: Tools/ExportJournalEntryLinesCsv
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> ExportJournalEntryLinesCsv()
|
||||
{
|
||||
try
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); }
|
||||
|
||||
var entries = await _unitOfWork.JournalEntries.FindAsync(j => j.CompanyId == companyId.Value, false, j => j.Lines);
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value);
|
||||
var accountNumberById = accounts.ToDictionary(a => a.Id, a => a.AccountNumber ?? "");
|
||||
var csv = GenerateJournalEntryLinesCsv(entries, accountNumberById);
|
||||
await LogExportAsync("JournalEntryLines", $"CSV export ({entries.Sum(e => e.Lines.Count)} lines)");
|
||||
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", $"journal_entry_lines_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error exporting journal entry lines to CSV");
|
||||
TempData["ErrorMessage"] = "An error occurred while exporting journal entry lines.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports all purchase orders for the current company as a CSV file, including the vendor
|
||||
/// company name resolved via eager loading. PO status is written as its enum name.
|
||||
@@ -2881,7 +2536,7 @@ public class ToolsController : Controller
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
var purchaseOrders = await _unitOfWork.PurchaseOrders.FindAsync(po => po.CompanyId == companyId, false, po => po.Vendor);
|
||||
var purchaseOrders = await _unitOfWork.PurchaseOrders.GetAllAsync(false, po => po.Vendor);
|
||||
var csv = GeneratePurchaseOrdersCsv(purchaseOrders);
|
||||
var fileName = $"purchase_orders_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
||||
|
||||
@@ -4318,35 +3973,6 @@ public class ToolsController : Controller
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a CSV of invoice line items — one row per item across all the given invoices. The parent
|
||||
/// invoice number and the line's revenue account number (resolved from <paramref name="accountNumberById"/>)
|
||||
/// are written so revenue attribution survives an export/import round-trip. Rows are emitted in
|
||||
/// DisplayOrder within each invoice.
|
||||
/// </summary>
|
||||
private string GenerateInvoiceItemsCsv(IEnumerable<Core.Entities.Invoice> invoices, IReadOnlyDictionary<int, string> accountNumberById)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine("InvoiceNumber,Description,Quantity,UnitPrice,TotalPrice,ColorName,RevenueAccountNumber,DisplayOrder,Notes");
|
||||
|
||||
foreach (var invoice in invoices)
|
||||
{
|
||||
foreach (var item in invoice.InvoiceItems.OrderBy(it => it.DisplayOrder))
|
||||
{
|
||||
var revenueAccountNumber = item.RevenueAccountId.HasValue
|
||||
&& accountNumberById.TryGetValue(item.RevenueAccountId.Value, out var num)
|
||||
? num : "";
|
||||
|
||||
sb.AppendLine($"{EscapeCsv(invoice.InvoiceNumber)},{EscapeCsv(item.Description)}," +
|
||||
$"{item.Quantity},{item.UnitPrice},{item.TotalPrice}," +
|
||||
$"{EscapeCsv(item.ColorName)},{EscapeCsv(revenueAccountNumber)}," +
|
||||
$"{item.DisplayOrder},{EscapeCsv(item.Notes)}");
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a CSV string for the given invoice payment records. The parent invoice number is
|
||||
/// resolved from the eagerly loaded <c>Invoice</c> navigation property. PaymentMethod is
|
||||
@@ -4355,111 +3981,13 @@ public class ToolsController : Controller
|
||||
private string GeneratePaymentsCsv(IEnumerable<Core.Entities.Payment> payments)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine("InvoiceNumber,Amount,PaymentDate,PaymentMethod,DepositAccountNumber,Reference,Notes");
|
||||
sb.AppendLine("InvoiceNumber,Amount,PaymentDate,PaymentMethod,Reference,Notes");
|
||||
|
||||
foreach (var payment in payments)
|
||||
{
|
||||
sb.AppendLine($"{EscapeCsv(payment.Invoice?.InvoiceNumber)}," +
|
||||
$"{payment.Amount},{payment.PaymentDate:yyyy-MM-dd}," +
|
||||
$"{payment.PaymentMethod},{EscapeCsv(payment.DepositAccount?.AccountNumber)}," +
|
||||
$"{EscapeCsv(payment.Reference)},{EscapeCsv(payment.Notes)}");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Builds the vendor bill header CSV — vendor by name, AP account by number.</summary>
|
||||
private string GenerateBillsCsv(IEnumerable<Core.Entities.Bill> bills)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine("BillNumber,VendorInvoiceNumber,VendorName,APAccountNumber,BillDate,DueDate,Status,Terms,Memo,SubTotal,TaxPercent,TaxAmount,Total,AmountPaid");
|
||||
|
||||
foreach (var bill in bills)
|
||||
{
|
||||
sb.AppendLine($"{EscapeCsv(bill.BillNumber)},{EscapeCsv(bill.VendorInvoiceNumber)}," +
|
||||
$"{EscapeCsv(bill.Vendor?.CompanyName)},{EscapeCsv(bill.APAccount?.AccountNumber)}," +
|
||||
$"{bill.BillDate:yyyy-MM-dd},{bill.DueDate?.ToString("yyyy-MM-dd")},{bill.Status}," +
|
||||
$"{EscapeCsv(bill.Terms)},{EscapeCsv(bill.Memo)}," +
|
||||
$"{bill.SubTotal},{bill.TaxPercent},{bill.TaxAmount},{bill.Total},{bill.AmountPaid}");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Builds the bill line-item CSV — one row per line, account/job resolved by number.</summary>
|
||||
private string GenerateBillLineItemsCsv(IEnumerable<Core.Entities.Bill> bills,
|
||||
IReadOnlyDictionary<int, string> accountNumberById, IReadOnlyDictionary<int, string> jobNumberById)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine("BillNumber,AccountNumber,JobNumber,Description,Quantity,UnitPrice,Amount,DisplayOrder");
|
||||
|
||||
foreach (var bill in bills)
|
||||
{
|
||||
foreach (var line in bill.LineItems.OrderBy(li => li.DisplayOrder))
|
||||
{
|
||||
var accountNumber = line.AccountId.HasValue && accountNumberById.TryGetValue(line.AccountId.Value, out var an) ? an : "";
|
||||
var jobNumber = line.JobId.HasValue && jobNumberById.TryGetValue(line.JobId.Value, out var jn) ? jn : "";
|
||||
sb.AppendLine($"{EscapeCsv(bill.BillNumber)},{EscapeCsv(accountNumber)},{EscapeCsv(jobNumber)}," +
|
||||
$"{EscapeCsv(line.Description)},{line.Quantity},{line.UnitPrice},{line.Amount},{line.DisplayOrder}");
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Builds the customer deposit CSV — customer by name, bank account + applied invoice resolved.</summary>
|
||||
private string GenerateDepositsCsv(IEnumerable<Core.Entities.Deposit> deposits, IReadOnlyDictionary<int, string> accountNumberById)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine("ReceiptNumber,CustomerName,Amount,PaymentMethod,ReceivedDate,DepositAccountNumber,AppliedToInvoiceNumber,AppliedDate,Reference,Notes");
|
||||
|
||||
foreach (var deposit in deposits)
|
||||
{
|
||||
var customerName = deposit.Customer != null
|
||||
? (!string.IsNullOrWhiteSpace(deposit.Customer.CompanyName)
|
||||
? deposit.Customer.CompanyName
|
||||
: $"{deposit.Customer.ContactFirstName} {deposit.Customer.ContactLastName}".Trim())
|
||||
: "";
|
||||
var depositAccountNumber = deposit.DepositAccountId.HasValue && accountNumberById.TryGetValue(deposit.DepositAccountId.Value, out var an) ? an : "";
|
||||
|
||||
sb.AppendLine($"{EscapeCsv(deposit.ReceiptNumber)},{EscapeCsv(customerName)},{deposit.Amount}," +
|
||||
$"{deposit.PaymentMethod},{deposit.ReceivedDate:yyyy-MM-dd},{EscapeCsv(depositAccountNumber)}," +
|
||||
$"{EscapeCsv(deposit.AppliedToInvoice?.InvoiceNumber)},{deposit.AppliedDate?.ToString("yyyy-MM-dd")}," +
|
||||
$"{EscapeCsv(deposit.Reference)},{EscapeCsv(deposit.Notes)}");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Builds the journal entry header CSV. Lines are exported separately.</summary>
|
||||
private string GenerateJournalEntriesCsv(IEnumerable<Core.Entities.JournalEntry> entries)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine("EntryNumber,EntryDate,Reference,Description,Status");
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
sb.AppendLine($"{EscapeCsv(entry.EntryNumber)},{entry.EntryDate:yyyy-MM-dd}," +
|
||||
$"{EscapeCsv(entry.Reference)},{EscapeCsv(entry.Description)},{entry.Status}");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Builds the journal entry line CSV — one row per debit/credit line, account by number.</summary>
|
||||
private string GenerateJournalEntryLinesCsv(IEnumerable<Core.Entities.JournalEntry> entries, IReadOnlyDictionary<int, string> accountNumberById)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine("EntryNumber,AccountNumber,DebitAmount,CreditAmount,Description,LineOrder");
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
foreach (var line in entry.Lines.OrderBy(l => l.LineOrder))
|
||||
{
|
||||
var accountNumber = accountNumberById.TryGetValue(line.AccountId, out var an) ? an : "";
|
||||
sb.AppendLine($"{EscapeCsv(entry.EntryNumber)},{EscapeCsv(accountNumber)}," +
|
||||
$"{line.DebitAmount},{line.CreditAmount},{EscapeCsv(line.Description)},{line.LineOrder}");
|
||||
}
|
||||
$"{payment.PaymentMethod},{EscapeCsv(payment.Reference)},{EscapeCsv(payment.Notes)}");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
@@ -4615,7 +4143,7 @@ public class ToolsController : Controller
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
var expenses = await _unitOfWork.Expenses.FindAsync(e => e.CompanyId == companyId, false,
|
||||
var expenses = await _unitOfWork.Expenses.GetAllAsync(false,
|
||||
e => e.ExpenseAccount,
|
||||
e => e.PaymentAccount,
|
||||
e => e.Vendor,
|
||||
|
||||
@@ -132,7 +132,7 @@ public class VendorCreditsController : Controller
|
||||
.Select(l => l.AccountId!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == vc.CompanyId && accountIds.Contains(a.Id));
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => accountIds.Contains(a.Id));
|
||||
ViewBag.AccountMap = accounts.ToDictionary(a => a.Id, a => $"{a.AccountNumber} – {a.Name}");
|
||||
|
||||
// Load bills referenced by applications
|
||||
@@ -357,9 +357,8 @@ public class VendorCreditsController : Controller
|
||||
|
||||
private async Task PopulateDropdownsAsync()
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId && v.IsActive);
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
|
||||
var vendors = await _unitOfWork.Vendors.FindAsync(v => v.IsActive);
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
|
||||
|
||||
ViewBag.VendorList = vendors
|
||||
.OrderBy(v => v.CompanyName)
|
||||
|
||||
@@ -463,9 +463,8 @@ public class VendorsController : Controller
|
||||
|
||||
private async Task PopulateExpenseAccountsAsync()
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var accounts = (await _unitOfWork.Accounts.FindAsync(
|
||||
a => a.CompanyId == companyId && a.IsActive && (a.AccountType == AccountType.Expense ||
|
||||
a => a.IsActive && (a.AccountType == AccountType.Expense ||
|
||||
a.AccountType == AccountType.CostOfGoods ||
|
||||
a.AccountType == AccountType.Asset)))
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
|
||||
namespace PowderCoating.Web.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Server-side validation for account selections that must be a "money" account — a payment
|
||||
/// source (bill payment), deposit target, or reconcilable account. The dropdowns already limit
|
||||
/// the choices, so this is defense in depth against tampered or stale POSTs (e.g. an account
|
||||
/// deleted/retyped between page load and submit): it rejects anything that isn't an active,
|
||||
/// company-owned Asset or Liability account before a GL posting is made against it.
|
||||
/// </summary>
|
||||
internal static class AccountGuard
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true when <paramref name="accountId"/> identifies an active account belonging to
|
||||
/// <paramref name="companyId"/> whose top-level type is Asset or Liability. Filters CompanyId
|
||||
/// explicitly (defense in depth alongside the global tenant filter).
|
||||
/// </summary>
|
||||
internal static async Task<bool> IsValidMoneyAccountAsync(IUnitOfWork unitOfWork, int? accountId, int companyId)
|
||||
{
|
||||
if (accountId == null) return false;
|
||||
var account = await unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.Id == accountId.Value && a.CompanyId == companyId && a.IsActive);
|
||||
return account != null
|
||||
&& (account.AccountType == AccountType.Asset || account.AccountType == AccountType.Liability);
|
||||
}
|
||||
}
|
||||
@@ -17,27 +17,6 @@ internal static class AccountingDropdownHelper
|
||||
/// Returns pre-projected SelectListItem collections so controllers avoid duplicating the
|
||||
/// LINQ-to-SelectListItem transform.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Loads the accounts a customer deposit can land in — any active Asset or Liability
|
||||
/// account for the company (filtered by parent AccountType, not sub-type, so accounts a
|
||||
/// company classified differently still appear). Checking/Cash accounts sort to the top
|
||||
/// as the usual choice. Used to populate the deposit modal's account dropdown on the Job
|
||||
/// and Quote details pages. CompanyId is filtered explicitly (defense in depth).
|
||||
/// </summary>
|
||||
internal static async Task<List<SelectListItem>> LoadDepositAccountsAsync(IUnitOfWork unitOfWork, int companyId)
|
||||
{
|
||||
var accounts = await unitOfWork.Accounts.FindAsync(
|
||||
a => a.CompanyId == companyId && a.IsActive
|
||||
&& (a.AccountType == AccountType.Asset || a.AccountType == AccountType.Liability));
|
||||
|
||||
return accounts
|
||||
.OrderByDescending(a => a.AccountSubType == AccountSubType.Checking ||
|
||||
a.AccountSubType == AccountSubType.Cash)
|
||||
.ThenBy(a => a.AccountNumber)
|
||||
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
internal static async Task<AccountingDropdowns> LoadAsync(IUnitOfWork unitOfWork)
|
||||
{
|
||||
var vendors = await unitOfWork.Vendors.FindAsync(v => v.IsActive);
|
||||
@@ -71,21 +50,17 @@ internal static class AccountingDropdownHelper
|
||||
.Select(a => new SelectListItem(accountLabel(a), a.Id.ToString()))
|
||||
.ToList(),
|
||||
|
||||
// Filter by parent AccountType only — not sub-type. Companies classify their
|
||||
// own accounts differently (e.g. a "Line of Credit" they treat as a payable),
|
||||
// so listing every account of the right top-level type lets them pick what they
|
||||
// actually use instead of silently hiding accounts on a sub-type mismatch.
|
||||
ApAccounts = allAccounts
|
||||
.Where(a => a.AccountType == AccountType.Liability)
|
||||
.Where(a => a.AccountSubType == AccountSubType.AccountsPayable)
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.Select(a => new SelectListItem(accountLabel(a), a.Id.ToString()))
|
||||
.ToList(),
|
||||
|
||||
// Payment sources span both Assets (cash/checking/savings) and Liabilities
|
||||
// (credit cards, lines of credit), so include both top-level types.
|
||||
BankAccounts = allAccounts
|
||||
.Where(a => a.AccountType == AccountType.Asset ||
|
||||
a.AccountType == AccountType.Liability)
|
||||
.Where(a => a.AccountSubType == AccountSubType.Cash ||
|
||||
a.AccountSubType == AccountSubType.Checking ||
|
||||
a.AccountSubType == AccountSubType.Savings ||
|
||||
a.AccountSubType == AccountSubType.CreditCard)
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.Select(a => new SelectListItem(accountLabel(a), a.Id.ToString()))
|
||||
.ToList(),
|
||||
|
||||
@@ -678,12 +678,6 @@ public static class HelpKnowledgeBase
|
||||
**Step 5 — Set up your Chart of Accounts (for billing/AP)**
|
||||
If you use the Bills and accounting features, go to [Chart of Accounts](/Accounts) and confirm the seeded accounts fit your setup. The wizard seeds a standard set automatically.
|
||||
|
||||
**Trial balance indicator (Chart of Accounts)**
|
||||
The Chart of Accounts page shows a Trial Balance badge: "Balanced" when total debits equal total credits, otherwise how far off the books are (excess debits/credits). A non-zero value usually means opening balances were entered without an offsetting entry, or a one-sided posting. Run Recalculate Balances first; if it persists, review opening balances.
|
||||
|
||||
**Default accounts (Chart of Accounts → Set Defaults)**
|
||||
On the Chart of Accounts page, the "Default Accounts" card lets you choose a default Revenue, COGS, and Inventory account for your company. These are used automatically when an item or invoice line doesn't specify one: invoice lines fall back to your default Revenue account (then to account 4000 if none is set), and new inventory and catalog items are pre-filled with your default COGS/Inventory accounts. Leave any blank to keep the current behavior. Note: setting BOTH a COGS and an Inventory Asset default makes new items post inventory-consumption COGS (perpetual inventory) — leave them blank if you expense materials when you purchase them.
|
||||
|
||||
**What happens if Operating Costs are zero?**
|
||||
If you skip the pricing setup steps, every quote will calculate $0 (or only the tax amount). The Dashboard "Setup Incomplete" card will show red badges pointing to exactly what's missing and link directly to the fix.
|
||||
|
||||
|
||||
@@ -565,14 +565,7 @@ public class QuickBooksOnlineService
|
||||
var parentName = name.Contains(':') ? name.Substring(0, name.LastIndexOf(':')).Trim() : null;
|
||||
|
||||
result.TotalRecords++;
|
||||
// QBO Type (high-level) is reliable; DetailType often isn't mappable and falls back to
|
||||
// Other. Reconcile so sub-type's parent always matches the type — otherwise an unmapped
|
||||
// liability/equity/revenue would get an expense-range sub-type and post with the wrong sign.
|
||||
var acctType = MapQboAccountType(typeStr);
|
||||
var subType = MapQboDetailType(detailType);
|
||||
if (AccountClassification.TypeForSubType(subType) != acctType)
|
||||
subType = AccountClassification.DefaultSubTypeForType(acctType);
|
||||
rows.Add((displayName, parentName, number, desc, acctType, subType));
|
||||
rows.Add((displayName, parentName, number, desc, MapQboAccountType(typeStr), MapQboDetailType(detailType)));
|
||||
}
|
||||
|
||||
// Pass 1: upsert every account WITHOUT parent links so they all get IDs.
|
||||
|
||||
@@ -36,34 +36,16 @@
|
||||
};
|
||||
}
|
||||
|
||||
@{
|
||||
var tbNet = (decimal)(ViewBag.TrialBalanceNet ?? 0m);
|
||||
var tbBalanced = Math.Abs(tbNet) < 0.01m;
|
||||
}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
|
||||
@if (Model.Any())
|
||||
{
|
||||
<div>
|
||||
@if (tbBalanced)
|
||||
{
|
||||
<span class="badge bg-success-subtle text-success-emphasis border border-success-subtle py-2 px-3">
|
||||
<i class="bi bi-check-circle me-1"></i>Trial balance: Balanced
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-warning-subtle text-warning-emphasis border border-warning-subtle py-2 px-3"
|
||||
title="Total debits minus total credits. A non-zero value usually means opening balances were entered without an offsetting entry, or a one-sided posting. Run Recalculate Balances; if it persists, review opening balances.">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>Trial balance off by @tbNet.ToString("C") (@(tbNet > 0 ? "excess debits" : "excess credits"))
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div></div>
|
||||
}
|
||||
<div class="d-flex justify-content-end mb-4">
|
||||
<div class="d-flex gap-2">
|
||||
<form asp-action="FixOpeningBalanceSigns" method="post"
|
||||
onsubmit="return confirm('Fix opening balance signs for Revenue, Liability, and Equity accounts imported from QuickBooks? This corrects negative balances caused by QB\'s sign convention.')">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-outline-warning"
|
||||
title="Fixes negative opening balances on Revenue/Liability/Equity accounts imported from QuickBooks IIF">
|
||||
<i class="bi bi-sign-stop me-1"></i>Fix QB Import Signs
|
||||
</button>
|
||||
</form>
|
||||
<form id="recalcBalancesForm" asp-action="RecalculateBalances" method="post">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="button" id="btnRecalcBalances" class="btn btn-outline-secondary"
|
||||
@@ -111,80 +93,6 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Model.Any())
|
||||
{
|
||||
var revenueAccts = ViewBag.DefaultRevenueAccounts as List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem> ?? new();
|
||||
var cogsAccts = ViewBag.DefaultCogsAccounts as List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem> ?? new();
|
||||
var inventoryAccts = ViewBag.DefaultInventoryAccounts as List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem> ?? new();
|
||||
int? selRevenue = ViewBag.DefaultRevenueAccountId as int?;
|
||||
int? selCogs = ViewBag.DefaultCogsAccountId as int?;
|
||||
int? selInventory = ViewBag.DefaultInventoryAccountId as int?;
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||
<h6 class="card-title mb-0"><i class="bi bi-gear me-2 text-primary"></i>Default Accounts</h6>
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#defaultAccountsBody">
|
||||
<i class="bi bi-pencil me-1"></i>Set Defaults
|
||||
</button>
|
||||
</div>
|
||||
<div id="defaultAccountsBody" class="collapse">
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-3">
|
||||
These accounts are used automatically when an item or invoice line doesn't specify one.
|
||||
Leave any blank to keep the current behavior.
|
||||
</p>
|
||||
<form asp-action="SaveDefaultAccounts" method="post">
|
||||
@Html.AntiForgeryToken()
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Revenue</label>
|
||||
<select name="defaultRevenueAccountId" class="form-select">
|
||||
<option value="">(No default — uses 4000)</option>
|
||||
@foreach (var o in revenueAccts)
|
||||
{
|
||||
<option value="@o.Value" selected="@(selRevenue?.ToString() == o.Value)">@o.Text</option>
|
||||
}
|
||||
</select>
|
||||
<small class="form-text text-muted">Fallback revenue account for invoice lines.</small>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">COGS</label>
|
||||
<select name="defaultCogsAccountId" class="form-select">
|
||||
<option value="">(No default)</option>
|
||||
@foreach (var o in cogsAccts)
|
||||
{
|
||||
<option value="@o.Value" selected="@(selCogs?.ToString() == o.Value)">@o.Text</option>
|
||||
}
|
||||
</select>
|
||||
<small class="form-text text-muted">Pre-fills new inventory & catalog items.</small>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Inventory Asset</label>
|
||||
<select name="defaultInventoryAccountId" class="form-select">
|
||||
<option value="">(No default)</option>
|
||||
@foreach (var o in inventoryAccts)
|
||||
{
|
||||
<option value="@o.Value" selected="@(selInventory?.ToString() == o.Value)">@o.Text</option>
|
||||
}
|
||||
</select>
|
||||
<small class="form-text text-muted">Pre-fills new inventory items.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-warning alert-permanent small mt-3 mb-3">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
Setting <strong>both</strong> a COGS and an Inventory Asset default makes new items post
|
||||
inventory-consumption COGS (perpetual inventory). Leave these blank if you expense materials
|
||||
when purchased.
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-check-lg me-1"></i>Save Defaults
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!Model.Any())
|
||||
{
|
||||
<div class="card shadow-sm border-0">
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
<div class="col-md-6 mb-3">
|
||||
<label asp-for="RevenueAccountId" class="form-label"></label>
|
||||
<select asp-for="RevenueAccountId" class="form-select" asp-items="ViewBag.RevenueAccounts">
|
||||
<option value="">@((ViewBag.HasDefaultRevenueAccount ?? false) ? "(Default revenue account)" : "(None)")</option>
|
||||
<option value="">(Default revenue account)</option>
|
||||
</select>
|
||||
<small class="form-text text-muted">Account credited when this item is invoiced.</small>
|
||||
</div>
|
||||
@@ -139,7 +139,7 @@
|
||||
<label asp-for="CogsAccountId" class="form-label"></label>
|
||||
<select asp-for="CogsAccountId" class="form-select" asp-items="ViewBag.CogsAccounts"
|
||||
data-quick-add-url="/Accounts/Create?preSubType=40" data-quick-add-title="Add COGS Account">
|
||||
<option value="">@((ViewBag.HasDefaultCogsAccount ?? false) ? "(Default COGS account)" : "(None)")</option>
|
||||
<option value="">(Default COGS account)</option>
|
||||
<option value="__new__">+ Add New Account…</option>
|
||||
</select>
|
||||
<small class="form-text text-muted">Account debited when materials are consumed.</small>
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
<div class="col-md-6 mb-3">
|
||||
<label asp-for="RevenueAccountId" class="form-label"></label>
|
||||
<select asp-for="RevenueAccountId" class="form-select" asp-items="ViewBag.RevenueAccounts">
|
||||
<option value="">@((ViewBag.HasDefaultRevenueAccount ?? false) ? "(Default revenue account)" : "(None)")</option>
|
||||
<option value="">(Default revenue account)</option>
|
||||
</select>
|
||||
<small class="form-text text-muted">Account credited when this item is invoiced.</small>
|
||||
</div>
|
||||
@@ -142,7 +142,7 @@
|
||||
<label asp-for="CogsAccountId" class="form-label"></label>
|
||||
<select asp-for="CogsAccountId" class="form-select" asp-items="ViewBag.CogsAccounts"
|
||||
data-quick-add-url="/Accounts/Create?preSubType=40" data-quick-add-title="Add COGS Account">
|
||||
<option value="">@((ViewBag.HasDefaultCogsAccount ?? false) ? "(Default COGS account)" : "(None)")</option>
|
||||
<option value="">(Default COGS account)</option>
|
||||
<option value="__new__">+ Add New Account…</option>
|
||||
</select>
|
||||
<small class="form-text text-muted">Account debited when materials are consumed.</small>
|
||||
|
||||
@@ -618,36 +618,6 @@
|
||||
|
||||
</div><!-- /tab-content -->
|
||||
|
||||
<!-- Maintenance Tools (SuperAdmin platform utilities) -->
|
||||
<div class="card shadow-sm mt-4">
|
||||
<div class="card-header bg-light">
|
||||
<h6 class="card-title mb-0">
|
||||
<i class="bi bi-tools me-2"></i>Maintenance Tools
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body d-flex flex-column gap-3">
|
||||
<div class="d-flex justify-content-between align-items-start border rounded p-3">
|
||||
<div>
|
||||
<h6 class="mb-1"><i class="bi bi-sign-stop me-1"></i>Fix QuickBooks Import Signs</h6>
|
||||
<p class="text-muted small mb-0">
|
||||
Flips negative opening balances on Revenue, Liability, and Equity accounts to positive.
|
||||
QuickBooks IIF exports store these credit-normal accounts as negative numbers; this
|
||||
corrects them so the Chart of Accounts reads correctly. Run <strong>Recalculate Balances</strong>
|
||||
on the company's Chart of Accounts afterward. Safe to run more than once.
|
||||
</p>
|
||||
</div>
|
||||
<form asp-action="FixOpeningBalanceSigns" asp-route-id="@Model.Id" method="post"
|
||||
onsubmit="return confirm('Fix opening balance signs for Revenue, Liability, and Equity accounts imported from QuickBooks for this company?')">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-outline-warning ms-3 text-nowrap"
|
||||
title="Fixes negative opening balances on Revenue/Liability/Equity accounts imported from QuickBooks IIF">
|
||||
<i class="bi bi-sign-stop me-1"></i>Fix QB Import Signs
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Danger Zone (outside tabs — always present) -->
|
||||
<div class="card shadow-sm border-danger mt-4">
|
||||
<div class="card-header bg-light">
|
||||
|
||||
@@ -237,40 +237,6 @@
|
||||
The chart of accounts is typically configured once during initial setup. You can add new accounts
|
||||
at any time if your accounting needs expand.
|
||||
</p>
|
||||
|
||||
<h3 class="h6 fw-semibold mt-3 mb-2">Default Accounts</h3>
|
||||
<p>
|
||||
The <strong>Default Accounts</strong> card at the top of the Chart of Accounts page lets you choose a
|
||||
default <strong>Revenue</strong>, <strong>COGS</strong>, and <strong>Inventory Asset</strong> account
|
||||
for your company. These are used automatically when an item or invoice line doesn't specify its own:
|
||||
</p>
|
||||
<ul class="mb-3">
|
||||
<li class="mb-1"><strong>Revenue</strong> — invoice lines without a specific revenue account fall back to this one (and to account 4000 if you haven't set a default).</li>
|
||||
<li class="mb-1"><strong>COGS</strong> and <strong>Inventory Asset</strong> — new inventory and catalog items are pre-filled with these, so you don't have to pick them every time. You can still change or clear them on each item.</li>
|
||||
</ul>
|
||||
<p>
|
||||
Leave any of them blank to keep the current behavior. Click <strong>Set Defaults</strong> to expand the
|
||||
card, choose your accounts, and save.
|
||||
</p>
|
||||
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-3" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 mt-1"></i>
|
||||
<div>
|
||||
Setting <strong>both</strong> a COGS and an Inventory Asset default makes new items record
|
||||
inventory-consumption cost (COGS) to the general ledger as they're used — this is
|
||||
perpetual-inventory accounting. If you expense materials when you <em>purchase</em> them, leave
|
||||
these two blank to avoid double-counting.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="h6 fw-semibold mt-3 mb-2">Trial Balance Indicator</h3>
|
||||
<p>
|
||||
The top of the Chart of Accounts page shows a <strong>Trial balance</strong> badge. When total
|
||||
debits equal total credits it reads <em>Balanced</em>; otherwise it shows how far off the books
|
||||
are (excess debits or credits). A non-zero value usually means opening balances were entered
|
||||
without an offsetting entry, or a one-sided posting occurred. Run <strong>Recalculate Balances</strong>
|
||||
first; if it persists, review your opening balances or ask your accountant.
|
||||
</p>
|
||||
|
||||
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-0" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 mt-1"></i>
|
||||
<div>
|
||||
|
||||
@@ -17,10 +17,8 @@
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<form asp-action="Create" method="post" id="inventory-create-form">
|
||||
<input type="hidden" asp-for="DuplicateOverrideInventoryItemId" id="duplicate-override-id" />
|
||||
<form asp-action="Create" method="post">
|
||||
<partial name="_ValidationSummary" />
|
||||
<div id="inventory-duplicate-status" class="d-none mb-3" role="alert"></div>
|
||||
|
||||
<!-- Basic Information -->
|
||||
<div class="mb-4">
|
||||
@@ -375,7 +373,7 @@
|
||||
<label asp-for="InventoryAccountId" class="form-label"></label>
|
||||
<select asp-for="InventoryAccountId" class="form-select" asp-items="ViewBag.InventoryAccounts"
|
||||
data-quick-add-url="/Accounts/Create?preSubType=4" data-quick-add-title="Add Inventory Account">
|
||||
<option value="">@((ViewBag.HasDefaultInventoryAccount ?? false) ? "(Default inventory account)" : "(None)")</option>
|
||||
<option value="">(Default inventory account)</option>
|
||||
<option value="__new__">+ Add New Account…</option>
|
||||
</select>
|
||||
<small class="form-text text-muted">Asset account where inventory value is tracked (e.g., 1200 Inventory - Powder).</small>
|
||||
@@ -384,7 +382,7 @@
|
||||
<label asp-for="CogsAccountId" class="form-label"></label>
|
||||
<select asp-for="CogsAccountId" class="form-select" asp-items="ViewBag.CogsAccounts"
|
||||
data-quick-add-url="/Accounts/Create?preSubType=40" data-quick-add-title="Add COGS Account">
|
||||
<option value="">@((ViewBag.HasDefaultCogsAccount ?? false) ? "(Default COGS account)" : "(None)")</option>
|
||||
<option value="">(Default COGS account)</option>
|
||||
<option value="__new__">+ Add New Account…</option>
|
||||
</select>
|
||||
<small class="form-text text-muted">Expense account debited when this material is consumed on a job.</small>
|
||||
@@ -430,15 +428,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<partial name="_LabelScanModal" />
|
||||
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false))
|
||||
{
|
||||
<partial name="_LabelScanModal" />
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
<partial name="_ValidationScriptsPartial" />
|
||||
<script>const inventoryFormIsCreate = true;</script>
|
||||
<script src="~/js/inventory-vendor-match.js"></script>
|
||||
<partial name="_InventoryColorFamilyScripts" />
|
||||
<script src="~/js/inventory-catalog-lookup.js"></script>
|
||||
<script src="~/js/inventory-duplicate-check.js"></script>
|
||||
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false))
|
||||
{
|
||||
<script src="~/js/inventory-label-scan.js"></script>
|
||||
|
||||
@@ -374,7 +374,7 @@
|
||||
<label asp-for="InventoryAccountId" class="form-label"></label>
|
||||
<select asp-for="InventoryAccountId" class="form-select" asp-items="ViewBag.InventoryAccounts"
|
||||
data-quick-add-url="/Accounts/Create?preSubType=4" data-quick-add-title="Add Inventory Account">
|
||||
<option value="">@((ViewBag.HasDefaultInventoryAccount ?? false) ? "(Default inventory account)" : "(None)")</option>
|
||||
<option value="">(Default inventory account)</option>
|
||||
<option value="__new__">+ Add New Account…</option>
|
||||
</select>
|
||||
<small class="form-text text-muted">Asset account where inventory value is tracked (e.g., 1200 Inventory - Powder).</small>
|
||||
@@ -383,7 +383,7 @@
|
||||
<label asp-for="CogsAccountId" class="form-label"></label>
|
||||
<select asp-for="CogsAccountId" class="form-select" asp-items="ViewBag.CogsAccounts"
|
||||
data-quick-add-url="/Accounts/Create?preSubType=40" data-quick-add-title="Add COGS Account">
|
||||
<option value="">@((ViewBag.HasDefaultCogsAccount ?? false) ? "(Default COGS account)" : "(None)")</option>
|
||||
<option value="">(Default COGS account)</option>
|
||||
<option value="__new__">+ Add New Account…</option>
|
||||
</select>
|
||||
<small class="form-text text-muted">Expense account debited when this material is consumed on a job.</small>
|
||||
@@ -448,14 +448,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<partial name="_LabelScanModal" />
|
||||
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false))
|
||||
{
|
||||
<partial name="_LabelScanModal" />
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
<partial name="_ValidationScriptsPartial" />
|
||||
<script src="~/js/inventory-vendor-match.js"></script>
|
||||
<partial name="_InventoryColorFamilyScripts" />
|
||||
<script src="~/js/inventory-catalog-lookup.js"></script>
|
||||
<script src="~/js/inventory-duplicate-check.js"></script>
|
||||
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false))
|
||||
{
|
||||
<script src="~/js/inventory-label-scan.js"></script>
|
||||
|
||||
@@ -195,15 +195,17 @@
|
||||
|
||||
function autoMatchVendor() {
|
||||
if (!isCoatingCategory(categorySelect?.value)) return;
|
||||
if (typeof window.matchInventoryVendor === 'function') {
|
||||
window.matchInventoryVendor(vendorSel, manufacturerEl?.value, null);
|
||||
}
|
||||
if (!vendorSel || vendorSel.value) return; // don't overwrite an existing selection
|
||||
const mfr = (manufacturerEl?.value?.trim() ?? '').toLowerCase();
|
||||
if (!mfr) return;
|
||||
const match = Array.from(vendorSel.options).find(o =>
|
||||
o.text.toLowerCase().includes(mfr) || mfr.includes(o.text.toLowerCase().trim())
|
||||
);
|
||||
if (match) vendorSel.value = match.value;
|
||||
}
|
||||
|
||||
if (manufacturerEl) {
|
||||
// Use 'change' (fires on blur with the full value) rather than 'input' so partial
|
||||
// mid-typing values like "P" don't trigger a wrong vendor pick.
|
||||
manufacturerEl.addEventListener('change', autoMatchVendor);
|
||||
manufacturerEl.addEventListener('input', autoMatchVendor);
|
||||
}
|
||||
if (colorNameEl) {
|
||||
colorNameEl.addEventListener('input', autoComposeName);
|
||||
@@ -419,15 +421,15 @@
|
||||
aiFilledColorFamilies = true;
|
||||
}
|
||||
|
||||
// Vendor: match on the Manufacturer field first (almost always populated and equal to
|
||||
// the vendor for the shop's distributors); fall back to the AI's price-derived vendorName.
|
||||
{
|
||||
// Vendor: match by name (case-insensitive) against dropdown options
|
||||
if (data.vendorName) {
|
||||
const vendorSel = document.getElementById('field-vendor');
|
||||
const mfrName = document.getElementById('field-manufacturer')?.value || data.manufacturer;
|
||||
if (typeof window.matchInventoryVendor === 'function' &&
|
||||
window.matchInventoryVendor(vendorSel, mfrName, data.vendorName, { force: forceRefill })) {
|
||||
filled.push('Vendor');
|
||||
aiFilledVendor = true;
|
||||
if (vendorSel && (forceRefill || !vendorSel.value)) {
|
||||
const needle = data.vendorName.toLowerCase();
|
||||
const match = Array.from(vendorSel.options).find(o =>
|
||||
o.text.toLowerCase().includes(needle) || needle.includes(o.text.toLowerCase().trim())
|
||||
);
|
||||
if (match) { vendorSel.value = match.value; filled.push('Vendor'); aiFilledVendor = true; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1306,17 +1306,6 @@
|
||||
<label class="form-label fw-semibold">Date Received <span class="text-danger">*</span></label>
|
||||
<input type="date" class="form-control" id="depositDate" name="receivedDate" required value="@(DateTime.Today.ToString("yyyy-MM-dd"))" />
|
||||
</div>
|
||||
@{ var depositAccounts = ViewBag.DepositAccounts as List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>; }
|
||||
@if (depositAccounts != null && depositAccounts.Count > 0)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Deposit To</label>
|
||||
<select class="form-select" id="depositAccount" name="depositAccountId" asp-items="depositAccounts">
|
||||
<option value="">Default (first checking/cash account)</option>
|
||||
</select>
|
||||
<small class="form-text text-muted">Bank or asset account this deposit is recorded against.</small>
|
||||
</div>
|
||||
}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Reference (check #, card last 4, etc.)</label>
|
||||
<input type="text" class="form-control" id="depositReference" name="reference" maxlength="200" />
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
@{
|
||||
ViewData["Title"] = "Record Sales Tax Payment";
|
||||
ViewData["PageIcon"] = "bi-cash-stack";
|
||||
var taxLiability = (decimal)(ViewBag.TaxLiability ?? 0m);
|
||||
var taxFound = (bool)(ViewBag.TaxAccountFound ?? false);
|
||||
var banks = ViewBag.BankAccounts as List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem> ?? new List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>();
|
||||
}
|
||||
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<a asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
|
||||
<h5 class="mb-0">Record Sales Tax Payment</h5>
|
||||
</div>
|
||||
|
||||
@if (!taxFound)
|
||||
{
|
||||
<div class="alert alert-warning">
|
||||
No active <strong>Sales Tax Payable (2200)</strong> account was found in your chart of accounts. Add one first.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row">
|
||||
<div class="col-lg-7">
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span class="text-muted d-block">Current Sales Tax Payable</span>
|
||||
<span class="text-muted small">Tax collected on invoices and owed to the authority.</span>
|
||||
</div>
|
||||
<span class="h4 mb-0 @(taxLiability > 0 ? "text-danger" : "text-success")">@taxLiability.ToString("C")</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form asp-action="SalesTaxPayment" method="post" class="card shadow-sm">
|
||||
@Html.AntiForgeryToken()
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Amount paid</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">$</span>
|
||||
<input type="number" step="0.01" min="0.01" name="amount" class="form-control"
|
||||
value="@(taxLiability > 0 ? taxLiability.ToString("0.00") : "")" required />
|
||||
</div>
|
||||
<div class="form-text">Defaults to the full balance — edit if you're paying a partial period.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Payment date</label>
|
||||
<input type="date" name="paymentDate" class="form-control" value="@DateTime.Today.ToString("yyyy-MM-dd")" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Paid from (bank account)</label>
|
||||
<select name="bankAccountId" class="form-select" required>
|
||||
<option value="">Select an account…</option>
|
||||
@foreach (var b in banks)
|
||||
{
|
||||
<option value="@b.Value">@b.Text</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Reference / period <span class="text-muted">(optional)</span></label>
|
||||
<input type="text" name="reference" class="form-control" placeholder="e.g. Q2 2026 sales tax" />
|
||||
</div>
|
||||
<div class="alert alert-light border small mb-3">
|
||||
Posts a journal entry: <strong>DR</strong> Sales Tax Payable / <strong>CR</strong> the chosen bank account.
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>Record payment</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -1898,17 +1898,6 @@
|
||||
<label class="form-label fw-semibold">Date Received <span class="text-danger">*</span></label>
|
||||
<input type="date" class="form-control" id="depositDate" name="receivedDate" required value="@(DateTime.Today.ToString("yyyy-MM-dd"))" />
|
||||
</div>
|
||||
@{ var depositAccounts = ViewBag.DepositAccounts as List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>; }
|
||||
@if (depositAccounts != null && depositAccounts.Count > 0)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Deposit To</label>
|
||||
<select class="form-select" id="depositAccount" name="depositAccountId" asp-items="depositAccounts">
|
||||
<option value="">Default (first checking/cash account)</option>
|
||||
</select>
|
||||
<small class="form-text text-muted">Bank or asset account this deposit is recorded against.</small>
|
||||
</div>
|
||||
}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Reference (check #, card last 4, etc.)</label>
|
||||
<input type="text" class="form-control" id="depositReference" name="reference" maxlength="200" />
|
||||
|
||||
@@ -220,14 +220,6 @@
|
||||
<p>All active accounts with debit and credit balances — validates that your books are in balance.</p>
|
||||
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
|
||||
</a>
|
||||
<a asp-controller="Reports" asp-action="Reconciliation" class="report-card">
|
||||
<div class="report-card-icon" style="background:#f0fdf4;color:#15803d;">
|
||||
<i class="bi bi-clipboard-check"></i>
|
||||
</div>
|
||||
<h5>Balance Reconciliation</h5>
|
||||
<p>Stored account balances vs. the recomputed ledger, plus AR/AP subledger vs. control — surfaces any drift.</p>
|
||||
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
|
||||
</a>
|
||||
<a asp-controller="Reports" asp-action="CashFlowStatement" class="report-card">
|
||||
<div class="report-card-icon" style="background:#ecfeff;color:#0891b2;">
|
||||
<i class="bi bi-water"></i>
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
@model PowderCoating.Application.DTOs.Accounting.BalanceReconciliationDto
|
||||
@using PowderCoating.Core.Enums
|
||||
@{
|
||||
ViewData["Title"] = "Balance Reconciliation";
|
||||
ViewData["PageIcon"] = "bi-clipboard-check";
|
||||
}
|
||||
|
||||
<style>
|
||||
@@media print { .no-print { display: none !important; } }
|
||||
.drift { background: #fef2f2; }
|
||||
</style>
|
||||
|
||||
<div class="d-flex align-items-center gap-2 mb-3 no-print">
|
||||
<a asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
|
||||
<p class="text-muted mb-0">
|
||||
As of @Model.AsOf.ToLocalTime().ToString("MMMM d, yyyy h:mm tt") ·
|
||||
@if (Model.AllReconciled)
|
||||
{
|
||||
<span class="text-success fw-semibold"><i class="bi bi-check-circle me-1"></i>Everything reconciles</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-danger fw-semibold">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>@Model.DriftedAccounts.Count() account(s) drifted@(Model.ArReconciled ? "" : ", AR off")@(Model.ApReconciled ? "" : ", AP off")
|
||||
</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Subledger vs control -->
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm h-100 @(Model.ArReconciled ? "" : "border-danger")">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted mb-2">Accounts Receivable</h6>
|
||||
<div class="d-flex justify-content-between"><span>GL control account</span><span>@Model.ArControlBalance.ToString("C")</span></div>
|
||||
<div class="d-flex justify-content-between"><span>Customer subledger (sum)</span><span>@Model.ArSubledgerTotal.ToString("C")</span></div>
|
||||
<hr class="my-2" />
|
||||
<div class="d-flex justify-content-between fw-semibold @(Model.ArReconciled ? "text-success" : "text-danger")">
|
||||
<span>Difference</span><span>@Model.ArDifference.ToString("C")</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm h-100 @(Model.ApReconciled ? "" : "border-danger")">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted mb-2">Accounts Payable</h6>
|
||||
<div class="d-flex justify-content-between"><span>GL control account</span><span>@Model.ApControlBalance.ToString("C")</span></div>
|
||||
<div class="d-flex justify-content-between"><span>Vendor subledger (sum)</span><span>@Model.ApSubledgerTotal.ToString("C")</span></div>
|
||||
<hr class="my-2" />
|
||||
<div class="d-flex justify-content-between fw-semibold @(Model.ApReconciled ? "text-success" : "text-danger")">
|
||||
<span>Difference</span><span>@Model.ApDifference.ToString("C")</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stored vs recomputed ledger, per account -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<h6 class="mb-0">Stored balance vs recomputed ledger</h6>
|
||||
<span class="ms-2 badge bg-secondary">@Model.AccountLines.Count accounts</span>
|
||||
</div>
|
||||
<p class="text-muted small mb-3">
|
||||
A difference means <code>Account.CurrentBalance</code> has drifted from what the source documents
|
||||
recompute to. Running <strong>Accounts → Recalculate Balances</strong> resets the stored value
|
||||
to the ledger value.
|
||||
</p>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th><th>Account</th><th>Type</th>
|
||||
<th class="text-end">Stored</th><th class="text-end">Ledger</th><th class="text-end">Difference</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var l in Model.AccountLines)
|
||||
{
|
||||
<tr class="@(l.IsReconciled ? "" : "drift")">
|
||||
<td>@l.AccountNumber</td>
|
||||
<td>@l.AccountName</td>
|
||||
<td><span class="text-muted small">@l.AccountType</span></td>
|
||||
<td class="text-end">@l.StoredBalance.ToString("C")</td>
|
||||
<td class="text-end">@l.LedgerBalance.ToString("C")</td>
|
||||
<td class="text-end @(l.IsReconciled ? "" : "text-danger fw-semibold")">
|
||||
@if (l.IsReconciled)
|
||||
{
|
||||
<i class="bi bi-check2 text-success"></i>
|
||||
}
|
||||
else
|
||||
{
|
||||
@l.Difference.ToString("C")
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -31,9 +31,6 @@
|
||||
<a asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
|
||||
<p class="text-muted mb-0">@Model.From.ToString("MMM d") – @Model.To.ToString("MMM d, yyyy") · @(Model.TaxableInvoiceCount + Model.NonTaxableInvoiceCount) invoices</p>
|
||||
<div class="ms-auto d-flex gap-2">
|
||||
<a href="@Url.Action("SalesTaxPayment", "JournalEntries")" class="btn btn-sm btn-primary no-print">
|
||||
<i class="bi bi-cash-stack me-1"></i>Record Payment
|
||||
</a>
|
||||
<a href="@Url.Action("SalesTaxCsv", new { from = Model.From.ToString("yyyy-MM-dd"), to = Model.To.ToString("yyyy-MM-dd") })"
|
||||
class="btn btn-sm btn-outline-success no-print">
|
||||
<i class="bi bi-filetype-csv me-1"></i>Export CSV
|
||||
|
||||
@@ -47,6 +47,9 @@
|
||||
"BaseUrl": "https://columbiacoatings.com",
|
||||
"ApiBasePath": "/wp-json/cca/v1"
|
||||
},
|
||||
"CatalogImport": {
|
||||
"Token": ""
|
||||
},
|
||||
"SendGrid": {
|
||||
"ApiKey": "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",
|
||||
"FromEmail": "spouliot@scppowdercoating.com",
|
||||
|
||||
@@ -198,16 +198,16 @@
|
||||
filled.push('Image');
|
||||
}
|
||||
|
||||
// Vendor dropdown — match on the Manufacturer field first, catalog vendor name as fallback
|
||||
// Vendor dropdown — match by name
|
||||
const vendorSel = document.getElementById('field-vendor');
|
||||
const mfrName = document.getElementById('field-manufacturer')?.value;
|
||||
if (typeof window.matchInventoryVendor === 'function' &&
|
||||
window.matchInventoryVendor(vendorSel, mfrName, item.vendorName)) {
|
||||
filled.push('Vendor');
|
||||
if (vendorSel && !vendorSel.value && item.vendorName) {
|
||||
const needle = item.vendorName.toLowerCase();
|
||||
const match = Array.from(vendorSel.options).find(o =>
|
||||
o.text.toLowerCase().includes(needle) || needle.includes(o.text.toLowerCase().trim())
|
||||
);
|
||||
if (match) { vendorSel.value = match.value; filled.push('Vendor'); }
|
||||
}
|
||||
|
||||
document.dispatchEvent(new CustomEvent('inventory:identity-changed'));
|
||||
|
||||
const discontinuedNote = item.isDiscontinued
|
||||
? ' <span class="badge bg-warning text-dark ms-1">Discontinued</span>' : '';
|
||||
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
/**
|
||||
* Shared inventory duplicate UI.
|
||||
*
|
||||
* Owns the "already in inventory" modal and the manual Create-form preflight check.
|
||||
* Label scanning calls window.inventoryDuplicateUi.show(...) so both entry paths use
|
||||
* the same prompt and add-stock implementation.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const modalEl = document.getElementById('addStockModal');
|
||||
const modal = modalEl ? bootstrap.Modal.getOrCreateInstance(modalEl) : null;
|
||||
const itemNameEl = document.getElementById('add-stock-item-name');
|
||||
const currentQtyEl = document.getElementById('add-stock-current-qty');
|
||||
const uomEl = document.getElementById('add-stock-uom-label');
|
||||
const qtyEl = document.getElementById('add-stock-qty');
|
||||
const costEl = document.getElementById('add-stock-cost');
|
||||
const notesEl = document.getElementById('add-stock-notes');
|
||||
const modalStatusEl = document.getElementById('add-stock-status');
|
||||
const addButton = document.getElementById('add-stock-confirm-btn');
|
||||
const createSeparateButton = document.getElementById('add-stock-new-btn');
|
||||
|
||||
let activeData = null;
|
||||
let activeOptions = {};
|
||||
|
||||
function show(data, options) {
|
||||
if (!modal || !data?.existingInventoryId) return;
|
||||
|
||||
activeData = data;
|
||||
activeOptions = options || {};
|
||||
const uom = data.existingUnitOfMeasure || 'lbs';
|
||||
|
||||
itemNameEl.textContent = data.existingInventoryName || data.colorName || 'This product';
|
||||
currentQtyEl.textContent = `${Number(data.existingQuantityOnHand || 0).toFixed(2)} ${uom}`;
|
||||
uomEl.textContent = uom;
|
||||
qtyEl.value = '';
|
||||
costEl.value = Number(data.unitPrice || 0) > 0 ? data.unitPrice : '';
|
||||
notesEl.value = '';
|
||||
modalStatusEl.className = 'd-none';
|
||||
modalStatusEl.textContent = '';
|
||||
addButton.disabled = false;
|
||||
addButton.innerHTML = '<i class="bi bi-plus-circle me-1"></i>Add Stock';
|
||||
createSeparateButton.classList.toggle('d-none', data.isBlocking === true);
|
||||
modal.show();
|
||||
}
|
||||
|
||||
async function addStock() {
|
||||
const quantity = Number(qtyEl.value);
|
||||
if (!quantity || quantity <= 0) {
|
||||
showModalStatus('danger', 'Enter a quantity greater than zero.');
|
||||
return;
|
||||
}
|
||||
|
||||
addButton.disabled = true;
|
||||
addButton.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving…';
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
inventoryItemId: activeData.existingInventoryId,
|
||||
quantity: quantity.toString()
|
||||
});
|
||||
const unitCost = Number(costEl.value);
|
||||
if (unitCost > 0) params.set('unitCost', unitCost.toString());
|
||||
if (notesEl.value.trim()) params.set('notes', notesEl.value.trim());
|
||||
|
||||
const response = await fetch(`/Inventory/AddStock?${params}`, { method: 'POST' });
|
||||
if (!response.ok) throw new Error(`Server error ${response.status}`);
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
showModalStatus('danger', result.errorMessage || 'Failed to add stock.');
|
||||
return;
|
||||
}
|
||||
|
||||
modal.hide();
|
||||
if (typeof activeOptions.onAdded === 'function') {
|
||||
activeOptions.onAdded(result, quantity, activeData);
|
||||
} else {
|
||||
showFormMessage(
|
||||
'success',
|
||||
`Added <strong>${quantity.toFixed(2)} ${escapeHtml(result.unitOfMeasure)}</strong> to ` +
|
||||
`<strong>${escapeHtml(result.itemName)}</strong>. New stock: ` +
|
||||
`${Number(result.newQuantityOnHand || 0).toFixed(2)} ${escapeHtml(result.unitOfMeasure)}.`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
showModalStatus('danger', error.message);
|
||||
} finally {
|
||||
addButton.disabled = false;
|
||||
addButton.innerHTML = '<i class="bi bi-plus-circle me-1"></i>Add Stock';
|
||||
}
|
||||
}
|
||||
|
||||
addButton?.addEventListener('click', addStock);
|
||||
createSeparateButton?.addEventListener('click', function () {
|
||||
modal?.hide();
|
||||
if (typeof activeOptions.onCreateSeparate === 'function') {
|
||||
activeOptions.onCreateSeparate(activeData);
|
||||
}
|
||||
});
|
||||
|
||||
window.inventoryDuplicateUi = { show };
|
||||
|
||||
const form = document.getElementById('inventory-create-form');
|
||||
const statusEl = document.getElementById('inventory-duplicate-status');
|
||||
const overrideEl = document.getElementById('duplicate-override-id');
|
||||
if (!form || !statusEl || !overrideEl) return;
|
||||
|
||||
const fields = {
|
||||
sku: document.getElementById('field-sku'),
|
||||
categoryId: document.getElementById('field-category'),
|
||||
manufacturer: document.getElementById('field-manufacturer'),
|
||||
manufacturerPartNumber: document.getElementById('field-partnumber'),
|
||||
colorName: document.getElementById('field-colorname')
|
||||
};
|
||||
|
||||
let timer = null;
|
||||
let requestVersion = 0;
|
||||
let latestDuplicate = null;
|
||||
let acknowledgedSignature = null;
|
||||
|
||||
function signature() {
|
||||
return Object.values(fields)
|
||||
.map(field => field?.value?.trim().toUpperCase() || '')
|
||||
.join('|');
|
||||
}
|
||||
|
||||
function scheduleCheck() {
|
||||
overrideEl.value = '';
|
||||
acknowledgedSignature = null;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => checkDuplicate(false), 400);
|
||||
}
|
||||
|
||||
Object.values(fields).forEach(field => {
|
||||
field?.addEventListener('input', scheduleCheck);
|
||||
field?.addEventListener('change', scheduleCheck);
|
||||
field?.addEventListener('blur', () => checkDuplicate(false));
|
||||
});
|
||||
document.addEventListener('inventory:identity-changed', scheduleCheck);
|
||||
|
||||
async function checkDuplicate(showChecking) {
|
||||
const version = ++requestVersion;
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(fields).forEach(([key, field]) => {
|
||||
if (field?.value?.trim()) params.set(key, field.value.trim());
|
||||
});
|
||||
|
||||
if (!params.has('sku') &&
|
||||
!(params.has('manufacturer') &&
|
||||
(params.has('manufacturerPartNumber') || params.has('colorName')))) {
|
||||
latestDuplicate = null;
|
||||
hideStatus();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (showChecking) showFormMessage('info', 'Checking existing inventory…');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/Inventory/CheckDuplicate?${params}`);
|
||||
if (!response.ok) throw new Error(`Server error ${response.status}`);
|
||||
const data = await response.json();
|
||||
if (version !== requestVersion) return latestDuplicate;
|
||||
|
||||
latestDuplicate = data.hasDuplicate ? data : null;
|
||||
if (!latestDuplicate) {
|
||||
hideStatus();
|
||||
return null;
|
||||
}
|
||||
|
||||
renderDuplicate(latestDuplicate);
|
||||
return latestDuplicate;
|
||||
} catch {
|
||||
if (showChecking) {
|
||||
showFormMessage(
|
||||
'warning',
|
||||
'Inventory could not be checked right now. The item will be checked again when saved.'
|
||||
);
|
||||
}
|
||||
return latestDuplicate;
|
||||
}
|
||||
}
|
||||
|
||||
function renderDuplicate(data) {
|
||||
const quantity = Number(data.existingQuantityOnHand || 0).toFixed(2);
|
||||
const uom = escapeHtml(data.existingUnitOfMeasure || 'lbs');
|
||||
const viewLink = `/Inventory/Details/${data.existingInventoryId}`;
|
||||
const overrideButton = data.isBlocking
|
||||
? ''
|
||||
: '<button type="button" class="btn btn-sm btn-outline-secondary duplicate-create-separate">Create separate entry</button>';
|
||||
|
||||
statusEl.className = 'alert alert-warning mb-3';
|
||||
statusEl.innerHTML = `
|
||||
<div class="d-flex gap-2 align-items-start">
|
||||
<i class="bi bi-exclamation-triangle-fill mt-1"></i>
|
||||
<div class="flex-grow-1">
|
||||
<div class="fw-semibold">Already in inventory</div>
|
||||
<div class="small">${escapeHtml(data.message)}</div>
|
||||
<div class="small text-muted mt-1">Current stock: ${quantity} ${uom}</div>
|
||||
<div class="d-flex flex-wrap gap-2 mt-2">
|
||||
<a class="btn btn-sm btn-outline-secondary" href="${viewLink}">View existing item</a>
|
||||
<button type="button" class="btn btn-sm btn-primary duplicate-add-stock">Add stock</button>
|
||||
${overrideButton}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
statusEl.querySelector('.duplicate-add-stock')?.addEventListener('click', () => show(data));
|
||||
statusEl.querySelector('.duplicate-create-separate')?.addEventListener('click', () => {
|
||||
overrideEl.value = data.existingInventoryId;
|
||||
acknowledgedSignature = signature();
|
||||
statusEl.className = 'alert alert-warning mb-3';
|
||||
statusEl.innerHTML =
|
||||
`<strong>Separate entry confirmed.</strong> ${escapeHtml(data.message)} ` +
|
||||
`<a class="alert-link" href="${viewLink}">View existing item</a>`;
|
||||
});
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async function (event) {
|
||||
if (form.dataset.duplicateValidated === 'true') {
|
||||
form.dataset.duplicateValidated = '';
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const duplicate = await checkDuplicate(true);
|
||||
const isAcknowledged = duplicate &&
|
||||
!duplicate.isBlocking &&
|
||||
Number(overrideEl.value) === Number(duplicate.existingInventoryId) &&
|
||||
acknowledgedSignature === signature();
|
||||
|
||||
if (!duplicate || isAcknowledged) {
|
||||
form.dataset.duplicateValidated = 'true';
|
||||
form.requestSubmit(event.submitter);
|
||||
return;
|
||||
}
|
||||
|
||||
statusEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
statusEl.querySelector('button, a')?.focus({ preventScroll: true });
|
||||
});
|
||||
|
||||
function showModalStatus(type, message) {
|
||||
modalStatusEl.className = `alert alert-${type} py-2 small`;
|
||||
modalStatusEl.textContent = message;
|
||||
}
|
||||
|
||||
function showFormMessage(type, message) {
|
||||
statusEl.className = `alert alert-${type} mb-3`;
|
||||
statusEl.innerHTML = message;
|
||||
}
|
||||
|
||||
function hideStatus() {
|
||||
statusEl.className = 'd-none mb-3';
|
||||
statusEl.innerHTML = '';
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
})();
|
||||
@@ -46,14 +46,33 @@
|
||||
const processingEl = document.getElementById('scan-processing');
|
||||
const processingMsgEl= document.getElementById('scan-processing-msg');
|
||||
|
||||
let _lastScanData = null;
|
||||
// Add-stock modal elements
|
||||
const addStockModalEl = document.getElementById('addStockModal');
|
||||
const bsAddStockModal = addStockModalEl ? new bootstrap.Modal(addStockModalEl) : null;
|
||||
const addStockItemName = document.getElementById('add-stock-item-name');
|
||||
const addStockCurrentQty= document.getElementById('add-stock-current-qty');
|
||||
const addStockUomLabel = document.getElementById('add-stock-uom-label');
|
||||
const addStockQtyInput = document.getElementById('add-stock-qty');
|
||||
const addStockCostInput = document.getElementById('add-stock-cost');
|
||||
const addStockNotesInput= document.getElementById('add-stock-notes');
|
||||
const addStockStatusEl = document.getElementById('add-stock-status');
|
||||
const addStockConfirmBtn= document.getElementById('add-stock-confirm-btn');
|
||||
|
||||
let _addStockItemId = null;
|
||||
let _lastScanData = null;
|
||||
|
||||
if (!modalEl || !videoEl || !canvasEl) return;
|
||||
|
||||
scanBtn.addEventListener('click', openScanner);
|
||||
modalEl.addEventListener('hide.bs.modal', onModalClose);
|
||||
if (shutterBtn) shutterBtn.addEventListener('click', captureFrame);
|
||||
|
||||
if (addStockConfirmBtn) addStockConfirmBtn.addEventListener('click', submitAddStock);
|
||||
// "Create new entry instead" hides the add-stock modal and pre-fills the create form
|
||||
const addStockNewBtn = document.getElementById('add-stock-new-btn');
|
||||
if (addStockNewBtn) addStockNewBtn.addEventListener('click', () => {
|
||||
bsAddStockModal?.hide();
|
||||
if (_lastScanData) fillFromScan(_lastScanData, /* skipDuplicatePrompt */ true);
|
||||
});
|
||||
window.addEventListener('beforeunload', releaseCamera);
|
||||
|
||||
// Pre-warm camera if browser has already granted permission (no prompt risk)
|
||||
@@ -307,10 +326,9 @@
|
||||
|
||||
if (data.existingInventoryId) {
|
||||
// Product already in inventory — show inline add-stock prompt
|
||||
_lastScanData = data;
|
||||
window.inventoryDuplicateUi?.show(data, {
|
||||
onCreateSeparate: () => fillFromScan(_lastScanData, true)
|
||||
});
|
||||
_lastScanData = data;
|
||||
_addStockItemId = data.existingInventoryId;
|
||||
openAddStockModal(data);
|
||||
} else {
|
||||
fillFromScan(data);
|
||||
}
|
||||
@@ -321,6 +339,79 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Add-stock modal ───────────────────────────────────────────────────
|
||||
|
||||
function openAddStockModal(data) {
|
||||
if (!bsAddStockModal) { fillFromScan(data); return; }
|
||||
|
||||
const uom = data.existingUnitOfMeasure || 'lbs';
|
||||
if (addStockItemName) addStockItemName.textContent = data.existingInventoryName || data.colorName || 'This product';
|
||||
if (addStockCurrentQty) addStockCurrentQty.textContent = `${(data.existingQuantityOnHand ?? 0).toFixed(2)} ${uom}`;
|
||||
if (addStockUomLabel) addStockUomLabel.textContent = uom;
|
||||
if (addStockQtyInput) addStockQtyInput.value = '';
|
||||
if (addStockCostInput) addStockCostInput.value = data.unitPrice > 0 ? data.unitPrice : '';
|
||||
if (addStockNotesInput) addStockNotesInput.value = '';
|
||||
if (addStockStatusEl) { addStockStatusEl.className = 'd-none'; addStockStatusEl.textContent = ''; }
|
||||
if (addStockConfirmBtn) addStockConfirmBtn.disabled = false;
|
||||
|
||||
bsAddStockModal.show();
|
||||
}
|
||||
|
||||
async function submitAddStock() {
|
||||
const qty = parseFloat(addStockQtyInput?.value);
|
||||
if (!qty || qty <= 0) {
|
||||
showAddStockStatus('danger', 'Please enter a quantity greater than zero.');
|
||||
return;
|
||||
}
|
||||
|
||||
addStockConfirmBtn.disabled = true;
|
||||
addStockConfirmBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving…';
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
inventoryItemId: _addStockItemId,
|
||||
quantity: qty,
|
||||
});
|
||||
const cost = parseFloat(addStockCostInput?.value);
|
||||
if (cost > 0) params.append('unitCost', cost);
|
||||
const notes = addStockNotesInput?.value?.trim();
|
||||
if (notes) params.append('notes', notes);
|
||||
|
||||
const resp = await fetch('/Inventory/AddStock?' + params.toString(), { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(`Server error ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
|
||||
if (!data.success) {
|
||||
showAddStockStatus('danger', data.errorMessage || 'Failed to add stock.');
|
||||
addStockConfirmBtn.disabled = false;
|
||||
addStockConfirmBtn.innerHTML = '<i class="bi bi-plus-circle me-1"></i>Add Stock';
|
||||
return;
|
||||
}
|
||||
|
||||
// Success — close modal and show confirmation on the form
|
||||
bsAddStockModal.hide();
|
||||
showFormStatus('success',
|
||||
`<i class="bi bi-check-circle-fill me-1"></i>` +
|
||||
`Added <strong>${qty.toFixed(2)} ${data.unitOfMeasure}</strong> to <strong>${data.itemName}</strong>. ` +
|
||||
`New stock: ${(data.newQuantityOnHand ?? 0).toFixed(2)} ${data.unitOfMeasure}. ` +
|
||||
`<a href="/Inventory/Details/${_addStockItemId}" class="alert-link">View item</a>`
|
||||
);
|
||||
|
||||
} catch (err) {
|
||||
showAddStockStatus('danger', 'Error: ' + err.message);
|
||||
addStockConfirmBtn.disabled = false;
|
||||
addStockConfirmBtn.innerHTML = '<i class="bi bi-plus-circle me-1"></i>Add Stock';
|
||||
}
|
||||
}
|
||||
|
||||
function showAddStockStatus(type, msg) {
|
||||
if (!addStockStatusEl) return;
|
||||
addStockStatusEl.className = `alert alert-${type} py-2 small`;
|
||||
addStockStatusEl.textContent = msg;
|
||||
}
|
||||
|
||||
// ── Fill the inventory form from scan result ───────────────────────────
|
||||
|
||||
function fillFromScan(data, skipDuplicatePrompt = false) {
|
||||
const filled = [];
|
||||
|
||||
@@ -401,10 +492,12 @@
|
||||
}
|
||||
|
||||
const vendorSel = document.getElementById('field-vendor');
|
||||
const mfrName = document.getElementById('field-manufacturer')?.value || data.manufacturer;
|
||||
if (typeof window.matchInventoryVendor === 'function' &&
|
||||
window.matchInventoryVendor(vendorSel, mfrName, data.vendorName)) {
|
||||
filled.push('Vendor');
|
||||
if (vendorSel && !vendorSel.value && data.vendorName) {
|
||||
const needle = data.vendorName.toLowerCase();
|
||||
const match = Array.from(vendorSel.options).find(o =>
|
||||
o.text.toLowerCase().includes(needle) || needle.includes(o.text.toLowerCase().trim())
|
||||
);
|
||||
if (match) { vendorSel.value = match.value; filled.push('Vendor'); }
|
||||
}
|
||||
|
||||
const catalogNote = data.wasInCatalog
|
||||
@@ -413,8 +506,6 @@
|
||||
? ' <span class="badge bg-success ms-1">Added to platform catalog</span>'
|
||||
: '';
|
||||
|
||||
document.dispatchEvent(new CustomEvent('inventory:identity-changed'));
|
||||
|
||||
if (data.existingInventoryId && !skipDuplicatePrompt) {
|
||||
// Duplicate handled by add-stock modal — don't show a banner here
|
||||
} else if (data.existingInventoryId && skipDuplicatePrompt) {
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* Shared vendor-dropdown auto-select for the Inventory Create/Edit forms.
|
||||
*
|
||||
* Why this exists: catalog lookup, AI lookup, label scan, and manual manufacturer entry
|
||||
* all need to pick the right Vendor option, and they used to each carry their own copy of
|
||||
* the matching logic. They disagreed on WHAT to match on — the AI path keyed off the
|
||||
* price-derived `vendorName` (which is null unless a price was scraped), so the vendor
|
||||
* only got selected "sometimes". This centralizes the rule:
|
||||
*
|
||||
* For ~95% of powders the manufacturer IS the vendor (Prismatic, Columbia,
|
||||
* All Powder Paints, Tiger, Powder Buy The Pound). So match on the Manufacturer
|
||||
* field first — it's almost always populated — and only fall back to the
|
||||
* AI/catalog-supplied vendor name when the manufacturer is blank.
|
||||
*
|
||||
* Brands sold by more than one distributor (e.g. PPG, KP Pigments) are intentionally
|
||||
* skipped so the user picks the vendor manually rather than getting a wrong guess.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Brands carried by multiple distributors — never auto-pick a vendor for these.
|
||||
// Lowercase; matched as a substring against the manufacturer name. Extend as needed.
|
||||
const AMBIGUOUS_BRANDS = ['ppg', 'kp pigments', 'kp pigment'];
|
||||
|
||||
function normalize(s) {
|
||||
return (s || '').toLowerCase().trim();
|
||||
}
|
||||
|
||||
function isAmbiguousBrand(name) {
|
||||
const n = normalize(name);
|
||||
return n.length > 0 && AMBIGUOUS_BRANDS.some(b => n.includes(b));
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the vendor dropdown option that best matches a manufacturer/vendor name.
|
||||
*
|
||||
* @param {HTMLSelectElement} vendorSelect the #field-vendor element
|
||||
* @param {string} manufacturerName primary name to match on (the Manufacturer field)
|
||||
* @param {string} fallbackVendorName AI/catalog vendor name, used only if manufacturer is blank
|
||||
* @param {{force?: boolean}} [opts] force=true overrides an existing selection (bad-match retry)
|
||||
* @returns {boolean} true if a vendor option was selected.
|
||||
*/
|
||||
window.matchInventoryVendor = function (vendorSelect, manufacturerName, fallbackVendorName, opts) {
|
||||
opts = opts || {};
|
||||
if (!vendorSelect) return false;
|
||||
// Don't clobber a choice the user (or a prior fill) already made, unless forcing a re-fill.
|
||||
if (vendorSelect.value && !opts.force) return false;
|
||||
|
||||
// Manufacturer drives the match; the price-derived vendor name is only a fallback.
|
||||
const name = normalize(manufacturerName) || normalize(fallbackVendorName);
|
||||
if (!name) return false;
|
||||
|
||||
// Brands sold by multiple distributors stay manual — don't guess.
|
||||
if (isAmbiguousBrand(name)) return false;
|
||||
|
||||
const match = Array.from(vendorSelect.options).find(function (o) {
|
||||
const t = normalize(o.text);
|
||||
// Skip the placeholder and the "Add new vendor" sentinel; require a real name to
|
||||
// avoid spurious substring hits (e.g. empty option text matches everything).
|
||||
if (!o.value || o.value === '__new__' || t.length < 3) return false;
|
||||
return t.includes(name) || name.includes(t);
|
||||
});
|
||||
|
||||
if (match) { vendorSelect.value = match.value; return true; }
|
||||
return false;
|
||||
};
|
||||
})();
|
||||
@@ -88,17 +88,7 @@
|
||||
tips: ['Download the CSV template to see the expected columns',
|
||||
'Customers must exist before importing — matched by CustomerEmail then Customer name',
|
||||
'Existing invoices matched by InvoiceNumber are updated; new ones are created',
|
||||
'Line items import separately — run the Invoice Line Items import after this one'] },
|
||||
|
||||
{ key: 'csv-invoiceitems',
|
||||
label: 'Invoice Line Items', icon: 'bi-list-ul', color: '#0e7490',
|
||||
desc: 'Invoice line items with revenue account attribution',
|
||||
dir: ['import'], fmt: ['csv'],
|
||||
endpoint: '/Tools/CsvImportInvoiceItems', accept: '.csv',
|
||||
template: '/Tools/DownloadInvoiceItemTemplate',
|
||||
tips: ['Import invoices first — each line is matched to its parent by InvoiceNumber',
|
||||
'RevenueAccountNumber is optional and matched against your Chart of Accounts',
|
||||
'Re-running is safe — duplicate lines (same description + total + order) are skipped'] },
|
||||
'Line items are not part of the CSV — this imports invoice headers and totals only'] },
|
||||
|
||||
{ key: 'csv-appointments',
|
||||
label: 'Appointments', icon: 'bi-calendar-check', color: '#2563eb',
|
||||
@@ -167,53 +157,6 @@
|
||||
'Valid PaymentMethod values: Cash, Check, CreditDebitCard, BankTransferACH, DigitalPayment',
|
||||
'Invoice AmountPaid and status are updated automatically after each payment'] },
|
||||
|
||||
{ key: 'csv-bills',
|
||||
label: 'Bills (AP)', icon: 'bi-file-earmark-ruled', color: '#b45309',
|
||||
desc: 'Vendor bill headers — vendor by name, AP account by number',
|
||||
dir: ['import'], fmt: ['csv'],
|
||||
endpoint: '/Tools/CsvImportBills', accept: '.csv',
|
||||
tips: ['Import Chart of Accounts and Vendors first',
|
||||
'VendorName matches a vendor; APAccountNumber matches your Chart of Accounts',
|
||||
'Existing bills matched by BillNumber are skipped',
|
||||
'Import bill line items separately after this'] },
|
||||
|
||||
{ key: 'csv-billlineitems',
|
||||
label: 'Bill Line Items', icon: 'bi-list-ul', color: '#b45309',
|
||||
desc: 'Bill line items with expense account attribution',
|
||||
dir: ['import'], fmt: ['csv'],
|
||||
endpoint: '/Tools/CsvImportBillLineItems', accept: '.csv',
|
||||
tips: ['Import bills first — lines are matched to their parent by BillNumber',
|
||||
'AccountNumber (expense/asset) and JobNumber are optional',
|
||||
'Re-running is safe — duplicate lines are skipped'] },
|
||||
|
||||
{ key: 'csv-deposits',
|
||||
label: 'Customer Deposits', icon: 'bi-piggy-bank', color: '#059669',
|
||||
desc: 'Deposits with customer, bank account, and applied invoice',
|
||||
dir: ['import'], fmt: ['csv'],
|
||||
endpoint: '/Tools/CsvImportDeposits', accept: '.csv',
|
||||
tips: ['Import Customers and Chart of Accounts first',
|
||||
'CustomerName matches a customer; DepositAccountNumber matches your Chart of Accounts',
|
||||
'AppliedToInvoiceNumber is optional — links the deposit to an invoice',
|
||||
'Existing deposits matched by ReceiptNumber are skipped'] },
|
||||
|
||||
{ key: 'csv-journalentries',
|
||||
label: 'Journal Entries', icon: 'bi-journal-bookmark', color: '#374151',
|
||||
desc: 'Journal entry headers — import lines separately after',
|
||||
dir: ['import'], fmt: ['csv'],
|
||||
endpoint: '/Tools/CsvImportJournalEntries', accept: '.csv',
|
||||
tips: ['Existing entries matched by EntryNumber are skipped',
|
||||
'Valid Status values: Draft, Posted, Reversed',
|
||||
'Import the entry lines separately after this'] },
|
||||
|
||||
{ key: 'csv-journalentrylines',
|
||||
label: 'Journal Entry Lines', icon: 'bi-list-columns', color: '#374151',
|
||||
desc: 'Debit/credit lines with account attribution',
|
||||
dir: ['import'], fmt: ['csv'],
|
||||
endpoint: '/Tools/CsvImportJournalEntryLines', accept: '.csv',
|
||||
tips: ['Import journal entries and Chart of Accounts first',
|
||||
'Lines are matched to their entry by EntryNumber; AccountNumber is required',
|
||||
'Each line is a debit or a credit — entries should balance'] },
|
||||
|
||||
{ key: 'csv-purchaseorders',
|
||||
label: 'Purchase Orders', icon: 'bi-cart', color: '#6b7280',
|
||||
desc: 'Purchase order headers with vendor, status, and totals',
|
||||
@@ -261,41 +204,11 @@
|
||||
desc: 'Invoice headers, amounts, status, and customer info',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportInvoicesCsv' },
|
||||
|
||||
{ key: 'exp-invoiceitems',
|
||||
label: 'Invoice Line Items', icon: 'bi-list-ul', color: '#0e7490',
|
||||
desc: 'Line items with revenue account, keyed by invoice number',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportInvoiceItemsCsv' },
|
||||
|
||||
{ key: 'exp-payments',
|
||||
label: 'Payments', icon: 'bi-cash-coin', color: '#059669',
|
||||
desc: 'Invoice payment records with method and reference',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportPaymentsCsv' },
|
||||
|
||||
{ key: 'exp-bills',
|
||||
label: 'Bills (AP)', icon: 'bi-file-earmark-ruled', color: '#b45309',
|
||||
desc: 'Vendor bill headers with vendor and AP account',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportBillsCsv' },
|
||||
|
||||
{ key: 'exp-billlineitems',
|
||||
label: 'Bill Line Items', icon: 'bi-list-ul', color: '#b45309',
|
||||
desc: 'Bill line items with expense account, keyed by bill number',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportBillLineItemsCsv' },
|
||||
|
||||
{ key: 'exp-deposits',
|
||||
label: 'Customer Deposits', icon: 'bi-piggy-bank', color: '#059669',
|
||||
desc: 'Deposits with customer, bank account, and applied invoice',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportDepositsCsv' },
|
||||
|
||||
{ key: 'exp-journalentries',
|
||||
label: 'Journal Entries', icon: 'bi-journal-bookmark', color: '#374151',
|
||||
desc: 'Journal entry headers with reference and status',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportJournalEntriesCsv' },
|
||||
|
||||
{ key: 'exp-journalentrylines',
|
||||
label: 'Journal Entry Lines', icon: 'bi-list-columns', color: '#374151',
|
||||
desc: 'Debit/credit lines with account, keyed by entry number',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportJournalEntryLinesCsv' },
|
||||
|
||||
{ key: 'exp-appointments',
|
||||
label: 'Appointments', icon: 'bi-calendar-check', color: '#d97706',
|
||||
desc: 'Customer, type, status, and scheduling details',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user