Compare commits

..

1 Commits

Author SHA1 Message Date
spouliot f671f7e62e Add WisePOS E in-person card payments (Stripe Terminal)
Server-driven Stripe Terminal integration for taking in-person card payments
against an invoice, running on the same Stripe Connect connected account used
for online payments. No native app or Terminal SDK — the WisePOS E is driven
from the web backend via Stripe's REST API.

- Domain: TerminalReader entity + status enum, PaymentMethod.CardReader,
  Company.StripeTerminalLocationId / TerminalSurchargeEnabled, DbSet + tenant
  filter + indexes, IUnitOfWork repo, migration AddTerminalReaders (additive).
- StripeConnectService: location/reader registration, list, delete, process
  payment on reader, status poll, cancel, and a test-mode simulated tap. All
  routed to the connected account like the existing online-payment methods.
- TerminalController: admin reader management + per-invoice ProcessPayment,
  PaymentStatus (poll), CancelPayment, SimulateTap (test mode only). Stores the
  PaymentIntent id on the invoice; the webhook remains the authoritative writer.
- PaymentController webhook: HandlePaymentSucceededAsync records source=terminal
  payments as CardReader (online path unchanged — no source key means no change);
  new terminal.reader.action_failed handler for declines/timeouts (notification
  only, no ledger mutation). Refund path reused unchanged.
- UI: Card Readers settings tab (register/list/deactivate + in-person surcharge
  toggle, default off with a compliance warning) and an invoice "Take Card
  Payment" modal with live status polling. External JS per project convention.
- Feature bundled with the existing online-payments entitlement (no new plan
  flag); additionally requires StripeConnectStatus == Active.
- Help: HelpKnowledgeBase + Invoices help article updated.
- Tests: TerminalController validation + surcharge-routing tests (241 pass).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 18:57:58 -04:00
149 changed files with 2779 additions and 77381 deletions
-285
View File
@@ -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 13) — **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 O1O9 + 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 #13/#5/#6/#8 remains unrecoverable (see top). Nothing merged to `master` yet.
-86
View File
@@ -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.
-118
View File
@@ -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 O1O8 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 O1O8 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 01 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.
-130
View File
@@ -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
@@ -1,46 +0,0 @@
namespace PowderCoating.Application.Constants;
/// <summary>
/// Central constants for the Columbia Coatings catalog integration — config keys, platform-setting
/// keys, and the derived provenance/manufacturer/category values the mapper assigns. Kept in one
/// place so the API client, sync mapper, scheduled job, and purge logic all agree on the strings.
/// </summary>
public static class ColumbiaIntegrationConstants
{
// ── Configuration keys (appsettings.json / environment) ───────────────
/// <summary>API key — secret, lives in config not the platform-settings DB.</summary>
public const string ConfigApiKey = "Columbia:ApiKey";
public const string ConfigBaseUrl = "Columbia:BaseUrl";
/// <summary>Configurable API namespace/base path, so an API version bump is a config change.</summary>
public const string ConfigApiBasePath = "Columbia:ApiBasePath";
public const string DefaultBaseUrl = "https://columbiacoatings.com";
public const string DefaultApiBasePath = "/wp-json/cca/v1";
/// <summary>Resource segment appended to the API base path for product endpoints.</summary>
public const string ProductsResource = "/products";
/// <summary>API caps per_page at 100.</summary>
public const int MaxPerPage = 100;
// ── Platform setting keys (SuperAdmin-managed, non-secret) ────────────
public const string SettingEnabled = "ColumbiaSyncEnabled";
public const string SettingIntervalDays = "ColumbiaSyncIntervalDays";
public const string SettingLastSyncedAt = "ColumbiaLastSyncedAt";
public const string SettingLastResult = "ColumbiaLastSyncResult";
public const int DefaultSyncIntervalDays = 7;
// ── Provenance ────────────────────────────────────────────────────────
/// <summary>Stored in <c>PowderCatalogItem.Source</c> — the purge key for right-to-delete.</summary>
public const string SourceName = "Columbia Coatings API";
// ── Derived manufacturers (PowderCatalogItem.VendorName) ──────────────
public const string ManufacturerColumbia = "Columbia Coatings";
public const string ManufacturerPpg = "PPG";
public const string ManufacturerKp = "KP Pigments";
// ── Derived category (PowderCatalogItem.Category) ─────────────────────
public const string CategoryPowderAdditives = "Powder Additives";
}
@@ -154,49 +154,6 @@ public class TrialBalanceLine
public decimal CreditBalance { get; set; } 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 ───────────────────────────────────────────────────────────── // ── Profit & Loss ─────────────────────────────────────────────────────────────
public class ProfitAndLossDto public class ProfitAndLossDto
@@ -1,49 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace PowderCoating.Application.DTOs.Columbia;
/// <summary>
/// Tolerant converter for Columbia image fields. WordPress/WooCommerce returns an object
/// (<c>{id,src,name,alt}</c>) when an image is present, but an empty array (<c>[]</c>) — or
/// sometimes <c>false</c>/empty string — when it is not. A single <see cref="ColumbiaImage"/>
/// can't bind to those non-object forms, so this converter reads the object when present and
/// yields null for anything else (consuming the token so deserialization continues).
/// </summary>
public class ColumbiaImageJsonConverter : JsonConverter<ColumbiaImage?>
{
public override ColumbiaImage? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
switch (reader.TokenType)
{
case JsonTokenType.StartObject:
using (var doc = JsonDocument.ParseValue(ref reader))
{
var el = doc.RootElement;
return new ColumbiaImage
{
Id = el.TryGetProperty("id", out var id) && id.TryGetInt32(out var i) ? i : 0,
Src = GetString(el, "src"),
Name = GetString(el, "name"),
Alt = GetString(el, "alt"),
};
}
case JsonTokenType.StartArray:
reader.Skip(); // empty/non-empty array form means "no image"
return null;
default:
// Primitive (false / "" / null / number): nothing to consume further.
return null;
}
}
public override void Write(Utf8JsonWriter writer, ColumbiaImage? value, JsonSerializerOptions options)
=> throw new NotSupportedException("Columbia image fields are read-only.");
private static string GetString(JsonElement el, string property) =>
el.TryGetProperty(property, out var v) && v.ValueKind == JsonValueKind.String
? v.GetString() ?? string.Empty
: string.Empty;
}
@@ -1,142 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace PowderCoating.Application.DTOs.Columbia;
/// <summary>
/// One page of the Columbia Coatings <c>GET /products</c> response: a list of products plus
/// pagination metadata. Property names are snake_case in the API and bound via the snake-case
/// naming policy configured on the client's <see cref="JsonSerializerOptions"/>.
/// </summary>
public class ColumbiaProductsResponse
{
public List<ColumbiaProduct> Items { get; set; } = new();
public ColumbiaPagination? Pagination { get; set; }
}
/// <summary>Pagination block returned alongside a product page.</summary>
public class ColumbiaPagination
{
public int Page { get; set; }
public int PerPage { get; set; }
public int Total { get; set; }
public int TotalPages { get; set; }
}
/// <summary>
/// A single Columbia Coatings product as returned by the API. This mirrors the wire shape, not our
/// catalog model — mapping into <c>PowderCatalogItem</c> happens in the sync mapper. Prices arrive
/// as strings; cure/spec fields are free text; documents are direct URLs.
/// </summary>
public class ColumbiaProduct
{
public int Id { get; set; }
/// <summary>"simple" or "variable". Variable products carry packaging/size variants in
/// <see cref="VariationPricing"/> and leave <see cref="TieredPricing"/> as an empty array.</summary>
public string ProductType { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Slug { get; set; } = string.Empty;
public string Sku { get; set; } = string.Empty;
public string Permalink { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
/// <summary>Columbia-specific values seen include "In Stock"/"instock", "formulated",
/// "drop_shipped", "multiple_variations", "outofstock", "onbackorder" — mixed casing.</summary>
public string StockStatus { get; set; } = string.Empty;
// ── Pricing (all strings on the wire) ─────────────────────────────────
public string Price { get; set; } = string.Empty;
public string RegularPrice { get; set; } = string.Empty;
public string SalePrice { get; set; } = string.Empty;
/// <summary>
/// Quantity-break pricing. POLYMORPHIC on the wire: an object
/// (<c>{type, minimum_quantity, tiers:[...]}</c>) on simple products, but an empty ARRAY
/// (<c>[]</c>) on variable products. Captured as a nullable raw <see cref="JsonElement"/> so
/// deserialization never throws on the type mismatch and an absent value is null (not an
/// invalid <c>Undefined</c> element); the mapper interprets it.
/// </summary>
public JsonElement? TieredPricing { get; set; }
/// <summary>Per-variant pricing for variable products (e.g. Bulk vs 1 lb Bags, or gram sizes).
/// Each variant has its own SKU and price. Empty for simple products.</summary>
public List<ColumbiaVariationPricing>? VariationPricing { get; set; }
// ── Documents (direct URLs) ───────────────────────────────────────────
public string SafetyDataSheet { get; set; } = string.Empty;
public string TechnicalDataSheet { get; set; } = string.Empty;
public string ProductFlyer { get; set; } = string.Empty;
public string ProductBrochure { get; set; } = string.Empty;
// ── Coating spec free-text ────────────────────────────────────────────
public string CureSchedule { get; set; } = string.Empty;
public string MilThickness { get; set; } = string.Empty;
/// <summary>Resin chemistry (e.g. "Polyester/TGIC", "TGIC", "Epoxy"). NOT finish/gloss.</summary>
public string Type { get; set; } = string.Empty;
public string ReleaseDate { get; set; } = string.Empty;
public string FormulationDate { get; set; } = string.Empty;
/// <summary>Free-text reformulation log, e.g. "Formulation Change: 05/22/26".</summary>
public string FormulationDateChanges { get; set; } = string.Empty;
// ── Content ───────────────────────────────────────────────────────────
/// <summary>HTML product description (WordPress markup).</summary>
public string Description { get; set; } = string.Empty;
public string ShortDescription { get; set; } = string.Empty;
public ColumbiaImage? FeaturedImage { get; set; }
// ── Taxonomy (arrays of {name}) — used at import to derive manufacturer/category, not stored raw ──
public List<ColumbiaNamed> Categories { get; set; } = new();
public List<ColumbiaNamed> Tags { get; set; } = new();
public List<ColumbiaNamed> PaColorGroup { get; set; } = new();
public List<ColumbiaAttribute> Attributes { get; set; } = new();
}
/// <summary>A pricing variant of a variable product (own SKU, own price/tiers).</summary>
public class ColumbiaVariationPricing
{
public int Id { get; set; }
public string Sku { get; set; } = string.Empty;
public List<ColumbiaAttributeValue> Attributes { get; set; } = new();
public string StockStatus { get; set; } = string.Empty;
public string Price { get; set; } = string.Empty;
public string RegularPrice { get; set; } = string.Empty;
public string SalePrice { get; set; } = string.Empty;
/// <summary>Same polymorphic object-or-array shape as the parent; captured raw (nullable).</summary>
public JsonElement? TieredPricing { get; set; }
}
/// <summary>An image object — featured or gallery.</summary>
public class ColumbiaImage
{
public int Id { get; set; }
public string Src { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Alt { get; set; } = string.Empty;
}
/// <summary>A simple <c>{name}</c> taxonomy entry (category, tag, or color group).</summary>
public class ColumbiaNamed
{
public string Name { get; set; } = string.Empty;
}
/// <summary>A product attribute with its option list, e.g. Color Group → [Blue], Packaging → [Bulk, 1 lb Bags].</summary>
public class ColumbiaAttribute
{
public string Name { get; set; } = string.Empty;
public List<ColumbiaNamed> Options { get; set; } = new();
}
/// <summary>A resolved attribute value on a specific variation, e.g. Packaging → "Bulk".</summary>
public class ColumbiaAttributeValue
{
public string Name { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
}
@@ -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")] [Name("PaymentMethod")]
public string PaymentMethod { get; set; } = "Cash"; 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")] [Name("Reference")]
public string? Reference { get; set; } public string? Reference { get; set; }
@@ -37,8 +37,6 @@ public class InventoryItemDto
public decimal AverageCost { get; set; } public decimal AverageCost { get; set; }
public decimal LastPurchasePrice { get; set; } public decimal LastPurchasePrice { get; set; }
public DateTime? LastPurchaseDate { get; set; } public DateTime? LastPurchaseDate { get; set; }
public decimal? CatalogReferencePrice { get; set; }
public DateTime? CatalogPriceUpdatedAt { get; set; }
public int? PrimaryVendorId { get; set; } public int? PrimaryVendorId { get; set; }
public string? PrimaryVendorName { get; set; } public string? PrimaryVendorName { get; set; }
public string? VendorPartNumber { get; set; } public string? VendorPartNumber { get; set; }
@@ -225,12 +223,6 @@ public class CreateInventoryItemDto
[Display(Name = "Incoming / On Order")] [Display(Name = "Incoming / On Order")]
public bool IsIncoming { get; set; } 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 public class UpdateInventoryItemDto : CreateInventoryItemDto
@@ -37,7 +37,6 @@ public class PaymentDtos
public string? Reference { get; set; } public string? Reference { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
public int? DepositAccountId { get; set; } public int? DepositAccountId { get; set; }
public bool SuppressNotification { get; set; }
} }
public class EditPaymentDto public class EditPaymentDto
@@ -0,0 +1,29 @@
namespace PowderCoating.Application.DTOs.Terminal
{
/// <summary>
/// Minimal postal address used to create a Stripe Terminal Location. Kept in the Application
/// layer so <c>IStripeConnectService</c> doesn't leak Stripe SDK types to controllers.
/// </summary>
public class TerminalAddressDto
{
public string Line1 { get; set; } = string.Empty;
public string City { get; set; } = string.Empty;
public string State { get; set; } = string.Empty;
public string PostalCode { get; set; } = string.Empty;
public string Country { get; set; } = "US";
}
/// <summary>
/// A Stripe Terminal reader as returned by the Stripe API, projected to a plain DTO for the
/// settings page and reconciliation. Stripe remains the source of truth for live network status.
/// </summary>
public class TerminalReaderDto
{
public string StripeReaderId { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
public string DeviceType { get; set; } = string.Empty;
public string? SerialNumber { get; set; }
public string? NetworkStatus { get; set; } // "online" / "offline"
public DateTime? LastSeenAt { get; set; }
}
}
@@ -1,41 +0,0 @@
namespace PowderCoating.Application.Interfaces;
/// <summary>
/// Orchestrates a full Columbia Coatings catalog sync: pull every product, map and upsert it, then
/// (only on a complete pull) reconcile discontinuations. Used by both the scheduled background job
/// and the manual "Sync now" admin action.
/// </summary>
public interface IColumbiaCatalogSyncService
{
/// <summary>
/// Runs one full sync. Assumes the caller has already decided it should run (enabled / due).
/// Returns a result describing the outcome; never throws for an expected failure (not
/// configured, partial pull, HTTP error) — those are reported on the result instead.
/// </summary>
Task<ColumbiaSyncResult> RunSyncAsync(CancellationToken cancellationToken = default);
}
/// <summary>Outcome of a Columbia catalog sync run.</summary>
public class ColumbiaSyncResult
{
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
public int TotalFetched { get; set; }
public int Inserted { get; set; }
public int Updated { get; set; }
public int Unchanged { get; set; }
public int Skipped { get; set; }
public int Discontinued { get; set; }
public int Reactivated { get; set; }
public DateTime StartedAt { get; set; }
public TimeSpan Duration { get; set; }
/// <summary>One-line summary suitable for storing in the last-result platform setting / UI.</summary>
public string Summary =>
Success
? $"{TotalFetched} fetched: {Inserted} new, {Updated} updated, {Unchanged} unchanged, " +
$"{Discontinued} discontinued, {Reactivated} reactivated ({Duration.TotalSeconds:F0}s)"
: $"Failed: {ErrorMessage}";
}
@@ -1,47 +0,0 @@
using PowderCoating.Application.DTOs.Columbia;
namespace PowderCoating.Application.Interfaces;
/// <summary>
/// Typed client for the Columbia Coatings product catalog API (<c>/wp-json/cca/v1</c>).
/// Read-only: lists products via the paged <c>GET /products</c> endpoint.
/// <para>
/// We deliberately page <c>/products</c> rather than using the bulk <c>export.json</c> download:
/// the export returns a temporary <c>download_url</c> to a static file under <c>/wp-content/uploads</c>,
/// which sits behind Cloudflare bot protection and 403s for non-browser clients. The
/// <c>/wp-json</c> API routes are allowlisted via the API key, so paging is the only path that
/// works reliably from a server.
/// </para>
/// </summary>
public interface IColumbiaCoatingsApiClient
{
/// <summary>
/// True when an API key is configured (<c>Columbia:ApiKey</c>). When false, callers should
/// skip the sync entirely rather than issue unauthenticated requests.
/// </summary>
bool IsConfigured { get; }
/// <summary>
/// Retrieves a single page of products. <paramref name="perPage"/> is capped at 100 by the API.
/// </summary>
Task<ColumbiaProductsResponse> GetProductsPageAsync(int page, int perPage, CancellationToken cancellationToken = default);
/// <summary>
/// Pages through the entire catalog and returns every product. Honors rate limiting
/// (429 / Retry-After). THROWS if any page fails after retries — callers must treat an
/// exception as "incomplete pull" and NOT run discontinuation logic against a partial set.
/// </summary>
Task<IReadOnlyList<ColumbiaProduct>> GetAllProductsAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Fetches a single product by exact SKU (<c>GET /products?sku=...</c>), or null if not found.
/// For ad-hoc refresh of one record without pulling the whole catalog.
/// </summary>
Task<ColumbiaProduct?> GetProductBySkuAsync(string sku, CancellationToken cancellationToken = default);
/// <summary>
/// Fetches a single product by WooCommerce product ID (<c>GET /products/{id}</c>), or null if
/// not found. Useful when we already store the catalog product's ID and want to refresh it.
/// </summary>
Task<ColumbiaProduct?> GetProductByIdAsync(int id, CancellationToken cancellationToken = default);
}
@@ -179,36 +179,10 @@ public interface ICsvImportService
/// <summary> /// <summary>
/// Import invoice headers from a CSV stream. Customers are resolved by CustomerEmail then /// Import invoice headers from a CSV stream. Customers are resolved by CustomerEmail then
/// CustomerName. Duplicate detection uses InvoiceNumber as the unique key. Existing invoices /// CustomerName. Duplicate detection uses InvoiceNumber as the unique key. Existing invoices
/// are updated; new ones are created. Line items are imported separately via /// are updated; new ones are created. Line items are not part of the CSV format.
/// <see cref="ImportInvoiceItemsAsync"/>.
/// </summary> /// </summary>
Task<CsvImportResultDto> ImportInvoicesAsync(Stream csvStream, int companyId); 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> /// <summary>Generate a CSV template file for payment imports.</summary>
byte[] GeneratePaymentTemplate(); byte[] GeneratePaymentTemplate();
@@ -33,12 +33,6 @@ public interface IFinancialReportService
/// <summary>Returns a Trial Balance using current account balances as of the given date.</summary> /// <summary>Returns a Trial Balance using current account balances as of the given date.</summary>
Task<TrialBalanceDto> GetTrialBalanceAsync(int companyId, DateTime asOf); 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> /// <summary>Looks up the accounting method configured for the given company. Returns Accrual if not found.</summary>
Task<AccountingMethod> GetCompanyAccountingMethodAsync(int companyId); Task<AccountingMethod> GetCompanyAccountingMethodAsync(int companyId);
@@ -59,17 +59,9 @@ public interface IInventoryAiLookupService
Task<InventoryAiLookupResult> ScanLabelAsync(string base64Image, string mediaType); Task<InventoryAiLookupResult> ScanLabelAsync(string base64Image, string mediaType);
/// <summary> /// <summary>
/// Fetches a Technical Data Sheet URL and extracts cure temperature, cure time, and specific /// Fetches a Technical Data Sheet URL and extracts cure temperature and cure time.
/// gravity. Called when the main lookup found a TDS URL but specs are still missing. /// Called when the main lookup found a TDS URL but cure specs are still missing.
/// Returns Success=false silently (no UI error) when the TDS is a PDF or unreachable. /// Returns Success=false silently (no UI error) when the TDS is a PDF or unreachable.
/// </summary> /// </summary>
Task<InventoryAiLookupResult> FetchTdsCureSpecsAsync(string tdsUrl, string? colorName); Task<InventoryAiLookupResult> FetchTdsCureSpecsAsync(string tdsUrl, string? colorName);
/// <summary>
/// Lazily fills a powder catalog item's specific gravity (and any missing cure specs) from its
/// TDS the first time it's needed, then derives theoretical coverage. No-op when specific
/// gravity is already known or no TDS URL is present. Persists the enrichment to the catalog so
/// it's done once and benefits every future use. Returns true if anything was filled.
/// </summary>
Task<bool> EnsureCatalogTdsSpecsAsync(PowderCoating.Core.Entities.PowderCatalogItem catalog);
} }
@@ -1,32 +0,0 @@
using PowderCoating.Core.Entities;
namespace PowderCoating.Application.Interfaces;
/// <summary>
/// Shared upsert for the platform powder catalog: matches incoming records to existing rows by
/// (VendorName, SKU), inserts new ones, and updates changed ones in place. Used by BOTH the manual
/// JSON file import and the Columbia API sync so there is a single upsert path, only the mapping
/// differs. Does NOT handle discontinuation — that is a sync-specific concern.
/// </summary>
public interface IPowderCatalogUpsertService
{
/// <summary>
/// Applies <paramref name="incoming"/> mapped catalog items. Only fields sourced from the feed
/// are copied on update; enrichment fields (specific gravity, coverage, transfer efficiency,
/// finish) are preserved so they are not wiped by a feed that never carries them. Changed and
/// inserted rows get <paramref name="runTimestamp"/> stamped on LastSyncedAt/UpdatedAt.
/// </summary>
Task<PowderCatalogUpsertResult> UpsertAsync(
IReadOnlyList<PowderCatalogItem> incoming,
DateTime runTimestamp,
CancellationToken cancellationToken = default);
}
/// <summary>Counts from an upsert run.</summary>
public class PowderCatalogUpsertResult
{
public int Inserted { get; set; }
public int Updated { get; set; }
public int Unchanged { get; set; }
public int Skipped { get; set; }
}
@@ -1,3 +1,5 @@
using PowderCoating.Application.DTOs.Terminal;
namespace PowderCoating.Application.Interfaces; namespace PowderCoating.Application.Interfaces;
public interface IStripeConnectService public interface IStripeConnectService
@@ -34,4 +36,75 @@ public interface IStripeConnectService
string currency, string currency,
string quoteNumber, string quoteNumber,
int quoteId); int quoteId);
// ----- Stripe Terminal (in-person card payments, WisePOS E) -----
// All methods route to the connected account via RequestOptions.StripeAccount, mirroring the
// online payment methods above. They return structured tuples instead of throwing.
/// <summary>
/// Creates the shop's single Stripe Terminal Location (one per company) from its address.
/// Readers must be attached to a Location. Returns the new Location id (tml_xxx).
/// </summary>
Task<(bool Success, string? LocationId, string? ErrorMessage)> CreateTerminalLocationAsync(
string connectedAccountId,
string displayName,
TerminalAddressDto address);
/// <summary>
/// Registers a physical (or simulated) reader to the shop's Location using the registration
/// code shown on the device. Returns the reader id (tmr_xxx), device type and serial number.
/// </summary>
Task<(bool Success, string? ReaderId, string? DeviceType, string? SerialNumber, string? ErrorMessage)> RegisterReaderAsync(
string connectedAccountId,
string locationId,
string registrationCode,
string label);
/// <summary>Lists the readers attached to the shop's Location (for status refresh/reconciliation).</summary>
Task<(bool Success, IReadOnlyList<TerminalReaderDto> Readers, string? ErrorMessage)> ListReadersAsync(
string connectedAccountId,
string locationId);
/// <summary>Unregisters (deletes) a reader from Stripe.</summary>
Task<(bool Success, string? ErrorMessage)> DeleteReaderAsync(
string connectedAccountId,
string readerId);
/// <summary>
/// Creates a card_present PaymentIntent for an invoice and pushes it to the physical reader,
/// which then prompts the customer to tap/insert/swipe. Metadata carries <c>source=terminal</c>
/// so the existing <c>payment_intent.succeeded</c> webhook records it as a card-reader payment.
/// Returns the PaymentIntent id so the caller can store it on the invoice for idempotency.
/// </summary>
Task<(bool Success, string? PaymentIntentId, string? ErrorMessage)> ProcessInvoicePaymentOnReaderAsync(
string connectedAccountId,
string readerId,
decimal amount,
decimal surchargeAmount,
string currency,
string invoiceNumber,
int invoiceId);
/// <summary>
/// Reads the reader's current action status for live UI feedback. The authoritative payment
/// record is still created by the webhook — this is only for showing progress to the clerk.
/// </summary>
Task<(bool Success, string? ActionStatus, string? ActionType, string? PaymentIntentId,
string? FailureCode, string? FailureMessage, string? NetworkStatus, string? ErrorMessage)> GetReaderStatusAsync(
string connectedAccountId,
string readerId);
/// <summary>Cancels the reader's in-progress action (clerk cancelled or wants to retry).</summary>
Task<(bool Success, string? ErrorMessage)> CancelReaderActionAsync(
string connectedAccountId,
string readerId);
/// <summary>
/// TEST MODE ONLY: simulates a card tap on a simulated reader so the payment can complete
/// without physical hardware. Uses the Stripe TestHelpers Terminal API. Callers must guard
/// this to test mode; it is a no-op against real readers.
/// </summary>
Task<(bool Success, string? ErrorMessage)> SimulatePresentPaymentMethodAsync(
string connectedAccountId,
string readerId);
} }
@@ -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();
}
}
@@ -149,12 +149,9 @@ public class PricingCalculationService : IPricingCalculationService
try try
{ {
var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(coat.InventoryItemId.Value); var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(coat.InventoryItemId.Value);
// Prefer the current catalog price (replacement cost) so quotes reflect the latest if (inventoryItem != null && inventoryItem.UnitCost > 0)
// price; fall back to the item's own cost when it isn't catalog-linked.
var effectiveCostPerLb = inventoryItem?.CatalogReferencePrice ?? inventoryItem?.UnitCost ?? 0m;
if (inventoryItem != null && effectiveCostPerLb > 0)
{ {
costPerLb = effectiveCostPerLb; costPerLb = inventoryItem.UnitCost;
isIncomingPowder = inventoryItem.IsIncoming; isIncomingPowder = inventoryItem.IsIncoming;
var coverage = coat.CoverageSqFtPerLb; var coverage = coat.CoverageSqFtPerLb;
var transferEfficiency = coat.TransferEfficiency; var transferEfficiency = coat.TransferEfficiency;
@@ -163,8 +160,8 @@ public class PricingCalculationService : IPricingCalculationService
var actualPoundsPerSqFt = poundsPerSqFt / (transferEfficiency / 100m); var actualPoundsPerSqFt = poundsPerSqFt / (transferEfficiency / 100m);
powderCostPerSqFt = actualPoundsPerSqFt * costPerLb; powderCostPerSqFt = actualPoundsPerSqFt * costPerLb;
_logger.LogInformation("Coat {CoatName}: Using inventory item: {InventoryItem} (IsIncoming={IsIncoming}), CostPerLb={CostPerLb}/lb (catalog ref={CatalogRef}), Coverage={Coverage}sqft/lb, Efficiency={Efficiency}%, Calculated={CalcCost}/sqft", _logger.LogInformation("Coat {CoatName}: Using inventory item: {InventoryItem} (IsIncoming={IsIncoming}), UnitCost={UnitCost}/lb, Coverage={Coverage}sqft/lb, Efficiency={Efficiency}%, Calculated={CalcCost}/sqft",
coat.CoatName, inventoryItem.Name, isIncomingPowder, costPerLb, inventoryItem.CatalogReferencePrice, coverage, transferEfficiency, powderCostPerSqFt); coat.CoatName, inventoryItem.Name, isIncomingPowder, inventoryItem.UnitCost, coverage, transferEfficiency, powderCostPerSqFt);
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -694,8 +691,7 @@ public class PricingCalculationService : IPricingCalculationService
var invItem = await _unitOfWork.InventoryItems.GetByIdAsync(c.InventoryItemId.Value); var invItem = await _unitOfWork.InventoryItems.GetByIdAsync(c.InventoryItemId.Value);
if (invItem?.IsIncoming == true) if (invItem?.IsIncoming == true)
{ {
// Bill the powder-to-order at the current catalog price when linked. customPowderOrderAmount += c.PowderToOrder.Value * invItem.UnitCost;
customPowderOrderAmount += c.PowderToOrder.Value * (invItem.CatalogReferencePrice ?? invItem.UnitCost);
var colorName = !string.IsNullOrWhiteSpace(c.ColorName) ? c.ColorName : invItem.Name; var colorName = !string.IsNullOrWhiteSpace(c.ColorName) ? c.ColorName : invItem.Name;
if (!string.IsNullOrWhiteSpace(colorName)) if (!string.IsNullOrWhiteSpace(colorName))
customPowderOrderColors.Add(colorName); customPowderOrderColors.Add(colorName);
@@ -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);
}
}
@@ -45,6 +45,11 @@ public class Company : BaseEntity
public decimal OnlinePaymentSurchargeValue { get; set; } = 0; // % or flat $ depending on type public decimal OnlinePaymentSurchargeValue { get; set; } = 0; // % or flat $ depending on type
public bool OnlineSurchargeAcknowledged { get; set; } = false; // shop accepted compliance disclaimer public bool OnlineSurchargeAcknowledged { get; set; } = false; // shop accepted compliance disclaimer
// Stripe Terminal — in-person card payments (WisePOS E). Runs on the same connected account
// as online payments; a single Terminal Location is created once per shop from its address.
public string? StripeTerminalLocationId { get; set; } // tml_xxx
public bool TerminalSurchargeEnabled { get; set; } = false; // default OFF — in-person surcharge rules vary by state
/// <summary>Internal notes about manual subscription changes (not shown to the company).</summary> /// <summary>Internal notes about manual subscription changes (not shown to the company).</summary>
public string? SubscriptionNotes { get; set; } public string? SubscriptionNotes { get; set; }
@@ -18,18 +18,6 @@ public class CompanyPreferences : BaseEntity
public string InvoiceNumberPrefix { get; set; } = "INV"; public string InvoiceNumberPrefix { get; set; } = "INV";
public bool UseMetricSystem { get; set; } = false; // False = Imperial (ft, lb), True = Metric (m, kg) 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 // Job / Workflow Defaults
public string DefaultJobPriority { get; set; } = "Normal"; public string DefaultJobPriority { get; set; } = "Normal";
public bool RequireCustomerPO { get; set; } = false; public bool RequireCustomerPO { get; set; } = false;
@@ -31,27 +31,6 @@ public class InventoryItem : BaseEntity
public string? SdsUrl { get; set; } // Safety Data Sheet URL (from powder catalog or manual entry) public string? SdsUrl { get; set; } // Safety Data Sheet URL (from powder catalog or manual entry)
public string? TdsUrl { get; set; } // Technical Data Sheet URL (from powder catalog or manual entry) public string? TdsUrl { get; set; } // Technical Data Sheet URL (from powder catalog or manual entry)
/// <summary>
/// Optional link to the platform powder catalog record this item was created from.
/// Populated when an item is added via the catalog lookup, or back-filled by Manufacturer+SKU.
/// Lets the inventory detail screen surface manufacturer-level status (e.g. "discontinued by
/// manufacturer — cannot reorder") and future price/reformulation change flags. Nulled — not
/// cascaded — if the source catalog data is purged (the shop's own stock record must survive).
/// </summary>
public int? PowderCatalogItemId { get; set; }
/// <summary>
/// Latest list price from the linked powder catalog, refreshed by the catalog sync. This is the
/// QUOTING price (current replacement cost) and is kept deliberately SEPARATE from
/// <see cref="UnitCost"/>/<see cref="AverageCost"/> (the actual paid cost basis that drives
/// inventory valuation and COGS). Quoting prefers this when present so quotes reflect the
/// current price; accounting never reads it. Null for manual/non-catalog powders.
/// </summary>
public decimal? CatalogReferencePrice { get; set; }
/// <summary>Timestamp (UTC) when <see cref="CatalogReferencePrice"/> was last refreshed by the sync.</summary>
public DateTime? CatalogPriceUpdatedAt { get; set; }
// Sample Panel Tracking (coating category items only) // Sample Panel Tracking (coating category items only)
public bool HasSamplePanel { get; set; } = false; public bool HasSamplePanel { get; set; } = false;
@@ -40,30 +40,9 @@ public class PowderCatalogItem
/// <summary>Cure hold time at cure temperature, in minutes.</summary> /// <summary>Cure hold time at cure temperature, in minutes.</summary>
public int? CureTimeMinutes { get; set; } public int? CureTimeMinutes { get; set; }
/// <summary>
/// Raw cure schedule text exactly as supplied by the vendor — e.g. "10 minutes @ 400°F".
/// Preserved verbatim because vendor formats vary wildly and some carry application notes
/// that don't reduce to a single temp/time pair (partial cures, clear-coat steps).
/// </summary>
public string? CureScheduleText { get; set; }
/// <summary>
/// All parsed cure curves as JSON — e.g. [{"tempF":400,"minutes":10},{"tempF":350,"minutes":20}].
/// Many powders list alternate lower-temperature curves; these matter for heat-sensitive
/// substrates that cannot take the standard 400°F cure, so we keep every curve, not just the
/// primary one in <see cref="CureTemperatureF"/>/<see cref="CureTimeMinutes"/>.
/// </summary>
public string? CureCurvesJson { get; set; }
/// <summary>Finish type — e.g. Gloss, Matte, Satin, Metallic, Texture.</summary> /// <summary>Finish type — e.g. Gloss, Matte, Satin, Metallic, Texture.</summary>
public string? Finish { get; set; } public string? Finish { get; set; }
/// <summary>Resin chemistry — e.g. "Polyester", "TGIC", "Epoxy", "Hybrid". Distinct from <see cref="Finish"/>.</summary>
public string? ChemistryType { get; set; }
/// <summary>Recommended film build (mil thickness) as free text from the vendor — e.g. "2.0-3.0 Mils".</summary>
public string? MilThickness { get; set; }
/// <summary>Comma-separated color family tags — e.g. "Blue,Purple".</summary> /// <summary>Comma-separated color family tags — e.g. "Blue,Purple".</summary>
public string? ColorFamilies { get; set; } public string? ColorFamilies { get; set; }
@@ -81,29 +60,6 @@ public class PowderCatalogItem
// ── Catalog management ──────────────────────────────────────────────── // ── Catalog management ────────────────────────────────────────────────
/// <summary>
/// Our internal product category — e.g. "Powder Additives" for pigments/additives that are
/// sold by weight in grams and mixed into clear rather than sprayed as a standalone powder.
/// Null/empty for standard powders. Derived at import from the vendor's taxonomy, NOT stored
/// from their raw category list.
/// </summary>
public string? Category { get; set; }
/// <summary>
/// Provenance of this record — e.g. "Columbia Coatings API". Kept SEPARATE from
/// <see cref="VendorName"/> (which holds the derived manufacturer) so we can honor a
/// distributor's right-to-delete by purging every record that came from their feed,
/// regardless of which manufacturer made the product.
/// </summary>
public string? Source { get; set; }
/// <summary>
/// Reformulation history as supplied by the vendor — e.g. "Formulation Change: 05/22/26".
/// Not a reliable modified-date (free text, reformulations only) but a useful signal that a
/// product's formula — and therefore its cure specs — may have changed.
/// </summary>
public string? FormulationChanges { get; set; }
/// <summary>True when the vendor has discontinued this product. Kept for historical lookups.</summary> /// <summary>True when the vendor has discontinued this product. Kept for historical lookups.</summary>
public bool IsDiscontinued { get; set; } = false; public bool IsDiscontinued { get; set; } = false;
@@ -0,0 +1,41 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Entities;
/// <summary>
/// A Stripe Terminal card reader (e.g. a BBPOS WisePOS E) registered to a company for in-person
/// card payments. The reader lives on the company's Stripe Connect connected account and is attached
/// to the company's single Terminal Location (<see cref="Company.StripeTerminalLocationId"/>).
/// <para>
/// We mirror only the identifiers and a friendly label locally; Stripe remains the source of truth
/// for live network status. <see cref="Status"/> is a local lifecycle flag (Active/Deactivated),
/// separate from Stripe's transient online/offline network state captured in
/// <see cref="LastKnownNetworkStatus"/>.
/// </para>
/// </summary>
public class TerminalReader : BaseEntity
{
/// <summary>Stripe reader id (tmr_xxx) returned when the reader is registered.</summary>
public string StripeReaderId { get; set; } = string.Empty;
/// <summary>Stripe Terminal Location id (tml_xxx) the reader is attached to — denormalized copy of <see cref="Company.StripeTerminalLocationId"/>.</summary>
public string StripeLocationId { get; set; } = string.Empty;
/// <summary>Shop-friendly name, e.g. "Front Counter".</summary>
public string Label { get; set; } = string.Empty;
/// <summary>Stripe device type, e.g. "bbpos_wisepos_e" or "simulated_wisepos_e" (test mode).</summary>
public string DeviceType { get; set; } = string.Empty;
/// <summary>Hardware serial number reported by Stripe, when available.</summary>
public string? SerialNumber { get; set; }
/// <summary>Local lifecycle state — whether the shop still uses this reader.</summary>
public TerminalReaderStatus Status { get; set; } = TerminalReaderStatus.Active;
/// <summary>Last network status snapshot from Stripe ("online"/"offline"), refreshed on poll. Advisory only.</summary>
public string? LastKnownNetworkStatus { get; set; }
/// <summary>When Stripe last saw the reader online, from the last status refresh.</summary>
public DateTime? LastSeenAt { get; set; }
}
@@ -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
};
}
+12 -1
View File
@@ -33,7 +33,18 @@ public enum PaymentMethod
CreditDebitCard = 2, CreditDebitCard = 2,
BankTransferACH = 3, BankTransferACH = 3,
DigitalPayment = 4, DigitalPayment = 4,
StoreCredit = 5 // Refund issued as store credit (creates a CreditMemo) StoreCredit = 5, // Refund issued as store credit (creates a CreditMemo)
CardReader = 6 // In-person card payment via a Stripe Terminal reader (WisePOS E)
}
/// <summary>
/// Local lifecycle state for a registered Stripe Terminal card reader. Distinct from Stripe's
/// network status (online/offline) — this tracks whether the shop still uses the reader.
/// </summary>
public enum TerminalReaderStatus
{
Active = 0,
Deactivated = 1 // Unregistered from Stripe and hidden from the shop's reader list
} }
public enum GiftCertificateStatus public enum GiftCertificateStatus
@@ -79,6 +79,7 @@ IRepository<ReworkRecord> ReworkRecords { get; }
IRepository<InvoiceItem> InvoiceItems { get; } IRepository<InvoiceItem> InvoiceItems { get; }
IRepository<Payment> Payments { get; } IRepository<Payment> Payments { get; }
IRepository<Deposit> Deposits { get; } IRepository<Deposit> Deposits { get; }
IRepository<TerminalReader> TerminalReaders { get; }
// Purchase Orders — typed repository for paged/filtered list and detail load // Purchase Orders — typed repository for paged/filtered list and detail load
IPurchaseOrderRepository PurchaseOrders { get; } IPurchaseOrderRepository PurchaseOrders { get; }
@@ -321,6 +321,8 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
/// Auto-applied to the invoice when it is created (unapplied deposits are swept into Payment records). /// Auto-applied to the invoice when it is created (unapplied deposits are swept into Payment records).
/// </summary> /// </summary>
public DbSet<Deposit> Deposits { get; set; } public DbSet<Deposit> Deposits { get; set; }
/// <summary>Registered Stripe Terminal card readers (WisePOS E) for in-person payments; tenant-filtered with soft delete.</summary>
public DbSet<TerminalReader> TerminalReaders { get; set; }
// Purchase Orders // Purchase Orders
/// <summary>Purchase orders issued to vendors; tenant-filtered with soft delete.</summary> /// <summary>Purchase orders issued to vendors; tenant-filtered with soft delete.</summary>
@@ -656,6 +658,8 @@ modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId)); !e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<Deposit>().HasQueryFilter(e => modelBuilder.Entity<Deposit>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId)); !e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<TerminalReader>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
// Deposit → Invoice (nullable, no cascade) // Deposit → Invoice (nullable, no cascade)
modelBuilder.Entity<Deposit>() modelBuilder.Entity<Deposit>()
@@ -946,26 +950,6 @@ modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
.HasForeignKey(i => i.CogsAccountId) .HasForeignKey(i => i.CogsAccountId)
.OnDelete(DeleteBehavior.NoAction); .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) // CatalogItem → RevenueAccount / CogsAccount (nullable, no cascade — accounts use soft delete)
modelBuilder.Entity<CatalogItem>() modelBuilder.Entity<CatalogItem>()
.HasOne(ci => ci.RevenueAccount) .HasOne(ci => ci.RevenueAccount)
@@ -1531,9 +1515,6 @@ modelBuilder.Entity<Job>()
modelBuilder.Entity<InventoryItem>() modelBuilder.Entity<InventoryItem>()
.HasIndex(i => new { i.CompanyId, i.SKU }) .HasIndex(i => new { i.CompanyId, i.SKU })
.IsUnique() .IsUnique()
// Filter on IsDeleted so soft-deleted items don't reserve their SKU and block a new
// (or re-created) item from reusing it — matching the app's soft-delete semantics.
.HasFilter("[IsDeleted] = 0")
.HasDatabaseName("IX_InventoryItems_CompanyId_SKU"); .HasDatabaseName("IX_InventoryItems_CompanyId_SKU");
modelBuilder.Entity<Company>() modelBuilder.Entity<Company>()
@@ -1935,6 +1916,15 @@ modelBuilder.Entity<Job>()
.HasIndex(p => new { p.CompanyId, p.PaymentDate }) .HasIndex(p => new { p.CompanyId, p.PaymentDate })
.HasDatabaseName("IX_Payments_CompanyId_PaymentDate"); .HasDatabaseName("IX_Payments_CompanyId_PaymentDate");
// Terminal readers — looked up by Stripe id (webhook/registration) and by company (settings list)
modelBuilder.Entity<TerminalReader>()
.HasIndex(r => r.StripeReaderId)
.IsUnique()
.HasDatabaseName("IX_TerminalReaders_StripeReaderId");
modelBuilder.Entity<TerminalReader>()
.HasIndex(r => r.CompanyId)
.HasDatabaseName("IX_TerminalReaders_CompanyId");
modelBuilder.Entity<NotificationLog>() modelBuilder.Entity<NotificationLog>()
.HasOne<Company>() .HasOne<Company>()
.WithMany() .WithMany()
@@ -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 = "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 = "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 }, 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 = "Payroll Liabilities", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = false, IsActive = true, CompanyId = company.Id, CreatedAt = now },
new() { AccountNumber = "2300", Name = "Customer Deposits", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = true, 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 },
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 },
// ── Equity ────────────────────────────────────────────────────── // ── Equity ──────────────────────────────────────────────────────
new() { AccountNumber = "3000", Name = "Owner's Equity", AccountType = AccountType.Equity, AccountSubType = AccountSubType.OwnersEquity, IsSystem = true, IsActive = true, CompanyId = company.Id, CreatedAt = now }, new() { AccountNumber = "3000", Name = "Owner's Equity", AccountType = AccountType.Equity, AccountSubType = AccountSubType.OwnersEquity, IsSystem = true, IsActive = true, CompanyId = company.Id, CreatedAt = now },
@@ -12,8 +12,8 @@ using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Migrations namespace PowderCoating.Infrastructure.Migrations
{ {
[DbContext(typeof(ApplicationDbContext))] [DbContext(typeof(ApplicationDbContext))]
[Migration("20260620134918_AddCompanyDefaultGlAccounts")] [Migration("20260615222914_AddTerminalReaders")]
partial class AddCompanyDefaultGlAccounts partial class AddTerminalReaders
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -1911,6 +1911,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("StripeSubscriptionId") b.Property<string>("StripeSubscriptionId")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<string>("StripeTerminalLocationId")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("SubscriptionEndDate") b.Property<DateTime?>("SubscriptionEndDate")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
@@ -1926,6 +1929,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int>("SubscriptionStatus") b.Property<int>("SubscriptionStatus")
.HasColumnType("int"); .HasColumnType("int");
b.Property<bool>("TerminalSurchargeEnabled")
.HasColumnType("bit");
b.Property<string>("TimeZone") b.Property<string>("TimeZone")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -2188,9 +2194,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("CreatedBy") b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<int?>("DefaultCogsAccountId")
.HasColumnType("int");
b.Property<string>("DefaultCurrency") b.Property<string>("DefaultCurrency")
.IsRequired() .IsRequired()
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -2199,9 +2202,6 @@ namespace PowderCoating.Infrastructure.Migrations
.IsRequired() .IsRequired()
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<int?>("DefaultInventoryAccountId")
.HasColumnType("int");
b.Property<string>("DefaultJobPriority") b.Property<string>("DefaultJobPriority")
.IsRequired() .IsRequired()
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -2213,9 +2213,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int>("DefaultQuoteValidityDays") b.Property<int>("DefaultQuoteValidityDays")
.HasColumnType("int"); .HasColumnType("int");
b.Property<int?>("DefaultRevenueAccountId")
.HasColumnType("int");
b.Property<string>("DefaultTimeFormat") b.Property<string>("DefaultTimeFormat")
.IsRequired() .IsRequired()
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -2392,12 +2389,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasIndex("CompanyId") b.HasIndex("CompanyId")
.IsUnique(); .IsUnique();
b.HasIndex("DefaultCogsAccountId");
b.HasIndex("DefaultInventoryAccountId");
b.HasIndex("DefaultRevenueAccountId");
b.ToTable("CompanyPreferences"); b.ToTable("CompanyPreferences");
}); });
@@ -4093,12 +4084,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<decimal>("AverageCost") b.Property<decimal>("AverageCost")
.HasColumnType("decimal(18,2)"); .HasColumnType("decimal(18,2)");
b.Property<DateTime?>("CatalogPriceUpdatedAt")
.HasColumnType("datetime2");
b.Property<decimal?>("CatalogReferencePrice")
.HasColumnType("decimal(18,2)");
b.Property<string>("Category") b.Property<string>("Category")
.IsRequired() .IsRequired()
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -4197,9 +4182,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("Notes") b.Property<string>("Notes")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<int?>("PowderCatalogItemId")
.HasColumnType("int");
b.Property<int?>("PrimaryVendorId") b.Property<int?>("PrimaryVendorId")
.HasColumnType("int"); .HasColumnType("int");
@@ -4268,8 +4250,7 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasIndex("CompanyId", "SKU") b.HasIndex("CompanyId", "SKU")
.IsUnique() .IsUnique()
.HasDatabaseName("IX_InventoryItems_CompanyId_SKU") .HasDatabaseName("IX_InventoryItems_CompanyId_SKU");
.HasFilter("[IsDeleted] = 0");
b.HasIndex("CompanyId", "QuantityOnHand", "ReorderPoint") b.HasIndex("CompanyId", "QuantityOnHand", "ReorderPoint")
.HasDatabaseName("IX_InventoryItems_CompanyId_Quantity_Reorder"); .HasDatabaseName("IX_InventoryItems_CompanyId_Quantity_Reorder");
@@ -6964,12 +6945,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("ApplicationGuideUrl") b.Property<string>("ApplicationGuideUrl")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<string>("Category")
.HasColumnType("nvarchar(max)");
b.Property<string>("ChemistryType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ColorFamilies") b.Property<string>("ColorFamilies")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -6983,12 +6958,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
b.Property<string>("CureCurvesJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("CureScheduleText")
.HasColumnType("nvarchar(max)");
b.Property<decimal?>("CureTemperatureF") b.Property<decimal?>("CureTemperatureF")
.HasColumnType("decimal(18,2)"); .HasColumnType("decimal(18,2)");
@@ -7001,9 +6970,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("Finish") b.Property<string>("Finish")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<string>("FormulationChanges")
.HasColumnType("nvarchar(max)");
b.Property<string>("ImageUrl") b.Property<string>("ImageUrl")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -7016,9 +6982,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<DateTime?>("LastSyncedAt") b.Property<DateTime?>("LastSyncedAt")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
b.Property<string>("MilThickness")
.HasColumnType("nvarchar(max)");
b.Property<string>("PriceTiersJson") b.Property<string>("PriceTiersJson")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -7035,9 +6998,6 @@ namespace PowderCoating.Infrastructure.Migrations
.IsRequired() .IsRequired()
.HasColumnType("nvarchar(450)"); .HasColumnType("nvarchar(450)");
b.Property<string>("Source")
.HasColumnType("nvarchar(max)");
b.Property<decimal?>("SpecificGravity") b.Property<decimal?>("SpecificGravity")
.HasColumnType("decimal(18,2)"); .HasColumnType("decimal(18,2)");
@@ -7259,7 +7219,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 1, Id = 1,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4507), CreatedAt = new DateTime(2026, 6, 15, 22, 29, 10, 622, DateTimeKind.Utc).AddTicks(5996),
Description = "Standard pricing for regular customers", Description = "Standard pricing for regular customers",
DiscountPercent = 0m, DiscountPercent = 0m,
IsActive = true, IsActive = true,
@@ -7270,7 +7230,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 2, Id = 2,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4514), CreatedAt = new DateTime(2026, 6, 15, 22, 29, 10, 622, DateTimeKind.Utc).AddTicks(6003),
Description = "5% discount for preferred customers", Description = "5% discount for preferred customers",
DiscountPercent = 5m, DiscountPercent = 5m,
IsActive = true, IsActive = true,
@@ -7281,7 +7241,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 3, Id = 3,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4515), CreatedAt = new DateTime(2026, 6, 15, 22, 29, 10, 622, DateTimeKind.Utc).AddTicks(6004),
Description = "10% discount for premium customers", Description = "10% discount for premium customers",
DiscountPercent = 10m, DiscountPercent = 10m,
IsActive = true, IsActive = true,
@@ -8787,6 +8747,78 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("TaxRates"); b.ToTable("TaxRates");
}); });
modelBuilder.Entity("PowderCoating.Core.Entities.TerminalReader", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("DeviceType")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("Label")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("LastKnownNetworkStatus")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("LastSeenAt")
.HasColumnType("datetime2");
b.Property<string>("SerialNumber")
.HasColumnType("nvarchar(max)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<string>("StripeLocationId")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("StripeReaderId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("CompanyId")
.HasDatabaseName("IX_TerminalReaders_CompanyId");
b.HasIndex("StripeReaderId")
.IsUnique()
.HasDatabaseName("IX_TerminalReaders_StripeReaderId");
b.ToTable("TerminalReaders");
});
modelBuilder.Entity("PowderCoating.Core.Entities.TermsAcceptance", b => modelBuilder.Entity("PowderCoating.Core.Entities.TermsAcceptance", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -9598,21 +9630,6 @@ namespace PowderCoating.Infrastructure.Migrations
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .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"); b.Navigation("Company");
}); });
@@ -0,0 +1,124 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddTerminalReaders : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "StripeTerminalLocationId",
table: "Companies",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "TerminalSurchargeEnabled",
table: "Companies",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.CreateTable(
name: "TerminalReaders",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
StripeReaderId = table.Column<string>(type: "nvarchar(450)", nullable: false),
StripeLocationId = table.Column<string>(type: "nvarchar(max)", nullable: false),
Label = table.Column<string>(type: "nvarchar(max)", nullable: false),
DeviceType = table.Column<string>(type: "nvarchar(max)", nullable: false),
SerialNumber = table.Column<string>(type: "nvarchar(max)", nullable: true),
Status = table.Column<int>(type: "int", nullable: false),
LastKnownNetworkStatus = table.Column<string>(type: "nvarchar(max)", nullable: true),
LastSeenAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CompanyId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_TerminalReaders", x => x.Id);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 15, 22, 29, 10, 622, DateTimeKind.Utc).AddTicks(5996));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 15, 22, 29, 10, 622, DateTimeKind.Utc).AddTicks(6003));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 15, 22, 29, 10, 622, DateTimeKind.Utc).AddTicks(6004));
migrationBuilder.CreateIndex(
name: "IX_TerminalReaders_CompanyId",
table: "TerminalReaders",
column: "CompanyId");
migrationBuilder.CreateIndex(
name: "IX_TerminalReaders_StripeReaderId",
table: "TerminalReaders",
column: "StripeReaderId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TerminalReaders");
migrationBuilder.DropColumn(
name: "StripeTerminalLocationId",
table: "Companies");
migrationBuilder.DropColumn(
name: "TerminalSurchargeEnabled",
table: "Companies");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4191));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4196));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4197));
}
}
}
@@ -1,141 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddColumbiaCatalogIntegrationFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Category",
table: "PowderCatalogItems",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ChemistryType",
table: "PowderCatalogItems",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "CureCurvesJson",
table: "PowderCatalogItems",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "CureScheduleText",
table: "PowderCatalogItems",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "FormulationChanges",
table: "PowderCatalogItems",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "MilThickness",
table: "PowderCatalogItems",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Source",
table: "PowderCatalogItems",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "PowderCatalogItemId",
table: "InventoryItems",
type: "int",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 14, 21, 27, 126, DateTimeKind.Utc).AddTicks(6644));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 14, 21, 27, 126, DateTimeKind.Utc).AddTicks(6651));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 14, 21, 27, 126, DateTimeKind.Utc).AddTicks(6652));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Category",
table: "PowderCatalogItems");
migrationBuilder.DropColumn(
name: "ChemistryType",
table: "PowderCatalogItems");
migrationBuilder.DropColumn(
name: "CureCurvesJson",
table: "PowderCatalogItems");
migrationBuilder.DropColumn(
name: "CureScheduleText",
table: "PowderCatalogItems");
migrationBuilder.DropColumn(
name: "FormulationChanges",
table: "PowderCatalogItems");
migrationBuilder.DropColumn(
name: "MilThickness",
table: "PowderCatalogItems");
migrationBuilder.DropColumn(
name: "Source",
table: "PowderCatalogItems");
migrationBuilder.DropColumn(
name: "PowderCatalogItemId",
table: "InventoryItems");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4191));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4196));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4197));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,88 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class SeedColumbiaSyncSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Seed the SuperAdmin-managed platform settings for the Columbia Coatings catalog sync.
// Idempotent so it is safe against a DB where keys were added manually. The API key
// itself is NOT here — secrets live in configuration (Columbia:ApiKey), not this table.
migrationBuilder.Sql(@"
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'ColumbiaSyncEnabled')
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
VALUES ('ColumbiaSyncEnabled','false','Columbia Coatings Sync Enabled','Master switch for the scheduled Columbia Coatings catalog sync. When off, no automatic or manual sync runs regardless of the configured API key.','Integrations');
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'ColumbiaSyncIntervalDays')
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
VALUES ('ColumbiaSyncIntervalDays','7','Columbia Sync Interval (days)','How many days between automatic Columbia catalog syncs. A full sync is cheap (~25 API calls), so daily (1) or weekly (7) keeps pricing fresh.','Integrations');
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'ColumbiaLastSyncedAt')
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
VALUES ('ColumbiaLastSyncedAt',NULL,'Columbia Last Synced At','Timestamp (UTC) of the last successful Columbia catalog sync. Set automatically by the sync job.','Integrations');
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'ColumbiaLastSyncResult')
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
VALUES ('ColumbiaLastSyncResult',NULL,'Columbia Last Sync Result','Summary of the last Columbia catalog sync run (inserted/updated/discontinued counts or error). Set automatically by the sync job.','Integrations');
");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(5997));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(6002));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(6003));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"
DELETE FROM PlatformSettings WHERE [Key] IN (
'ColumbiaSyncEnabled','ColumbiaSyncIntervalDays','ColumbiaLastSyncedAt','ColumbiaLastSyncResult'
);
");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 14, 21, 27, 126, DateTimeKind.Utc).AddTicks(6644));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 14, 21, 27, 126, DateTimeKind.Utc).AddTicks(6651));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 14, 21, 27, 126, DateTimeKind.Utc).AddTicks(6652));
}
}
}
@@ -1,82 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class FilterInventorySkuUniqueIndexOnSoftDelete : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_InventoryItems_CompanyId_SKU",
table: "InventoryItems");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 16, 33, 6, 662, DateTimeKind.Utc).AddTicks(4251));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 16, 33, 6, 662, DateTimeKind.Utc).AddTicks(4258));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 16, 33, 6, 662, DateTimeKind.Utc).AddTicks(4259));
migrationBuilder.CreateIndex(
name: "IX_InventoryItems_CompanyId_SKU",
table: "InventoryItems",
columns: new[] { "CompanyId", "SKU" },
unique: true,
filter: "[IsDeleted] = 0");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_InventoryItems_CompanyId_SKU",
table: "InventoryItems");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(5997));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(6002));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(6003));
migrationBuilder.CreateIndex(
name: "IX_InventoryItems_CompanyId_SKU",
table: "InventoryItems",
columns: new[] { "CompanyId", "SKU" },
unique: true);
}
}
}
@@ -1,81 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddInventoryCatalogReferencePrice : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "CatalogPriceUpdatedAt",
table: "InventoryItems",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "CatalogReferencePrice",
table: "InventoryItems",
type: "decimal(18,2)",
nullable: true);
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));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CatalogPriceUpdatedAt",
table: "InventoryItems");
migrationBuilder.DropColumn(
name: "CatalogReferencePrice",
table: "InventoryItems");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 16, 33, 6, 662, DateTimeKind.Utc).AddTicks(4251));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 16, 33, 6, 662, DateTimeKind.Utc).AddTicks(4258));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 16, 33, 6, 662, DateTimeKind.Utc).AddTicks(4259));
}
}
}
@@ -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));
}
}
}
@@ -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));
}
}
}
@@ -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));
}
}
}
@@ -1908,6 +1908,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("StripeSubscriptionId") b.Property<string>("StripeSubscriptionId")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<string>("StripeTerminalLocationId")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("SubscriptionEndDate") b.Property<DateTime?>("SubscriptionEndDate")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
@@ -1923,6 +1926,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int>("SubscriptionStatus") b.Property<int>("SubscriptionStatus")
.HasColumnType("int"); .HasColumnType("int");
b.Property<bool>("TerminalSurchargeEnabled")
.HasColumnType("bit");
b.Property<string>("TimeZone") b.Property<string>("TimeZone")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -2185,9 +2191,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("CreatedBy") b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<int?>("DefaultCogsAccountId")
.HasColumnType("int");
b.Property<string>("DefaultCurrency") b.Property<string>("DefaultCurrency")
.IsRequired() .IsRequired()
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -2196,9 +2199,6 @@ namespace PowderCoating.Infrastructure.Migrations
.IsRequired() .IsRequired()
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<int?>("DefaultInventoryAccountId")
.HasColumnType("int");
b.Property<string>("DefaultJobPriority") b.Property<string>("DefaultJobPriority")
.IsRequired() .IsRequired()
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -2210,9 +2210,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int>("DefaultQuoteValidityDays") b.Property<int>("DefaultQuoteValidityDays")
.HasColumnType("int"); .HasColumnType("int");
b.Property<int?>("DefaultRevenueAccountId")
.HasColumnType("int");
b.Property<string>("DefaultTimeFormat") b.Property<string>("DefaultTimeFormat")
.IsRequired() .IsRequired()
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -2389,12 +2386,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasIndex("CompanyId") b.HasIndex("CompanyId")
.IsUnique(); .IsUnique();
b.HasIndex("DefaultCogsAccountId");
b.HasIndex("DefaultInventoryAccountId");
b.HasIndex("DefaultRevenueAccountId");
b.ToTable("CompanyPreferences"); b.ToTable("CompanyPreferences");
}); });
@@ -4090,12 +4081,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<decimal>("AverageCost") b.Property<decimal>("AverageCost")
.HasColumnType("decimal(18,2)"); .HasColumnType("decimal(18,2)");
b.Property<DateTime?>("CatalogPriceUpdatedAt")
.HasColumnType("datetime2");
b.Property<decimal?>("CatalogReferencePrice")
.HasColumnType("decimal(18,2)");
b.Property<string>("Category") b.Property<string>("Category")
.IsRequired() .IsRequired()
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -4194,9 +4179,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("Notes") b.Property<string>("Notes")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<int?>("PowderCatalogItemId")
.HasColumnType("int");
b.Property<int?>("PrimaryVendorId") b.Property<int?>("PrimaryVendorId")
.HasColumnType("int"); .HasColumnType("int");
@@ -4265,8 +4247,7 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasIndex("CompanyId", "SKU") b.HasIndex("CompanyId", "SKU")
.IsUnique() .IsUnique()
.HasDatabaseName("IX_InventoryItems_CompanyId_SKU") .HasDatabaseName("IX_InventoryItems_CompanyId_SKU");
.HasFilter("[IsDeleted] = 0");
b.HasIndex("CompanyId", "QuantityOnHand", "ReorderPoint") b.HasIndex("CompanyId", "QuantityOnHand", "ReorderPoint")
.HasDatabaseName("IX_InventoryItems_CompanyId_Quantity_Reorder"); .HasDatabaseName("IX_InventoryItems_CompanyId_Quantity_Reorder");
@@ -6961,12 +6942,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("ApplicationGuideUrl") b.Property<string>("ApplicationGuideUrl")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<string>("Category")
.HasColumnType("nvarchar(max)");
b.Property<string>("ChemistryType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ColorFamilies") b.Property<string>("ColorFamilies")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -6980,12 +6955,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
b.Property<string>("CureCurvesJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("CureScheduleText")
.HasColumnType("nvarchar(max)");
b.Property<decimal?>("CureTemperatureF") b.Property<decimal?>("CureTemperatureF")
.HasColumnType("decimal(18,2)"); .HasColumnType("decimal(18,2)");
@@ -6998,9 +6967,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("Finish") b.Property<string>("Finish")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<string>("FormulationChanges")
.HasColumnType("nvarchar(max)");
b.Property<string>("ImageUrl") b.Property<string>("ImageUrl")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -7013,9 +6979,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<DateTime?>("LastSyncedAt") b.Property<DateTime?>("LastSyncedAt")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
b.Property<string>("MilThickness")
.HasColumnType("nvarchar(max)");
b.Property<string>("PriceTiersJson") b.Property<string>("PriceTiersJson")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -7032,9 +6995,6 @@ namespace PowderCoating.Infrastructure.Migrations
.IsRequired() .IsRequired()
.HasColumnType("nvarchar(450)"); .HasColumnType("nvarchar(450)");
b.Property<string>("Source")
.HasColumnType("nvarchar(max)");
b.Property<decimal?>("SpecificGravity") b.Property<decimal?>("SpecificGravity")
.HasColumnType("decimal(18,2)"); .HasColumnType("decimal(18,2)");
@@ -7256,7 +7216,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 1, Id = 1,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4507), CreatedAt = new DateTime(2026, 6, 15, 22, 29, 10, 622, DateTimeKind.Utc).AddTicks(5996),
Description = "Standard pricing for regular customers", Description = "Standard pricing for regular customers",
DiscountPercent = 0m, DiscountPercent = 0m,
IsActive = true, IsActive = true,
@@ -7267,7 +7227,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 2, Id = 2,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4514), CreatedAt = new DateTime(2026, 6, 15, 22, 29, 10, 622, DateTimeKind.Utc).AddTicks(6003),
Description = "5% discount for preferred customers", Description = "5% discount for preferred customers",
DiscountPercent = 5m, DiscountPercent = 5m,
IsActive = true, IsActive = true,
@@ -7278,7 +7238,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 3, Id = 3,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4515), CreatedAt = new DateTime(2026, 6, 15, 22, 29, 10, 622, DateTimeKind.Utc).AddTicks(6004),
Description = "10% discount for premium customers", Description = "10% discount for premium customers",
DiscountPercent = 10m, DiscountPercent = 10m,
IsActive = true, IsActive = true,
@@ -8784,6 +8744,78 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("TaxRates"); b.ToTable("TaxRates");
}); });
modelBuilder.Entity("PowderCoating.Core.Entities.TerminalReader", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("DeviceType")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("Label")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("LastKnownNetworkStatus")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("LastSeenAt")
.HasColumnType("datetime2");
b.Property<string>("SerialNumber")
.HasColumnType("nvarchar(max)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<string>("StripeLocationId")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("StripeReaderId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("CompanyId")
.HasDatabaseName("IX_TerminalReaders_CompanyId");
b.HasIndex("StripeReaderId")
.IsUnique()
.HasDatabaseName("IX_TerminalReaders_StripeReaderId");
b.ToTable("TerminalReaders");
});
modelBuilder.Entity("PowderCoating.Core.Entities.TermsAcceptance", b => modelBuilder.Entity("PowderCoating.Core.Entities.TermsAcceptance", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -9595,21 +9627,6 @@ namespace PowderCoating.Infrastructure.Migrations
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .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"); b.Navigation("Company");
}); });
@@ -150,6 +150,7 @@ public class UnitOfWork : IUnitOfWork
private IRepository<InvoiceItem>? _invoiceItems; private IRepository<InvoiceItem>? _invoiceItems;
private IRepository<Payment>? _payments; private IRepository<Payment>? _payments;
private IRepository<Deposit>? _deposits; private IRepository<Deposit>? _deposits;
private IRepository<TerminalReader>? _terminalReaders;
// Expense Tracking / Accounts Payable // Expense Tracking / Accounts Payable
private IRepository<Account>? _accounts; private IRepository<Account>? _accounts;
@@ -555,6 +556,10 @@ public class UnitOfWork : IUnitOfWork
public IRepository<Deposit> Deposits => public IRepository<Deposit> Deposits =>
_deposits ??= new Repository<Deposit>(_context); _deposits ??= new Repository<Deposit>(_context);
/// <summary>Repository for <see cref="TerminalReader"/> registered Stripe Terminal card readers.</summary>
public IRepository<TerminalReader> TerminalReaders =>
_terminalReaders ??= new Repository<TerminalReader>(_context);
// Expense Tracking / Accounts Payable // Expense Tracking / Accounts Payable
/// <summary>Repository for <see cref="Account"/> chart-of-accounts entries; supports self-referencing parent/child hierarchy.</summary> /// <summary>Repository for <see cref="Account"/> chart-of-accounts entries; supports self-referencing parent/child hierarchy.</summary>
public IRepository<Account> Accounts => public IRepository<Account> Accounts =>
@@ -1,299 +0,0 @@
using System.Globalization;
using System.Net;
using System.Text.Json;
using System.Text.RegularExpressions;
using PowderCoating.Application.Constants;
using PowderCoating.Application.DTOs.Columbia;
using PowderCoating.Core.Entities;
namespace PowderCoating.Infrastructure.Services.Columbia;
/// <summary>
/// Maps a Columbia Coatings API product onto our platform <see cref="PowderCatalogItem"/>.
/// Pure, static, side-effect free so the tricky bits (manufacturer derivation, the free-text
/// cure-schedule parser, HTML stripping) can be unit tested directly against captured fixtures.
/// <para>
/// Columbia is a distributor reselling multiple brands, so <see cref="PowderCatalogItem.VendorName"/>
/// holds the DERIVED manufacturer (PPG / KP Pigments / Columbia) while
/// <see cref="PowderCatalogItem.Source"/> records the feed ("Columbia Coatings API") for
/// right-to-delete purges. The vendor's own categories/tags are read here only to derive the
/// manufacturer and additive flag — they are never stored raw.
/// </para>
/// </summary>
public static class ColumbiaCatalogMapper
{
/// <summary>A single parsed cure curve — hold <see cref="Minutes"/> at <see cref="TempF"/>.</summary>
public readonly record struct CureCurve(int TempF, int Minutes);
// Resin chemistries that mean "polyester + TGIC" but arrive formatted three different ways.
private static readonly Regex PolyesterTgic =
new(@"^\s*(polyester\s*[/ ]\s*tgic|tgic\s*[/ ]\s*polyester)\s*$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
// "10 minutes @ 400°F", "7 minutes at 375 F", "Metal Temperature: 10 minutes at 400°F (204°C)".
// Degree glyph is optional and may be ° (U+00B0), ˚ (U+02DA), or º (U+00BA).
private static readonly Regex CureCurveRegex =
new(@"(\d+)\s*min(?:ute)?s?\.?\s*(?:@|at)\s*(\d{2,3})\s*[°˚º]?\s*F",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex HtmlTag = new("<[^>]+>", RegexOptions.Compiled);
private static readonly Regex WhitespaceRun = new(@"\s{2,}", RegexOptions.Compiled);
private static readonly JsonSerializerOptions JsonOut = new() { WriteIndented = false };
/// <summary>
/// True for products that should not be in the powder catalog as standalone colors:
/// physical swatch cards (not powder at all), and tester (4 oz) / sample (5 lb) listings that
/// are just smaller SIZES of a parent powder that already exists as its own product. Detected
/// by specific SKU suffixes (-SW / -04) and unambiguous name markers ("SWATCH", "Tester",
/// "Sample ("). The sample-size "-S" SKU suffix is intentionally NOT used on its own — the
/// "Sample (" name marker catches every sample without risking a real SKU that ends in -S.
/// </summary>
public static bool IsExcludedProduct(ColumbiaProduct p)
{
var sku = p.Sku ?? string.Empty;
if (sku.EndsWith("-SW", StringComparison.OrdinalIgnoreCase)
|| sku.EndsWith("-04", StringComparison.OrdinalIgnoreCase))
return true;
var name = p.Name ?? string.Empty;
return name.Contains("SWATCH", StringComparison.OrdinalIgnoreCase)
|| name.Contains("Tester", StringComparison.OrdinalIgnoreCase)
|| name.Contains("Sample (", StringComparison.OrdinalIgnoreCase);
}
/// <summary>Maps a Columbia product into a fully populated (unsaved) catalog item.</summary>
public static PowderCatalogItem Map(ColumbiaProduct p)
{
var curves = ParseCureCurves(p.CureSchedule);
var primary = curves.Count > 0 ? curves[0] : (CureCurve?)null;
return new PowderCatalogItem
{
VendorName = DeriveManufacturer(p),
Sku = p.Sku.Trim(),
ColorName = p.Name.Trim(),
Source = ColumbiaIntegrationConstants.SourceName,
Category = IsAdditive(p) ? ColumbiaIntegrationConstants.CategoryPowderAdditives : null,
Description = StripHtml(p.Description),
UnitPrice = ParseBasePrice(p),
PriceTiersJson = BuildPriceTiersJson(p),
ImageUrl = NullIfBlank(p.FeaturedImage?.Src),
SdsUrl = NullIfBlank(p.SafetyDataSheet),
TdsUrl = NullIfBlank(p.TechnicalDataSheet),
ApplicationGuideUrl = NullIfBlank(FirstNonBlank(p.ProductFlyer, p.ProductBrochure)),
ProductUrl = NullIfBlank(p.Permalink),
ChemistryType = NormalizeChemistry(p.Type),
MilThickness = NullIfBlank(p.MilThickness),
CureScheduleText = NullIfBlank(p.CureSchedule),
CureCurvesJson = curves.Count > 0 ? JsonSerializer.Serialize(curves, JsonOut) : null,
CureTemperatureF = primary?.TempF,
CureTimeMinutes = primary?.Minutes,
RequiresClearCoat = DetectRequiresClearCoat(p),
ColorFamilies = BuildColorFamilies(p),
FormulationChanges = NullIfBlank(p.FormulationDateChanges),
// Coverage / specific gravity / transfer efficiency are not in the API — left null for
// lazy TDS/AI enrichment on first use. IsDiscontinued is handled by the sync sweep.
};
}
// ── Manufacturer derivation ───────────────────────────────────────────
/// <summary>
/// Derives the manufacturer from the product's taxonomy/SKU. Columbia resells PPG powders and
/// KP Pigments additives through the same feed; everything else is Columbia's own brand.
/// </summary>
public static string DeriveManufacturer(ColumbiaProduct p)
{
if (IsKpPigments(p))
return ColumbiaIntegrationConstants.ManufacturerKp;
if (IsPpg(p))
return ColumbiaIntegrationConstants.ManufacturerPpg;
return ColumbiaIntegrationConstants.ManufacturerColumbia;
}
private static bool IsKpPigments(ColumbiaProduct p) =>
p.Sku.StartsWith("ADD-", StringComparison.OrdinalIgnoreCase)
|| CategoryStartsWith(p, "KP");
private static bool IsPpg(ColumbiaProduct p) =>
CategoryStartsWith(p, "PPG");
/// <summary>
/// True for pigments/additives sold by weight (grams) rather than sprayed powders. These get
/// forced into the "Powder Additives" category. Keyed off the broad Additives category and the
/// ADD- SKU prefix, not just the KP brand (there are ~98 non-KP additives).
/// </summary>
public static bool IsAdditive(ColumbiaProduct p) =>
p.Sku.StartsWith("ADD-", StringComparison.OrdinalIgnoreCase)
|| p.Categories.Any(c => c.Name.Equals("Additives", StringComparison.OrdinalIgnoreCase))
|| CategoryStartsWith(p, "KP");
private static bool CategoryStartsWith(ColumbiaProduct p, string prefix) =>
p.Categories.Any(c => c.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
// ── Pricing ───────────────────────────────────────────────────────────
/// <summary>
/// Base unit price = the top-level <c>price</c> (falling back to <c>regular_price</c>). For
/// variable products the parent <c>price</c> already carries the lead variant's price, while
/// <c>regular_price</c> is often "0", so price is preferred.
/// </summary>
public static decimal ParseBasePrice(ColumbiaProduct p)
{
if (TryParseMoney(p.Price, out var price) && price > 0)
return price;
if (TryParseMoney(p.RegularPrice, out var regular) && regular > 0)
return regular;
// Variable product with a zero parent price: fall back to the lowest variant price.
var variantPrices = (p.VariationPricing ?? new List<ColumbiaVariationPricing>())
.Select(v => TryParseMoney(v.Price, out var vp) ? vp : 0m)
.Where(v => v > 0)
.ToList();
return variantPrices.Count > 0 ? variantPrices.Min() : 0m;
}
/// <summary>
/// Captures quantity-break / variant pricing as JSON for later use. For variable products this
/// is the per-variant pricing (Bulk vs 1 lb Bags, gram sizes); for simple products it's the
/// tiered_pricing object. Null when neither is present.
/// </summary>
public static string? BuildPriceTiersJson(ColumbiaProduct p)
{
if (p.VariationPricing is { Count: > 0 })
return JsonSerializer.Serialize(p.VariationPricing, JsonOut);
if (p.TieredPricing is { ValueKind: JsonValueKind.Object } tiered)
{
// Only keep it if it actually carries tiers (avoid storing empty {type,...} shells).
if (tiered.TryGetProperty("tiers", out var tiers)
&& tiers.ValueKind == JsonValueKind.Array
&& tiers.GetArrayLength() > 0)
{
return tiered.GetRawText();
}
}
return null;
}
private static bool TryParseMoney(string? s, out decimal value) =>
decimal.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out value);
// ── Cure schedule parsing ─────────────────────────────────────────────
/// <summary>
/// Extracts every "N minutes at/@ TTT°F" curve from a free-text cure schedule, in document
/// order. The first is treated as the primary/standard curve; the rest are alternate (often
/// lower-temperature) curves preserved for heat-sensitive substrates. Returns an empty list for
/// schedules with no parseable temp/time pair (partial-cure / clear-coat instructions).
/// </summary>
public static List<CureCurve> ParseCureCurves(string? cureSchedule)
{
var result = new List<CureCurve>();
if (string.IsNullOrWhiteSpace(cureSchedule))
return result;
foreach (Match m in CureCurveRegex.Matches(cureSchedule))
{
if (int.TryParse(m.Groups[1].Value, out var minutes)
&& int.TryParse(m.Groups[2].Value, out var tempF)
&& tempF is >= 150 and <= 600 // sanity: real cure temps
&& minutes is > 0 and <= 120)
{
var curve = new CureCurve(tempF, minutes);
if (!result.Contains(curve))
result.Add(curve);
}
}
return result;
}
// Powders that genuinely REQUIRE a clear coat say so explicitly. A casual "apply a clear coat
// for added durability" must NOT trip this — that over-flagged ~half the catalog and would pad
// quotes with unnecessary clear-coat steps.
private static readonly string[] RequiresClearPhrases =
{
"requires a clear", "requires clear", "require a clear",
"must be clear coated", "must be cleared", "needs a clear",
"clear coat is required", "clear coat required", "requires a clearcoat",
"requires a top coat", "clear coat to activate", "clear coat to achieve",
"requires a clear coat",
};
/// <summary>
/// Flags powders that genuinely need a clear coat: multi-step partial-cure (Illusion-style)
/// schedules, Columbia's named "Illusion" line, or explicit requirement phrasing. Casual
/// "you can clear coat this" mentions are intentionally ignored.
/// </summary>
public static bool DetectRequiresClearCoat(ColumbiaProduct p)
{
var cure = p.CureSchedule ?? string.Empty;
var name = p.Name ?? string.Empty;
// Partial-cure / multi-step instructions are the "apply this, then clear" case.
if (cure.Contains("partial cure", StringComparison.OrdinalIgnoreCase))
return true;
// Columbia's Illusion line needs a clear top coat to develop the effect.
if (name.Contains("Illusion", StringComparison.OrdinalIgnoreCase))
return true;
var text = $"{name} {cure} {p.Description}";
return RequiresClearPhrases.Any(phrase => text.Contains(phrase, StringComparison.OrdinalIgnoreCase));
}
// ── Misc field helpers ────────────────────────────────────────────────
/// <summary>Joins the color-group taxonomy ({name} entries) into a comma-separated families string.</summary>
public static string? BuildColorFamilies(ColumbiaProduct p)
{
var groups = p.PaColorGroup.Select(g => g.Name.Trim()).Where(n => n.Length > 0).Distinct().ToList();
if (groups.Count == 0)
{
// Fall back to the "Color Group" attribute options when the taxonomy is empty.
groups = p.Attributes
.Where(a => a.Name.Equals("Color Group", StringComparison.OrdinalIgnoreCase))
.SelectMany(a => a.Options.Select(o => o.Name.Trim()))
.Where(n => n.Length > 0)
.Distinct()
.ToList();
}
return groups.Count > 0 ? string.Join(",", groups) : null;
}
/// <summary>Normalizes resin chemistry — trims, and collapses the three Polyester/TGIC spellings.</summary>
public static string? NormalizeChemistry(string? type)
{
if (string.IsNullOrWhiteSpace(type))
return null;
var trimmed = type.Trim();
return PolyesterTgic.IsMatch(trimmed) ? "Polyester/TGIC" : trimmed;
}
/// <summary>Strips HTML tags/entities from a description and collapses whitespace to plain text.</summary>
public static string? StripHtml(string? html)
{
if (string.IsNullOrWhiteSpace(html))
return null;
var text = HtmlTag.Replace(html, " ");
text = WebUtility.HtmlDecode(text);
text = text.Replace("\r", " ").Replace("\n", " ").Replace("\t", " ");
text = WhitespaceRun.Replace(text, " ").Trim();
return text.Length > 0 ? text : null;
}
private static string? NullIfBlank(string? s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim();
private static string? FirstNonBlank(params string?[] values) =>
values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v));
}
@@ -1,187 +0,0 @@
using System.Diagnostics;
using System.Globalization;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.Constants;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
namespace PowderCoating.Infrastructure.Services.Columbia;
/// <summary>
/// Full Columbia Coatings catalog sync: pages the API, maps each product, upserts via the shared
/// <see cref="IPowderCatalogUpsertService"/>, then reconciles discontinuations against the complete
/// pull. The discontinuation sweep runs ONLY after a successful full fetch — a partial pull (any
/// page failure throws from the client) aborts before the sweep so a transient error can never mass
/// flag the catalog as discontinued.
/// </summary>
public class ColumbiaCatalogSyncService : IColumbiaCatalogSyncService
{
private readonly IColumbiaCoatingsApiClient _client;
private readonly IPowderCatalogUpsertService _upsert;
private readonly IUnitOfWork _unitOfWork;
private readonly IPlatformSettingsService _settings;
private readonly ILogger<ColumbiaCatalogSyncService> _logger;
public ColumbiaCatalogSyncService(
IColumbiaCoatingsApiClient client,
IPowderCatalogUpsertService upsert,
IUnitOfWork unitOfWork,
IPlatformSettingsService settings,
ILogger<ColumbiaCatalogSyncService> logger)
{
_client = client;
_upsert = upsert;
_unitOfWork = unitOfWork;
_settings = settings;
_logger = logger;
}
/// <inheritdoc />
public async Task<ColumbiaSyncResult> RunSyncAsync(CancellationToken cancellationToken = default)
{
var result = new ColumbiaSyncResult { StartedAt = DateTime.UtcNow };
var stopwatch = Stopwatch.StartNew();
if (!_client.IsConfigured)
{
result.ErrorMessage = "Columbia API key is not configured.";
await RecordResultAsync(result);
return result;
}
try
{
// Full pull — throws on any page failure, which we treat as an incomplete sync.
var products = await _client.GetAllProductsAsync(cancellationToken);
result.TotalFetched = products.Count;
// Map and de-duplicate by (VendorName, SKU) in case the feed repeats a SKU.
// Exclude swatch cards and tester/sample size-variants — not standalone powder colors.
var mapped = products
.Where(p => !ColumbiaCatalogMapper.IsExcludedProduct(p))
.Select(ColumbiaCatalogMapper.Map)
.Where(m => !string.IsNullOrWhiteSpace(m.Sku))
.GroupBy(m => $"{m.VendorName}|{m.Sku}", StringComparer.OrdinalIgnoreCase)
.Select(g => g.First())
.ToList();
var upsertResult = await _upsert.UpsertAsync(mapped, result.StartedAt, cancellationToken);
result.Inserted = upsertResult.Inserted;
result.Updated = upsertResult.Updated;
result.Unchanged = upsertResult.Unchanged;
result.Skipped = upsertResult.Skipped;
// Remove any excluded records (swatches) that were synced before the exclusion existed,
// so they're deleted outright rather than lingering as "discontinued" powders.
await RemoveExcludedRecordsAsync();
// Complete pull succeeded — safe to reconcile discontinuations.
var incomingKeys = mapped
.Select(m => $"{m.VendorName}|{m.Sku}")
.ToHashSet(StringComparer.OrdinalIgnoreCase);
(result.Discontinued, result.Reactivated) =
await ReconcileDiscontinuationsAsync(incomingKeys, result.StartedAt);
result.Success = true;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Columbia catalog sync failed; skipping discontinuation sweep.");
result.Success = false;
result.ErrorMessage = ex.Message;
}
stopwatch.Stop();
result.Duration = stopwatch.Elapsed;
await RecordResultAsync(result);
return result;
}
/// <summary>
/// Flags catalog items sourced from Columbia that were NOT in this complete pull as discontinued,
/// and reactivates any previously-discontinued item that has reappeared. Returns (discontinued,
/// reactivated) counts.
/// </summary>
private async Task<(int Discontinued, int Reactivated)> ReconcileDiscontinuationsAsync(
HashSet<string> incomingKeys, DateTime runTimestamp)
{
var sourced = await _unitOfWork.PowderCatalog.FindAsync(
p => p.Source == ColumbiaIntegrationConstants.SourceName);
var discontinued = 0;
var reactivated = 0;
foreach (var item in sourced)
{
var present = incomingKeys.Contains($"{item.VendorName}|{item.Sku}");
if (!present && !item.IsDiscontinued)
{
item.IsDiscontinued = true;
item.UpdatedAt = runTimestamp;
await _unitOfWork.PowderCatalog.UpdateAsync(item);
discontinued++;
}
else if (present && item.IsDiscontinued)
{
item.IsDiscontinued = false;
item.UpdatedAt = runTimestamp;
await _unitOfWork.PowderCatalog.UpdateAsync(item);
reactivated++;
}
}
if (discontinued > 0 || reactivated > 0)
await _unitOfWork.CompleteAsync();
return (discontinued, reactivated);
}
/// <summary>
/// Deletes Columbia-sourced catalog rows that should not be in the catalog (swatch cards and
/// tester/sample size-variants). Mirrors <see cref="ColumbiaCatalogMapper.IsExcludedProduct"/>
/// on the stored columns. A no-op once the catalog is clean; guards against records synced
/// before the exclusion rule and ensures excluded items are removed, not flagged discontinued.
/// </summary>
private async Task RemoveExcludedRecordsAsync()
{
var excluded = (await _unitOfWork.PowderCatalog.FindAsync(p =>
p.Source == ColumbiaIntegrationConstants.SourceName
&& (p.Sku.EndsWith("-SW")
|| p.Sku.EndsWith("-04")
|| p.ColorName.Contains("SWATCH")
|| p.ColorName.Contains("Tester")
|| p.ColorName.Contains("Sample (")))).ToList();
if (excluded.Count == 0)
return;
foreach (var e in excluded)
await _unitOfWork.PowderCatalog.DeleteAsync(e);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Columbia sync: removed {Count} excluded record(s) (swatch/tester/sample) from the catalog.", excluded.Count);
}
/// <summary>Persists the run outcome to the last-synced / last-result platform settings.</summary>
private async Task RecordResultAsync(ColumbiaSyncResult result)
{
if (result.Success)
{
await _settings.SetAsync(
ColumbiaIntegrationConstants.SettingLastSyncedAt,
result.StartedAt.ToString("O", CultureInfo.InvariantCulture),
updatedBy: "Columbia Sync");
}
await _settings.SetAsync(
ColumbiaIntegrationConstants.SettingLastResult,
result.Summary,
updatedBy: "Columbia Sync");
}
}
@@ -1,199 +0,0 @@
using System.Net;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.Constants;
using PowderCoating.Application.DTOs.Columbia;
using PowderCoating.Application.Interfaces;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// HTTP client for the Columbia Coatings product catalog API. Reads the API key and base URL from
/// configuration (<c>Columbia:ApiKey</c> / <c>Columbia:BaseUrl</c>), sends the <c>X-API-Key</c>
/// header, and pages the catalog via <c>GET /products</c>. Honors the documented rate limit
/// (120 requests / 60s) by retrying on HTTP 429 after the <c>Retry-After</c> interval.
/// </summary>
public class ColumbiaCoatingsApiClient : IColumbiaCoatingsApiClient
{
private const int MaxRetriesPer429 = 5;
private const int DefaultRetryAfterSeconds = 5;
private const int MaxPagesSafetyCap = 1000; // guards against a server that never reports last page
private readonly IHttpClientFactory _httpClientFactory;
private readonly IConfiguration _config;
private readonly ILogger<ColumbiaCoatingsApiClient> _logger;
/// <summary>
/// Columbia returns snake_case JSON; the snake-case naming policy binds it to our PascalCase DTOs
/// without per-property attributes. Case-insensitive as a belt-and-braces fallback.
/// </summary>
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
PropertyNameCaseInsensitive = true,
Converters = { new ColumbiaImageJsonConverter() },
};
public ColumbiaCoatingsApiClient(
IHttpClientFactory httpClientFactory,
IConfiguration config,
ILogger<ColumbiaCoatingsApiClient> logger)
{
_httpClientFactory = httpClientFactory;
_config = config;
_logger = logger;
}
private string? ApiKey => _config[ColumbiaIntegrationConstants.ConfigApiKey];
private string BaseUrl =>
(_config[ColumbiaIntegrationConstants.ConfigBaseUrl] ?? ColumbiaIntegrationConstants.DefaultBaseUrl)
.TrimEnd('/');
private string ApiBasePath =>
(_config[ColumbiaIntegrationConstants.ConfigApiBasePath] ?? ColumbiaIntegrationConstants.DefaultApiBasePath)
.Trim('/');
/// <summary>Fully-qualified products endpoint: host + configurable API base path + /products.</summary>
private string ProductsUrl => $"{BaseUrl}/{ApiBasePath}{ColumbiaIntegrationConstants.ProductsResource}";
public bool IsConfigured => !string.IsNullOrWhiteSpace(ApiKey);
/// <inheritdoc />
public async Task<ColumbiaProductsResponse> GetProductsPageAsync(
int page, int perPage, CancellationToken cancellationToken = default)
{
EnsureConfigured();
perPage = Math.Clamp(perPage, 1, ColumbiaIntegrationConstants.MaxPerPage);
var url = $"{ProductsUrl}?page={page}&per_page={perPage}";
var json = await SendWithRetryAsync(url, $"page {page}", cancellationToken);
if (json == null)
return new ColumbiaProductsResponse();
return JsonSerializer.Deserialize<ColumbiaProductsResponse>(json, JsonOptions) ?? new ColumbiaProductsResponse();
}
/// <inheritdoc />
public async Task<ColumbiaProduct?> GetProductBySkuAsync(string sku, CancellationToken cancellationToken = default)
{
EnsureConfigured();
if (string.IsNullOrWhiteSpace(sku))
return null;
var url = $"{ProductsUrl}?sku={Uri.EscapeDataString(sku)}&per_page=1";
var json = await SendWithRetryAsync(url, $"sku {sku}", cancellationToken);
if (json == null)
return null;
var response = JsonSerializer.Deserialize<ColumbiaProductsResponse>(json, JsonOptions);
return response?.Items.FirstOrDefault();
}
/// <inheritdoc />
public async Task<ColumbiaProduct?> GetProductByIdAsync(int id, CancellationToken cancellationToken = default)
{
EnsureConfigured();
// The by-id endpoint returns a bare product object (not the {items,pagination} envelope).
var url = $"{ProductsUrl}/{id}";
var json = await SendWithRetryAsync(url, $"id {id}", cancellationToken);
if (json == null)
return null;
return JsonSerializer.Deserialize<ColumbiaProduct>(json, JsonOptions);
}
/// <inheritdoc />
public async Task<IReadOnlyList<ColumbiaProduct>> GetAllProductsAsync(
CancellationToken cancellationToken = default)
{
EnsureConfigured();
var all = new List<ColumbiaProduct>();
for (var page = 1; page <= MaxPagesSafetyCap; page++)
{
var response = await GetProductsPageAsync(page, ColumbiaIntegrationConstants.MaxPerPage, cancellationToken);
if (response.Items.Count == 0)
break;
all.AddRange(response.Items);
// Stop when the pagination block says we've reached the last page.
if (response.Pagination is { TotalPages: > 0 } p && page >= p.TotalPages)
break;
}
_logger.LogInformation("Columbia API: retrieved {Count} products across paged requests.", all.Count);
return all;
}
/// <summary>Throws when no API key is configured so callers fail fast rather than 401.</summary>
private void EnsureConfigured()
{
if (!IsConfigured)
throw new InvalidOperationException("Columbia Coatings API key is not configured (Columbia:ApiKey).");
}
/// <summary>
/// Issues a GET with the API key header and returns the response body. Retries on HTTP 429
/// (honoring Retry-After) up to <see cref="MaxRetriesPer429"/>. Returns null on 404 so
/// single-product lookups surface "not found" without throwing; throws on any other non-success.
/// <paramref name="describe"/> is a short label (e.g. "page 3", "sku ABC") for log/error context.
/// </summary>
private async Task<string?> SendWithRetryAsync(string url, string describe, CancellationToken cancellationToken)
{
for (var attempt = 1; ; attempt++)
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("X-API-Key", ApiKey);
var client = _httpClientFactory.CreateClient();
using var response = await client.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.TooManyRequests)
{
if (attempt > MaxRetriesPer429)
throw new HttpRequestException(
$"Columbia API still rate-limiting after {MaxRetriesPer429} retries ({describe}).");
var delaySeconds = GetRetryAfterSeconds(response) ?? DefaultRetryAfterSeconds;
_logger.LogWarning(
"Columbia API returned 429 ({Describe}, attempt {Attempt}); waiting {Delay}s before retry.",
describe, attempt, delaySeconds);
await Task.Delay(TimeSpan.FromSeconds(delaySeconds), cancellationToken);
continue;
}
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync(cancellationToken);
}
}
/// <summary>
/// Parses the <c>Retry-After</c> header (delta-seconds or HTTP-date form) into whole seconds,
/// or null when absent/unparseable so the caller can fall back to a default.
/// </summary>
private static int? GetRetryAfterSeconds(HttpResponseMessage response)
{
var retryAfter = response.Headers.RetryAfter;
if (retryAfter == null)
return null;
if (retryAfter.Delta is { } delta)
return Math.Max(1, (int)Math.Ceiling(delta.TotalSeconds));
if (retryAfter.Date is { } date)
{
var seconds = (int)Math.Ceiling((date - DateTimeOffset.UtcNow).TotalSeconds);
return seconds > 0 ? seconds : 1;
}
return null;
}
}
@@ -2866,10 +2866,6 @@ public class CsvImportService : ICsvImportService
continue; 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; DateTime? openingBalanceDate = null;
if (!string.IsNullOrWhiteSpace(record.OpeningBalanceDate) if (!string.IsNullOrWhiteSpace(record.OpeningBalanceDate)
&& DateTime.TryParse(record.OpeningBalanceDate, out var parsedDate)) && 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() public byte[] GeneratePaymentTemplate()
{ {
using var memoryStream = new MemoryStream(); using var memoryStream = new MemoryStream();
@@ -3254,7 +3223,6 @@ public class CsvImportService : ICsvImportService
Amount = 250.00m, Amount = 250.00m,
PaymentDate = DateTime.Today, PaymentDate = DateTime.Today,
PaymentMethod = "Check", PaymentMethod = "Check",
DepositAccountNumber = "10100",
Reference = "CHK-1234", Reference = "CHK-1234",
Notes = "" Notes = ""
}); });
@@ -3264,651 +3232,6 @@ public class CsvImportService : ICsvImportService
return memoryStream.ToArray(); 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) public async Task<CsvImportResultDto> ImportPaymentsAsync(Stream csvStream, int companyId)
{ {
var result = new CsvImportResultDto(); var result = new CsvImportResultDto();
@@ -3933,14 +3256,6 @@ public class CsvImportService : ICsvImportService
var invoiceByNumber = invoices.Where(i => !string.IsNullOrEmpty(i.InvoiceNumber)) var invoiceByNumber = invoices.Where(i => !string.IsNullOrEmpty(i.InvoiceNumber))
.ToDictionary(i => i.InvoiceNumber.Trim(), i => i, StringComparer.OrdinalIgnoreCase); .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>() var validMethods = Enum.GetNames<PaymentMethod>()
.ToDictionary(n => n.ToLower(), n => Enum.Parse<PaymentMethod>(n), StringComparer.OrdinalIgnoreCase); .ToDictionary(n => n.ToLower(), n => Enum.Parse<PaymentMethod>(n), StringComparer.OrdinalIgnoreCase);
@@ -3986,18 +3301,6 @@ public class CsvImportService : ICsvImportService
method = PaymentMethod.Cash; 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 var payment = new Core.Entities.Payment
{ {
InvoiceId = invoice.Id, InvoiceId = invoice.Id,
@@ -4005,7 +3308,6 @@ public class CsvImportService : ICsvImportService
Amount = record.Amount, Amount = record.Amount,
PaymentDate = new DateTime(paymentDate.Year, paymentDate.Month, paymentDate.Day, 0, 0, 0, DateTimeKind.Utc), PaymentDate = new DateTime(paymentDate.Year, paymentDate.Month, paymentDate.Day, 0, 0, 0, DateTimeKind.Utc),
PaymentMethod = method, PaymentMethod = method,
DepositAccountId = depositAccountId,
Reference = string.IsNullOrWhiteSpace(record.Reference) ? null : record.Reference.Trim(), Reference = string.IsNullOrWhiteSpace(record.Reference) ? null : record.Reference.Trim(),
Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim() Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim()
}; };
@@ -1,7 +1,6 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Accounting; using PowderCoating.Application.DTOs.Accounting;
using PowderCoating.Application.Interfaces; using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Accounting;
using PowderCoating.Core.Entities; using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums; using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data; using PowderCoating.Infrastructure.Data;
@@ -19,65 +18,10 @@ namespace PowderCoating.Infrastructure.Services;
public class FinancialReportService : IFinancialReportService public class FinancialReportService : IFinancialReportService
{ {
private readonly ApplicationDbContext _context; private readonly ApplicationDbContext _context;
private readonly ILedgerService _ledger;
public FinancialReportService(ApplicationDbContext context, ILedgerService ledger) public FinancialReportService(ApplicationDbContext context)
{ {
_context = 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/> /// <inheritdoc/>
@@ -89,7 +33,7 @@ public class FinancialReportService : IFinancialReportService
var isCash = accountingMethod == AccountingMethod.Cash; var isCash = accountingMethod == AccountingMethod.Cash;
var revenueAccounts = await _context.Accounts 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); .ToDictionaryAsync(a => a.Id);
var revenueLines = new List<FinancialReportLine>(); 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) // Cash basis: total payments received in period (not split by revenue account)
var cashRevenue = await _context.Payments 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) && p.Invoice.Status != InvoiceStatus.Voided)
.SumAsync(p => (decimal?)p.Amount) ?? 0; .SumAsync(p => (decimal?)p.Amount) ?? 0;
if (cashRevenue > 0) if (cashRevenue > 0)
revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Cash Receipts", Amount = cashRevenue }); 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 else
{ {
// Accrual basis: revenue = invoice item amounts by invoice date // Accrual basis: revenue = invoice item amounts by invoice date
var accrualRevenue = await _context.InvoiceItems var accrualRevenue = await _context.InvoiceItems
.Where(ii => ii.CompanyId == companyId .Where(ii => ii.RevenueAccountId != null
&& ii.RevenueAccountId != null
&& ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Draft
&& ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.Status != InvoiceStatus.Voided
&& ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd) && ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd)
@@ -137,8 +72,7 @@ public class FinancialReportService : IFinancialReportService
.OrderBy(l => l.AccountNumber)); .OrderBy(l => l.AccountNumber));
var unlinkedRevenue = await _context.InvoiceItems var unlinkedRevenue = await _context.InvoiceItems
.Where(ii => ii.CompanyId == companyId .Where(ii => ii.RevenueAccountId == null
&& ii.RevenueAccountId == null
&& ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Draft
&& ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.Status != InvoiceStatus.Voided
&& ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd) && 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. // Contra-revenue: discounts granted and credit memos applied reduce gross revenue.
var periodDiscounts = await _context.Invoices 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) && i.DiscountAmount > 0 && i.InvoiceDate >= from && i.InvoiceDate <= toEnd)
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m; .SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m;
// Credit-memo contra-revenue is recognized at issue (DR Sales Discounts). Net for the period = var periodCredits = await _context.CreditMemoApplications
// memos issued in the period minus the unapplied remainder of memos voided in the period. .Where(a => a.AppliedDate >= from && a.AppliedDate <= toEnd
var periodCmIssued = await _context.CreditMemos && a.Invoice.Status != InvoiceStatus.Voided)
.Where(m => m.CompanyId == companyId && m.IssueDate >= from && m.IssueDate <= toEnd) .SumAsync(a => (decimal?)a.AmountApplied) ?? 0m;
.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 totalDeductions = periodDiscounts + periodCredits; var totalDeductions = periodDiscounts + periodCredits;
if (totalDeductions > 0) if (totalDeductions > 0)
revenueLines.Add(new FinancialReportLine revenueLines.Add(new FinancialReportLine
@@ -170,26 +98,9 @@ public class FinancialReportService : IFinancialReportService
Amount = -totalDeductions 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. // GC sales are deferred to GC Liability at issuance; revenue is recognized on redemption.
var periodGcReclassified = await _context.InvoiceItems var periodGcReclassified = await _context.InvoiceItems
.Where(ii => ii.CompanyId == companyId .Where(ii => ii.IsGiftCertificate
&& ii.IsGiftCertificate
&& ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Draft
&& ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.Status != InvoiceStatus.Voided
&& ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd) && 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). // Voided GCs with remaining balance are breakage income (liability extinguished).
var periodGcBreakage = await _context.GiftCertificates 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.UpdatedAt >= from && gc.UpdatedAt <= toEnd
&& gc.OriginalAmount > gc.RedeemedAmount) && gc.OriginalAmount > gc.RedeemedAmount)
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m; .SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m;
@@ -223,7 +134,7 @@ public class FinancialReportService : IFinancialReportService
if (isCash) if (isCash)
{ {
var cashExpenses = await _context.Expenses 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) .GroupBy(e => e.ExpenseAccountId)
.Select(g => new { AccountId = g.Key, Amount = g.Sum(e => e.Amount) }) .Select(g => new { AccountId = g.Key, Amount = g.Sum(e => e.Amount) })
.ToListAsync(); .ToListAsync();
@@ -232,7 +143,7 @@ public class FinancialReportService : IFinancialReportService
// Pro-rate paid bill line items by payment fraction (bill total may be partial) // Pro-rate paid bill line items by payment fraction (bill total may be partial)
var paidBillLines = await _context.BillPayments 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) .Include(bp => bp.Bill).ThenInclude(b => b.LineItems)
.ToListAsync(); .ToListAsync();
foreach (var bp in paidBillLines) foreach (var bp in paidBillLines)
@@ -245,7 +156,7 @@ public class FinancialReportService : IFinancialReportService
else else
{ {
var accrualExpenses = await _context.Expenses 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) .GroupBy(e => e.ExpenseAccountId)
.Select(g => new { AccountId = g.Key, Amount = g.Sum(e => e.Amount) }) .Select(g => new { AccountId = g.Key, Amount = g.Sum(e => e.Amount) })
.ToListAsync(); .ToListAsync();
@@ -253,8 +164,7 @@ public class FinancialReportService : IFinancialReportService
expenseAmounts[e.AccountId] = expenseAmounts.GetValueOrDefault(e.AccountId) + e.Amount; expenseAmounts[e.AccountId] = expenseAmounts.GetValueOrDefault(e.AccountId) + e.Amount;
var accrualBillLines = await _context.BillLineItems var accrualBillLines = await _context.BillLineItems
.Where(bli => bli.CompanyId == companyId .Where(bli => bli.AccountId != null
&& bli.AccountId != null
&& bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Draft
&& bli.Bill.Status != BillStatus.Voided && bli.Bill.Status != BillStatus.Voided
&& bli.Bill.BillDate >= from && bli.Bill.BillDate <= toEnd) && bli.Bill.BillDate >= from && bli.Bill.BillDate <= toEnd)
@@ -263,23 +173,10 @@ public class FinancialReportService : IFinancialReportService
.ToListAsync(); .ToListAsync();
foreach (var b in accrualBillLines) foreach (var b in accrualBillLines)
expenseAmounts[b.AccountId] = expenseAmounts.GetValueOrDefault(b.AccountId) + b.Amount; 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 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); .ToDictionaryAsync(a => a.Id);
var cogsLines = new List<FinancialReportLine>(); 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) // Pre-compute balance contributions per account (batch GROUP BY queries avoid N+1)
var depositsByAcct = await _context.Payments var depositsByAcct = await _context.Payments
.Where(p => p.CompanyId == companyId && p.PaymentDate <= asOfEnd && p.DepositAccountId != null .Where(p => p.PaymentDate <= asOfEnd && p.DepositAccountId != null
&& p.Invoice.Status != InvoiceStatus.Voided) && p.Invoice.Status != InvoiceStatus.Voided
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
.GroupBy(p => p.DepositAccountId!.Value) .GroupBy(p => p.DepositAccountId!.Value)
.Select(g => new { Id = g.Key, Amount = g.Sum(p => p.Amount) }) .Select(g => new { Id = g.Key, Amount = g.Sum(p => p.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount); .ToDictionaryAsync(g => g.Id, g => g.Amount);
var expFromByAcct = await _context.Expenses var expFromByAcct = await _context.Expenses
.Where(e => e.CompanyId == companyId && e.Date <= asOfEnd) .Where(e => e.Date <= asOfEnd)
.GroupBy(e => e.PaymentAccountId) .GroupBy(e => e.PaymentAccountId)
.Select(g => new { Id = g.Key, Amount = g.Sum(e => e.Amount) }) .Select(g => new { Id = g.Key, Amount = g.Sum(e => e.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount); .ToDictionaryAsync(g => g.Id, g => g.Amount);
var bpFromByAcct = await _context.BillPayments var bpFromByAcct = await _context.BillPayments
.Where(bp => bp.CompanyId == companyId && bp.PaymentDate <= asOfEnd) .Where(bp => bp.PaymentDate <= asOfEnd)
.GroupBy(bp => bp.BankAccountId) .GroupBy(bp => bp.BankAccountId)
.Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) }) .Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount); .ToDictionaryAsync(g => g.Id, g => g.Amount);
var billsByApAcct = await _context.Bills 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) .GroupBy(b => b.APAccountId)
.Select(g => new { Id = g.Key, Amount = g.Sum(b => b.Total) }) .Select(g => new { Id = g.Key, Amount = g.Sum(b => b.Total) })
.ToDictionaryAsync(g => g.Id, g => g.Amount); .ToDictionaryAsync(g => g.Id, g => g.Amount);
var bpByApAcct = await _context.BillPayments var bpByApAcct = await _context.BillPayments
.Where(bp => bp.CompanyId == companyId && bp.PaymentDate <= asOfEnd) .Where(bp => bp.PaymentDate <= asOfEnd)
.GroupBy(bp => bp.Bill.APAccountId) .GroupBy(bp => bp.Bill.APAccountId)
.Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) }) .Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount); .ToDictionaryAsync(g => g.Id, g => g.Amount);
// AP: vendor credit applications reduce AP (DR side) when matched against specific bills. // AP: vendor credit applications reduce AP (DR side) when matched against specific bills.
var vcByApAcctBs = await _context.VendorCreditApplications var vcByApAcctBs = await _context.VendorCreditApplications
.Where(vca => vca.CompanyId == companyId && vca.AppliedDate <= asOfEnd) .Where(vca => vca.AppliedDate <= asOfEnd)
.GroupBy(vca => vca.VendorCredit.APAccountId) .GroupBy(vca => vca.VendorCredit.APAccountId)
.Select(g => new { Id = g.Key, Amount = g.Sum(vca => vca.Amount) }) .Select(g => new { Id = g.Key, Amount = g.Sum(vca => vca.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount); .ToDictionaryAsync(g => g.Id, g => g.Amount);
var taxByAcct = await _context.Invoices 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.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
&& i.InvoiceDate <= asOfEnd) && i.InvoiceDate <= asOfEnd)
.GroupBy(i => i.SalesTaxAccountId!.Value) .GroupBy(i => i.SalesTaxAccountId!.Value)
@@ -365,67 +263,32 @@ public class FinancialReportService : IFinancialReportService
.ToDictionaryAsync(g => g.Id, g => g.Amount); .ToDictionaryAsync(g => g.Id, g => g.Amount);
var arDebits = await _context.Invoices 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; .SumAsync(i => (decimal?)i.Total) ?? 0;
var arCredits = await _context.Payments var arCredits = await _context.Payments
.Where(p => p.CompanyId == companyId && p.PaymentDate <= asOfEnd .Where(p => p.PaymentDate <= asOfEnd
&& p.Invoice.Status != InvoiceStatus.Voided) && p.Invoice.Status != InvoiceStatus.Voided
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
.SumAsync(p => (decimal?)p.Amount) ?? 0; .SumAsync(p => (decimal?)p.Amount) ?? 0;
// Credit memo applications reduce open AR (CR AR when a credit is applied to an invoice). // Credit memo applications reduce open AR (CR AR when a credit is applied to an invoice).
var cmAppliedBs = await _context.CreditMemoApplications arCredits += await _context.CreditMemoApplications
.Where(a => a.CompanyId == companyId && a.AppliedDate <= asOfEnd && a.Invoice.Status != InvoiceStatus.Voided) .Where(a => a.AppliedDate <= asOfEnd && a.Invoice.Status != InvoiceStatus.Voided)
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0; .SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
arCredits += cmAppliedBs; // Refunds reverse collected payments — they re-open AR so reduce net AR credits.
// Gift-certificate redemptions also credit AR (ApplyGiftCertificate posts DR 2500 / CR AR). arCredits -= await _context.Refunds
// Mirror the posting here so AR is not overstated and the entry's two sides stay balanced. .Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted)
var gcRedeemedBs = await _context.GiftCertificateRedemptions .SumAsync(r => (decimal?)r.Amount) ?? 0m;
.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 by bank account: money that left the account (CR to checking/bank). // Refunds by bank account: money that left the account (CR to checking/bank).
var refundsByAcctBs = await _context.Refunds 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) .GroupBy(r => r.DepositAccountId!.Value)
.Select(g => new { Id = g.Key, Amount = g.Sum(r => r.Amount) }) .Select(g => new { Id = g.Key, Amount = g.Sum(r => r.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount); .ToDictionaryAsync(g => g.Id, g => g.Amount);
// Deposits by bank account: cash received at deposit recording time (DR bank). // Deposits by bank account: cash received at deposit recording time (DR bank).
var depositsByAcctDepBs = await _context.Deposits 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) .GroupBy(d => d.DepositAccountId!.Value)
.Select(g => new { Id = g.Key, Amount = g.Sum(d => d.Amount) }) .Select(g => new { Id = g.Key, Amount = g.Sum(d => d.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount); .ToDictionaryAsync(g => g.Id, g => g.Amount);
@@ -436,11 +299,11 @@ public class FinancialReportService : IFinancialReportService
.Select(a => (int?)a.Id).FirstOrDefaultAsync(); .Select(a => (int?)a.Id).FirstOrDefaultAsync();
var custDepositsCreditsBs = custDepositsAcctIdBs.HasValue var custDepositsCreditsBs = custDepositsAcctIdBs.HasValue
? (await _context.Deposits ? (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; .SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
var custDepositsDebitsBs = custDepositsAcctIdBs.HasValue var custDepositsDebitsBs = custDepositsAcctIdBs.HasValue
? (await _context.Deposits ? (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; .SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
// Gift Certificate Liability (2500): balance driven by GC issuances, redemptions, and voids. // 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(); .Select(a => (int?)a.Id).FirstOrDefaultAsync();
var gcLiabilityCreditsBs = gcLiabilityAcctIdBs.HasValue var gcLiabilityCreditsBs = gcLiabilityAcctIdBs.HasValue
? (await _context.GiftCertificates ? (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; .SumAsync(gc => (decimal?)gc.OriginalAmount) ?? 0m) : 0m;
var gcLiabilityDebitsBs = gcLiabilityAcctIdBs.HasValue var gcLiabilityDebitsBs = gcLiabilityAcctIdBs.HasValue
? ((await _context.GiftCertificateRedemptions ? ((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) .SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0m)
+ (await _context.GiftCertificates + (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) && gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount)
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m)) : 0m; .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 // 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). // (accruals, depreciation, year-end closes, and other adjustments not in the tables above).
var lifetimeRevenue = await _context.InvoiceItems 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; .SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
var lifetimeDiscounts = isCash ? 0m var lifetimeDiscounts = isCash ? 0m
: (await _context.Invoices : (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) && i.DiscountAmount > 0 && i.InvoiceDate <= asOfEnd)
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m); .SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m);
// Credit memos are contra-revenue recognized at issue (DR Sales Discounts). Net revenue is // Credit memos applied to invoices reduce net revenue (contra-revenue, same as discounts).
// reduced by the issued amount (active memos in full + applied portion of voided memos). var lifetimeCreditMemos = isCash ? 0m
var lifetimeCreditMemos = isCash ? 0m : cmContraRevenueBs; : (await _context.CreditMemoApplications
.Where(a => a.AppliedDate <= asOfEnd && a.Invoice.Status != InvoiceStatus.Voided)
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0m);
var lifetimeDirectExp = await _context.Expenses var lifetimeDirectExp = await _context.Expenses
.Where(e => e.CompanyId == companyId && e.Date <= asOfEnd) .Where(e => e.Date <= asOfEnd)
.SumAsync(e => (decimal?)e.Amount) ?? 0; .SumAsync(e => (decimal?)e.Amount) ?? 0;
var lifetimeBillCosts = await _context.BillLineItems 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; .SumAsync(bli => (decimal?)bli.Amount) ?? 0;
// JE net effect on revenue accounts (positive = additional revenue recognised via manual JE) // 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. // GC items sold via invoices are reclassified to GC Liability and not yet earned income.
var lifetimeGcReclassified = await _context.InvoiceItems 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.Draft
&& ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.Status != InvoiceStatus.Voided
&& ii.Invoice.InvoiceDate <= asOfEnd) && ii.Invoice.InvoiceDate <= asOfEnd)
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0m; .SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0m;
// Voided GCs with remaining balance become breakage income (the liability is extinguished). // Voided GCs with remaining balance become breakage income (the liability is extinguished).
var lifetimeGcBreakage = await _context.GiftCertificates 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) && gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount)
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m; .SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m;
var retainedEarnings = lifetimeRevenue + jeRevNet var retainedEarnings = lifetimeRevenue + jeRevNet
- lifetimeDiscounts - lifetimeDiscounts
- lifetimeCreditMemos - lifetimeCreditMemos
- refundReturnsTotalBs // revenue portion of cash refunds (reversed sales)
- lifetimeGcReclassified // deferred to GC Liability, not earned yet - lifetimeGcReclassified // deferred to GC Liability, not earned yet
+ lifetimeGcBreakage // breakage income when GC voided with balance + lifetimeGcBreakage // breakage income when GC voided with balance
- lifetimeDirectExp - lifetimeDirectExp
@@ -533,7 +397,7 @@ public class FinancialReportService : IFinancialReportService
- jeExpNet; - jeExpNet;
var accounts = await _context.Accounts var accounts = await _context.Accounts
.Where(a => a.CompanyId == companyId && a.IsActive) .Where(a => a.IsActive)
.OrderBy(a => a.AccountNumber) .OrderBy(a => a.AccountNumber)
.ToListAsync(); .ToListAsync();
@@ -561,17 +425,11 @@ public class FinancialReportService : IFinancialReportService
credits += taxByAcct.GetValueOrDefault(a.Id); credits += taxByAcct.GetValueOrDefault(a.Id);
credits += refundsByAcctBs.GetValueOrDefault(a.Id); // refunds reduce bank balance credits += refundsByAcctBs.GetValueOrDefault(a.Id); // refunds reduce bank balance
debits += depositsByAcctDepBs.GetValueOrDefault(a.Id); // deposits increase 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) if (gcLiabilityAcctIdBs.HasValue && a.Id == gcLiabilityAcctIdBs.Value)
{ {
credits += gcLiabilityCreditsBs; // GC issued → CR liability credits += gcLiabilityCreditsBs; // GC issued → CR liability
debits += gcLiabilityDebitsBs; // redeemed/voided → DR 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) if (custDepositsAcctIdBs.HasValue && a.Id == custDepositsAcctIdBs.Value)
{ {
credits += custDepositsCreditsBs; // deposits taken → CR liability credits += custDepositsCreditsBs; // deposits taken → CR liability
@@ -641,8 +499,7 @@ public class FinancialReportService : IFinancialReportService
var openInvoices = await _context.Invoices var openInvoices = await _context.Invoices
.Include(i => i.Customer) .Include(i => i.Customer)
.Where(i => i.CompanyId == companyId .Where(i => i.Status != InvoiceStatus.Draft
&& i.Status != InvoiceStatus.Draft
&& i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.Voided
&& i.Status != InvoiceStatus.Paid && i.Status != InvoiceStatus.Paid
&& i.InvoiceDate <= asOfEnd && i.InvoiceDate <= asOfEnd
@@ -722,15 +579,14 @@ public class FinancialReportService : IFinancialReportService
var invoices = await _context.Invoices var invoices = await _context.Invoices
.Include(i => i.Customer) .Include(i => i.Customer)
.Include(i => i.Payments) .Include(i => i.Payments)
.Where(i => i.CompanyId == companyId .Where(i => i.Status != InvoiceStatus.Draft
&& i.Status != InvoiceStatus.Draft
&& i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.Voided
&& i.InvoiceDate >= from && i.InvoiceDate <= toEnd) && i.InvoiceDate >= from && i.InvoiceDate <= toEnd)
.OrderBy(i => i.InvoiceDate) .OrderBy(i => i.InvoiceDate)
.ToListAsync(); .ToListAsync();
var collectedInPeriod = await _context.Payments 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; .SumAsync(p => (decimal?)p.Amount) ?? 0;
var byCustomer = invoices var byCustomer = invoices
@@ -995,8 +851,9 @@ public class FinancialReportService : IFinancialReportService
// Bank/cash: customer payments deposited here (DR) // Bank/cash: customer payments deposited here (DR)
var depositsByAcct = await _context.Payments var depositsByAcct = await _context.Payments
.Where(p => p.CompanyId == companyId && p.PaymentDate <= asOfEnd && p.DepositAccountId != null .Where(p => p.PaymentDate <= asOfEnd && p.DepositAccountId != null
&& p.Invoice.Status != InvoiceStatus.Voided) && p.Invoice.Status != InvoiceStatus.Voided
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
.GroupBy(p => p.DepositAccountId!.Value) .GroupBy(p => p.DepositAccountId!.Value)
.Select(g => new { Id = g.Key, Amt = g.Sum(p => p.Amount) }) .Select(g => new { Id = g.Key, Amt = g.Sum(p => p.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt); .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 // 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. // issues a credit note and it is matched against a specific bill.
var vcByApAcct = await _context.VendorCreditApplications var vcByApAcct = await _context.VendorCreditApplications
.Where(vca => vca.CompanyId == companyId && vca.AppliedDate <= asOfEnd) .Where(vca => vca.AppliedDate <= asOfEnd)
.GroupBy(vca => vca.VendorCredit.APAccountId) .GroupBy(vca => vca.VendorCredit.APAccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(vca => vca.Amount) }) .Select(g => new { Id = g.Key, Amt = g.Sum(vca => vca.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt); .ToDictionaryAsync(g => g.Id, g => g.Amt);
// Bank/cash: expenses paid from here (CR) // Bank/cash: expenses paid from here (CR)
var expFromByAcct = await _context.Expenses var expFromByAcct = await _context.Expenses
.Where(e => e.CompanyId == companyId && e.Date <= asOfEnd) .Where(e => e.Date <= asOfEnd)
.GroupBy(e => e.PaymentAccountId) .GroupBy(e => e.PaymentAccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(e => e.Amount) }) .Select(g => new { Id = g.Key, Amt = g.Sum(e => e.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt); .ToDictionaryAsync(g => g.Id, g => g.Amt);
// Bank/cash: bill payments made from here (CR) // Bank/cash: bill payments made from here (CR)
var bpFromByAcct = await _context.BillPayments var bpFromByAcct = await _context.BillPayments
.Where(bp => bp.CompanyId == companyId && bp.PaymentDate <= asOfEnd) .Where(bp => bp.PaymentDate <= asOfEnd)
.GroupBy(bp => bp.BankAccountId) .GroupBy(bp => bp.BankAccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(bp => bp.Amount) }) .Select(g => new { Id = g.Key, Amt = g.Sum(bp => bp.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt); .ToDictionaryAsync(g => g.Id, g => g.Amt);
// AP: bills increase AP (CR) // AP: bills increase AP (CR)
var billsByApAcct = await _context.Bills 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) .GroupBy(b => b.APAccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(b => b.Total) }) .Select(g => new { Id = g.Key, Amt = g.Sum(b => b.Total) })
.ToDictionaryAsync(g => g.Id, g => g.Amt); .ToDictionaryAsync(g => g.Id, g => g.Amt);
// AP: bill payments reduce AP (DR) // AP: bill payments reduce AP (DR)
var bpByApAcct = await _context.BillPayments var bpByApAcct = await _context.BillPayments
.Where(bp => bp.CompanyId == companyId && bp.PaymentDate <= asOfEnd) .Where(bp => bp.PaymentDate <= asOfEnd)
.GroupBy(bp => bp.Bill.APAccountId) .GroupBy(bp => bp.Bill.APAccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(bp => bp.Amount) }) .Select(g => new { Id = g.Key, Amt = g.Sum(bp => bp.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt); .ToDictionaryAsync(g => g.Id, g => g.Amt);
// Tax liability: sales tax collected (CR) // Tax liability: sales tax collected (CR)
var taxByAcct = await _context.Invoices 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.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
&& i.InvoiceDate <= asOfEnd) && i.InvoiceDate <= asOfEnd)
.GroupBy(i => i.SalesTaxAccountId!.Value) .GroupBy(i => i.SalesTaxAccountId!.Value)
@@ -1048,7 +905,7 @@ public class FinancialReportService : IFinancialReportService
// Revenue accounts: invoice line items (CR) // Revenue accounts: invoice line items (CR)
var revenueByAcct = await _context.InvoiceItems 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.Draft
&& ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.Status != InvoiceStatus.Voided
&& ii.Invoice.InvoiceDate <= asOfEnd) && ii.Invoice.InvoiceDate <= asOfEnd)
@@ -1058,14 +915,14 @@ public class FinancialReportService : IFinancialReportService
// Expense accounts: direct expenses (DR) // Expense accounts: direct expenses (DR)
var expenseByAcct = await _context.Expenses var expenseByAcct = await _context.Expenses
.Where(e => e.CompanyId == companyId && e.Date <= asOfEnd) .Where(e => e.Date <= asOfEnd)
.GroupBy(e => e.ExpenseAccountId) .GroupBy(e => e.ExpenseAccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(e => e.Amount) }) .Select(g => new { Id = g.Key, Amt = g.Sum(e => e.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt); .ToDictionaryAsync(g => g.Id, g => g.Amt);
// Expense/COGS accounts: vendor bill line items (DR) // Expense/COGS accounts: vendor bill line items (DR)
var billLinesByAcct = await _context.BillLineItems 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.Draft
&& bli.Bill.Status != BillStatus.Voided && bli.Bill.Status != BillStatus.Voided
&& bli.Bill.BillDate <= asOfEnd) && bli.Bill.BillDate <= asOfEnd)
@@ -1073,25 +930,6 @@ public class FinancialReportService : IFinancialReportService
.Select(g => new { Id = g.Key, Amt = g.Sum(bli => bli.Amount) }) .Select(g => new { Id = g.Key, Amt = g.Sum(bli => bli.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt); .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). // 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. // 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. // Credit memo applications are also added to AR credits below so the double-entry balances.
@@ -1106,50 +944,33 @@ public class FinancialReportService : IFinancialReportService
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
var cmApplied = await _context.CreditMemoApplications var cmApplied = await _context.CreditMemoApplications
.Where(a => a.CompanyId == companyId && a.AppliedDate <= asOfEnd .Where(a => a.AppliedDate <= asOfEnd
&& a.Invoice.Status != InvoiceStatus.Voided) && a.Invoice.Status != InvoiceStatus.Voided)
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0m; .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>(); var discountsByAcct = new Dictionary<int, decimal>();
if (discountAcctId.HasValue) if (discountAcctId.HasValue)
{ {
var totalDiscounts = await _context.Invoices 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.Draft
&& i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.Voided
&& i.InvoiceDate <= asOfEnd) && i.InvoiceDate <= asOfEnd)
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m; .SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m;
if (totalDiscounts + cmContraRevenue > 0) if (totalDiscounts + cmApplied > 0)
discountsByAcct[discountAcctId.Value] = totalDiscounts + cmContraRevenue; discountsByAcct[discountAcctId.Value] = totalDiscounts + cmApplied;
} }
// JE lines: posted entries debit/credit all account types // JE lines: posted entries debit/credit all account types
var jeDebitsByAcct = await _context.JournalEntryLines 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) && l.JournalEntry.EntryDate <= asOfEnd)
.GroupBy(l => l.AccountId) .GroupBy(l => l.AccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(l => l.DebitAmount) }) .Select(g => new { Id = g.Key, Amt = g.Sum(l => l.DebitAmount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt); .ToDictionaryAsync(g => g.Id, g => g.Amt);
var jeCreditsByAcct = await _context.JournalEntryLines 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) && l.JournalEntry.EntryDate <= asOfEnd)
.GroupBy(l => l.AccountId) .GroupBy(l => l.AccountId)
.Select(g => new { Id = g.Key, Amt = g.Sum(l => l.CreditAmount) }) .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 // Credits include both cash payments and credit memo applications (which reduce open AR
// when a customer credit is applied against a specific invoice). // when a customer credit is applied against a specific invoice).
var arTotalDebits = await _context.Invoices 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) && i.InvoiceDate <= asOfEnd)
.SumAsync(i => (decimal?)i.Total) ?? 0m; .SumAsync(i => (decimal?)i.Total) ?? 0m;
var arTotalCredits = await _context.Payments var arTotalCredits = await _context.Payments
.Where(p => p.CompanyId == companyId && p.PaymentDate <= asOfEnd .Where(p => p.PaymentDate <= asOfEnd
&& p.Invoice.Status != InvoiceStatus.Voided) && p.Invoice.Status != InvoiceStatus.Voided
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
.SumAsync(p => (decimal?)p.Amount) ?? 0m; .SumAsync(p => (decimal?)p.Amount) ?? 0m;
arTotalCredits += cmApplied; // credit memo applications reduce AR balance 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 → // Refunds reverse collected payments — reduce net AR credits (re-opens the receivable).
// DR Sales Tax Payable (relieves the liability), cash → CR bank (refundsByAcct below). They no var refundTotal = await _context.Refunds
// longer touch AR. Store-credit refunds post via CreditMemo, not the GL, so are excluded. .Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted)
var saleReversingRefunds = await _context.Refunds .SumAsync(r => (decimal?)r.Amount) ?? 0m;
.Where(r => r.CompanyId == companyId && r.RefundDate <= asOfEnd && !r.IsDeleted && r.Invoice != null arTotalCredits -= refundTotal;
&& 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 by bank account: money leaving the account (CR to checking/bank). // Refunds by bank account: money leaving the account (CR to checking/bank).
var refundsByAcct = await _context.Refunds 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) .GroupBy(r => r.DepositAccountId!.Value)
.Select(g => new { Id = g.Key, Amt = g.Sum(r => r.Amount) }) .Select(g => new { Id = g.Key, Amt = g.Sum(r => r.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt); .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). // 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. // Deposit-sourced Payments have DepositAccountId = null, so there is no double-count with depositsByAcct.
var depositsByAcctDep = await _context.Deposits 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) .GroupBy(d => d.DepositAccountId!.Value)
.Select(g => new { Id = g.Key, Amt = g.Sum(d => d.Amount) }) .Select(g => new { Id = g.Key, Amt = g.Sum(d => d.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt); .ToDictionaryAsync(g => g.Id, g => g.Amt);
@@ -1219,11 +1017,11 @@ public class FinancialReportService : IFinancialReportService
.Select(a => (int?)a.Id).FirstOrDefaultAsync(); .Select(a => (int?)a.Id).FirstOrDefaultAsync();
var custDepositsCredits = custDepositsAcctId.HasValue var custDepositsCredits = custDepositsAcctId.HasValue
? (await _context.Deposits ? (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; .SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
var custDepositsDebits = custDepositsAcctId.HasValue var custDepositsDebits = custDepositsAcctId.HasValue
? (await _context.Deposits ? (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; .SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
// Gift Certificate Liability (2500): balance driven by GC issuances, redemptions, and voids. // 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(); .Select(a => (int?)a.Id).FirstOrDefaultAsync();
var gcLiabilityCredits = gcLiabilityAcctId.HasValue var gcLiabilityCredits = gcLiabilityAcctId.HasValue
? (await _context.GiftCertificates ? (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; .SumAsync(gc => (decimal?)gc.OriginalAmount) ?? 0m) : 0m;
var gcLiabilityDebits = gcLiabilityAcctId.HasValue var gcLiabilityDebits = gcLiabilityAcctId.HasValue
? ((await _context.GiftCertificateRedemptions ? ((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) .SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0m)
+ (await _context.GiftCertificates + (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) && gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount)
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m)) : 0m; .SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m)) : 0m;
@@ -1279,13 +1077,8 @@ public class FinancialReportService : IFinancialReportService
debits += expenseByAcct.GetValueOrDefault(a.Id); debits += expenseByAcct.GetValueOrDefault(a.Id);
debits += billLinesByAcct.GetValueOrDefault(a.Id); debits += billLinesByAcct.GetValueOrDefault(a.Id);
debits += discountsByAcct.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 credits += refundsByAcct.GetValueOrDefault(a.Id); // refunds reduce bank balance
debits += depositsByAcctDep.GetValueOrDefault(a.Id); // deposits increase 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) if (gcLiabilityAcctId.HasValue && a.Id == gcLiabilityAcctId.Value)
{ {
credits += gcLiabilityCredits; // GC issued → CR liability credits += gcLiabilityCredits; // GC issued → CR liability
@@ -1296,11 +1089,6 @@ public class FinancialReportService : IFinancialReportService
credits += custDepositsCredits; // deposits taken → CR liability credits += custDepositsCredits; // deposits taken → CR liability
debits += custDepositsDebits; // deposits applied → DR 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) // 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 // Opening balance: invoiced paid before period start
var preInvoiced = await _context.Invoices 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.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
&& i.InvoiceDate < from) && i.InvoiceDate < from)
.SumAsync(i => (decimal?)i.Total) ?? 0; .SumAsync(i => (decimal?)i.Total) ?? 0;
var prePaid = await _context.Payments 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.Invoice.Status != InvoiceStatus.Voided
&& p.PaymentDate < from) && p.PaymentDate < from)
.SumAsync(p => (decimal?)p.Amount) ?? 0; .SumAsync(p => (decimal?)p.Amount) ?? 0;
var preCredits = await _context.CreditMemoApplications 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; .SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
var openingBalance = preInvoiced - prePaid - preCredits; var openingBalance = preInvoiced - prePaid - preCredits;
@@ -1406,7 +1194,7 @@ public class FinancialReportService : IFinancialReportService
var lines = new List<StatementLineDto>(); var lines = new List<StatementLineDto>();
var periodInvoices = await _context.Invoices 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.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
&& i.InvoiceDate >= from && i.InvoiceDate <= toEnd) && i.InvoiceDate >= from && i.InvoiceDate <= toEnd)
.AsNoTracking().ToListAsync(); .AsNoTracking().ToListAsync();
@@ -1423,7 +1211,7 @@ public class FinancialReportService : IFinancialReportService
var periodPayments = await _context.Payments var periodPayments = await _context.Payments
.Include(p => p.Invoice) .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.Invoice.Status != InvoiceStatus.Voided
&& p.PaymentDate >= from && p.PaymentDate <= toEnd) && p.PaymentDate >= from && p.PaymentDate <= toEnd)
.AsNoTracking().ToListAsync(); .AsNoTracking().ToListAsync();
@@ -1441,7 +1229,7 @@ public class FinancialReportService : IFinancialReportService
var periodCredits = await _context.CreditMemoApplications var periodCredits = await _context.CreditMemoApplications
.Include(a => a.Invoice) .Include(a => a.Invoice)
.Include(a => a.CreditMemo) .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) && a.AppliedDate >= from && a.AppliedDate <= toEnd)
.AsNoTracking().ToListAsync(); .AsNoTracking().ToListAsync();
@@ -1492,15 +1280,15 @@ public class FinancialReportService : IFinancialReportService
// Opening balance: bills payments credits before period start // Opening balance: bills payments credits before period start
var preBills = await _context.Bills 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.Status != BillStatus.Draft && b.Status != BillStatus.Voided
&& b.BillDate < from) && b.BillDate < from)
.SumAsync(b => (decimal?)b.Total) ?? 0; .SumAsync(b => (decimal?)b.Total) ?? 0;
var prePayments = await _context.BillPayments 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; .SumAsync(bp => (decimal?)bp.Amount) ?? 0;
var preVcApplied = await _context.VendorCreditApplications 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; .SumAsync(vca => (decimal?)vca.Amount) ?? 0;
var openingBalance = preBills - prePayments - preVcApplied; var openingBalance = preBills - prePayments - preVcApplied;
@@ -1508,7 +1296,7 @@ public class FinancialReportService : IFinancialReportService
var lines = new List<StatementLineDto>(); var lines = new List<StatementLineDto>();
var periodBills = await _context.Bills 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.Status != BillStatus.Draft && b.Status != BillStatus.Voided
&& b.BillDate >= from && b.BillDate <= toEnd) && b.BillDate >= from && b.BillDate <= toEnd)
.AsNoTracking().ToListAsync(); .AsNoTracking().ToListAsync();
@@ -1525,7 +1313,7 @@ public class FinancialReportService : IFinancialReportService
var periodPayments = await _context.BillPayments var periodPayments = await _context.BillPayments
.Include(bp => bp.Bill) .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) && bp.PaymentDate >= from && bp.PaymentDate <= toEnd)
.AsNoTracking().ToListAsync(); .AsNoTracking().ToListAsync();
@@ -1542,7 +1330,7 @@ public class FinancialReportService : IFinancialReportService
var periodVcApplied = await _context.VendorCreditApplications var periodVcApplied = await _context.VendorCreditApplications
.Include(vca => vca.VendorCredit) .Include(vca => vca.VendorCredit)
.Include(vca => vca.Bill) .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) && vca.AppliedDate >= from && vca.AppliedDate <= toEnd)
.AsNoTracking().ToListAsync(); .AsNoTracking().ToListAsync();
@@ -6,7 +6,6 @@ using Anthropic.SDK.Messaging;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using PowderCoating.Application.Interfaces; using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces; using PowderCoating.Core.Interfaces;
namespace PowderCoating.Infrastructure.Services; namespace PowderCoating.Infrastructure.Services;
@@ -542,20 +541,18 @@ Rules:
// Targeted prompt: we only need cure specs from this document // Targeted prompt: we only need cure specs from this document
const string curePrompt = @"You are reading a Technical Data Sheet (TDS) for a powder coating product. const string curePrompt = @"You are reading a Technical Data Sheet (TDS) for a powder coating product.
Extract the cure schedule and the specific gravity. Respond with a valid JSON object no markdown, no explanation: Extract ONLY the cure schedule. Respond with a valid JSON object no markdown, no explanation:
{ {
""cureTemperatureF"": number or null, ""cureTemperatureF"": number or null,
""cureTimeMinutes"": number or null, ""cureTimeMinutes"": number or null,
""specificGravity"": number or null, ""reasoning"": ""one sentence: what cure schedule you found""
""reasoning"": ""one sentence: what cure schedule and specific gravity you found""
} }
Rules: Rules:
- cureTemperatureF: the recommended cure temperature in °F. Convert from °C if needed (multiply by 1.8 + 32). If a range is given use the midpoint. Most powders cure 325400 °F. - cureTemperatureF: the recommended cure temperature in °F. Convert from °C if needed (multiply by 1.8 + 32). If a range is given use the midpoint. Most powders cure 325400 °F.
- cureTimeMinutes: the hold time at cure temperature in minutes (NOT total oven time). Typically 1020 min. - cureTimeMinutes: the hold time at cure temperature in minutes (NOT total oven time). Typically 1020 min.
- specificGravity: the specific gravity / density value from the TDS (often labeled ""Specific Gravity"" or ""Density""). Typically 1.21.8. Null if not stated. - If neither value can be found in the document, return null for both.";
- Return null for any value not found in the document.";
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine("Technical Data Sheet content:"); sb.AppendLine("Technical Data Sheet content:");
@@ -606,12 +603,11 @@ Rules:
Success = true, Success = true,
CureTemperatureF = GetDecimal(parsed, "cureTemperatureF"), CureTemperatureF = GetDecimal(parsed, "cureTemperatureF"),
CureTimeMinutes = GetInt(parsed, "cureTimeMinutes"), CureTimeMinutes = GetInt(parsed, "cureTimeMinutes"),
SpecificGravity = GetDecimal(parsed, "specificGravity"),
Reasoning = GetString(parsed, "reasoning"), Reasoning = GetString(parsed, "reasoning"),
}; };
_logger.LogInformation("TDS spec lookup for {Url}: temp={Temp}°F, time={Time}min, sg={Sg} ({Reasoning})", _logger.LogInformation("TDS cure lookup for {Url}: temp={Temp}°F, time={Time}min ({Reasoning})",
tdsUrl, result.CureTemperatureF, result.CureTimeMinutes, result.SpecificGravity, result.Reasoning); tdsUrl, result.CureTemperatureF, result.CureTimeMinutes, result.Reasoning);
return result; return result;
} }
catch (Exception ex) catch (Exception ex)
@@ -621,58 +617,6 @@ Rules:
} }
} }
/// <inheritdoc />
public async Task<bool> EnsureCatalogTdsSpecsAsync(PowderCatalogItem catalog)
{
// Already enriched, or nothing to read from. Specific gravity is the trigger: it's never in
// the API feed, so its absence means this item hasn't been TDS-enriched yet.
if (catalog == null || catalog.SpecificGravity.HasValue || string.IsNullOrWhiteSpace(catalog.TdsUrl))
return false;
var tds = await FetchTdsCureSpecsAsync(catalog.TdsUrl, catalog.ColorName);
if (!tds.Success)
return false;
var changed = false;
if (tds.SpecificGravity is > 0)
{
catalog.SpecificGravity = tds.SpecificGravity;
changed = true;
}
if (!catalog.CureTemperatureF.HasValue && tds.CureTemperatureF.HasValue)
{
catalog.CureTemperatureF = tds.CureTemperatureF;
changed = true;
}
if (!catalog.CureTimeMinutes.HasValue && tds.CureTimeMinutes.HasValue)
{
catalog.CureTimeMinutes = tds.CureTimeMinutes;
changed = true;
}
// Derive theoretical coverage once specific gravity is known.
if (!catalog.CoverageSqFtPerLb.HasValue && catalog.SpecificGravity is > 0)
{
catalog.CoverageSqFtPerLb = Math.Round(
TheoreticalCoverageConstant / (catalog.SpecificGravity.Value * DefaultCoverageThicknessMils),
2, MidpointRounding.AwayFromZero);
changed = true;
}
if (changed)
{
catalog.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.PowderCatalog.UpdateAsync(catalog);
await _unitOfWork.CompleteAsync();
_logger.LogInformation(
"Lazily enriched catalog item {Vendor} {Sku} from TDS: sg={Sg}, cure={Temp}F/{Time}min, coverage={Cov}",
catalog.VendorName, catalog.Sku, catalog.SpecificGravity, catalog.CureTemperatureF,
catalog.CureTimeMinutes, catalog.CoverageSqFtPerLb);
}
return changed;
}
// ── Manufacturer URL pattern: build direct product page URL ─────────────── // ── Manufacturer URL pattern: build direct product page URL ───────────────
/// <summary> /// <summary>
@@ -2,7 +2,6 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using PowderCoating.Application.DTOs.Accounting; using PowderCoating.Application.DTOs.Accounting;
using PowderCoating.Application.Interfaces; using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Accounting;
using PowderCoating.Core.Enums; using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data; using PowderCoating.Infrastructure.Data;
@@ -200,43 +199,6 @@ public class LedgerService : ILedgerService
LinkId = inv.Id 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) ──────────── // ── 6. Direct expenses categorized to this account (DEBIT) ────────────
// e.g. Expense account 6200 receives direct expense entries // e.g. Expense account 6200 receives direct expense entries
var expensesTo = await _context.Expenses var expensesTo = await _context.Expenses
@@ -350,29 +312,24 @@ public class LedgerService : ILedgerService
LinkId = cm.InvoiceId LinkId = cm.InvoiceId
}); });
// Gift-certificate redemptions reduce open AR (CREDIT)ApplyGiftCertificate posts DR 2500 / CR AR. // Refunds re-open AR (DEBIT — customer owes again after refund)
var arGcRedemptions = await _context.GiftCertificateRedemptions var arRefunds = await _context.Refunds
.Include(r => r.Invoice) .Include(r => r.Invoice)
.Include(r => r.GiftCertificate) .Where(r => r.RefundDate >= fromDate && r.RefundDate <= toDate && !r.IsDeleted)
.Where(r => !r.IsDeleted && r.RedeemedDate >= fromDate && r.RedeemedDate <= toDate
&& r.Invoice.Status != InvoiceStatus.Voided)
.ToListAsync(); .ToListAsync();
foreach (var r in arGcRedemptions) foreach (var r in arRefunds)
entries.Add(new LedgerEntryDto entries.Add(new LedgerEntryDto
{ {
Date = r.RedeemedDate, Date = r.RefundDate,
Reference = r.GiftCertificate?.CertificateCode ?? $"GC-{r.GiftCertificateId}", Reference = r.Reference ?? $"REF-{r.Id}",
Source = "Gift Certificate", Source = "Refund",
Description = $"GC redeemed on {r.Invoice?.InvoiceNumber}", Description = r.Reason,
Debit = 0, Debit = r.Amount,
Credit = r.AmountRedeemed, Credit = 0,
LinkController = "Invoices", LinkController = "Invoices",
LinkId = r.InvoiceId 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 ──────────────────────────────────────────────── // ── 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 ────────────────── // ── 10. Journal Entry lines touching this account ──────────────────
var jeLines = await _context.JournalEntryLines var jeLines = await _context.JournalEntryLines
.Include(l => l.JournalEntry) .Include(l => l.JournalEntry)
@@ -756,27 +594,6 @@ public class LedgerService : ILedgerService
&& i.InvoiceDate < beforeDate) && i.InvoiceDate < beforeDate)
.SumAsync(i => (decimal?)i.TaxAmount) ?? 0; .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) // 6. Direct expenses categorized to this account (DEBIT)
debits += await _context.Expenses debits += await _context.Expenses
.Where(e => e.ExpenseAccountId == accountId && e.Date < beforeDate) .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) .Where(a => a.AppliedDate < beforeDate && a.Invoice.Status != InvoiceStatus.Voided)
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0; .SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
// Gift-certificate redemptions credit AR (DR 2500 / CR AR), same as in GetAccountLedgerAsync. debits += await _context.Refunds
credits += await _context.GiftCertificateRedemptions .Where(r => !r.IsDeleted && r.RefundDate < beforeDate)
.Where(r => !r.IsDeleted && r.RedeemedDate < beforeDate && r.Invoice.Status != InvoiceStatus.Voided) .SumAsync(r => (decimal?)r.Amount) ?? 0;
.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.
} }
// 9. Accounts Payable // 9. Accounts Payable
@@ -861,55 +674,6 @@ public class LedgerService : ILedgerService
.SumAsync(d => (decimal?)d.Amount) ?? 0; .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) // 10. Posted journal entry lines touching this account (prior to period)
debits += await _context.JournalEntryLines debits += await _context.JournalEntryLines
.Where(l => l.AccountId == accountId .Where(l => l.AccountId == accountId
@@ -1,303 +0,0 @@
using Microsoft.Extensions.Logging;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Single upsert path for the platform <see cref="PowderCatalogItem"/> master list, shared by the
/// JSON file import and the Columbia API sync. Match key is (VendorName, SKU), case-insensitive.
/// </summary>
public class PowderCatalogUpsertService : IPowderCatalogUpsertService
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<PowderCatalogUpsertService> _logger;
public PowderCatalogUpsertService(IUnitOfWork unitOfWork, ILogger<PowderCatalogUpsertService> logger)
{
_unitOfWork = unitOfWork;
_logger = logger;
}
/// <inheritdoc />
public async Task<PowderCatalogUpsertResult> UpsertAsync(
IReadOnlyList<PowderCatalogItem> incoming,
DateTime runTimestamp,
CancellationToken cancellationToken = default)
{
var result = new PowderCatalogUpsertResult();
// Load existing rows for just the vendors we're touching, keyed by (vendor|sku) lower-cased.
var vendorNames = incoming
.Select(i => i.VendorName)
.Where(v => !string.IsNullOrWhiteSpace(v))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var existing = (await _unitOfWork.PowderCatalog.FindAsync(p => vendorNames.Contains(p.VendorName)))
.ToDictionary(KeyOf, StringComparer.OrdinalIgnoreCase);
var toAdd = new List<PowderCatalogItem>();
foreach (var item in incoming)
{
if (string.IsNullOrWhiteSpace(item.Sku) || string.IsNullOrWhiteSpace(item.ColorName))
{
result.Skipped++;
continue;
}
if (existing.TryGetValue(KeyOf(item), out var record))
{
if (ApplyFeedFields(record, item))
{
record.UpdatedAt = runTimestamp;
record.LastSyncedAt = runTimestamp;
await _unitOfWork.PowderCatalog.UpdateAsync(record);
result.Updated++;
}
else
{
result.Unchanged++;
}
}
else
{
item.CreatedAt = runTimestamp;
item.LastSyncedAt = runTimestamp;
toAdd.Add(item);
result.Inserted++;
}
}
if (toAdd.Count > 0)
await _unitOfWork.PowderCatalog.AddRangeAsync(toAdd);
await _unitOfWork.CompleteAsync();
// Push current catalog price + product data down to any tenant inventory linked to these
// catalog rows, so quotes reflect the current price.
var propagated = await PropagateToLinkedInventoryAsync();
_logger.LogInformation(
"Powder catalog upsert: {Inserted} inserted, {Updated} updated, {Unchanged} unchanged, {Skipped} skipped; {Propagated} linked inventory item(s) refreshed.",
result.Inserted, result.Updated, result.Unchanged, result.Skipped, propagated);
return result;
}
/// <summary>
/// Keeps tenant inventory in step with the catalog (across all companies): first self-heals by
/// linking any unlinked item to its catalog row by identity, then refreshes every linked item
/// with the catalog's current price and product data. Returns the number of items touched.
/// </summary>
public async Task<int> PropagateToLinkedInventoryAsync()
{
var linkedCount = await LinkUnlinkedInventoryAsync();
var refreshedCount = await RefreshLinkedInventoryAsync();
return linkedCount + refreshedCount;
}
/// <summary>
/// Self-heals the catalog link: finds inventory items with no <see cref="InventoryItem.PowderCatalogItemId"/>
/// that match a catalog row by Manufacturer + ManufacturerPartNumber (the catalog SKU), sets the
/// FK, and applies the catalog price/product data. Only links on a confident match (exact SKU,
/// matching vendor, or a single unambiguous candidate) so it never mis-links. Returns the count
/// newly linked. This backfills items created before linking existed, on every environment, with
/// no manual step.
/// </summary>
private async Task<int> LinkUnlinkedInventoryAsync()
{
var unlinked = (await _unitOfWork.InventoryItems.FindAsync(
i => i.PowderCatalogItemId == null
&& i.Manufacturer != null && i.Manufacturer != ""
&& i.ManufacturerPartNumber != null && i.ManufacturerPartNumber != "",
ignoreQueryFilters: true)).ToList();
if (unlinked.Count == 0)
return 0;
var partNumbers = unlinked.Select(i => i.ManufacturerPartNumber!).Distinct().ToList();
var bySku = (await _unitOfWork.PowderCatalog.FindAsync(p => partNumbers.Contains(p.Sku)))
.GroupBy(c => c.Sku, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
if (bySku.Count == 0)
return 0;
var linked = 0;
foreach (var inv in unlinked)
{
if (!bySku.TryGetValue(inv.ManufacturerPartNumber!, out var candidates))
continue;
var mfr = inv.Manufacturer!.Trim().ToLower();
var match = candidates.FirstOrDefault(c => c.VendorName.ToLower().Contains(mfr))
?? (candidates.Count == 1 ? candidates[0] : null);
if (match == null)
continue;
inv.PowderCatalogItemId = match.Id;
ApplyCatalogToLinkedInventory(inv, match);
await _unitOfWork.InventoryItems.UpdateAsync(inv);
linked++;
}
if (linked > 0)
await _unitOfWork.CompleteAsync();
return linked;
}
/// <summary>
/// Refreshes every tenant inventory item linked to a powder catalog row (across all companies)
/// with the catalog's current list price and product data. Sets
/// <see cref="InventoryItem.CatalogReferencePrice"/> (the QUOTING price) and product spec/doc
/// fields, but NEVER the cost basis (UnitCost/AverageCost/LastPurchasePrice), quantity, notes,
/// image, location, or stock levels — those are tenant-owned. EF persists only items that
/// actually changed, so this is a cheap no-op when nothing moved. Returns the number updated.
/// </summary>
private async Task<int> RefreshLinkedInventoryAsync()
{
var linked = (await _unitOfWork.InventoryItems.FindAsync(
i => i.PowderCatalogItemId != null, ignoreQueryFilters: true)).ToList();
if (linked.Count == 0)
return 0;
var catalogIds = linked.Select(i => i.PowderCatalogItemId!.Value).Distinct().ToList();
var catalogById = (await _unitOfWork.PowderCatalog.FindAsync(p => catalogIds.Contains(p.Id)))
.ToDictionary(p => p.Id);
var updated = 0;
foreach (var inv in linked)
{
if (!catalogById.TryGetValue(inv.PowderCatalogItemId!.Value, out var cat))
continue;
if (ApplyCatalogToLinkedInventory(inv, cat))
{
await _unitOfWork.InventoryItems.UpdateAsync(inv);
updated++;
}
}
if (updated > 0)
await _unitOfWork.CompleteAsync();
return updated;
}
/// <summary>
/// Applies the catalog's current price and product data onto a linked inventory item, returning
/// true if anything changed. Sets the quoting reference price (only when the catalog has a real
/// price &gt; 0) and refreshes product/spec fields where the catalog has a value — never erasing
/// tenant data with catalog nulls, and never touching cost basis, quantity, notes, image, or
/// stock levels.
/// </summary>
private static bool ApplyCatalogToLinkedInventory(InventoryItem inv, PowderCatalogItem cat)
{
var changed = false;
// Quoting price (the point of this): keep the current catalog list price, separate from cost.
if (cat.UnitPrice > 0 && inv.CatalogReferencePrice != cat.UnitPrice)
{
inv.CatalogReferencePrice = cat.UnitPrice;
inv.CatalogPriceUpdatedAt = DateTime.UtcNow;
changed = true;
}
// Product data — refresh from the catalog where it has a value (catalog is authoritative on
// these); do not null out a tenant value the catalog doesn't carry.
changed |= SetStrIfCatalogHas(() => inv.Description, v => inv.Description = v, cat.Description);
changed |= SetStrIfCatalogHas(() => inv.Finish, v => inv.Finish = v, cat.Finish);
changed |= SetStrIfCatalogHas(() => inv.ColorFamilies, v => inv.ColorFamilies = v, cat.ColorFamilies);
changed |= SetStrIfCatalogHas(() => inv.SdsUrl, v => inv.SdsUrl = v, cat.SdsUrl);
changed |= SetStrIfCatalogHas(() => inv.TdsUrl, v => inv.TdsUrl = v, cat.TdsUrl);
changed |= SetStrIfCatalogHas(() => inv.SpecPageUrl, v => inv.SpecPageUrl = v, cat.ProductUrl);
if (cat.CureTemperatureF.HasValue && inv.CureTemperatureF != cat.CureTemperatureF)
{ inv.CureTemperatureF = cat.CureTemperatureF; changed = true; }
if (cat.CureTimeMinutes.HasValue && inv.CureTimeMinutes != cat.CureTimeMinutes)
{ inv.CureTimeMinutes = cat.CureTimeMinutes; changed = true; }
if (cat.CoverageSqFtPerLb.HasValue && inv.CoverageSqFtPerLb != cat.CoverageSqFtPerLb)
{ inv.CoverageSqFtPerLb = cat.CoverageSqFtPerLb; changed = true; }
if (cat.SpecificGravity.HasValue && inv.SpecificGravity != cat.SpecificGravity)
{ inv.SpecificGravity = cat.SpecificGravity; changed = true; }
if (cat.TransferEfficiency.HasValue && inv.TransferEfficiency != cat.TransferEfficiency)
{ inv.TransferEfficiency = cat.TransferEfficiency; changed = true; }
if (cat.RequiresClearCoat == true && !inv.RequiresClearCoat)
{ inv.RequiresClearCoat = true; changed = true; }
return changed;
}
/// <summary>Sets a string property from the catalog only when the catalog value is non-blank and differs.</summary>
private static bool SetStrIfCatalogHas(Func<string?> get, Action<string?> set, string? catalogValue)
{
if (!string.IsNullOrWhiteSpace(catalogValue) && !string.Equals(get(), catalogValue, StringComparison.Ordinal))
{
set(catalogValue);
return true;
}
return false;
}
private static string KeyOf(PowderCatalogItem p) => $"{p.VendorName}|{p.Sku}";
/// <summary>
/// Copies feed-sourced fields from <paramref name="src"/> onto <paramref name="dest"/> and
/// returns true if anything changed. Deliberately leaves enrichment fields (SpecificGravity,
/// CoverageSqFtPerLb, TransferEfficiency, Finish) and lifecycle flags untouched — those are
/// owned by lazy TDS/AI enrichment and the discontinuation sweep, not the feed.
/// </summary>
private static bool ApplyFeedFields(PowderCatalogItem dest, PowderCatalogItem src)
{
var changed = false;
changed |= Set(() => dest.ColorName, v => dest.ColorName = v, src.ColorName);
changed |= Set(() => dest.Description, v => dest.Description = v, src.Description);
changed |= src.UnitPrice > 0 && dest.UnitPrice != src.UnitPrice && Assign(() => dest.UnitPrice = src.UnitPrice);
changed |= Set(() => dest.PriceTiersJson, v => dest.PriceTiersJson = v, src.PriceTiersJson);
changed |= Set(() => dest.ImageUrl, v => dest.ImageUrl = v, src.ImageUrl);
changed |= Set(() => dest.SdsUrl, v => dest.SdsUrl = v, src.SdsUrl);
changed |= Set(() => dest.TdsUrl, v => dest.TdsUrl = v, src.TdsUrl);
changed |= Set(() => dest.ApplicationGuideUrl, v => dest.ApplicationGuideUrl = v, src.ApplicationGuideUrl);
changed |= Set(() => dest.ProductUrl, v => dest.ProductUrl = v, src.ProductUrl);
changed |= Set(() => dest.ChemistryType, v => dest.ChemistryType = v, src.ChemistryType);
changed |= Set(() => dest.MilThickness, v => dest.MilThickness = v, src.MilThickness);
changed |= Set(() => dest.CureScheduleText, v => dest.CureScheduleText = v, src.CureScheduleText);
changed |= Set(() => dest.CureCurvesJson, v => dest.CureCurvesJson = v, src.CureCurvesJson);
changed |= src.CureTemperatureF.HasValue && dest.CureTemperatureF != src.CureTemperatureF && Assign(() => dest.CureTemperatureF = src.CureTemperatureF);
changed |= src.CureTimeMinutes.HasValue && dest.CureTimeMinutes != src.CureTimeMinutes && Assign(() => dest.CureTimeMinutes = src.CureTimeMinutes);
changed |= src.RequiresClearCoat.HasValue && dest.RequiresClearCoat != src.RequiresClearCoat && Assign(() => dest.RequiresClearCoat = src.RequiresClearCoat);
changed |= Set(() => dest.ColorFamilies, v => dest.ColorFamilies = v, src.ColorFamilies);
changed |= Set(() => dest.FormulationChanges, v => dest.FormulationChanges = v, src.FormulationChanges);
changed |= Set(() => dest.Category, v => dest.Category = v, src.Category);
changed |= Set(() => dest.Source, v => dest.Source = v, src.Source);
return changed;
}
/// <summary>
/// Sets a nullable-string property when the feed provides a non-blank value that differs.
/// Merge semantics: a blank incoming value is ignored, so a partial feed (e.g. the Prismatic
/// file import, which omits cure/chemistry) never nulls out existing data.
/// </summary>
private static bool Set(Func<string?> get, Action<string?> set, string? newValue)
{
if (string.IsNullOrWhiteSpace(newValue))
return false;
if (!string.Equals(get(), newValue, StringComparison.Ordinal))
{
set(newValue);
return true;
}
return false;
}
/// <summary>Helper so a value assignment can participate in a boolean OR chain.</summary>
private static bool Assign(Action assign)
{
assign();
return true;
}
}
@@ -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 = "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 = "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 }, 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 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 },
// 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 = "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 }, 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 ──────────────────────────────────────────────────────── // ── EQUITY ────────────────────────────────────────────────────────
@@ -85,7 +77,6 @@ public partial class SeedDataService
// A credit-normal account with a debit balance appears in the Trial Balance debit column, // 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. // 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 = "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 ──────────────────────────────────────────── // ── 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 }, 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++; 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; return added;
} }
} }
@@ -1,5 +1,6 @@
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using PowderCoating.Application.DTOs.Terminal;
using PowderCoating.Application.Interfaces; using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Enums; using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces; using PowderCoating.Core.Interfaces;
@@ -253,4 +254,258 @@ public class StripeConnectService : IStripeConnectService
} }
} }
// ===== Stripe Terminal (in-person card payments, WisePOS E) =====
/// <summary>True when the Connect secret key is a test-mode key (sk_test_…).</summary>
private bool IsTestMode => SecretKey.StartsWith("sk_test_", StringComparison.Ordinal);
/// <inheritdoc/>
public async Task<(bool Success, string? LocationId, string? ErrorMessage)> CreateTerminalLocationAsync(
string connectedAccountId,
string displayName,
TerminalAddressDto address)
{
try
{
var options = new Stripe.Terminal.LocationCreateOptions
{
DisplayName = displayName,
Address = new AddressOptions
{
Line1 = address.Line1,
City = address.City,
State = address.State,
PostalCode = address.PostalCode,
Country = address.Country
}
};
var client = new StripeClient(SecretKey);
var requestOptions = new RequestOptions { StripeAccount = connectedAccountId };
var service = new Stripe.Terminal.LocationService(client);
var location = await service.CreateAsync(options, requestOptions);
return (true, location.Id, null);
}
catch (StripeException ex)
{
_logger.LogError(ex, "Failed to create Terminal Location for account {AccountId}", connectedAccountId);
return (false, null, ex.StripeError?.Message ?? ex.Message);
}
}
/// <inheritdoc/>
public async Task<(bool Success, string? ReaderId, string? DeviceType, string? SerialNumber, string? ErrorMessage)> RegisterReaderAsync(
string connectedAccountId,
string locationId,
string registrationCode,
string label)
{
try
{
var options = new Stripe.Terminal.ReaderCreateOptions
{
RegistrationCode = registrationCode,
Label = label,
Location = locationId
};
var client = new StripeClient(SecretKey);
var requestOptions = new RequestOptions { StripeAccount = connectedAccountId };
var service = new Stripe.Terminal.ReaderService(client);
var reader = await service.CreateAsync(options, requestOptions);
return (true, reader.Id, reader.DeviceType, reader.SerialNumber, null);
}
catch (StripeException ex)
{
_logger.LogError(ex, "Failed to register Terminal reader for account {AccountId}", connectedAccountId);
return (false, null, null, null, ex.StripeError?.Message ?? ex.Message);
}
}
/// <inheritdoc/>
public async Task<(bool Success, IReadOnlyList<TerminalReaderDto> Readers, string? ErrorMessage)> ListReadersAsync(
string connectedAccountId,
string locationId)
{
try
{
var options = new Stripe.Terminal.ReaderListOptions { Location = locationId };
var client = new StripeClient(SecretKey);
var requestOptions = new RequestOptions { StripeAccount = connectedAccountId };
var service = new Stripe.Terminal.ReaderService(client);
var readers = await service.ListAsync(options, requestOptions);
var dtos = readers.Data.Select(r => new TerminalReaderDto
{
StripeReaderId = r.Id,
Label = r.Label,
DeviceType = r.DeviceType,
SerialNumber = r.SerialNumber,
NetworkStatus = r.Status
}).ToList();
return (true, dtos, null);
}
catch (StripeException ex)
{
_logger.LogError(ex, "Failed to list Terminal readers for account {AccountId}", connectedAccountId);
return (false, Array.Empty<TerminalReaderDto>(), ex.StripeError?.Message ?? ex.Message);
}
}
/// <inheritdoc/>
public async Task<(bool Success, string? ErrorMessage)> DeleteReaderAsync(
string connectedAccountId,
string readerId)
{
try
{
var client = new StripeClient(SecretKey);
var requestOptions = new RequestOptions { StripeAccount = connectedAccountId };
var service = new Stripe.Terminal.ReaderService(client);
await service.DeleteAsync(readerId, null, requestOptions);
return (true, null);
}
catch (StripeException ex)
{
_logger.LogError(ex, "Failed to delete Terminal reader {ReaderId}", readerId);
return (false, ex.StripeError?.Message ?? ex.Message);
}
}
/// <inheritdoc/>
public async Task<(bool Success, string? PaymentIntentId, string? ErrorMessage)> ProcessInvoicePaymentOnReaderAsync(
string connectedAccountId,
string readerId,
decimal amount,
decimal surchargeAmount,
string currency,
string invoiceNumber,
int invoiceId)
{
try
{
var totalWithSurcharge = amount + surchargeAmount;
var amountInCents = (long)Math.Round(totalWithSurcharge * 100, MidpointRounding.AwayFromZero);
var client = new StripeClient(SecretKey);
var requestOptions = new RequestOptions { StripeAccount = connectedAccountId };
// 1) Create a card_present PaymentIntent. Note: do NOT set AutomaticPaymentMethods here —
// it is incompatible with an explicit card_present payment method type.
var piOptions = new PaymentIntentCreateOptions
{
Amount = amountInCents,
Currency = currency.ToLower(),
Description = $"Invoice {invoiceNumber}",
PaymentMethodTypes = new List<string> { "card_present" },
CaptureMethod = "automatic",
Metadata = new Dictionary<string, string>
{
{ "invoice_id", invoiceId.ToString() },
{ "invoice_number", invoiceNumber },
{ "surcharge_amount", surchargeAmount.ToString("F2") },
{ "source", "terminal" }
}
};
var piService = new PaymentIntentService(client);
var intent = await piService.CreateAsync(piOptions, requestOptions);
// 2) Push the PaymentIntent to the physical reader; it prompts the customer to present a card.
var processOptions = new Stripe.Terminal.ReaderProcessPaymentIntentOptions
{
PaymentIntent = intent.Id,
ProcessConfig = new Stripe.Terminal.ReaderProcessConfigOptions
{
EnableCustomerCancellation = true,
SkipTipping = true
}
};
var readerService = new Stripe.Terminal.ReaderService(client);
await readerService.ProcessPaymentIntentAsync(readerId, processOptions, requestOptions);
return (true, intent.Id, null);
}
catch (StripeException ex)
{
_logger.LogError(ex, "Failed to process Terminal payment for invoice {InvoiceId} on reader {ReaderId}", invoiceId, readerId);
return (false, null, ex.StripeError?.Message ?? ex.Message);
}
}
/// <inheritdoc/>
public async Task<(bool Success, string? ActionStatus, string? ActionType, string? PaymentIntentId,
string? FailureCode, string? FailureMessage, string? NetworkStatus, string? ErrorMessage)> GetReaderStatusAsync(
string connectedAccountId,
string readerId)
{
try
{
var client = new StripeClient(SecretKey);
var requestOptions = new RequestOptions { StripeAccount = connectedAccountId };
var service = new Stripe.Terminal.ReaderService(client);
var reader = await service.GetAsync(readerId, null, requestOptions);
var action = reader.Action;
return (
true,
action?.Status,
action?.Type,
action?.ProcessPaymentIntent?.PaymentIntentId,
action?.FailureCode,
action?.FailureMessage,
reader.Status,
null);
}
catch (StripeException ex)
{
_logger.LogError(ex, "Failed to read Terminal reader status {ReaderId}", readerId);
return (false, null, null, null, null, null, null, ex.StripeError?.Message ?? ex.Message);
}
}
/// <inheritdoc/>
public async Task<(bool Success, string? ErrorMessage)> CancelReaderActionAsync(
string connectedAccountId,
string readerId)
{
try
{
var client = new StripeClient(SecretKey);
var requestOptions = new RequestOptions { StripeAccount = connectedAccountId };
var service = new Stripe.Terminal.ReaderService(client);
await service.CancelActionAsync(readerId, null, requestOptions);
return (true, null);
}
catch (StripeException ex)
{
_logger.LogError(ex, "Failed to cancel Terminal reader action {ReaderId}", readerId);
return (false, ex.StripeError?.Message ?? ex.Message);
}
}
/// <inheritdoc/>
public async Task<(bool Success, string? ErrorMessage)> SimulatePresentPaymentMethodAsync(
string connectedAccountId,
string readerId)
{
if (!IsTestMode)
return (false, "Simulated card taps are only available in Stripe test mode.");
try
{
var client = new StripeClient(SecretKey);
var requestOptions = new RequestOptions { StripeAccount = connectedAccountId };
var service = new Stripe.TestHelpers.Terminal.ReaderService(client);
await service.PresentPaymentMethodAsync(readerId, new Stripe.TestHelpers.Terminal.ReaderPresentPaymentMethodOptions(), requestOptions);
return (true, null);
}
catch (StripeException ex)
{
_logger.LogError(ex, "Failed to simulate card tap on reader {ReaderId}", readerId);
return (false, ex.StripeError?.Message ?? ex.Message);
}
}
} }
@@ -1,112 +0,0 @@
using System.Globalization;
using PowderCoating.Application.Constants;
using PowderCoating.Application.Interfaces;
namespace PowderCoating.Web.BackgroundServices;
/// <summary>
/// Runs the Columbia Coatings catalog sync on a schedule. Wakes hourly and triggers a full sync
/// only when the master switch (<c>ColumbiaSyncEnabled</c>) is on and the configured interval
/// (<c>ColumbiaSyncIntervalDays</c>) has elapsed since the last successful run. A full sync is
/// cheap (~25 API calls), so an hourly due-check is negligible; the actual work runs at most once
/// per interval. No-ops quietly when disabled or unconfigured.
/// </summary>
public class ColumbiaCatalogSyncBackgroundService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<ColumbiaCatalogSyncBackgroundService> _logger;
private static readonly TimeSpan CheckInterval = TimeSpan.FromHours(1);
private static readonly TimeSpan StartupDelay = TimeSpan.FromMinutes(2);
/// <summary>
/// Uses <see cref="IServiceScopeFactory"/> because a <see cref="BackgroundService"/> is a
/// singleton and the sync service / platform settings are scoped.
/// </summary>
public ColumbiaCatalogSyncBackgroundService(
IServiceScopeFactory scopeFactory,
ILogger<ColumbiaCatalogSyncBackgroundService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("ColumbiaCatalogSyncBackgroundService started.");
try
{
await Task.Delay(StartupDelay, stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
await RunIfDueAsync(stoppingToken);
await Task.Delay(CheckInterval, stoppingToken);
}
}
catch (OperationCanceledException)
{
// Shutting down — expected.
}
}
/// <summary>
/// Checks the enable switch and the elapsed interval, and runs a sync when due. Failures from
/// the sync itself are reported on its result (and recorded in platform settings) rather than
/// thrown, so a bad run never tears down the loop.
/// </summary>
private async Task RunIfDueAsync(CancellationToken ct)
{
using var scope = _scopeFactory.CreateScope();
var settings = scope.ServiceProvider.GetRequiredService<IPlatformSettingsService>();
if (!await settings.GetBoolAsync(ColumbiaIntegrationConstants.SettingEnabled))
return; // master switch off
var intervalDays = Math.Max(1, await settings.GetIntAsync(
ColumbiaIntegrationConstants.SettingIntervalDays,
ColumbiaIntegrationConstants.DefaultSyncIntervalDays));
if (!IsDue(await settings.GetAsync(ColumbiaIntegrationConstants.SettingLastSyncedAt), intervalDays))
return; // synced recently enough
var sync = scope.ServiceProvider.GetRequiredService<IColumbiaCatalogSyncService>();
try
{
_logger.LogInformation("Columbia scheduled sync starting (interval {Days}d).", intervalDays);
var result = await sync.RunSyncAsync(ct);
if (result.Success)
_logger.LogInformation("Columbia scheduled sync complete: {Summary}", result.Summary);
else
_logger.LogWarning("Columbia scheduled sync did not succeed: {Error}", result.ErrorMessage);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Columbia scheduled sync threw unexpectedly.");
}
}
/// <summary>
/// A sync is due when there is no recorded last-sync timestamp, or the configured number of
/// days has elapsed since it. An unparseable timestamp is treated as "due".
/// </summary>
private static bool IsDue(string? lastSyncedRaw, int intervalDays)
{
if (string.IsNullOrWhiteSpace(lastSyncedRaw))
return true;
if (!DateTime.TryParse(lastSyncedRaw, CultureInfo.InvariantCulture,
DateTimeStyles.RoundtripKind, out var lastSynced))
return true;
return DateTime.UtcNow - lastSynced.ToUniversalTime() >= TimeSpan.FromDays(intervalDays);
}
}
@@ -55,8 +55,7 @@ public class AccountsController : Controller
// GET: /Accounts // GET: /Accounts
public async Task<IActionResult> Index() public async Task<IActionResult> Index()
{ {
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var accounts = await _unitOfWork.Accounts.GetAllAsync(false, a => a.ParentAccount);
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId, false, a => a.ParentAccount);
var dtos = _mapper.Map<List<AccountListDto>>(accounts.OrderBy(a => a.AccountNumber).ToList()); 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) .OrderBy(g => (int)g.Key)
.ToList(); .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); return View(grouped);
} }
@@ -99,7 +87,18 @@ public class AccountsController : Controller
if (preSubType.HasValue) if (preSubType.HasValue)
{ {
dto.AccountSubType = preSubType.Value; 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; ViewBag.Inline = inline;
if (inline) if (inline)
@@ -135,7 +134,7 @@ public class AccountsController : Controller
var currentUser = await _userManager.GetUserAsync(User); var currentUser = await _userManager.GetUserAsync(User);
// Check for duplicate account number // 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()) if (existing.Any())
{ {
ModelState.AddModelError(nameof(dto.AccountNumber), "An account with this number already exists."); 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); var account = _mapper.Map<Account>(dto);
account.CompanyId = currentUser!.CompanyId; account.CompanyId = currentUser!.CompanyId;
account.CreatedBy = currentUser.Email; 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.Accounts.AddAsync(account);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
@@ -217,7 +213,7 @@ public class AccountsController : Controller
// Check duplicate number (excluding self) // Check duplicate number (excluding self)
var existing = await _unitOfWork.Accounts.FindAsync( 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()) if (existing.Any())
{ {
ModelState.AddModelError(nameof(dto.AccountNumber), "An account with this number already exists."); ModelState.AddModelError(nameof(dto.AccountNumber), "An account with this number already exists.");
@@ -226,9 +222,6 @@ public class AccountsController : Controller
} }
_mapper.Map(dto, account); _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.UpdatedAt = DateTime.UtcNow;
account.UpdatedBy = (await _userManager.GetUserAsync(User))?.Email; account.UpdatedBy = (await _userManager.GetUserAsync(User))?.Email;
@@ -328,49 +321,20 @@ public class AccountsController : Controller
} }
/// <summary> /// <summary>
/// Builds the Revenue / COGS / Inventory account dropdowns and the company's currently-selected /// One-time data repair for companies whose chart of accounts was imported from QuickBooks
/// default account IDs for the "Default Accounts" card on the Chart of Accounts page. Revenue and /// IIF files. QuickBooks IIF exports store credit-normal account opening balances as negative
/// COGS are filtered by their top-level AccountType; the inventory-asset list shows all Asset /// numbers (e.g. Revenue accounts), but the application's convention is to store all opening
/// accounts (Inventory sub-type first) so a company that classified its inventory account /// balances as positive amounts with the credit/debit nature implied by account type. This
/// differently can still pick it. Reuses the already-loaded <paramref name="accounts"/> list. /// 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> /// </summary>
private async Task PopulateDefaultAccountViewDataAsync(int companyId, IEnumerable<Account> accounts) // POST: /Accounts/FixOpeningBalanceSigns
{ // One-time fix: QB IIF imports store credit-normal accounts with negative opening balances.
SelectListItem Item(Account a) => new($"{a.AccountNumber} {a.Name}", a.Id.ToString()); // This flips them to positive so the chart of accounts displays correctly.
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
[HttpPost, ValidateAntiForgeryToken] [HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)] [Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public async Task<IActionResult> SaveDefaultAccounts( public async Task<IActionResult> FixOpeningBalanceSigns()
int? defaultRevenueAccountId, int? defaultCogsAccountId, int? defaultInventoryAccountId)
{ {
var companyId = _tenantContext.GetCurrentCompanyId(); var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) if (companyId == null)
@@ -381,37 +345,30 @@ public class AccountsController : Controller
try try
{ {
var prefs = await _unitOfWork.CompanyPreferences var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
.FirstOrDefaultAsync(p => p.CompanyId == companyId.Value && !p.IsDeleted); int fixed_ = 0;
if (prefs == null) foreach (var acct in accounts)
{ {
TempData["Error"] = "Company preferences not found."; if (acct.OpeningBalance < 0 &&
return RedirectToAction(nameof(Index)); acct.AccountType is Core.Enums.AccountType.Revenue
} or Core.Enums.AccountType.Liability
or Core.Enums.AccountType.Equity)
// 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; acct.OpeningBalance = Math.Abs(acct.OpeningBalance);
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync( acct.CurrentBalance = Math.Abs(acct.CurrentBalance);
a => a.Id == id.Value && a.CompanyId == companyId.Value && a.IsActive); await _unitOfWork.Accounts.UpdateAsync(acct);
return acct != null && allowed.Contains(acct.AccountType) ? acct.Id : null; fixed_++;
}
} }
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(); await _unitOfWork.CompleteAsync();
TempData["Success"] = fixed_ > 0
TempData["Success"] = "Default accounts saved. New items and invoice lines will use these when no account is chosen."; ? $"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) catch (Exception ex)
{ {
_logger.LogError(ex, "Error saving default accounts for company {CompanyId}", companyId); _logger.LogError(ex, "Error fixing opening balance signs for company {CompanyId}", companyId);
TempData["Error"] = "An error occurred while saving the default accounts."; TempData["Error"] = "An error occurred while fixing opening balances.";
} }
return RedirectToAction(nameof(Index)); return RedirectToAction(nameof(Index));
@@ -482,7 +439,7 @@ public class AccountsController : Controller
public async Task<IActionResult> YearEndClose() public async Task<IActionResult> YearEndClose()
{ {
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; 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) .OrderByDescending(y => y.ClosedYear)
.ToList(); .ToList();
@@ -507,7 +464,7 @@ public class AccountsController : Controller
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Idempotency check // 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) if (existing != null)
{ {
TempData["Error"] = $"{year} has already been closed (JE {existing.JournalEntryId})."; TempData["Error"] = $"{year} has already been closed (JE {existing.JournalEntryId}).";
@@ -515,7 +472,7 @@ public class AccountsController : Controller
} }
// Load all active accounts with balances // 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 revenueAccounts = accounts.Where(a => a.AccountType == AccountType.Revenue).ToList();
var expenseAccounts = accounts.Where(a => var expenseAccounts = accounts.Where(a =>
@@ -659,8 +616,7 @@ public class AccountsController : Controller
/// </summary> /// </summary>
private async Task PopulateDropdownsAsync(int? excludeId = null) private async Task PopulateDropdownsAsync(int? excludeId = null)
{ {
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var allAccounts = await _unitOfWork.Accounts.FindAsync(a => excludeId == null || a.Id != excludeId.Value);
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && (excludeId == null || a.Id != excludeId.Value));
ViewBag.ParentAccounts = allAccounts ViewBag.ParentAccounts = allAccounts
.OrderBy(a => a.AccountNumber) .OrderBy(a => a.AccountNumber)
@@ -65,7 +65,7 @@ public class AiQuickQuoteController : Controller
try try
{ {
var powders = await _unitOfWork.InventoryItems.FindAsync(i => 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()) if (powders.Any())
avgPowderCost = powders.Average(p => p.UnitCost); avgPowderCost = powders.Average(p => p.UnitCost);
} }
@@ -180,7 +180,7 @@ public class AiQuickQuoteController : Controller
var context = new CompanyAiContext { ProfileText = costs.AiContextProfile }; var context = new CompanyAiContext { ProfileText = costs.AiContextProfile };
var predictions = await _unitOfWork.AiItemPredictions.FindAsync( 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 context.AcceptedExamples = predictions
.OrderByDescending(p => p.CreatedAt) .OrderByDescending(p => p.CreatedAt)
@@ -213,9 +213,8 @@ public class AiQuickQuoteController : Controller
{ {
try try
{ {
var companyId = (await _userManager.GetUserAsync(User))?.CompanyId ?? 0;
var inventory = await _unitOfWork.InventoryItems.FindAsync( var inventory = await _unitOfWork.InventoryItems.FindAsync(
i => i.CompanyId == companyId && i.IsActive, i => i.IsActive,
false, false,
i => i.InventoryCategory); i => i.InventoryCategory);
@@ -268,7 +267,7 @@ public class AiQuickQuoteController : Controller
private async Task<Customer> GetOrCreateWalkInCustomerAsync(int companyId) private async Task<Customer> GetOrCreateWalkInCustomerAsync(int companyId)
{ {
var existing = (await _unitOfWork.Customers.FindAsync( 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(); .FirstOrDefault();
if (existing != null) return existing; if (existing != null) return existing;
@@ -8,7 +8,6 @@ using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums; using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces; using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants; using PowderCoating.Shared.Constants;
using PowderCoating.Web.Helpers;
namespace PowderCoating.Web.Controllers; namespace PowderCoating.Web.Controllers;
@@ -74,13 +73,6 @@ public class BankReconciliationsController : Controller
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; 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 // Set beginning balance from last completed reconciliation for this account, or 0
var lastCompleted = (await _unitOfWork.BankReconciliations.FindAsync( var lastCompleted = (await _unitOfWork.BankReconciliations.FindAsync(
br => br.CompanyId == companyId br => br.CompanyId == companyId
@@ -373,14 +365,11 @@ public class BankReconciliationsController : Controller
private async Task PopulateAccountDropdownAsync() 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( var accounts = await _unitOfWork.Accounts.FindAsync(
a => a.CompanyId == companyId && a.IsActive a => a.IsActive
&& (a.AccountType == AccountType.Asset && (a.AccountSubType == AccountSubType.Checking
|| a.AccountType == AccountType.Liability)); || a.AccountSubType == AccountSubType.Savings
|| a.AccountSubType == AccountSubType.Cash));
ViewBag.AccountSelectList = accounts ViewBag.AccountSelectList = accounts
.OrderBy(a => a.AccountNumber) .OrderBy(a => a.AccountNumber)
@@ -202,14 +202,14 @@ public class BillsController : Controller
} }
var apAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync( 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 // Vendor default expense account, fall back to first expense/COGS account
int? defaultExpenseAccountId = po.Vendor?.DefaultExpenseAccountId; int? defaultExpenseAccountId = po.Vendor?.DefaultExpenseAccountId;
if (!defaultExpenseAccountId.HasValue) if (!defaultExpenseAccountId.HasValue)
{ {
var fallbackAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync( 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; defaultExpenseAccountId = fallbackAccount?.Id;
} }
@@ -272,9 +272,8 @@ public class BillsController : Controller
}; };
// Pre-fill AP account // Pre-fill AP account
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var apAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync( var apAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.CompanyId == companyId && a.AccountSubType == AccountSubType.AccountsPayable); a => a.AccountSubType == AccountSubType.AccountsPayable);
dto.APAccountId = apAccount?.Id ?? 0; dto.APAccountId = apAccount?.Id ?? 0;
// Pre-fill default expense account for vendor // 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? bill = null;
// Bill entity, PO back-reference, and optional immediate payment all commit // 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); var dto = _mapper.Map<BillDto>(bill);
// Payment form defaults // 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( var bankAccounts = (await _unitOfWork.Accounts.FindAsync(
a => a.CompanyId == bill.CompanyId && a => a.AccountSubType == AccountSubType.Cash ||
(a.AccountType == AccountType.Asset || a.AccountSubType == AccountSubType.Checking ||
a.AccountType == AccountType.Liability))) a.AccountSubType == AccountSubType.Savings ||
a.AccountSubType == AccountSubType.CreditCard))
.OrderBy(a => a.AccountNumber) .OrderBy(a => a.AccountNumber)
.ToList(); .ToList();
@@ -728,14 +716,6 @@ public class BillsController : Controller
return RedirectToAction(nameof(Details), new { id = dto.BillId }); 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 currentUser = await _userManager.GetUserAsync(User);
var payment = _mapper.Map<BillPayment>(dto); var payment = _mapper.Map<BillPayment>(dto);
@@ -861,13 +841,6 @@ public class BillsController : Controller
var payment = await _unitOfWork.BillPayments.GetByIdAsync(dto.PaymentId); var payment = await _unitOfWork.BillPayments.GetByIdAsync(dto.PaymentId);
if (payment == null) return NotFound(); 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); var currentUser = await _userManager.GetUserAsync(User);
// If the bank account changed, reverse the old balance entry and apply the new one // 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." }); return Json(new { success = false, error = "File must be under 10 MB." });
// Load expense accounts for matching // Load expense accounts for matching
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
var expenseAccounts = allAccounts var expenseAccounts = allAccounts
.Where(a => a.AccountType == AccountType.Expense || .Where(a => a.AccountType == AccountType.Expense ||
a.AccountType == AccountType.CostOfGoods || a.AccountType == AccountType.CostOfGoods ||
@@ -1124,6 +1096,7 @@ public class BillsController : Controller
var imageBytes = ms.ToArray(); var imageBytes = ms.ToArray();
var result = await _accountingAi.ScanReceiptAsync(imageBytes, receiptImage.ContentType, expenseAccounts); 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 ?? ""; var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
await _usageLogger.LogAsync(companyId, userId, AppConstants.AiFeatures.ReceiptScan, inputLength: (int)receiptImage.Length); await _usageLogger.LogAsync(companyId, userId, AppConstants.AiFeatures.ReceiptScan, inputLength: (int)receiptImage.Length);
return Json(result); return Json(result);
@@ -1150,8 +1123,7 @@ public class BillsController : Controller
// Load expense accounts if not supplied // Load expense accounts if not supplied
if (!request.AvailableAccounts.Any()) if (!request.AvailableAccounts.Any())
{ {
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
request.AvailableAccounts = allAccounts request.AvailableAccounts = allAccounts
.Where(a => a.AccountType == AccountType.Expense || .Where(a => a.AccountType == AccountType.Expense ||
a.AccountType == AccountType.CostOfGoods || 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 companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
var cutoff = DateTime.Today.AddMonths(-12); 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) .Where(b => b.Status != BillStatus.Voided && b.BillDate >= cutoff)
.ToList(); .ToList();
@@ -30,8 +30,7 @@ public class BudgetsController : Controller
/// <summary>Lists all budgets for the current company ordered by fiscal year descending.</summary> /// <summary>Lists all budgets for the current company ordered by fiscal year descending.</summary>
public async Task<IActionResult> Index() public async Task<IActionResult> Index()
{ {
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var budgets = (await _unitOfWork.Budgets.FindAsync(b => true, false, b => b.Lines))
var budgets = (await _unitOfWork.Budgets.FindAsync(b => b.CompanyId == companyId, false, b => b.Lines))
.OrderByDescending(b => b.FiscalYear) .OrderByDescending(b => b.FiscalYear)
.ThenBy(b => b.Name) .ThenBy(b => b.Name)
.ToList(); .ToList();
@@ -247,16 +246,15 @@ public class BudgetsController : Controller
private async Task<List<Account>> GetBudgetableAccountsAsync() private async Task<List<Account>> GetBudgetableAccountsAsync()
{ {
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var accounts = await _unitOfWork.Accounts.FindAsync( 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(); return accounts.OrderBy(a => a.AccountNumber).ToList();
} }
private async Task ClearDefaultFlagAsync(int companyId, int fiscalYear, int? excludeId) private async Task ClearDefaultFlagAsync(int companyId, int fiscalYear, int? excludeId)
{ {
var others = await _unitOfWork.Budgets.FindAsync( 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) foreach (var b in others)
{ {
b.IsDefault = false; b.IsDefault = false;
@@ -208,16 +208,10 @@ namespace PowderCoating.Web.Controllers
var useMetric = await _tenantContext.UseMetricSystemAsync(); var useMetric = await _tenantContext.UseMetricSystemAsync();
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric); 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 var model = new CreateCatalogItemDto
{ {
CategoryId = categoryId ?? 0, CategoryId = categoryId ?? 0,
DisplayOrder = 0, DisplayOrder = 0
RevenueAccountId = prefs?.DefaultRevenueAccountId,
CogsAccountId = prefs?.DefaultCogsAccountId
}; };
return View(model); return View(model);
@@ -500,8 +494,7 @@ namespace PowderCoating.Web.Controllers
{ {
try try
{ {
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var items = await _unitOfWork.CatalogItems.FindAsync(i => i.CategoryId == categoryId && i.IsActive);
var items = await _unitOfWork.CatalogItems.FindAsync(i => i.CompanyId == companyId && i.CategoryId == categoryId && i.IsActive);
var itemDtos = items var itemDtos = items
.OrderBy(i => i.DisplayOrder) .OrderBy(i => i.DisplayOrder)
@@ -542,9 +535,8 @@ namespace PowderCoating.Web.Controllers
{ {
try try
{ {
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var items = await _unitOfWork.CatalogItems.FindAsync( 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 var result = items
.OrderBy(i => i.Category.Name) .OrderBy(i => i.Category.Name)
@@ -678,8 +670,7 @@ namespace PowderCoating.Web.Controllers
return; return;
} }
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
var revenueAccounts = accounts var revenueAccounts = accounts
.Where(a => a.AccountType == PowderCoating.Core.Enums.AccountType.Revenue) .Where(a => a.AccountType == PowderCoating.Core.Enums.AccountType.Revenue)
@@ -695,13 +686,6 @@ namespace PowderCoating.Web.Controllers
ViewBag.RevenueAccounts = revenueAccounts; ViewBag.RevenueAccounts = revenueAccounts;
ViewBag.CogsAccounts = cogsAccounts; 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> /// <summary>
@@ -914,7 +898,7 @@ namespace PowderCoating.Web.Controllers
// Get all active catalog items with their categories // Get all active catalog items with their categories
var items = await _unitOfWork.CatalogItems.FindAsync( var items = await _unitOfWork.CatalogItems.FindAsync(
ci => ci.CompanyId == currentUser.CompanyId && ci.IsActive, ci => ci.IsActive,
false, false,
ci => ci.Category ci => ci.Category
); );
@@ -969,7 +953,7 @@ namespace PowderCoating.Web.Controllers
r => r.CompanyId == currentUser.CompanyId); r => r.CompanyId == currentUser.CompanyId);
var report = existing.OrderByDescending(r => r.RunAt).FirstOrDefault(); 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(); ViewBag.ActiveItemCount = pricedItems.Count();
if (report != null) if (report != null)
@@ -1053,7 +1037,7 @@ namespace PowderCoating.Web.Controllers
// Load active catalog items with a real price — skip $0 items (placeholders, // Load active catalog items with a real price — skip $0 items (placeholders,
// category headers, etc.) since there's no pricing to evaluate. // category headers, etc.) since there's no pricing to evaluate.
var items = (await _unitOfWork.CatalogItems.FindAsync( 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) 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> /// <summary>
/// Renders the form for adding an additional CompanyAdmin user to an existing company. /// 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 /// Used when a company needs more than one admin or when the original admin's account must
@@ -158,6 +158,11 @@ public class CompanySettingsController : Controller
ViewBag.StripeConnectConfigured = !string.IsNullOrWhiteSpace(connectClientId) ViewBag.StripeConnectConfigured = !string.IsNullOrWhiteSpace(connectClientId)
&& !connectClientId.Contains("your_connect_client_id_here", StringComparison.OrdinalIgnoreCase); && !connectClientId.Contains("your_connect_client_id_here", StringComparison.OrdinalIgnoreCase);
// Stripe Terminal (Card Readers tab) — current in-person surcharge toggle + test-mode flag
ViewBag.TerminalSurchargeEnabled = company.TerminalSurchargeEnabled;
ViewBag.TerminalTestMode =
(_configuration["Stripe:Connect:SecretKey"] ?? string.Empty).StartsWith("sk_test_", StringComparison.Ordinal);
// Load notification templates for inline tab // Load notification templates for inline tab
var existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value); var existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value);
var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList()); var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList());
@@ -990,7 +995,7 @@ public class CompanySettingsController : Controller
// Add job counts // Add job counts
foreach (var dto in dtos) 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); return Json(dtos);
@@ -1023,7 +1028,7 @@ public class CompanySettingsController : Controller
// Check if status code already exists for this company // Check if status code already exists for this company
var exists = await _unitOfWork.JobStatusLookups var exists = await _unitOfWork.JobStatusLookups
.AnyAsync(s => s.CompanyId == companyId.Value && s.StatusCode == dto.StatusCode); .AnyAsync(s => s.StatusCode == dto.StatusCode);
if (exists) if (exists)
return Json(new { success = false, message = "Status code already exists" }); return Json(new { success = false, message = "Status code already exists" });
@@ -1100,7 +1105,7 @@ public class CompanySettingsController : Controller
return Json(new { success = false, message = "Cannot delete system-defined status" }); return Json(new { success = false, message = "Cannot delete system-defined status" });
// Check if status is in use // 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) if (inUse)
return Json(new { success = false, message = "Status is in use and cannot be deleted" }); return Json(new { success = false, message = "Status is in use and cannot be deleted" });
@@ -1184,7 +1189,7 @@ public class CompanySettingsController : Controller
// Add job counts // Add job counts
foreach (var dto in dtos) 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); return Json(dtos);
@@ -1216,7 +1221,7 @@ public class CompanySettingsController : Controller
// Check if priority code already exists for this company // Check if priority code already exists for this company
var exists = await _unitOfWork.JobPriorityLookups var exists = await _unitOfWork.JobPriorityLookups
.AnyAsync(p => p.CompanyId == companyId.Value && p.PriorityCode == dto.PriorityCode); .AnyAsync(p => p.PriorityCode == dto.PriorityCode);
if (exists) if (exists)
return Json(new { success = false, message = "Priority code already exists" }); return Json(new { success = false, message = "Priority code already exists" });
@@ -1290,7 +1295,7 @@ public class CompanySettingsController : Controller
return Json(new { success = false, message = "Cannot delete system-defined priority" }); return Json(new { success = false, message = "Cannot delete system-defined priority" });
// Check if priority is in use // 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) if (inUse)
return Json(new { success = false, message = "Priority is in use and cannot be deleted" }); return Json(new { success = false, message = "Priority is in use and cannot be deleted" });
@@ -1370,7 +1375,7 @@ public class CompanySettingsController : Controller
// Add quote counts // Add quote counts
foreach (var dto in dtos) 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); return Json(dtos);
@@ -1403,7 +1408,7 @@ public class CompanySettingsController : Controller
// Check if status code already exists for this company // Check if status code already exists for this company
var exists = await _unitOfWork.QuoteStatusLookups var exists = await _unitOfWork.QuoteStatusLookups
.AnyAsync(s => s.CompanyId == companyId.Value && s.StatusCode == dto.StatusCode); .AnyAsync(s => s.StatusCode == dto.StatusCode);
if (exists) if (exists)
return Json(new { success = false, message = "Status code already exists" }); return Json(new { success = false, message = "Status code already exists" });
@@ -1411,7 +1416,7 @@ public class CompanySettingsController : Controller
if (dto.IsApprovedStatus) if (dto.IsApprovedStatus)
{ {
var hasApproved = await _unitOfWork.QuoteStatusLookups var hasApproved = await _unitOfWork.QuoteStatusLookups
.AnyAsync(s => s.CompanyId == companyId.Value && s.IsApprovedStatus); .AnyAsync(s => s.IsApprovedStatus);
if (hasApproved) if (hasApproved)
return Json(new { success = false, message = "Only one status can be marked as 'Approved'" }); return Json(new { success = false, message = "Only one status can be marked as 'Approved'" });
} }
@@ -1419,7 +1424,7 @@ public class CompanySettingsController : Controller
if (dto.IsConvertedStatus) if (dto.IsConvertedStatus)
{ {
var hasConverted = await _unitOfWork.QuoteStatusLookups var hasConverted = await _unitOfWork.QuoteStatusLookups
.AnyAsync(s => s.CompanyId == companyId.Value && s.IsConvertedStatus); .AnyAsync(s => s.IsConvertedStatus);
if (hasConverted) if (hasConverted)
return Json(new { success = false, message = "Only one status can be marked as 'Converted'" }); return Json(new { success = false, message = "Only one status can be marked as 'Converted'" });
} }
@@ -1466,7 +1471,7 @@ public class CompanySettingsController : Controller
if (dto.IsApprovedStatus && !status.IsApprovedStatus) if (dto.IsApprovedStatus && !status.IsApprovedStatus)
{ {
var hasApproved = await _unitOfWork.QuoteStatusLookups 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) if (hasApproved)
return Json(new { success = false, message = "Only one status can be marked as 'Approved'" }); return Json(new { success = false, message = "Only one status can be marked as 'Approved'" });
} }
@@ -1474,7 +1479,7 @@ public class CompanySettingsController : Controller
if (dto.IsConvertedStatus && !status.IsConvertedStatus) if (dto.IsConvertedStatus && !status.IsConvertedStatus)
{ {
var hasConverted = await _unitOfWork.QuoteStatusLookups 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) if (hasConverted)
return Json(new { success = false, message = "Only one status can be marked as 'Converted'" }); return Json(new { success = false, message = "Only one status can be marked as 'Converted'" });
} }
@@ -1512,7 +1517,7 @@ public class CompanySettingsController : Controller
return Json(new { success = false, message = "Cannot delete system-defined status" }); return Json(new { success = false, message = "Cannot delete system-defined status" });
// Check if status is in use // 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) if (inUse)
return Json(new { success = false, message = "Status is in use and cannot be deleted" }); return Json(new { success = false, message = "Status is in use and cannot be deleted" });
@@ -1909,7 +1914,7 @@ public class CompanySettingsController : Controller
// Add appointment counts // Add appointment counts
foreach (var dto in dtos) 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); return Json(dtos);
@@ -1941,7 +1946,7 @@ public class CompanySettingsController : Controller
// Check if type code already exists for this company // Check if type code already exists for this company
var exists = await _unitOfWork.AppointmentTypeLookups var exists = await _unitOfWork.AppointmentTypeLookups
.AnyAsync(t => t.CompanyId == companyId.Value && t.TypeCode == dto.TypeCode); .AnyAsync(t => t.TypeCode == dto.TypeCode);
if (exists) if (exists)
return Json(new { success = false, message = "Type code already exists" }); return Json(new { success = false, message = "Type code already exists" });
@@ -2015,7 +2020,7 @@ public class CompanySettingsController : Controller
return Json(new { success = false, message = "Cannot delete system-defined type" }); return Json(new { success = false, message = "Cannot delete system-defined type" });
// Check if type is in use // 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) if (inUse)
return Json(new { success = false, message = "Type is in use and cannot be deleted" }); return Json(new { success = false, message = "Type is in use and cannot be deleted" });
@@ -2095,7 +2100,7 @@ public class CompanySettingsController : Controller
// Add item counts // Add item counts
foreach (var dto in dtos) 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); return Json(dtos);
@@ -2127,7 +2132,7 @@ public class CompanySettingsController : Controller
// Check if category code already exists for this company // Check if category code already exists for this company
var exists = await _unitOfWork.InventoryCategoryLookups var exists = await _unitOfWork.InventoryCategoryLookups
.AnyAsync(c => c.CompanyId == companyId.Value && c.CategoryCode == dto.CategoryCode); .AnyAsync(c => c.CategoryCode == dto.CategoryCode);
if (exists) if (exists)
return Json(new { success = false, message = "Category code already exists" }); return Json(new { success = false, message = "Category code already exists" });
@@ -2193,7 +2198,7 @@ public class CompanySettingsController : Controller
return Json(new { success = false, message = "Category not found" }); return Json(new { success = false, message = "Category not found" });
// Check if category is in use // 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) if (inUse)
return Json(new { success = false, message = "Category is in use and cannot be deleted" }); return Json(new { success = false, message = "Category is in use and cannot be deleted" });
@@ -2404,7 +2409,7 @@ public class CompanySettingsController : Controller
return Json(new { success = false, message = "Oven not found." }); return Json(new { success = false, message = "Oven not found." });
// Check if any quotes reference this oven // 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) if (usageCount > 0)
return Json(new { success = false, message = $"Cannot delete: {usageCount} quote(s) reference this oven. Deactivate it instead." }); 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] [HttpGet]
public async Task<IActionResult> Index(string? status, string? search) public async Task<IActionResult> Index(string? status, string? search)
{ {
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var memos = await _unitOfWork.CreditMemos.FindAsync( var memos = await _unitOfWork.CreditMemos.FindAsync(
m => m.CompanyId == companyId, false, m => true, false,
m => m.Customer); m => m.Customer);
if (!string.IsNullOrWhiteSpace(search)) if (!string.IsNullOrWhiteSpace(search))
@@ -178,13 +177,6 @@ public class CreditMemosController : Controller
await _unitOfWork.CompleteAsync(); 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)}."; TempData["Success"] = $"Credit memo {memoNumber} for {vm.Amount:C} issued to {DisplayName(customer)}.";
return RedirectToAction(nameof(Details), new { id = memo.Id }); return RedirectToAction(nameof(Details), new { id = memo.Id });
} }
@@ -260,14 +252,18 @@ public class CreditMemosController : Controller
await _unitOfWork.Invoices.UpdateAsync(invoice); await _unitOfWork.Invoices.UpdateAsync(invoice);
} }
// GL: applying the credit relieves the liability — DR Customer Credits (2350) / CR AR. // GL: DR 4950 Sales Discounts (contra-revenue) / CR AR.
// The contra-revenue (Sales Discounts) was recognized when the credit was issued. // The dynamic report computation attributes credit memo applications to both
// Keeps Account.CurrentBalance in sync for RecalculateAllAsync and direct readers. // accounts already; this call keeps Account.CurrentBalance in sync for
// RecalculateAllAsync and any tools that read it directly.
var arAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync( var arAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.CompanyId == invoice.CompanyId && a.AccountSubType == AccountSubType.AccountsReceivable && a.IsActive); a => a.AccountSubType == AccountSubType.AccountsReceivable && a.IsActive);
var customerCreditsAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync( var discountAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.CompanyId == invoice.CompanyId && a.AccountNumber == "2350" && a.IsActive); a => a.AccountNumber == "4950" && a.IsActive)
await _accountBalanceService.DebitAsync(customerCreditsAcct?.Id, applyAmount); ?? 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 _accountBalanceService.CreditAsync(arAcct?.Id, applyAmount);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
@@ -312,15 +308,6 @@ public class CreditMemosController : Controller
await _unitOfWork.Customers.UpdateAsync(memo.Customer); 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(); await _unitOfWork.CompleteAsync();
TempData["Success"] = "Credit memo voided. Unapplied balance reversed from customer credit."; TempData["Success"] = "Credit memo voided. Unapplied balance reversed from customer credit.";
return RedirectToAction(nameof(Details), new { id }); return RedirectToAction(nameof(Details), new { id });
@@ -1411,8 +1411,7 @@ public class CustomersController : Controller
/// </summary> /// </summary>
private async Task PopulatePricingTiersAsync() private async Task PopulatePricingTiersAsync()
{ {
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var tiers = await _unitOfWork.PricingTiers.FindAsync(t => t.IsActive);
var tiers = await _unitOfWork.PricingTiers.FindAsync(t => t.CompanyId == companyId && t.IsActive);
ViewBag.PricingTiers = tiers ViewBag.PricingTiers = tiers
.OrderBy(t => t.TierName) .OrderBy(t => t.TierName)
.Select(t => new SelectListItem .Select(t => new SelectListItem
@@ -25,7 +25,6 @@ public class DashboardController : Controller
private readonly ICompanyConfigHealthService _configHealth; private readonly ICompanyConfigHealthService _configHealth;
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
private readonly ISubscriptionService _subscriptionService; private readonly ISubscriptionService _subscriptionService;
private readonly IInventoryAiLookupService _aiLookupService;
public DashboardController( public DashboardController(
IUnitOfWork unitOfWork, IUnitOfWork unitOfWork,
@@ -34,8 +33,7 @@ public class DashboardController : Controller
ITenantContext tenantContext, ITenantContext tenantContext,
ICompanyConfigHealthService configHealth, ICompanyConfigHealthService configHealth,
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
ISubscriptionService subscriptionService, ISubscriptionService subscriptionService)
IInventoryAiLookupService aiLookupService)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_logger = logger; _logger = logger;
@@ -44,7 +42,6 @@ public class DashboardController : Controller
_configHealth = configHealth; _configHealth = configHealth;
_userManager = userManager; _userManager = userManager;
_subscriptionService = subscriptionService; _subscriptionService = subscriptionService;
_aiLookupService = aiLookupService;
} }
/// <summary> /// <summary>
@@ -499,7 +496,7 @@ public class DashboardController : Controller
return null; return null;
// These share the same scoped DbContext so must run sequentially // 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 // ignoreQueryFilters so soft-deleted lookups (UpdatedAt set on delete) are also visible
var hasCustomizedLookups = await _unitOfWork.JobStatusLookups.AnyAsync( var hasCustomizedLookups = await _unitOfWork.JobStatusLookups.AnyAsync(
j => j.CompanyId == companyId && j.UpdatedAt != null, j => j.CompanyId == companyId && j.UpdatedAt != null,
@@ -768,147 +765,27 @@ public class DashboardController : Controller
UpdatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
}; };
// Enrich from the platform powder catalog so the new inventory record carries the full
// spec/doc set (cure schedule, SDS/TDS, sample image, color families) rather than just
// the color code/name carried on the quote. Match by the catalog SKU (stored as the
// coat's colorCode), preferring the same manufacturer; fall back to color name.
await EnrichInventoryFromCatalogAsync(inventoryItem, colorCode, colorName, manufacturer);
var linkedCount = await FinalizeReceivedPowderAsync(
coat, inventoryItem, lbsReceived, companyId, colorCode, colorName, primaryVendorId,
jobItem?.Job?.JobNumber);
return Json(new { success = true, itemName = inventoryItem.Name, sku = inventoryItem.SKU, linkedCount });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error adding custom powder to inventory for coat {CoatId}", coatId);
return Json(new { success = false, message = "An error occurred while saving." });
}
}
/// <summary>
/// Finds the platform powder catalog row for an inventory/coat identity: by catalog SKU
/// (stored as the coat's color code), preferring the same manufacturer, then by color name.
/// Returns null when no match is found.
/// </summary>
private async Task<PowderCatalogItem?> FindCatalogByIdentityAsync(
string? colorCode, string? colorName, string? manufacturer)
{
var code = colorCode?.Trim();
if (!string.IsNullOrWhiteSpace(code))
{
var codeLower = code.ToLower();
var hits = (await _unitOfWork.PowderCatalog.FindAsync(p => p.Sku.ToLower() == codeLower)).ToList();
var mfr = manufacturer?.Trim().ToLower();
var match = (!string.IsNullOrWhiteSpace(mfr)
? hits.FirstOrDefault(p => p.VendorName.ToLower().Contains(mfr))
: null)
?? hits.FirstOrDefault();
if (match != null)
return match;
}
if (!string.IsNullOrWhiteSpace(colorName))
{
var nameLower = colorName.Trim().ToLower();
return (await _unitOfWork.PowderCatalog.FindAsync(p => p.ColorName.ToLower() == nameLower))
.FirstOrDefault();
}
return null;
}
/// <summary>
/// Copies catalog spec/document fields onto an inventory item — cure schedule, coverage,
/// specific gravity, transfer efficiency, SDS/TDS links, sample image, color families, product
/// page — and links <see cref="InventoryItem.PowderCatalogItemId"/>. Only fills gaps, so any
/// value already set (e.g. entered on the receive form) is preserved.
/// </summary>
private static void ApplyCatalogToInventory(InventoryItem item, PowderCatalogItem catalog)
{
item.PowderCatalogItemId = catalog.Id;
if (string.IsNullOrWhiteSpace(item.ManufacturerPartNumber)) item.ManufacturerPartNumber = catalog.Sku;
if (string.IsNullOrWhiteSpace(item.Manufacturer)) item.Manufacturer = catalog.VendorName;
if (string.IsNullOrWhiteSpace(item.ColorName)) item.ColorName = catalog.ColorName;
if (string.IsNullOrWhiteSpace(item.Finish)) item.Finish = catalog.Finish;
if (string.IsNullOrWhiteSpace(item.ColorFamilies)) item.ColorFamilies = catalog.ColorFamilies;
if (string.IsNullOrWhiteSpace(item.ImageUrl)) item.ImageUrl = catalog.ImageUrl;
if (string.IsNullOrWhiteSpace(item.SdsUrl)) item.SdsUrl = catalog.SdsUrl;
if (string.IsNullOrWhiteSpace(item.TdsUrl)) item.TdsUrl = catalog.TdsUrl;
if (string.IsNullOrWhiteSpace(item.SpecPageUrl)) item.SpecPageUrl = catalog.ProductUrl;
item.CureTemperatureF ??= catalog.CureTemperatureF;
item.CureTimeMinutes ??= catalog.CureTimeMinutes;
item.SpecificGravity ??= catalog.SpecificGravity;
item.CoverageSqFtPerLb ??= catalog.CoverageSqFtPerLb ?? 30m;
item.TransferEfficiency ??= catalog.TransferEfficiency ?? 65m;
if (!item.RequiresClearCoat && catalog.RequiresClearCoat == true)
item.RequiresClearCoat = true;
if (item.UnitCost <= 0 && catalog.UnitPrice > 0)
{
item.UnitCost = catalog.UnitPrice;
item.LastPurchasePrice = catalog.UnitPrice;
}
// Quoting reference price (current catalog list price) — separate from cost basis above.
if (catalog.UnitPrice > 0)
{
item.CatalogReferencePrice = catalog.UnitPrice;
item.CatalogPriceUpdatedAt = DateTime.UtcNow;
}
}
/// <summary>
/// Fills blank spec/document fields on a received custom-powder inventory item from the matching
/// platform powder catalog row, so the tenant gets a complete record instead of just the color
/// code/name carried on the quote. No-op when the powder isn't in the catalog.
/// </summary>
private async Task EnrichInventoryFromCatalogAsync(
InventoryItem item, string? colorCode, string? colorName, string? manufacturer)
{
var catalog = await FindCatalogByIdentityAsync(colorCode, colorName, manufacturer);
if (catalog == null)
return;
// First use — lazily fill specific gravity / cure from the TDS before copying onto the item.
await _aiLookupService.EnsureCatalogTdsSpecsAsync(catalog);
ApplyCatalogToInventory(item, catalog);
}
/// <summary>
/// Shared finalize for a received powder: saves the inventory item, writes the opening Purchase
/// transaction, marks the coat received and links it, then links any sibling coats ordering the
/// same color. Returns the number of additional coats linked. Used by both the manual modal
/// (<see cref="AddCustomPowderToInventory"/>) and the catalog auto-receive
/// (<see cref="ReceivePowderFromCatalog"/>).
/// </summary>
private async Task<int> FinalizeReceivedPowderAsync(
JobItemCoat coat, InventoryItem inventoryItem, decimal lbsReceived, int companyId,
string? colorCode, string? colorName, int? primaryVendorId, string? jobNumber)
{
await _unitOfWork.InventoryItems.AddAsync(inventoryItem); await _unitOfWork.InventoryItems.AddAsync(inventoryItem);
await _unitOfWork.CompleteAsync(); // flush to get inventoryItem.Id await _unitOfWork.CompleteAsync(); // flush to get inventoryItem.Id
// Opening stock transaction
var transaction = new InventoryTransaction var transaction = new InventoryTransaction
{ {
CompanyId = companyId, CompanyId = companyId,
InventoryItemId = inventoryItem.Id, InventoryItemId = inventoryItem.Id,
TransactionType = InventoryTransactionType.Purchase, TransactionType = InventoryTransactionType.Purchase,
Quantity = lbsReceived, Quantity = lbsReceived,
UnitCost = inventoryItem.UnitCost, UnitCost = unitCost ?? 0,
TotalCost = lbsReceived * inventoryItem.UnitCost, TotalCost = lbsReceived * (unitCost ?? 0),
TransactionDate = DateTime.UtcNow, TransactionDate = DateTime.UtcNow,
Notes = $"Initial stock — received from powder order for job {jobNumber}", Notes = $"Initial stock — received from powder order for job {jobItem?.Job?.JobNumber}",
BalanceAfter = lbsReceived, BalanceAfter = lbsReceived,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
}; };
await _unitOfWork.InventoryTransactions.AddAsync(transaction); await _unitOfWork.InventoryTransactions.AddAsync(transaction);
// Mark coat as received and link to the new inventory item
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
coat.PowderReceived = true; coat.PowderReceived = true;
coat.PowderReceivedAt = DateTime.UtcNow; coat.PowderReceivedAt = DateTime.UtcNow;
@@ -916,18 +793,21 @@ public class DashboardController : Controller
coat.PowderReceivedLbs = lbsReceived; coat.PowderReceivedLbs = lbsReceived;
coat.InventoryItemId = inventoryItem.Id; coat.InventoryItemId = inventoryItem.Id;
var candidateCoats = await _unitOfWork.JobItemCoats.GetCandidateCoatsForLinkingAsync(coat.Id, companyId); // Scan for sibling coats with the same custom powder and link them to the new item
var candidateCoats = await _unitOfWork.JobItemCoats.GetCandidateCoatsForLinkingAsync(coatId, companyId);
var linkedCount = 0; int linkedCount = 0;
foreach (var other in candidateCoats) foreach (var other in candidateCoats)
{ {
var colorMatch = !string.IsNullOrWhiteSpace(colorCode) bool colorMatch = !string.IsNullOrWhiteSpace(colorCode)
? string.Equals(other.ColorCode?.Trim(), colorCode.Trim(), StringComparison.OrdinalIgnoreCase) ? string.Equals(other.ColorCode?.Trim(), colorCode.Trim(), StringComparison.OrdinalIgnoreCase)
: !string.IsNullOrWhiteSpace(colorName) && : !string.IsNullOrWhiteSpace(colorName) &&
string.Equals(other.ColorName?.Trim(), colorName.Trim(), StringComparison.OrdinalIgnoreCase); string.Equals(other.ColorName?.Trim(), colorName.Trim(), StringComparison.OrdinalIgnoreCase);
if (!colorMatch) continue; if (!colorMatch) continue;
if (primaryVendorId.HasValue && other.VendorId.HasValue && other.VendorId != primaryVendorId) continue;
if (primaryVendorId.HasValue && other.VendorId.HasValue && other.VendorId != primaryVendorId)
continue;
other.InventoryItemId = inventoryItem.Id; other.InventoryItemId = inventoryItem.Id;
linkedCount++; linkedCount++;
@@ -938,112 +818,12 @@ public class DashboardController : Controller
linkedCount, inventoryItem.Id, inventoryItem.SKU); linkedCount, inventoryItem.Id, inventoryItem.SKU);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
return linkedCount;
}
/// <summary> return Json(new { success = true, itemName = inventoryItem.Name, sku = inventoryItem.SKU, linkedCount });
/// Generates a unique powder SKU for a company in the form <c>{CODE}-{YYMM}-{####}</c>, where
/// CODE is the (padded) inventory category code. Mirrors the inventory SKU pattern used when
/// adding catalog-sourced powders.
/// </summary>
private async Task<string> GeneratePowderSkuAsync(InventoryCategoryLookup category)
{
var code = category.CategoryCode.Length >= 4
? category.CategoryCode[..4].ToUpperInvariant()
: category.CategoryCode.ToUpperInvariant().PadRight(4, 'X');
var yearMonth = DateTime.Now.ToString("yyMM");
var prefix = $"{code}-{yearMonth}-";
var allItems = await _unitOfWork.InventoryItems.GetAllAsync(ignoreQueryFilters: true);
var maxSeq = allItems
.Where(i => i.SKU.StartsWith(prefix))
.Select(i => int.TryParse(i.SKU[prefix.Length..], out var n) ? n : 0)
.DefaultIfEmpty(0)
.Max();
return $"{prefix}{(maxSeq + 1):D4}";
}
/// <summary>
/// Receives an ordered custom powder straight into inventory WITHOUT the manual modal when the
/// powder is already in the master catalog — the new record is fully populated from the catalog
/// (specs, SDS/TDS, image, pricing). Returns <c>needsDetails = true</c> (without saving) when
/// the powder isn't in the catalog or no coating category is configured, signaling the caller to
/// fall back to the manual entry modal.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ReceivePowderFromCatalog(int coatId, decimal lbsReceived)
{
try
{
if (lbsReceived <= 0)
return Json(new { success = false, message = "Quantity received must be greater than zero." });
var coat = await _unitOfWork.JobItemCoats.GetByIdAsync(coatId);
if (coat == null)
return Json(new { success = false, message = "Coat record not found." });
var jobItem = await _unitOfWork.JobItems.FirstOrDefaultAsync(
i => i.Coats.Any(c => c.Id == coatId), false, i => i.Job);
var companyId = jobItem?.Job?.CompanyId ?? _tenantContext.GetCurrentCompanyId() ?? 0;
// Only auto-receive when the powder resolves in the master catalog; otherwise the caller
// opens the manual modal.
var catalog = await FindCatalogByIdentityAsync(coat.ColorCode, coat.ColorName, null);
if (catalog == null)
return Json(new { success = false, needsDetails = true });
// First use — lazily fill specific gravity / cure from the TDS so the new record is complete.
await _aiLookupService.EnsureCatalogTdsSpecsAsync(catalog);
// Resolve the company's POWDER (coating) inventory category.
var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
var coatingCategory = categories.FirstOrDefault(c => c.IsActive && c.IsCoating
&& c.CategoryCode.Equals("POWDER", StringComparison.OrdinalIgnoreCase))
?? categories.Where(c => c.IsActive && c.IsCoating)
.OrderBy(c => c.DisplayOrder).FirstOrDefault();
if (coatingCategory == null)
return Json(new { success = false, needsDetails = true });
var sku = await GeneratePowderSkuAsync(coatingCategory);
var inventoryItem = new InventoryItem
{
CompanyId = companyId,
SKU = sku,
Name = catalog.ColorName,
ColorName = catalog.ColorName,
ColorCode = coat.ColorCode,
InventoryCategoryId = coatingCategory.Id,
Category = coatingCategory.DisplayName,
QuantityOnHand = lbsReceived,
UnitOfMeasure = "lbs",
UnitCost = catalog.UnitPrice,
LastPurchasePrice = catalog.UnitPrice,
LastPurchaseDate = DateTime.UtcNow,
IsActive = true,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
ApplyCatalogToInventory(inventoryItem, catalog);
var linkedCount = await FinalizeReceivedPowderAsync(
coat, inventoryItem, lbsReceived, companyId, coat.ColorCode, coat.ColorName, null,
jobItem?.Job?.JobNumber);
return Json(new
{
success = true,
fromCatalog = true,
itemName = inventoryItem.Name,
sku = inventoryItem.SKU,
linkedCount
});
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error auto-receiving powder from catalog for coat {CoatId}", coatId); _logger.LogError(ex, "Error adding custom powder to inventory for coat {CoatId}", coatId);
return Json(new { success = false, message = "An error occurred while saving." }); return Json(new { success = false, message = "An error occurred while saving." });
} }
} }
@@ -9,7 +9,6 @@ using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums; using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces; using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants; using PowderCoating.Shared.Constants;
using PowderCoating.Web.Helpers;
using QuestPDF.Fluent; using QuestPDF.Fluent;
using QuestPDF.Helpers; using QuestPDF.Helpers;
using QuestPDF.Infrastructure; using QuestPDF.Infrastructure;
@@ -64,8 +63,7 @@ public class DepositsController : Controller
string paymentMethod, string paymentMethod,
DateTime receivedDate, DateTime receivedDate,
string? reference, string? reference,
string? notes, string? notes)
int? depositAccountId = null)
{ {
try try
{ {
@@ -82,32 +80,7 @@ public class DepositsController : Controller
if (currentUser == null) return Unauthorized(); if (currentUser == null) return Unauthorized();
var receiptNumber = await GenerateReceiptNumberAsync(currentUser.CompanyId); var receiptNumber = await GenerateReceiptNumberAsync(currentUser.CompanyId);
var checkingAcctId = await GetCheckingAccountIdAsync(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 deposit = new Deposit var deposit = new Deposit
{ {
@@ -120,7 +93,7 @@ public class DepositsController : Controller
ReceivedDate = receivedDate, ReceivedDate = receivedDate,
Reference = reference, Reference = reference,
Notes = notes, Notes = notes,
DepositAccountId = depositAcctId, DepositAccountId = checkingAcctId,
RecordedById = currentUser.Id, RecordedById = currentUser.Id,
CompanyId = currentUser.CompanyId, CompanyId = currentUser.CompanyId,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
@@ -131,7 +104,8 @@ public class DepositsController : Controller
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
// GL: DR Checking (cash received) / CR Customer Deposits 2300 (liability until applied to invoice). // 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); await _accountBalanceService.CreditAsync(custDepositsAcctId, deposit.Amount);
return Json(new return Json(new
@@ -105,9 +105,8 @@ public class ExpensesController : Controller
ViewBag.To = to?.ToString("yyyy-MM-dd"); ViewBag.To = to?.ToString("yyyy-MM-dd");
ViewBag.TotalAmount = dtos.Sum(e => e.Amount); ViewBag.TotalAmount = dtos.Sum(e => e.Amount);
var legacyUser = await _userManager.GetUserAsync(User);
var expenseAccounts = (await _unitOfWork.Accounts.FindAsync( var expenseAccounts = (await _unitOfWork.Accounts.FindAsync(
a => a.CompanyId == legacyUser!.CompanyId && a.IsActive && a => a.IsActive &&
(a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods))) (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods)))
.OrderBy(a => a.AccountNumber) .OrderBy(a => a.AccountNumber)
.ToList(); .ToList();
@@ -480,8 +479,7 @@ public class ExpensesController : Controller
if (!request.AvailableAccounts.Any()) if (!request.AvailableAccounts.Any())
{ {
var currentUser = await _userManager.GetUserAsync(User); var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == currentUser!.CompanyId && a.IsActive);
request.AvailableAccounts = allAccounts request.AvailableAccounts = allAccounts
.Where(a => a.AccountType == AccountType.Expense || .Where(a => a.AccountType == AccountType.Expense ||
a.AccountType == AccountType.CostOfGoods) a.AccountType == AccountType.CostOfGoods)
@@ -44,9 +44,8 @@ public class FixedAssetsController : Controller
[HttpGet] [HttpGet]
public async Task<IActionResult> Index() public async Task<IActionResult> Index()
{ {
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var assets = await _unitOfWork.FixedAssets.FindAsync( var assets = await _unitOfWork.FixedAssets.FindAsync(
fa => fa.CompanyId == companyId, false, fa => true, false,
fa => fa.AssetAccount, fa => fa.AssetAccount,
fa => fa.DepreciationExpenseAccount, fa => fa.DepreciationExpenseAccount,
fa => fa.AccumDepreciationAccount); fa => fa.AccumDepreciationAccount);
@@ -193,7 +192,7 @@ public class FixedAssetsController : Controller
var currentUser = await _userManager.GetUserAsync(User); var currentUser = await _userManager.GetUserAsync(User);
var assets = await _unitOfWork.FixedAssets.FindAsync( var assets = await _unitOfWork.FixedAssets.FindAsync(
fa => fa.CompanyId == companyId && !fa.IsDisposed, false, fa => !fa.IsDisposed, false,
fa => fa.DepreciationEntries); fa => fa.DepreciationEntries);
int posted = 0, skipped = 0; int posted = 0, skipped = 0;
@@ -314,8 +313,7 @@ public class FixedAssetsController : Controller
private async Task PopulateAccountsAsync() private async Task PopulateAccountsAsync()
{ {
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
var list = accounts.OrderBy(a => a.AccountNumber).ThenBy(a => a.Name).ToList(); var list = accounts.OrderBy(a => a.AccountNumber).ThenBy(a => a.Name).ToList();
ViewBag.AssetAccounts = list ViewBag.AssetAccounts = list
@@ -62,9 +62,8 @@ public class GiftCertificatesController : Controller
/// </summary> /// </summary>
public async Task<IActionResult> Index(string? searchTerm, string? statusFilter) public async Task<IActionResult> Index(string? searchTerm, string? statusFilter)
{ {
var companyId = (await _userManager.GetUserAsync(User))?.CompanyId ?? 0;
var certs = await _unitOfWork.GiftCertificates.FindAsync( var certs = await _unitOfWork.GiftCertificates.FindAsync(
gc => gc.CompanyId == companyId, false, gc => true, false,
gc => gc.RecipientCustomer, gc => gc.RecipientCustomer,
gc => gc.PurchasingCustomer); gc => gc.PurchasingCustomer);
@@ -255,14 +254,14 @@ public class GiftCertificatesController : Controller
if (dto.IssuedReason == GiftCertificateIssuedReason.Sold) if (dto.IssuedReason == GiftCertificateIssuedReason.Sold)
{ {
var checkingAcctId = await _unitOfWork.Accounts.FirstOrDefaultAsync( 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)); || a.AccountSubType == AccountSubTypeEnum.Cash));
await _accountBalanceService.DebitAsync(checkingAcctId?.Id, cert.OriginalAmount); await _accountBalanceService.DebitAsync(checkingAcctId?.Id, cert.OriginalAmount);
} }
else else
{ {
var discountAcctId = await _unitOfWork.Accounts.FirstOrDefaultAsync( 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); await _accountBalanceService.DebitAsync(discountAcctId?.Id, cert.OriginalAmount);
} }
@@ -311,7 +310,7 @@ public class GiftCertificatesController : Controller
var companyId = currentUser?.CompanyId ?? 0; var companyId = currentUser?.CompanyId ?? 0;
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(companyId); var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(companyId);
var otherIncomeAcctId = await _unitOfWork.Accounts.FirstOrDefaultAsync( 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.DebitAsync(gcLiabilityAcctId, remaining);
await _accountBalanceService.CreditAsync(otherIncomeAcctId?.Id, remaining); await _accountBalanceService.CreditAsync(otherIncomeAcctId?.Id, remaining);
} }
@@ -421,8 +420,7 @@ public class GiftCertificatesController : Controller
/// </summary> /// </summary>
private async Task PopulateCustomersAsync() private async Task PopulateCustomersAsync()
{ {
var companyId = (await _userManager.GetUserAsync(User))?.CompanyId ?? 0; var customers = await _unitOfWork.Customers.FindAsync(c => c.IsActive);
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId && c.IsActive);
var list = customers var list = customers
.OrderBy(c => c.CompanyName ?? c.ContactFirstName) .OrderBy(c => c.CompanyName ?? c.ContactFirstName)
.Select(c => new SelectListItem .Select(c => new SelectListItem
@@ -439,7 +437,7 @@ public class GiftCertificatesController : Controller
private async Task<int?> GetGcLiabilityAccountIdAsync(int companyId) private async Task<int?> GetGcLiabilityAccountIdAsync(int companyId)
{ {
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync( var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "2500"); a => a.IsActive && a.AccountNumber == "2500");
return acct?.Id; return acct?.Id;
} }
@@ -479,14 +477,14 @@ public class GiftCertificatesController : Controller
if (dto.IssuedReason == GiftCertificateIssuedReason.Sold) if (dto.IssuedReason == GiftCertificateIssuedReason.Sold)
{ {
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync( 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)); || a.AccountSubType == AccountSubTypeEnum.Cash));
checkingAcctId = acct?.Id; checkingAcctId = acct?.Id;
} }
else else
{ {
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync( var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "4950"); a => a.IsActive && a.AccountNumber == "4950");
discountAcctId = acct?.Id; discountAcctId = acct?.Id;
} }
@@ -126,12 +126,11 @@ public class InAppNotificationsController : Controller
public async Task<IActionResult> MarkAllRead() public async Task<IActionResult> MarkAllRead()
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var companyId = _tenant.GetCurrentCompanyId() ?? 0;
var unread = _tenant.IsPlatformAdmin() var unread = _tenant.IsPlatformAdmin()
? (await _unitOfWork.InAppNotifications.FindAsync( ? (await _unitOfWork.InAppNotifications.FindAsync(
n => !n.IsDeleted && n.CompanyId == 0 && !n.IsRead, ignoreQueryFilters: true)).ToList() 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) foreach (var n in unread)
{ {
@@ -193,9 +193,8 @@ public class InventoryController : Controller
return RedirectToAction(nameof(Index)); return RedirectToAction(nameof(Index));
var loc = location.Trim(); var loc = location.Trim();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var items = await _unitOfWork.InventoryItems.FindAsync( 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()); var dtos = _mapper.Map<List<InventoryListDto>>(items.OrderBy(i => i.Name).ToList());
ViewBag.Location = loc; ViewBag.Location = loc;
@@ -241,17 +240,6 @@ public class InventoryController : Controller
var useMetric = await _tenantContext.UseMetricSystemAsync(); var useMetric = await _tenantContext.UseMetricSystemAsync();
ViewBag.CoverageUnit = _measurementService.GetCoverageUnitLabel(useMetric); ViewBag.CoverageUnit = _measurementService.GetCoverageUnitLabel(useMetric);
// Manufacturer-level catalog status: prefer the linked catalog row, fall back to an
// identity match for items added before they were linked. Drives the "discontinued by
// manufacturer — cannot reorder" warning. This is distinct from the shop's own
// IsActive/DiscontinuedDate (whether the shop still stocks it).
var catalogItem = item.PowderCatalogItemId.HasValue
? await _unitOfWork.PowderCatalog.GetByIdAsync(item.PowderCatalogItemId.Value)
: await FindCatalogMatchAsync(item.Manufacturer, item.ManufacturerPartNumber);
ViewBag.CatalogDiscontinued = catalogItem?.IsDiscontinued ?? false;
ViewBag.CatalogVendorName = catalogItem?.VendorName;
ViewBag.CatalogProductUrl = catalogItem?.ProductUrl;
return View(itemDto); return View(itemDto);
} }
catch (Exception ex) catch (Exception ex)
@@ -276,18 +264,10 @@ public class InventoryController : Controller
ViewBag.UseMetric = useMetric; ViewBag.UseMetric = useMetric;
ViewBag.CoverageUnit = _measurementService.GetCoverageUnitLabel(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 return View(new CreateInventoryItemDto
{ {
CoverageSqFtPerLb = 30, CoverageSqFtPerLb = 30,
TransferEfficiency = 65, TransferEfficiency = 65
InventoryAccountId = prefs?.DefaultInventoryAccountId,
CogsAccountId = prefs?.DefaultCogsAccountId
}); });
} }
@@ -307,27 +287,6 @@ public class InventoryController : Controller
return View(dto); 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 try
{ {
var item = _mapper.Map<InventoryItem>(dto); var item = _mapper.Map<InventoryItem>(dto);
@@ -336,21 +295,11 @@ public class InventoryController : Controller
item.Name = ToTitleCase(item.Name); item.Name = ToTitleCase(item.Name);
// Populate legacy Category field from lookup table // Populate legacy Category field from lookup table
if (item.InventoryCategoryId.HasValue)
{
var category = await _unitOfWork.InventoryCategoryLookups.GetByIdAsync(item.InventoryCategoryId.Value);
if (category != null) if (category != null)
item.Category = category.DisplayName; 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
// can use the current catalog price.
var catalogMatch = await FindCatalogMatchAsync(item.Manufacturer, item.ManufacturerPartNumber);
if (catalogMatch != null)
{
item.PowderCatalogItemId = catalogMatch.Id;
if (catalogMatch.UnitPrice > 0)
{
item.CatalogReferencePrice = catalogMatch.UnitPrice;
item.CatalogPriceUpdatedAt = DateTime.UtcNow;
}
} }
await _unitOfWork.InventoryItems.AddAsync(item); await _unitOfWork.InventoryItems.AddAsync(item);
@@ -814,24 +763,6 @@ public class InventoryController : Controller
/// Returns (wasInCatalog, addedToCatalog) so callers can surface UI badges. /// Returns (wasInCatalog, addedToCatalog) so callers can surface UI badges.
/// Mutates <paramref name="result"/> in place. /// Mutates <paramref name="result"/> in place.
/// </summary> /// </summary>
/// <summary>
/// Finds the platform powder catalog row matching an inventory item's identity
/// (Manufacturer + ManufacturerPartNumber), or null. Used to set
/// <see cref="InventoryItem.PowderCatalogItemId"/> and to surface manufacturer-level status
/// (e.g. discontinued / cannot reorder) on the detail screen.
/// </summary>
private async Task<PowderCatalogItem?> FindCatalogMatchAsync(string? manufacturer, string? sku)
{
if (string.IsNullOrWhiteSpace(manufacturer) || string.IsNullOrWhiteSpace(sku))
return null;
var skuLower = sku.Trim().ToLower();
var mfrLower = manufacturer.Trim().ToLower();
var hits = await _unitOfWork.PowderCatalog.FindAsync(p =>
p.Sku.ToLower() == skuLower && p.VendorName.ToLower().Contains(mfrLower));
return hits.FirstOrDefault();
}
private async Task<(bool wasInCatalog, bool addedToCatalog)> EnrichFromCatalogAsync( private async Task<(bool wasInCatalog, bool addedToCatalog)> EnrichFromCatalogAsync(
InventoryAiLookupResult result, bool autoContribute) InventoryAiLookupResult result, bool autoContribute)
{ {
@@ -1068,12 +999,45 @@ public class InventoryController : Controller
// TDS cure fallback — same logic as AiLookup button // TDS cure fallback — same logic as AiLookup button
await ApplyTdsCureFallbackAsync(aiResult, colorName); await ApplyTdsCureFallbackAsync(aiResult, colorName);
var duplicate = await FindInventoryDuplicateAsync( // Check if this product already exists in the tenant's inventory.
null, // Match by ManufacturerPartNumber first (most precise); fall back to color name + manufacturer.
manufacturer, // Returns the first active match so the UI can prompt to add stock inline.
sku, int? existingInventoryId = null;
colorName, string? existingInventoryName = null;
isCoating: true); 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 return Json(new
{ {
@@ -1098,61 +1062,16 @@ public class InventoryController : Controller
vendorName = manufacturer, vendorName = manufacturer,
wasInCatalog = wasInCatalog, wasInCatalog = wasInCatalog,
addedToCatalog = addedToCatalog, addedToCatalog = addedToCatalog,
existingInventoryId = duplicate?.Item.Id, existingInventoryId = existingInventoryId,
existingInventoryName = duplicate?.Item.Name, existingInventoryName = existingInventoryName,
existingQuantityOnHand = duplicate?.Item.QuantityOnHand, existingQuantityOnHand = existingQuantityOnHand,
existingUnitOfMeasure = duplicate?.Item.UnitOfMeasure, existingUnitOfMeasure = existingUnitOfMeasure,
duplicateMatchType = duplicate?.MatchType.ToString(),
reasoning = aiResult.Reasoning, reasoning = aiResult.Reasoning,
}); });
} }
/// <summary> /// <summary>
/// Checks the current tenant's active inventory for an existing SKU or powder identity. /// Adds stock to an existing inventory item from the label scanner inline prompt.
/// 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.
/// Creates a Purchase transaction and updates QuantityOnHand without navigating away. /// Creates a Purchase transaction and updates QuantityOnHand without navigating away.
/// </summary> /// </summary>
[HttpPost] [HttpPost]
@@ -1301,10 +1220,6 @@ public class InventoryController : Controller
if (catalogItem == null) if (catalogItem == null)
return Json(new { success = false, error = "Catalog item not found." }); return Json(new { success = false, error = "Catalog item not found." });
// First use of this powder — lazily fill specific gravity / cure from its TDS so the new
// inventory record (and the catalog) carry complete specs. No-op once already enriched.
await _aiLookupService.EnsureCatalogTdsSpecsAsync(catalogItem);
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Find the default coating category to assign. // Find the default coating category to assign.
@@ -1342,7 +1257,6 @@ public class InventoryController : Controller
ColorName = catalogItem.ColorName, ColorName = catalogItem.ColorName,
Manufacturer = catalogItem.VendorName, Manufacturer = catalogItem.VendorName,
ManufacturerPartNumber= catalogItem.Sku, ManufacturerPartNumber= catalogItem.Sku,
PowderCatalogItemId = catalogItem.Id,
Finish = catalogItem.Finish, Finish = catalogItem.Finish,
ColorFamilies = catalogItem.ColorFamilies, ColorFamilies = catalogItem.ColorFamilies,
RequiresClearCoat = catalogItem.RequiresClearCoat ?? false, RequiresClearCoat = catalogItem.RequiresClearCoat ?? false,
@@ -1358,8 +1272,6 @@ public class InventoryController : Controller
UnitCost = catalogItem.UnitPrice, UnitCost = catalogItem.UnitPrice,
AverageCost = catalogItem.UnitPrice, AverageCost = catalogItem.UnitPrice,
LastPurchasePrice = catalogItem.UnitPrice, LastPurchasePrice = catalogItem.UnitPrice,
CatalogReferencePrice = catalogItem.UnitPrice > 0 ? catalogItem.UnitPrice : (decimal?)null,
CatalogPriceUpdatedAt = catalogItem.UnitPrice > 0 ? DateTime.UtcNow : (DateTime?)null,
QuantityOnHand = 0, QuantityOnHand = 0,
UnitOfMeasure = "lbs", UnitOfMeasure = "lbs",
InventoryCategoryId = coatingCategory.Id, InventoryCategoryId = coatingCategory.Id,
@@ -1385,7 +1297,7 @@ public class InventoryController : Controller
efficiency = item.TransferEfficiency ?? 65m, efficiency = item.TransferEfficiency ?? 65m,
unitOfMeasure= item.UnitOfMeasure, unitOfMeasure= item.UnitOfMeasure,
categoryName = coatingCategory.DisplayName, categoryName = coatingCategory.DisplayName,
costPerLb = item.CatalogReferencePrice ?? item.UnitCost, costPerLb = item.UnitCost,
colorName = item.ColorName ?? item.Name, colorName = item.ColorName ?? item.Name,
colorCode = "", colorCode = "",
isIncoming = true isIncoming = true
@@ -1398,48 +1310,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) private static decimal GetEffectiveTransferEfficiency(decimal? transferEfficiency)
{ {
return transferEfficiency ?? DefaultTransferEfficiency; return transferEfficiency ?? DefaultTransferEfficiency;
@@ -1532,9 +1402,8 @@ public class InventoryController : Controller
{ {
try try
{ {
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allCoatings = (await _unitOfWork.InventoryItems.FindAsync( var allCoatings = (await _unitOfWork.InventoryItems.FindAsync(
i => i.CompanyId == companyId && i.InventoryCategory != null && i.InventoryCategory.IsCoating, i => i.InventoryCategory != null && i.InventoryCategory.IsCoating,
false, false,
i => i.InventoryCategory)) i => i.InventoryCategory))
.OrderBy(i => i.Manufacturer).ThenBy(i => i.ColorName).ThenBy(i => i.Name) .OrderBy(i => i.Manufacturer).ThenBy(i => i.ColorName).ThenBy(i => i.Name)
@@ -1611,7 +1480,7 @@ public class InventoryController : Controller
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
ViewBag.AiInventoryAssistEnabled = await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId); 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(); .OrderBy(v => v.CompanyName).ToList();
ViewBag.Vendors = new SelectList(vendors, "Id", "CompanyName"); ViewBag.Vendors = new SelectList(vendors, "Id", "CompanyName");
@@ -1650,17 +1519,12 @@ public class InventoryController : Controller
new SelectListItem { Value = "rolls", Text = "Rolls" } 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 ViewBag.InventoryAccounts = accounts
.Where(a => a.AccountType == AccountType.Asset) .Where(a => a.AccountType == AccountType.Asset
.OrderByDescending(a => a.AccountSubType == AccountSubType.Inventory) && a.AccountSubType == AccountSubType.Inventory)
.ThenBy(a => a.AccountNumber) .OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString())) .Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList(); .ToList();
@@ -1669,13 +1533,6 @@ public class InventoryController : Controller
.OrderBy(a => a.AccountNumber) .OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString())) .Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList(); .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> /// <summary>
@@ -1853,16 +1710,13 @@ public class InventoryController : Controller
item.UpdatedAt = DateTime.UtcNow; item.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.InventoryItems.UpdateAsync(item); 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 var txn = new InventoryTransaction
{ {
InventoryItemId = item.Id, InventoryItemId = item.Id,
TransactionType = transactionType, TransactionType = transactionType,
Quantity = -quantityUsed, Quantity = -quantityUsed,
UnitCost = effectiveUnitCost, UnitCost = item.UnitCost,
TotalCost = quantityUsed * effectiveUnitCost, TotalCost = quantityUsed * item.UnitCost,
TransactionDate = DateTime.UtcNow, TransactionDate = DateTime.UtcNow,
BalanceAfter = item.QuantityOnHand, BalanceAfter = item.QuantityOnHand,
JobId = jobId, JobId = jobId,
@@ -1876,7 +1730,7 @@ public class InventoryController : Controller
if (item.CogsAccountId.HasValue && item.InventoryAccountId.HasValue) 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.DebitAsync(item.CogsAccountId, cost);
await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost); await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost);
} }
@@ -2227,7 +2081,7 @@ public class InventoryController : Controller
return BadRequest("Only usage transactions can be edited here."); return BadRequest("Only usage transactions can be edited here.");
var allJobs = await _unitOfWork.Jobs.FindAsync( var allJobs = await _unitOfWork.Jobs.FindAsync(
j => j.CompanyId == txn.CompanyId && !j.JobStatus.IsTerminalStatus, j => !j.JobStatus.IsTerminalStatus,
false, false,
j => j.Customer, j => j.Customer,
j => j.JobStatus); j => j.JobStatus);
@@ -7,7 +7,6 @@ using PowderCoating.Application.DTOs.Common;
using PowderCoating.Application.DTOs.Invoice; using PowderCoating.Application.DTOs.Invoice;
using PowderCoating.Application.DTOs.Quote; using PowderCoating.Application.DTOs.Quote;
using PowderCoating.Application.Interfaces; using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Accounting;
using PowderCoating.Core.Entities; using PowderCoating.Core.Entities;
using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.Rendering;
using PowderCoating.Core.Enums; using PowderCoating.Core.Enums;
@@ -31,6 +30,7 @@ public class InvoicesController : Controller
private readonly INotificationService _notificationService; private readonly INotificationService _notificationService;
private readonly IAccountBalanceService _accountBalanceService; private readonly IAccountBalanceService _accountBalanceService;
private readonly ICompanyLogoService _logoService; private readonly ICompanyLogoService _logoService;
private readonly IConfiguration _configuration;
public InvoicesController( public InvoicesController(
IUnitOfWork unitOfWork, IUnitOfWork unitOfWork,
@@ -41,7 +41,8 @@ public class InvoicesController : Controller
ITenantContext tenantContext, ITenantContext tenantContext,
INotificationService notificationService, INotificationService notificationService,
IAccountBalanceService accountBalanceService, IAccountBalanceService accountBalanceService,
ICompanyLogoService logoService) ICompanyLogoService logoService,
IConfiguration configuration)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_mapper = mapper; _mapper = mapper;
@@ -52,6 +53,7 @@ public class InvoicesController : Controller
_notificationService = notificationService; _notificationService = notificationService;
_accountBalanceService = accountBalanceService; _accountBalanceService = accountBalanceService;
_logoService = logoService; _logoService = logoService;
_configuration = configuration;
} }
private static readonly string[] StandardPaymentTerms = private static readonly string[] StandardPaymentTerms =
@@ -83,15 +85,14 @@ public class InvoicesController : Controller
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
/// <summary> /// <summary>
/// Displays the paginated invoice list with multi-mode filtering. The filter cascade handles /// Displays the paginated invoice list with multi-mode filtering. The filter cascade handles
/// statusGroup pills (unpaid/partial/paid/all) plus legacy flag combinations (overdue/outstanding/thisMonth) /// nine combinations of overdue/outstanding/thisMonth flags with status and search term so the
/// so the database receives a single targeted predicate — no full-table load then in-memory LINQ. /// database receives a single targeted predicate — no full-table load then in-memory LINQ.
/// Balance-due sort is computed in the ORDER BY expression rather than a stored column because /// Balance-due sort is computed in the ORDER BY expression rather than a stored column because
/// balance = Total AmountPaid CreditApplied GiftCertificateRedeemed changes on every payment. /// balance = Total AmountPaid CreditApplied GiftCertificateRedeemed changes on every payment.
/// </summary> /// </summary>
public async Task<IActionResult> Index( public async Task<IActionResult> Index(
string? searchTerm, string? searchTerm,
InvoiceStatus? statusFilter, InvoiceStatus? statusFilter,
string? statusGroup,
string? sortColumn, string? sortColumn,
string sortDirection = "desc", string sortDirection = "desc",
bool outstandingOnly = false, bool outstandingOnly = false,
@@ -102,11 +103,6 @@ public class InvoicesController : Controller
{ {
try try
{ {
// Default landing: show unpaid invoices so the list is immediately actionable.
if (string.IsNullOrEmpty(statusGroup) && !statusFilter.HasValue &&
string.IsNullOrEmpty(searchTerm) && !outstandingOnly && !thisMonthOnly && !overdueOnly)
return RedirectToAction("Index", new { statusGroup = "unpaid" });
var today = DateTime.Today; var today = DateTime.Today;
var startOfMonth = new DateTime(today.Year, today.Month, 1); var startOfMonth = new DateTime(today.Year, today.Month, 1);
var endOfMonth = startOfMonth.AddMonths(1); var endOfMonth = startOfMonth.AddMonths(1);
@@ -123,18 +119,7 @@ public class InvoicesController : Controller
System.Linq.Expressions.Expression<Func<Invoice, bool>>? filter = null; System.Linq.Expressions.Expression<Func<Invoice, bool>>? filter = null;
// Status-group pills take priority over the dropdown and legacy flags. if (overdueOnly)
if (!string.IsNullOrEmpty(statusGroup))
{
filter = statusGroup switch
{
"unpaid" => i => i.Status == InvoiceStatus.Draft || i.Status == InvoiceStatus.Sent || i.Status == InvoiceStatus.Overdue,
"partial" => i => i.Status == InvoiceStatus.PartiallyPaid,
"paid" => i => i.Status == InvoiceStatus.Paid,
_ => null // "all" — no predicate
};
}
else if (overdueOnly)
{ {
filter = i => (i.Status == InvoiceStatus.Sent || i.Status == InvoiceStatus.PartiallyPaid || i.Status == InvoiceStatus.Overdue) filter = i => (i.Status == InvoiceStatus.Sent || i.Status == InvoiceStatus.PartiallyPaid || i.Status == InvoiceStatus.Overdue)
&& i.DueDate.HasValue && i.DueDate.Value < today; && i.DueDate.HasValue && i.DueDate.Value < today;
@@ -233,21 +218,12 @@ public class InvoicesController : Controller
ViewBag.SearchTerm = searchTerm; ViewBag.SearchTerm = searchTerm;
ViewBag.StatusFilter = statusFilter; ViewBag.StatusFilter = statusFilter;
ViewBag.StatusGroup = statusGroup;
ViewBag.OutstandingOnly = outstandingOnly; ViewBag.OutstandingOnly = outstandingOnly;
ViewBag.ThisMonthOnly = thisMonthOnly; ViewBag.ThisMonthOnly = thisMonthOnly;
ViewBag.OverdueOnly = overdueOnly; ViewBag.OverdueOnly = overdueOnly;
ViewBag.SortColumn = gridRequest.SortColumn; ViewBag.SortColumn = gridRequest.SortColumn;
ViewBag.SortDirection = gridRequest.SortDirection; 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);
return View(pagedResult); return View(pagedResult);
} }
catch (Exception ex) catch (Exception ex)
@@ -305,10 +281,23 @@ public class InvoicesController : Controller
ViewBag.OnlinePaymentsEnabled = onlinePaymentsAllowed ViewBag.OnlinePaymentsEnabled = onlinePaymentsAllowed
&& company?.StripeConnectStatus == StripeConnectStatus.Active; && company?.StripeConnectStatus == StripeConnectStatus.Active;
// In-person card reader (Stripe Terminal) — bundled with the online-payments entitlement.
// Surface the active readers + a "Take Card Payment" button only when at least one exists.
var terminalReaders = (await _unitOfWork.TerminalReaders.FindAsync(
r => r.Status == Core.Enums.TerminalReaderStatus.Active))
.OrderBy(r => r.Label)
.Select(r => new SelectListItem(r.Label, r.Id.ToString()))
.ToList();
ViewBag.TerminalReaders = terminalReaders;
ViewBag.TerminalPaymentsEnabled = onlinePaymentsAllowed
&& company?.StripeConnectStatus == StripeConnectStatus.Active
&& terminalReaders.Count > 0;
ViewBag.TerminalTestMode =
(_configuration["Stripe:Connect:SecretKey"] ?? string.Empty).StartsWith("sk_test_", StringComparison.Ordinal);
// Expense accounts for the write-off bad-debt modal // Expense accounts for the write-off bad-debt modal
var expenseCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var expenseAccounts = await _unitOfWork.Accounts.FindAsync( 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 ViewBag.ExpenseAccounts = expenseAccounts
.OrderBy(a => a.AccountNumber).ThenBy(a => a.Name) .OrderBy(a => a.AccountNumber).ThenBy(a => a.Name)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString())) .Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
@@ -413,15 +402,8 @@ public class InvoicesController : Controller
.ToDictionary(ci => ci.Id) .ToDictionary(ci => ci.Id)
: new Dictionary<int, CatalogItem>(); : new Dictionary<int, CatalogItem>();
// Fall back to the company's configured default revenue account when a catalog item // Fall back to the default revenue account (4000) if a catalog item has no specific account
// has no specific account; if none is configured (or it has since been deactivated), var defaultRevenueAccount = await _unitOfWork.Accounts
// 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
.FirstOrDefaultAsync(a => a.AccountNumber == "4000" && a.IsActive); .FirstOrDefaultAsync(a => a.AccountNumber == "4000" && a.IsActive);
// Deserialize the job's pricing snapshot up front — it is authoritative for discount, // Deserialize the job's pricing snapshot up front — it is authoritative for discount,
@@ -1345,9 +1327,7 @@ public class InvoicesController : Controller
}); // end ExecuteInTransactionAsync }); // end ExecuteInTransactionAsync
// Notify (non-blocking) — skipped if user explicitly suppressed it // Notify (non-blocking)
if (!dto.SuppressNotification)
{
try try
{ {
await _notificationService.NotifyPaymentReceivedAsync(invoice, payment); await _notificationService.NotifyPaymentReceivedAsync(invoice, payment);
@@ -1356,7 +1336,6 @@ public class InvoicesController : Controller
{ {
_logger.LogWarning(notifyEx, "Payment recorded but notification failed"); _logger.LogWarning(notifyEx, "Payment recorded but notification failed");
} }
}
var paymentNotifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id, _tenantContext.GetCurrentCompanyId() ?? 0); var paymentNotifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id, _tenantContext.GetCurrentCompanyId() ?? 0);
@@ -2414,7 +2393,7 @@ public class InvoicesController : Controller
return Json(new { taxPercent = 0m, taxRateName = (string?)null }); return Json(new { taxPercent = 0m, taxRateName = (string?)null });
var defaultRate = await _unitOfWork.TaxRates 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 return Json(new
{ {
@@ -2451,7 +2430,7 @@ public class InvoicesController : Controller
// Merchandise items for the invoice merch picker (all active IsMerchandise items) // Merchandise items for the invoice merch picker (all active IsMerchandise items)
var allMerchItems = await _unitOfWork.CatalogItems.FindAsync( 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 var merchItems = allMerchItems
.OrderBy(i => i.Category.Name).ThenBy(i => i.DisplayOrder).ThenBy(i => i.Name) .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 }) .Select(i => new { i.Id, i.Name, i.SKU, CategoryName = i.Category.Name, i.DefaultPrice, i.RevenueAccountId })
@@ -2467,8 +2446,7 @@ public class InvoicesController : Controller
/// </summary> /// </summary>
private async Task PopulateBankAccountsAsync() private async Task PopulateBankAccountsAsync()
{ {
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive
&& (a.AccountSubType == AccountSubType.Cash || && (a.AccountSubType == AccountSubType.Cash ||
a.AccountSubType == AccountSubType.Checking || a.AccountSubType == AccountSubType.Checking ||
a.AccountSubType == AccountSubType.Savings)); a.AccountSubType == AccountSubType.Savings));
@@ -2483,7 +2461,7 @@ public class InvoicesController : Controller
private async Task<int?> GetCheckingAccountIdAsync(int companyId) private async Task<int?> GetCheckingAccountIdAsync(int companyId)
{ {
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync( 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)); || a.AccountSubType == AccountSubType.Cash));
return acct?.Id; return acct?.Id;
} }
@@ -2492,23 +2470,7 @@ public class InvoicesController : Controller
private async Task<int?> GetCustomerDepositsAccountIdAsync(int companyId) private async Task<int?> GetCustomerDepositsAccountIdAsync(int companyId)
{ {
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync( var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "2300"); a => a.IsActive && a.AccountNumber == "2300");
return acct?.Id;
}
/// <summary>Returns the Sales Returns &amp; 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");
return acct?.Id; return acct?.Id;
} }
@@ -2516,7 +2478,7 @@ public class InvoicesController : Controller
private async Task<int?> GetArAccountIdAsync(int companyId) private async Task<int?> GetArAccountIdAsync(int companyId)
{ {
var accounts = await _unitOfWork.Accounts.FindAsync( 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; return accounts.FirstOrDefault()?.Id;
} }
@@ -2527,7 +2489,7 @@ public class InvoicesController : Controller
private async Task<int?> GetBadDebtAccountIdAsync(int companyId) private async Task<int?> GetBadDebtAccountIdAsync(int companyId)
{ {
var expenses = await _unitOfWork.Accounts.FindAsync( 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) return expenses.FirstOrDefault(a => a.Name.Contains("bad", StringComparison.OrdinalIgnoreCase)
|| a.Name.Contains("debt", StringComparison.OrdinalIgnoreCase))?.Id || a.Name.Contains("debt", StringComparison.OrdinalIgnoreCase))?.Id
?? expenses.FirstOrDefault()?.Id; ?? expenses.FirstOrDefault()?.Id;
@@ -2558,9 +2520,9 @@ public class InvoicesController : Controller
private async Task<int?> ResolveSalesTaxAccountIdAsync(int companyId) private async Task<int?> ResolveSalesTaxAccountIdAsync(int companyId)
{ {
var taxAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync( 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( 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; return taxAccount?.Id;
} }
@@ -2572,9 +2534,9 @@ public class InvoicesController : Controller
private async Task<int?> GetSalesDiscountAccountIdAsync(int companyId) private async Task<int?> GetSalesDiscountAccountIdAsync(int companyId)
{ {
var discountAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync( 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( 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; return discountAccount?.Id;
} }
@@ -2582,7 +2544,7 @@ public class InvoicesController : Controller
private async Task<int?> GetGcLiabilityAccountIdAsync(int companyId) private async Task<int?> GetGcLiabilityAccountIdAsync(int companyId)
{ {
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync( var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "2500"); a => a.IsActive && a.AccountNumber == "2500");
return acct?.Id; return acct?.Id;
} }
@@ -2690,32 +2652,24 @@ public class InvoicesController : Controller
} }
await _unitOfWork.CompleteAsync(); 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."; TempData["Success"] = $"Refund of {dto.Amount:C} applied as store credit. Credit memo {memoNumber} created.";
} }
else else
{ {
// "Reverse the sale": a cash refund contra's the original sale instead of re-opening AR. // Adjust customer AR balance — they're owed money back
// GL: DR Sales Returns (revenue portion) + DR Sales Tax Payable (tax portion) / CR Bank. if (invoice.Customer != null)
// 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 invoice.Customer.CurrentBalance -= dto.Amount;
// so LedgerService and FinancialReportService recompute the same way. await _unitOfWork.Customers.UpdateAsync(invoice.Customer);
}
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
var (returnsPortion, taxPortion) = RefundAllocation.Split(dto.Amount, invoice.TaxAmount, invoice.Total); // GL: DR AR (un-collects the payment) / CR Bank (cash leaves).
var salesReturnsAccountId = await GetSalesReturnsAccountIdAsync(companyId); // Mirrors how FinancialReportService accounts for refunds:
var salesTaxAccountId = invoice.SalesTaxAccountId ?? await ResolveSalesTaxAccountIdAsync(companyId); // arTotalCredits -= refundTotal; refundsByAcct credits the bank account.
var arAccountId = await GetArAccountIdAsync(companyId);
await _accountBalanceService.DebitAsync(salesReturnsAccountId, returnsPortion); await _accountBalanceService.DebitAsync(arAccountId, dto.Amount);
if (taxPortion > 0)
await _accountBalanceService.DebitAsync(salesTaxAccountId, taxPortion);
await _accountBalanceService.CreditAsync(dto.DepositAccountId, dto.Amount); await _accountBalanceService.CreditAsync(dto.DepositAccountId, dto.Amount);
TempData["Success"] = $"Refund of {dto.Amount:C} recorded successfully. Please issue the refund manually."; TempData["Success"] = $"Refund of {dto.Amount:C} recorded successfully. Please issue the refund manually.";
@@ -2766,14 +2720,12 @@ public class InvoicesController : Controller
if (refund.RefundMethod == PaymentMethod.StoreCredit) if (refund.RefundMethod == PaymentMethod.StoreCredit)
{ {
// Cancel the linked CreditMemo and reverse the unapplied store-credit remainder. // Cancel the linked CreditMemo and reverse the CreditBalance
decimal creditReversed = refund.Amount;
if (refund.CreditMemoId.HasValue) if (refund.CreditMemoId.HasValue)
{ {
var memo = await _unitOfWork.CreditMemos.GetByIdAsync(refund.CreditMemoId.Value); var memo = await _unitOfWork.CreditMemos.GetByIdAsync(refund.CreditMemoId.Value);
if (memo != null && memo.Status == CreditMemoStatus.Active) if (memo != null && memo.Status == CreditMemoStatus.Active)
{ {
creditReversed = memo.Amount - memo.AmountApplied; // only the unapplied remainder
memo.Status = CreditMemoStatus.Voided; memo.Status = CreditMemoStatus.Voided;
memo.UpdatedAt = DateTime.UtcNow; memo.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.CreditMemos.UpdateAsync(memo); await _unitOfWork.CreditMemos.UpdateAsync(memo);
@@ -2782,30 +2734,22 @@ public class InvoicesController : Controller
if (customer != null) if (customer != null)
{ {
customer.CreditBalance = Math.Max(0, customer.CreditBalance - creditReversed); customer.CreditBalance -= refund.Amount;
await _unitOfWork.Customers.UpdateAsync(customer); 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 else
{ {
// Reverse the "reverse the sale" posting: CR Sales Returns + CR Sales Tax Payable / DR Bank. // Reverse the AR balance adjustment
// The customer's AR balance was not touched when the refund was issued, so it is not touched here. if (customer != null)
var (returnsPortion, taxPortion) = RefundAllocation.Split(refund.Amount, refund.Invoice.TaxAmount, refund.Invoice.Total); {
var salesReturnsAccountId = await GetSalesReturnsAccountIdAsync(refund.Invoice.CompanyId); customer.CurrentBalance += refund.Amount;
var salesTaxAccountId = refund.Invoice.SalesTaxAccountId ?? await ResolveSalesTaxAccountIdAsync(refund.Invoice.CompanyId); await _unitOfWork.Customers.UpdateAsync(customer);
}
await _accountBalanceService.CreditAsync(salesReturnsAccountId, returnsPortion); // GL reversal: CR AR / DR Bank — mirrors the DR AR / CR Bank posted in IssueRefund.
if (taxPortion > 0) var arAccountId = await GetArAccountIdAsync(refund.Invoice.CompanyId);
await _accountBalanceService.CreditAsync(salesTaxAccountId, taxPortion); await _accountBalanceService.CreditAsync(arAccountId, refund.Amount);
await _accountBalanceService.DebitAsync(refund.DepositAccountId, refund.Amount); await _accountBalanceService.DebitAsync(refund.DepositAccountId, refund.Amount);
} }
@@ -2868,14 +2812,6 @@ public class InvoicesController : Controller
} }
await _unitOfWork.CompleteAsync(); 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."; TempData["Success"] = $"Credit memo {memoNumber} for {dto.Amount:C} issued to customer.";
} }
catch (Exception ex) catch (Exception ex)
@@ -2962,11 +2898,9 @@ public class InvoicesController : Controller
} }
// GL: DR Sales Discounts 4950 / CR AR — same as CreditMemosController.Apply. // 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 arAccountId = await GetArAccountIdAsync(invoice.CompanyId);
var customerCreditsAcctId = await GetCustomerCreditsAccountIdAsync(invoice.CompanyId); var discountAcctId = await GetSalesDiscountAccountIdAsync(invoice.CompanyId);
await _accountBalanceService.DebitAsync(customerCreditsAcctId, applyAmount); await _accountBalanceService.DebitAsync(discountAcctId, applyAmount);
await _accountBalanceService.CreditAsync(arAccountId, applyAmount); await _accountBalanceService.CreditAsync(arAccountId, applyAmount);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
@@ -3009,15 +2943,6 @@ public class InvoicesController : Controller
await _unitOfWork.Customers.UpdateAsync(memo.Customer); 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(); await _unitOfWork.CompleteAsync();
TempData["Success"] = "Credit memo voided."; TempData["Success"] = "Credit memo voided.";
return RedirectToAction(nameof(Details), new { id = invoiceId }); 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) // Pill badge counts — always global (not scoped to current filter/page)
var today = DateTime.Today; var today = DateTime.Today;
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; ViewBag.AllJobCount = await _unitOfWork.Jobs.CountAsync();
ViewBag.AllJobCount = await _unitOfWork.Jobs.CountAsync(j => j.CompanyId == companyId);
ViewBag.ActiveCount = await _unitOfWork.Jobs.CountAsync(j => 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.ReadyForPickup
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled); && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled);
ViewBag.OverdueCount = await _unitOfWork.Jobs.CountAsync(j => 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.Completed
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.ReadyForPickup && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.ReadyForPickup
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled); && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled);
ViewBag.CompletedCount = await _unitOfWork.Jobs.CountAsync(j => 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.ReadyForPickup
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Delivered)); || j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Delivered);
ViewBag.ReadyCount = await _unitOfWork.Jobs.CountAsync(j => 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 // Set ViewBag for sorting
ViewBag.SearchTerm = searchTerm; ViewBag.SearchTerm = searchTerm;
@@ -451,9 +446,6 @@ public class JobsController : Controller
ViewBag.JobInvoiceStatus = jobInvoice?.Status; ViewBag.JobInvoiceStatus = jobInvoice?.Status;
ViewBag.JobVoidedInvoices = voidedInvoices; 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 // Workers dropdown for inline assignment
await PopulateWorkersDropdown(); await PopulateWorkersDropdown();
@@ -2176,12 +2168,10 @@ public class JobsController : Controller
try try
{ {
var today = date?.Date ?? DateTime.Today; var today = date?.Date ?? DateTime.Today;
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Load all non-terminal statuses for the progress strip (excluding nav/hold/cancel) // Load all non-terminal statuses for the progress strip (excluding nav/hold/cancel)
var allStatusesEnum = await _unitOfWork.JobStatusLookups.FindAsync(s => 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.Delivered && s.StatusCode != AppConstants.StatusCodes.Job.Quoted
&& s.StatusCode != AppConstants.StatusCodes.Job.Pending && s.StatusCode != AppConstants.StatusCodes.Job.Approved); && s.StatusCode != AppConstants.StatusCodes.Job.Pending && s.StatusCode != AppConstants.StatusCodes.Job.Approved);
var allStatuses = allStatusesEnum.OrderBy(s => s.DisplayOrder).ToList(); var allStatuses = allStatusesEnum.OrderBy(s => s.DisplayOrder).ToList();
@@ -2191,7 +2181,7 @@ public class JobsController : Controller
// Get existing priority records for today // Get existing priority records for today
var existingPriorities = await _unitOfWork.JobDailyPriorities 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); var priorityDict = existingPriorities.ToDictionary(p => p.JobId);
@@ -2288,8 +2278,7 @@ public class JobsController : Controller
if (!companyId.HasValue) return RedirectToAction(nameof(Index)); if (!companyId.HasValue) return RedirectToAction(nameof(Index));
var allStatusesEnum = await _unitOfWork.JobStatusLookups.FindAsync(s => 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.OnHold && s.StatusCode != AppConstants.StatusCodes.Job.Cancelled
&& s.StatusCode != AppConstants.StatusCodes.Job.Delivered); && s.StatusCode != AppConstants.StatusCodes.Job.Delivered);
var allStatuses = allStatusesEnum.OrderBy(s => s.DisplayOrder).ToList(); var allStatuses = allStatusesEnum.OrderBy(s => s.DisplayOrder).ToList();
@@ -3008,17 +2997,13 @@ public class JobsController : Controller
inventoryItem.QuantityOnHand -= deductNow; inventoryItem.QuantityOnHand -= deductNow;
await _unitOfWork.InventoryItems.UpdateAsync(inventoryItem); 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 var transaction = new InventoryTransaction
{ {
InventoryItemId = inventoryItem.Id, InventoryItemId = inventoryItem.Id,
TransactionType = InventoryTransactionType.JobUsage, TransactionType = InventoryTransactionType.JobUsage,
Quantity = -deductNow, Quantity = -deductNow,
UnitCost = effectiveUnitCost, UnitCost = inventoryItem.UnitCost,
TotalCost = effectiveUnitCost * deductNow, TotalCost = inventoryItem.UnitCost * deductNow,
TransactionDate = DateTime.UtcNow, TransactionDate = DateTime.UtcNow,
JobId = job.Id, JobId = job.Id,
Reference = job.JobNumber, Reference = job.JobNumber,
@@ -3030,7 +3015,7 @@ public class JobsController : Controller
if (inventoryItem.CogsAccountId.HasValue && inventoryItem.InventoryAccountId.HasValue) 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.DebitAsync(inventoryItem.CogsAccountId, cost);
await _accountBalanceService.CreditAsync(inventoryItem.InventoryAccountId, cost); await _accountBalanceService.CreditAsync(inventoryItem.InventoryAccountId, cost);
} }
@@ -3507,8 +3492,7 @@ public class JobsController : Controller
efficiency = i.TransferEfficiency ?? 65m, efficiency = i.TransferEfficiency ?? 65m,
unitOfMeasure = i.UnitOfMeasure ?? "lbs", unitOfMeasure = i.UnitOfMeasure ?? "lbs",
categoryName = i.InventoryCategory!.DisplayName, categoryName = i.InventoryCategory!.DisplayName,
// Quote at the current catalog price when linked; fall back to their cost otherwise. costPerLb = i.UnitCost,
costPerLb = i.CatalogReferencePrice ?? i.UnitCost,
colorName = i.ColorName ?? i.Name, colorName = i.ColorName ?? i.Name,
colorCode = i.ColorCode ?? "", colorCode = i.ColorCode ?? "",
isIncoming = i.IsIncoming isIncoming = i.IsIncoming
@@ -57,14 +57,13 @@ public class JobsPriorityController : Controller
public async Task<IActionResult> Index(DateTime? date) public async Task<IActionResult> Index(DateTime? date)
{ {
var today = date?.Date ?? DateTime.Today; var today = date?.Date ?? DateTime.Today;
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Get all jobs scheduled for today with related data // Get all jobs scheduled for today with related data
var jobs = await _unitOfWork.Jobs.GetScheduledJobsForDateAsync(today); var jobs = await _unitOfWork.Jobs.GetScheduledJobsForDateAsync(today);
// Get existing priority records for today // Get existing priority records for today
var existingPriorities = await _unitOfWork.JobDailyPriorities 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); var priorityDict = existingPriorities.ToDictionary(p => p.JobId);
@@ -91,6 +90,7 @@ public class JobsPriorityController : Controller
.ToList(); .ToList();
// Get priorities and workers for modal options // Get priorities and workers for modal options
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var priorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId); var priorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId);
var workers = await _userManager.Users var workers = await _userManager.Users
.Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null) .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) // Get maintenance records scheduled for today (Scheduled or InProgress)
var maintenanceItems = (await _unitOfWork.MaintenanceRecords.FindAsync( 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), (m.Status == MaintenanceStatus.Scheduled || m.Status == MaintenanceStatus.InProgress),
false, false,
m => m.Equipment, m => m.AssignedUser)) m => m.Equipment, m => m.AssignedUser))
@@ -169,11 +169,10 @@ public class JobsPriorityController : Controller
} }
var today = DateTime.Today; var today = DateTime.Today;
var cid = _tenantContext.GetCurrentCompanyId() ?? 0;
// Get all existing priority records for today // Get all existing priority records for today
var existingPriorities = await _unitOfWork.JobDailyPriorities 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); var priorityDict = existingPriorities.ToDictionary(p => p.JobId);
@@ -118,7 +118,7 @@ public class JournalEntriesController : Controller
// Load account names for lines // Load account names for lines
var accountIds = je.Lines.Select(l => l.AccountId).Distinct().ToList(); 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}"); ViewBag.AccountMap = accounts.ToDictionary(a => a.Id, a => $"{a.AccountNumber} {a.Name}");
// Reversal metadata // Reversal metadata
@@ -196,113 +196,6 @@ public class JournalEntriesController : Controller
return RedirectToAction(nameof(Details), new { id }); 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 ────────────────────────────────────────────────────────────── // ── Reverse ──────────────────────────────────────────────────────────────
[HttpPost] [HttpPost]
@@ -474,8 +367,7 @@ public class JournalEntriesController : Controller
private async Task PopulateAccountDropdownAsync() private async Task PopulateAccountDropdownAsync()
{ {
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
ViewBag.AccountSelectList = accounts ViewBag.AccountSelectList = accounts
.OrderBy(a => a.AccountNumber) .OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem .Select(a => new SelectListItem
@@ -582,9 +582,7 @@ public class KioskController : Controller
[Authorize] [Authorize]
public async Task<IActionResult> Intakes(string? filter) public async Task<IActionResult> Intakes(string? filter)
{ {
var companyId = GetCurrentCompanyId(); var sessions = await _unitOfWork.KioskSessions.GetAllAsync(false,
var sessions = await _unitOfWork.KioskSessions.FindAsync(
s => s.CompanyId == companyId, false,
s => s.LinkedCustomer, s => s.LinkedCustomer,
s => s.LinkedJob); s => s.LinkedJob);
@@ -11,9 +11,7 @@ using PowderCoating.Web.Hubs;
namespace PowderCoating.Web.Controllers; namespace PowderCoating.Web.Controllers;
// Oven batch scheduling is shop-floor job management — gated to CanManageJobs so [Authorize]
// low-privilege roles can't create/modify/delete batches. (Audit #3, 2026-06-20.)
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
public class OvenSchedulerController : Controller public class OvenSchedulerController : Controller
{ {
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
@@ -63,17 +61,16 @@ public class OvenSchedulerController : Controller
public async Task<IActionResult> Index(DateTime? date, string goal = "maximize_throughput") public async Task<IActionResult> Index(DateTime? date, string goal = "maximize_throughput")
{ {
var scheduledDate = date?.Date ?? DateTime.Today; var scheduledDate = date?.Date ?? DateTime.Today;
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Load active Named Ovens — filter IsActive at database level // 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) .OrderBy(o => o.DisplayOrder).ThenBy(o => o.Label)
.ToList(); .ToList();
// Load batches for the selected date — filter at database level with includes // Load batches for the selected date — filter at database level with includes
var scheduledDateEnd = scheduledDate.AddDays(1); var scheduledDateEnd = scheduledDate.AddDays(1);
var batches = (await _unitOfWork.OvenBatches.FindAsync( 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, && b.Status != OvenBatchStatus.Cancelled,
false, false,
b => b.OvenCost, b => b.Items)) 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 // Load jobs in the queue — filter by status at database level
var queueJobs = (await _unitOfWork.Jobs.FindAsync( 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, false,
j => j.Customer, j => j.JobStatus, j => j.JobPriority, j => j.JobItems)) j => j.Customer, j => j.JobStatus, j => j.JobPriority, j => j.JobItems))
.ToList(); .ToList();
@@ -129,14 +126,14 @@ public class OvenSchedulerController : Controller
// Determine which coats are already scheduled — filter out removed/cancelled at database level // Determine which coats are already scheduled — filter out removed/cancelled at database level
var scheduledCoatIds = (await _unitOfWork.OvenBatchItems.FindAsync( 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, false,
i => i.Batch)) i => i.Batch))
.Select(i => i.JobItemCoatId) .Select(i => i.JobItemCoatId)
.ToHashSet(); .ToHashSet();
// Get company defaults // 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; var defaultCycleMinutes = companyCosts?.DefaultOvenCycleMinutes ?? 45;
// Build the view model // Build the view model
@@ -381,6 +381,11 @@ public class PaymentController : Controller
var dispute = stripeEvent.Data.Object as Dispute; var dispute = stripeEvent.Data.Object as Dispute;
if (dispute != null) await HandleDisputeClosedAsync(dispute); if (dispute != null) await HandleDisputeClosedAsync(dispute);
} }
else if (stripeEvent.Type == "terminal.reader.action_failed")
{
var reader = stripeEvent.Data.Object as Stripe.Terminal.Reader;
if (reader != null) await HandleReaderActionFailedAsync(reader);
}
return Ok(); return Ok();
} }
@@ -459,15 +464,24 @@ public class PaymentController : Controller
// Create a Payment record so the payment appears in AR and bank reports, and make the // Create a Payment record so the payment appears in AR and bank reports, and make the
// matching GL entries. Manual payments go through RecordPayment which does the same thing; // matching GL entries. Manual payments go through RecordPayment which does the same thing;
// this makes Stripe payments consistent with that path. // this makes Stripe payments consistent with that path.
// In-person Terminal payments carry source=terminal so we can record them as a card-reader
// payment (vs an online card-not-present payment) for clearer reporting. Everything else —
// GL posting, status machine, notifications — is identical.
var isTerminal = intent.Metadata.GetValueOrDefault("source") == "terminal";
var (arAcctId, checkingAcctId) = await GetGlAccountIdsAsync(invoice.CompanyId); var (arAcctId, checkingAcctId) = await GetGlAccountIdsAsync(invoice.CompanyId);
var stripePayment = new Core.Entities.Payment var stripePayment = new Core.Entities.Payment
{ {
InvoiceId = invoice.Id, InvoiceId = invoice.Id,
Amount = netPayment, Amount = netPayment,
PaymentDate = DateTime.UtcNow, PaymentDate = DateTime.UtcNow,
PaymentMethod = PowderCoating.Core.Enums.PaymentMethod.CreditDebitCard, PaymentMethod = isTerminal
? PowderCoating.Core.Enums.PaymentMethod.CardReader
: PowderCoating.Core.Enums.PaymentMethod.CreditDebitCard,
Reference = intent.Id, Reference = intent.Id,
Notes = $"Online payment via Stripe. Surcharge: {surcharge:C}", Notes = isTerminal
? $"In-person card payment via Stripe Terminal. Surcharge: {surcharge:C}"
: $"Online payment via Stripe. Surcharge: {surcharge:C}",
DepositAccountId = checkingAcctId, DepositAccountId = checkingAcctId,
CompanyId = invoice.CompanyId, CompanyId = invoice.CompanyId,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
@@ -502,6 +516,45 @@ public class PaymentController : Controller
} }
} }
/// <summary>
/// Handles a <c>terminal.reader.action_failed</c> event (declined card, customer cancellation,
/// reader timeout). This is observability only — no payment occurred, so nothing is written to the
/// ledger. The clerk's live status poll is the primary feedback channel; this fires an in-app
/// notification as a backstop in case they navigated away. Resolves the company via the invoice the
/// failed PaymentIntent was created for. Uses <c>IgnoreQueryFilters</c> (no tenant context here).
/// </summary>
private async Task HandleReaderActionFailedAsync(Stripe.Terminal.Reader reader)
{
var failureMessage = reader.Action?.FailureMessage ?? "The card reader payment did not complete.";
var paymentIntentId = reader.Action?.ProcessPaymentIntent?.PaymentIntentId;
_logger.LogWarning("Terminal reader {ReaderId} action failed: {Code} {Message} (PI={PI})",
reader.Id, reader.Action?.FailureCode, failureMessage, paymentIntentId);
if (string.IsNullOrEmpty(paymentIntentId)) return;
var invoice = await _context.Invoices
.IgnoreQueryFilters()
.FirstOrDefaultAsync(i => i.StripePaymentIntentId == paymentIntentId && !i.IsDeleted);
if (invoice == null) return;
try
{
await _inApp.CreateAsync(
companyId: invoice.CompanyId,
title: "Card Reader Payment Failed",
message: $"The card reader payment for invoice {invoice.InvoiceNumber} failed: {failureMessage}",
notificationType: "PaymentFailed",
link: $"/Invoices/Details/{invoice.Id}",
invoiceId: invoice.Id,
customerId: invoice.CustomerId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "In-app notification failed for terminal failure on invoice {InvoiceId}", invoice.Id);
}
}
/// <summary> /// <summary>
/// Processes a successful <c>payment_intent.succeeded</c> event for a quote deposit. Creates a /// Processes a successful <c>payment_intent.succeeded</c> event for a quote deposit. Creates a
/// <c>Deposit</c> ledger record so the deposit appears in the customer's deposit history and can /// <c>Deposit</c> ledger record so the deposit appears in the customer's deposit history and can
@@ -1,6 +1,5 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using PowderCoating.Application.Constants;
using PowderCoating.Application.DTOs.Common; using PowderCoating.Application.DTOs.Common;
using PowderCoating.Application.DTOs.Inventory; using PowderCoating.Application.DTOs.Inventory;
using PowderCoating.Application.Interfaces; using PowderCoating.Application.Interfaces;
@@ -18,28 +17,17 @@ public class PowderCatalogController : Controller
{ {
private const decimal DefaultTransferEfficiency = 65m; private const decimal DefaultTransferEfficiency = 65m;
private const string JsonImportSource = "Manual JSON Import";
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private readonly IInventoryAiLookupService _aiLookupService; private readonly IInventoryAiLookupService _aiLookupService;
private readonly IColumbiaCatalogSyncService _columbiaSyncService;
private readonly IPowderCatalogUpsertService _upsertService;
private readonly IPlatformSettingsService _platformSettings;
private readonly ILogger<PowderCatalogController> _logger; private readonly ILogger<PowderCatalogController> _logger;
public PowderCatalogController( public PowderCatalogController(
IUnitOfWork unitOfWork, IUnitOfWork unitOfWork,
IInventoryAiLookupService aiLookupService, IInventoryAiLookupService aiLookupService,
IColumbiaCatalogSyncService columbiaSyncService,
IPowderCatalogUpsertService upsertService,
IPlatformSettingsService platformSettings,
ILogger<PowderCatalogController> logger) ILogger<PowderCatalogController> logger)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_aiLookupService = aiLookupService; _aiLookupService = aiLookupService;
_columbiaSyncService = columbiaSyncService;
_upsertService = upsertService;
_platformSettings = platformSettings;
_logger = logger; _logger = logger;
} }
@@ -147,11 +135,6 @@ public class PowderCatalogController : Controller
} }
}; };
// Columbia sync status for the admin panel (last run + master switch).
ViewBag.ColumbiaSyncEnabled = await _platformSettings.GetBoolAsync(ColumbiaIntegrationConstants.SettingEnabled);
ViewBag.ColumbiaLastSyncedAt = await _platformSettings.GetAsync(ColumbiaIntegrationConstants.SettingLastSyncedAt);
ViewBag.ColumbiaLastResult = await _platformSettings.GetAsync(ColumbiaIntegrationConstants.SettingLastResult);
return View(vm); return View(vm);
} }
@@ -439,78 +422,6 @@ public class PowderCatalogController : Controller
return Json(results); return Json(results);
} }
/// <summary>
/// Manually triggers a full Columbia Coatings catalog sync (SuperAdmin only). Bypasses the
/// scheduled interval. Reports the run outcome via TempData on the catalog index.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SyncColumbia(CancellationToken cancellationToken)
{
var result = await _columbiaSyncService.RunSyncAsync(cancellationToken);
if (result.Success)
TempData["Success"] = $"Columbia sync complete - {result.Summary}";
else
TempData["Error"] = $"Columbia sync failed: {result.ErrorMessage}";
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Right-to-delete: removes every catalog record sourced from the Columbia Coatings API
/// (regardless of derived manufacturer, since PPG/KP products were served through that feed)
/// and nulls any inventory links to them across all tenants. The shops' own inventory stock
/// records survive — only the catalog link and discontinued badge are lost. SuperAdmin only.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> PurgeColumbiaData()
{
var sourced = (await _unitOfWork.PowderCatalog.FindAsync(
p => p.Source == ColumbiaIntegrationConstants.SourceName)).ToList();
if (sourced.Count == 0)
{
TempData["Error"] = "There is no Columbia Coatings API data to remove.";
return RedirectToAction(nameof(Index));
}
var ids = sourced.Select(p => p.Id).ToList();
// Null the inventory links across ALL tenants (platform-level purge). A tenant's stock
// record is their data and must survive — it keeps its add-time snapshot, losing only the
// live catalog link.
var linked = (await _unitOfWork.InventoryItems.FindAsync(
i => i.PowderCatalogItemId.HasValue && ids.Contains(i.PowderCatalogItemId.Value),
ignoreQueryFilters: true)).ToList();
foreach (var inv in linked)
{
inv.PowderCatalogItemId = null;
await _unitOfWork.InventoryItems.UpdateAsync(inv);
}
foreach (var p in sourced)
await _unitOfWork.PowderCatalog.DeleteAsync(p);
await _unitOfWork.CompleteAsync();
// Reset sync tracking so the admin panel reflects the purge.
await _platformSettings.SetAsync(ColumbiaIntegrationConstants.SettingLastSyncedAt, null, "Columbia Purge");
await _platformSettings.SetAsync(
ColumbiaIntegrationConstants.SettingLastResult,
$"Purged {sourced.Count:N0} records on {DateTime.UtcNow:yyyy-MM-dd}",
"Columbia Purge");
_logger.LogWarning(
"Columbia data purge: deleted {Count} catalog records, unlinked {Linked} inventory items.",
sourced.Count, linked.Count);
TempData["Success"] =
$"Removed {sourced.Count:N0} Columbia Coatings catalog record(s) and unlinked " +
$"{linked.Count:N0} inventory item(s). Inventory stock was preserved.";
return RedirectToAction(nameof(Index));
}
// Private helpers // Private helpers
private async Task ApplyTdsCureFallbackAsync(InventoryAiLookupResult result, string? colorName) private async Task ApplyTdsCureFallbackAsync(InventoryAiLookupResult result, string? colorName)
@@ -538,10 +449,13 @@ public class PowderCatalogController : Controller
return new PowderCatalogImportResult { Success = false, ErrorMessage = "JSON must have a top-level 'results' array." }; return new PowderCatalogImportResult { Success = false, ErrorMessage = "JSON must have a top-level 'results' array." };
} }
// Map the scrape format to catalog items, then hand off to the shared upsert path (same // Load existing records for this vendor into a lookup dictionary
// one the Columbia API sync uses) so there is a single insert/update/diff implementation. var existing = (await _unitOfWork.PowderCatalog.FindAsync(p => p.VendorName == vendorName))
var mapped = new List<PowderCatalogItem>(); .ToDictionary(p => p.Sku, StringComparer.OrdinalIgnoreCase);
int skipped = 0, errors = 0;
var now = DateTime.UtcNow;
int inserted = 0, updated = 0, skipped = 0, errors = 0;
var toAdd = new List<PowderCatalogItem>();
foreach (var item in resultsEl.EnumerateArray()) foreach (var item in resultsEl.EnumerateArray())
{ {
@@ -555,21 +469,49 @@ public class PowderCatalogController : Controller
continue; continue;
} }
mapped.Add(new PowderCatalogItem var rawDesc = item.GetStringOrNull("description");
var cleanDesc = StripBoilerplate(rawDesc);
var unitPrice = ExtractBasePrice(item);
var priceTiersJson = item.TryGetProperty("price_tiers", out var tiersEl)
? tiersEl.GetRawText()
: null;
if (existing.TryGetValue(sku, out var record))
{
record.ColorName = colorName;
record.Description = cleanDesc;
record.UnitPrice = unitPrice;
record.PriceTiersJson = priceTiersJson;
record.ImageUrl = item.GetStringOrNull("sample_image_url");
record.SdsUrl = item.GetStringOrNull("safety_data_sheet_url");
record.TdsUrl = item.GetStringOrNull("technical_data_sheet_url");
record.ApplicationGuideUrl = item.GetStringOrNull("application_guide_url");
record.ProductUrl = item.GetStringOrNull("product_url");
record.UpdatedAt = now;
record.LastSyncedAt = now;
await _unitOfWork.PowderCatalog.UpdateAsync(record);
updated++;
}
else
{
toAdd.Add(new PowderCatalogItem
{ {
VendorName = vendorName, VendorName = vendorName,
Source = JsonImportSource,
Sku = sku, Sku = sku,
ColorName = colorName, ColorName = colorName,
Description = StripBoilerplate(item.GetStringOrNull("description")), Description = cleanDesc,
UnitPrice = ExtractBasePrice(item), UnitPrice = unitPrice,
PriceTiersJson = item.TryGetProperty("price_tiers", out var tiersEl) ? tiersEl.GetRawText() : null, PriceTiersJson = priceTiersJson,
ImageUrl = item.GetStringOrNull("sample_image_url"), ImageUrl = item.GetStringOrNull("sample_image_url"),
SdsUrl = item.GetStringOrNull("safety_data_sheet_url"), SdsUrl = item.GetStringOrNull("safety_data_sheet_url"),
TdsUrl = item.GetStringOrNull("technical_data_sheet_url"), TdsUrl = item.GetStringOrNull("technical_data_sheet_url"),
ApplicationGuideUrl = item.GetStringOrNull("application_guide_url"), ApplicationGuideUrl = item.GetStringOrNull("application_guide_url"),
ProductUrl = item.GetStringOrNull("product_url"), ProductUrl = item.GetStringOrNull("product_url"),
CreatedAt = now,
LastSyncedAt = now
}); });
inserted++;
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -578,14 +520,17 @@ public class PowderCatalogController : Controller
} }
} }
var upsert = await _upsertService.UpsertAsync(mapped, DateTime.UtcNow); if (toAdd.Any())
await _unitOfWork.PowderCatalog.AddRangeAsync(toAdd);
await _unitOfWork.CompleteAsync();
return new PowderCatalogImportResult return new PowderCatalogImportResult
{ {
Success = true, Success = true,
Inserted = upsert.Inserted, Inserted = inserted,
Updated = upsert.Updated, Updated = updated,
Skipped = skipped + upsert.Skipped, Skipped = skipped,
Errors = errors Errors = errors
}; };
} }
@@ -68,8 +68,7 @@ public class PricingTiersController : Controller
return View(dto); return View(dto);
// Check for duplicate name // Check for duplicate name
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var existing = await _unitOfWork.PricingTiers.FindAsync(t => t.TierName == dto.TierName);
var existing = await _unitOfWork.PricingTiers.FindAsync(t => t.CompanyId == companyId && t.TierName == dto.TierName);
if (existing.Any()) if (existing.Any())
{ {
ModelState.AddModelError(nameof(dto.TierName), "A tier with this name already exists."); 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(); if (entity == null) return NotFound();
// Check for duplicate name (excluding this record) // Check for duplicate name (excluding this record)
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var duplicate = await _unitOfWork.PricingTiers.FindAsync( 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()) if (duplicate.Any())
{ {
ModelState.AddModelError(nameof(dto.TierName), "A tier with this name already exists."); 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(); if (entity == null) return NotFound();
// Block delete if customers are assigned to this tier // Block delete if customers are assigned to this tier
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var assignedCustomers = await _unitOfWork.Customers.FindAsync(c => c.PricingTierId == id);
var assignedCustomers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId && c.PricingTierId == id);
if (assignedCustomers.Any()) if (assignedCustomers.Any())
{ {
TempData["ErrorMessage"] = $"Cannot delete '{entity.TierName}' — {assignedCustomers.Count()} customer(s) are assigned to it. Reassign them first."; 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); 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 // Get customer info if exists
if (quote.CustomerId.HasValue) if (quote.CustomerId.HasValue)
{ {
@@ -2548,8 +2545,7 @@ public class QuotesController : Controller
efficiency = i.TransferEfficiency ?? 65m, efficiency = i.TransferEfficiency ?? 65m,
unitOfMeasure = i.UnitOfMeasure ?? "lbs", unitOfMeasure = i.UnitOfMeasure ?? "lbs",
categoryName = i.InventoryCategory!.DisplayName, categoryName = i.InventoryCategory!.DisplayName,
// Quote at the current catalog price when linked; fall back to their cost otherwise. costPerLb = i.UnitCost,
costPerLb = i.CatalogReferencePrice ?? i.UnitCost,
colorName = i.ColorName ?? i.Name, colorName = i.ColorName ?? i.Name,
colorCode = i.ColorCode ?? "", colorCode = i.ColorCode ?? "",
isIncoming = i.IsIncoming isIncoming = i.IsIncoming
@@ -3428,7 +3424,7 @@ public class QuotesController : Controller
try try
{ {
var powders = await _unitOfWork.InventoryItems.FindAsync(i => 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; avgPowderCost = powders.Any() ? powders.Average(p => p.UnitCost) : 8m;
} }
catch catch
@@ -3522,7 +3518,7 @@ public class QuotesController : Controller
try try
{ {
var powders = await _unitOfWork.InventoryItems.FindAsync(i => 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; avgPowderCost = powders.Any() ? powders.Average(p => p.UnitCost) : 8m;
} }
catch { avgPowderCost = 8m; } catch { avgPowderCost = 8m; }
@@ -3617,7 +3613,7 @@ public class QuotesController : Controller
// Pull recent accepted predictions (user didn't override) as few-shot calibration examples // Pull recent accepted predictions (user didn't override) as few-shot calibration examples
var allPredictions = await _unitOfWork.AiItemPredictions.FindAsync( 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 context.AcceptedExamples = allPredictions
.OrderByDescending(p => p.CreatedAt) .OrderByDescending(p => p.CreatedAt)
@@ -3660,11 +3656,9 @@ public class QuotesController : Controller
{ {
var sqFtMin = sqFt * 0.4m; var sqFtMin = sqFt * 0.4m;
var sqFtMax = sqFt * 2.5m; var sqFtMax = sqFt * 2.5m;
var companyId = (await _userManager.GetUserAsync(User))?.CompanyId ?? 0;
var matches = await _unitOfWork.JobItems.FindAsync( var matches = await _unitOfWork.JobItems.FindAsync(
ji => ji.CompanyId == companyId ji => ji.Complexity == complexity
&& ji.Complexity == complexity
&& ji.SurfaceAreaSqFt >= sqFtMin && ji.SurfaceAreaSqFt >= sqFtMin
&& ji.SurfaceAreaSqFt <= sqFtMax && ji.SurfaceAreaSqFt <= sqFtMax
&& ji.UnitPrice > 0 && ji.UnitPrice > 0
@@ -3672,7 +3666,7 @@ public class QuotesController : Controller
var jobIds = matches.Select(ji => ji.JobId).Distinct().ToList(); var jobIds = matches.Select(ji => ji.JobId).Distinct().ToList();
var completedStatusIds = (await _unitOfWork.JobStatusLookups.FindAsync( 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(); .Select(s => s.Id).ToHashSet();
var completedJobs = await _unitOfWork.Jobs.FindAsync( var completedJobs = await _unitOfWork.Jobs.FindAsync(
j => jobIds.Contains(j.Id) && completedStatusIds.Contains(j.JobStatusId)); j => jobIds.Contains(j.Id) && completedStatusIds.Contains(j.JobStatusId));
@@ -590,8 +590,7 @@ public class ReportsController : Controller
// === POWDER USAGE ANALYTICS === // === POWDER USAGE ANALYTICS ===
var powderTransactions = (await _unitOfWork.InventoryTransactions var powderTransactions = (await _unitOfWork.InventoryTransactions
.FindAsync(t => t.CompanyId == companyId .FindAsync(t => t.TransactionType == InventoryTransactionType.JobUsage
&& t.TransactionType == InventoryTransactionType.JobUsage
&& t.TransactionDate >= startDate, && t.TransactionDate >= startDate,
false, false,
t => t.InventoryItem)) t => t.InventoryItem))
@@ -1251,20 +1250,6 @@ public class ReportsController : Controller
return View(dto); 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> /// <summary>
/// PDF export of the Trial Balance report. Same inline/attachment pattern as other PDF actions. /// PDF export of the Trial Balance report. Same inline/attachment pattern as other PDF actions.
/// Gated behind <see cref="AllowAccounting"/>. /// Gated behind <see cref="AllowAccounting"/>.
@@ -2513,7 +2498,7 @@ public class ReportsController : Controller
var reportYear = year ?? DateTime.Now.Year; var reportYear = year ?? DateTime.Now.Year;
// Load all budgets for the year for the selector // 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(); .OrderBy(b => b.Name).ToList();
Core.Entities.Budget? budget = null; 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.GetByIdAsync(budgetId.Value, false, b => b.Lines);
budget ??= (await _unitOfWork.Budgets.FindAsync( 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( 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.ReportYear = reportYear;
ViewBag.Budget = budget; ViewBag.Budget = budget;
@@ -2559,7 +2544,7 @@ public class ReportsController : Controller
// Load account metadata for budget lines // Load account metadata for budget lines
var accountIds = budget.Lines.Select(l => l.AccountId).Distinct().ToList(); 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); .ToDictionary(a => a.Id);
var rows = new List<BudgetVsActualRow>(); 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); var periodEnd = new DateTime(reportYear, 12, 31, 23, 59, 59, DateTimeKind.Utc);
// Load 1099-eligible vendors // 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>(); var rows = new List<Vendor1099Row>();
@@ -134,8 +134,7 @@ public class TaxRatesController : Controller
/// </summary> /// </summary>
private async Task ClearOtherDefaultsAsync(int exceptId) private async Task ClearOtherDefaultsAsync(int exceptId)
{ {
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var others = await _unitOfWork.TaxRates.FindAsync(r => r.IsDefault && r.Id != exceptId);
var others = await _unitOfWork.TaxRates.FindAsync(r => r.CompanyId == companyId && r.IsDefault && r.Id != exceptId);
foreach (var r in others) foreach (var r in others)
r.IsDefault = false; r.IsDefault = false;
} }
@@ -0,0 +1,342 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Application.DTOs.Terminal;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Authenticated, tenant-scoped controller for Stripe Terminal in-person card payments (WisePOS E).
/// Handles reader registration/management (admin) and pushing an invoice payment to a physical reader
/// plus live status polling (anyone who can manage invoices).
/// <para>
/// The authoritative payment record is always created by the existing <c>payment_intent.succeeded</c>
/// webhook in <see cref="PaymentController"/> — this controller only kicks off the charge on the reader
/// and reports progress. See <c>docs</c>/the plan for the full flow.
/// </para>
/// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageInvoices)]
public class TerminalController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly IStripeConnectService _stripeConnect;
private readonly ITenantContext _tenantContext;
private readonly IConfiguration _configuration;
private readonly ILogger<TerminalController> _logger;
public TerminalController(
IUnitOfWork unitOfWork,
IStripeConnectService stripeConnect,
ITenantContext tenantContext,
IConfiguration configuration,
ILogger<TerminalController> logger)
{
_unitOfWork = unitOfWork;
_stripeConnect = stripeConnect;
_tenantContext = tenantContext;
_configuration = configuration;
_logger = logger;
}
/// <summary>Current tenant's company id. The CanManageInvoices policy guarantees a company-scoped user;
/// a 0 fallback fails safe (matches no company) for the theoretical claim-less case.</summary>
private int CompanyId => _tenantContext.GetCurrentCompanyId() ?? 0;
/// <summary>True when the Connect secret key is a test-mode key — gates the simulated-tap endpoint.</summary>
private bool IsTestMode =>
(_configuration["Stripe:Connect:SecretKey"] ?? string.Empty).StartsWith("sk_test_", StringComparison.Ordinal);
// ===== Reader management (admin) =====
/// <summary>
/// Registers a Stripe Terminal reader to the company using the registration code shown on the
/// device. Lazily creates the company's single Terminal Location from its address on first use.
/// Requires company-admin rights in addition to the controller's invoice policy.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public async Task<IActionResult> RegisterReader(string registrationCode, string label)
{
if (string.IsNullOrWhiteSpace(registrationCode) || string.IsNullOrWhiteSpace(label))
return Json(new { success = false, error = "A registration code and a label are both required." });
var companyId = CompanyId;
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
if (company == null)
return Json(new { success = false, error = "Company not found." });
if (company.StripeConnectStatus != StripeConnectStatus.Active || string.IsNullOrEmpty(company.StripeAccountId))
return Json(new { success = false, error = "Connect your Stripe account before registering a reader." });
// Ensure the shop's Terminal Location exists (one per company).
var (locOk, locationId, locError) = await EnsureLocationAsync(company);
if (!locOk)
return Json(new { success = false, error = locError });
var (ok, readerId, deviceType, serial, error) = await _stripeConnect.RegisterReaderAsync(
company.StripeAccountId!, locationId!, registrationCode.Trim(), label.Trim());
if (!ok)
return Json(new { success = false, error });
var reader = new TerminalReader
{
CompanyId = companyId,
StripeReaderId = readerId!,
StripeLocationId = locationId!,
Label = label.Trim(),
DeviceType = deviceType ?? string.Empty,
SerialNumber = serial,
Status = TerminalReaderStatus.Active
};
await _unitOfWork.TerminalReaders.AddAsync(reader);
await _unitOfWork.CompleteAsync();
return Json(new { success = true, reader = ToJson(reader) });
}
/// <summary>
/// Saves the in-person (Terminal) surcharge toggle. Defaults off; enabling it applies the same
/// percent/flat fee configured for online payments to card-reader charges. Admin only.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public async Task<IActionResult> UpdateTerminalSettings(bool surchargeEnabled)
{
var companyId = CompanyId;
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
if (company == null)
return Json(new { success = false, error = "Company not found." });
company.TerminalSurchargeEnabled = surchargeEnabled;
company.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.Companies.UpdateAsync(company);
await _unitOfWork.CompleteAsync();
return Json(new { success = true });
}
/// <summary>Returns the company's active registered readers (JSON) for the settings tab.</summary>
[HttpGet]
public async Task<IActionResult> ListReaders()
{
var companyId = CompanyId;
var readers = await _unitOfWork.TerminalReaders.FindAsync(
r => r.CompanyId == companyId && r.Status == TerminalReaderStatus.Active);
return Json(new { success = true, readers = readers.OrderBy(r => r.Label).Select(ToJson) });
}
/// <summary>Unregisters a reader from Stripe and soft-deletes the local record.</summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public async Task<IActionResult> DeactivateReader(int id)
{
var companyId = CompanyId;
var reader = await _unitOfWork.TerminalReaders.GetByIdAsync(id);
if (reader == null || reader.CompanyId != companyId)
return Json(new { success = false, error = "Reader not found." });
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
if (company?.StripeAccountId != null)
{
// Best-effort delete on Stripe; proceed with local cleanup even if it has already been removed there.
var (ok, error) = await _stripeConnect.DeleteReaderAsync(company.StripeAccountId, reader.StripeReaderId);
if (!ok)
_logger.LogWarning("Stripe reader delete failed for {ReaderId}: {Error}", reader.StripeReaderId, error);
}
reader.Status = TerminalReaderStatus.Deactivated;
await _unitOfWork.TerminalReaders.SoftDeleteAsync(reader);
await _unitOfWork.CompleteAsync();
return Json(new { success = true });
}
// ===== Taking a payment =====
/// <summary>
/// Creates a card_present PaymentIntent for the invoice and pushes it to the selected reader.
/// Stores the returned PaymentIntent id on the invoice so the webhook's idempotency guard works.
/// Does NOT record the payment — the <c>payment_intent.succeeded</c> webhook is authoritative.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> ProcessPayment(int invoiceId, int readerId, decimal amount)
{
var companyId = CompanyId;
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
if (company == null || company.StripeConnectStatus != StripeConnectStatus.Active || string.IsNullOrEmpty(company.StripeAccountId))
return Json(new { success = false, error = "Stripe is not connected for this company." });
var invoice = await _unitOfWork.Invoices.GetByIdAsync(invoiceId);
if (invoice == null || invoice.CompanyId != companyId)
return Json(new { success = false, error = "Invoice not found." });
if (invoice.Status == InvoiceStatus.Voided)
return Json(new { success = false, error = "This invoice has been voided." });
if (invoice.BalanceDue <= 0)
return Json(new { success = false, error = "This invoice is already paid in full." });
if (amount <= 0 || amount > invoice.BalanceDue)
return Json(new { success = false, error = "Enter an amount between $0 and the balance due." });
var reader = await _unitOfWork.TerminalReaders.GetByIdAsync(readerId);
if (reader == null || reader.CompanyId != companyId || reader.Status != TerminalReaderStatus.Active)
return Json(new { success = false, error = "Card reader not found." });
// In-person surcharge is OFF unless the shop has explicitly enabled it (compliance varies by state).
var surcharge = company.TerminalSurchargeEnabled ? CalculateSurcharge(amount, company) : 0m;
var (ok, paymentIntentId, error) = await _stripeConnect.ProcessInvoicePaymentOnReaderAsync(
company.StripeAccountId!, reader.StripeReaderId, amount, surcharge, "usd", invoice.InvoiceNumber, invoice.Id);
if (!ok)
return Json(new { success = false, error });
// Persist the PI id so HandlePaymentSucceededAsync's idempotency guard matches (mirrors PaymentController.CreateIntent).
invoice.StripePaymentIntentId = paymentIntentId;
if (invoice.OnlinePaymentStatus == OnlinePaymentStatus.NotApplicable)
invoice.OnlinePaymentStatus = OnlinePaymentStatus.Pending;
invoice.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.Invoices.UpdateAsync(invoice);
await _unitOfWork.CompleteAsync();
return Json(new { success = true, paymentIntentId });
}
/// <summary>
/// Polls the reader's action status for live UI feedback and reports whether the webhook has
/// already recorded the payment (derived from the invoice's online payment status for this PI).
/// </summary>
[HttpGet]
public async Task<IActionResult> PaymentStatus(int readerId, string paymentIntentId)
{
var companyId = CompanyId;
var reader = await _unitOfWork.TerminalReaders.GetByIdAsync(readerId);
if (reader == null || reader.CompanyId != companyId)
return Json(new { success = false, error = "Card reader not found." });
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
if (company?.StripeAccountId == null)
return Json(new { success = false, error = "Stripe is not connected." });
var status = await _stripeConnect.GetReaderStatusAsync(company.StripeAccountId, reader.StripeReaderId);
// The webhook is the source of truth — check whether it has landed for this PaymentIntent.
var invoice = (await _unitOfWork.Invoices.FindAsync(
i => i.CompanyId == companyId && i.StripePaymentIntentId == paymentIntentId)).FirstOrDefault();
var webhookRecorded = invoice != null
&& invoice.OnlinePaymentStatus is OnlinePaymentStatus.Paid or OnlinePaymentStatus.PartiallyPaid;
return Json(new
{
success = status.Success,
actionStatus = status.ActionStatus,
failureCode = status.FailureCode,
failureMessage = status.FailureMessage,
webhookRecorded,
error = status.ErrorMessage
});
}
/// <summary>Cancels an in-progress reader action (clerk cancelled or wants to retry).</summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> CancelPayment(int readerId)
{
var companyId = CompanyId;
var reader = await _unitOfWork.TerminalReaders.GetByIdAsync(readerId);
if (reader == null || reader.CompanyId != companyId)
return Json(new { success = false, error = "Card reader not found." });
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
if (company?.StripeAccountId == null)
return Json(new { success = false, error = "Stripe is not connected." });
var (ok, error) = await _stripeConnect.CancelReaderActionAsync(company.StripeAccountId, reader.StripeReaderId);
return Json(new { success = ok, error });
}
/// <summary>
/// TEST MODE ONLY: simulates a card tap on a simulated reader so a payment can complete without
/// hardware. Returns 404 in production so the endpoint cannot be probed there.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> SimulateTap(int readerId)
{
if (!IsTestMode)
return NotFound();
var companyId = CompanyId;
var reader = await _unitOfWork.TerminalReaders.GetByIdAsync(readerId);
if (reader == null || reader.CompanyId != companyId)
return Json(new { success = false, error = "Card reader not found." });
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
if (company?.StripeAccountId == null)
return Json(new { success = false, error = "Stripe is not connected." });
var (ok, error) = await _stripeConnect.SimulatePresentPaymentMethodAsync(company.StripeAccountId, reader.StripeReaderId);
return Json(new { success = ok, error });
}
// ===== Helpers =====
/// <summary>
/// Ensures the company has a Stripe Terminal Location, creating one from its address if needed and
/// persisting the id. Returns the location id to attach readers to.
/// </summary>
private async Task<(bool Success, string? LocationId, string? Error)> EnsureLocationAsync(Company company)
{
if (!string.IsNullOrEmpty(company.StripeTerminalLocationId))
return (true, company.StripeTerminalLocationId, null);
if (string.IsNullOrWhiteSpace(company.Address) || string.IsNullOrWhiteSpace(company.City)
|| string.IsNullOrWhiteSpace(company.State) || string.IsNullOrWhiteSpace(company.ZipCode))
{
return (false, null, "Complete your company address (street, city, state, ZIP) before registering a reader.");
}
var address = new TerminalAddressDto
{
Line1 = company.Address!,
City = company.City!,
State = company.State!,
PostalCode = company.ZipCode!,
Country = "US"
};
var (ok, locationId, error) = await _stripeConnect.CreateTerminalLocationAsync(
company.StripeAccountId!, company.CompanyName, address);
if (!ok)
return (false, null, error);
company.StripeTerminalLocationId = locationId;
company.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.Companies.UpdateAsync(company);
await _unitOfWork.CompleteAsync();
return (true, locationId, null);
}
/// <summary>Mirrors the online surcharge calculation (percent/flat) used by PaymentController.</summary>
private static decimal CalculateSurcharge(decimal amount, Company company)
{
return company.OnlinePaymentSurchargeType switch
{
OnlinePaymentSurchargeType.Percent => Math.Round(amount * (company.OnlinePaymentSurchargeValue / 100m), 2),
OnlinePaymentSurchargeType.Flat => company.OnlinePaymentSurchargeValue,
_ => 0m
};
}
/// <summary>Projects a reader to the anonymous shape returned to the settings tab JS.</summary>
private static object ToJson(TerminalReader r) => new
{
id = r.Id,
label = r.Label,
deviceType = r.DeviceType,
serialNumber = r.SerialNumber,
networkStatus = r.LastKnownNetworkStatus,
lastSeenAt = r.LastSeenAt
};
}
@@ -3,21 +3,16 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Import;
using PowderCoating.Application.Interfaces; using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities; using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums; using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces; using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data; using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
using System.Security.Claims; using System.Security.Claims;
namespace PowderCoating.Web.Controllers; namespace PowderCoating.Web.Controllers;
// Bulk import/export + QuickBooks migration tools — gated to the financial-management [Authorize]
// 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)]
public class ToolsController : Controller public class ToolsController : Controller
{ {
private readonly IUnitOfWork _unitOfWork; 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> /// <summary>
/// Downloads a blank CSV template for the native payment bulk import. /// Downloads a blank CSV template for the native payment bulk import.
/// Columns match the native ExportPaymentsCsv output for round-trip compatibility. /// 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"); 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> /// <summary>
/// Bulk-imports payment records from a native CSV file. Invoices are resolved by InvoiceNumber. /// 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 /// Duplicate payments (same invoice + date + amount) are skipped. Updates the invoice AmountPaid
@@ -2180,7 +2044,7 @@ public class ToolsController : Controller
} }
// 11. Invoices // 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 invoicesCsv = GenerateInvoicesCsv(invoices);
var invoicesEntry = archive.CreateEntry($"invoices_{timestamp}.csv"); var invoicesEntry = archive.CreateEntry($"invoices_{timestamp}.csv");
using (var entryStream = invoicesEntry.Open()) using (var entryStream = invoicesEntry.Open())
@@ -2199,17 +2063,6 @@ public class ToolsController : Controller
await writer.WriteAsync(accountsCsv); 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 // 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 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); var expensesCsv = GenerateExpensesCsv(expenses);
@@ -2221,7 +2074,7 @@ public class ToolsController : Controller
} }
// 14. Payments // 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 paymentsCsv = GeneratePaymentsCsv(payments);
var paymentsEntry = archive.CreateEntry($"payments_{timestamp}.csv"); var paymentsEntry = archive.CreateEntry($"payments_{timestamp}.csv");
using (var entryStream = paymentsEntry.Open()) using (var entryStream = paymentsEntry.Open())
@@ -2230,48 +2083,8 @@ public class ToolsController : Controller
await writer.WriteAsync(paymentsCsv); 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 // 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 purchaseOrdersCsv = GeneratePurchaseOrdersCsv(purchaseOrders);
var purchaseOrdersEntry = archive.CreateEntry($"purchase_orders_{timestamp}.csv"); var purchaseOrdersEntry = archive.CreateEntry($"purchase_orders_{timestamp}.csv");
using (var entryStream = purchaseOrdersEntry.Open()) using (var entryStream = purchaseOrdersEntry.Open())
@@ -2334,7 +2147,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index)); 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 csv = GenerateCustomersCsv(customers);
var fileName = $"customers_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; var fileName = $"customers_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -2368,7 +2181,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index)); 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 csv = GenerateQuotesCsv(quotes);
var fileName = $"quotes_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; var fileName = $"quotes_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -2401,7 +2214,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index)); 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 csv = GenerateJobsCsv(jobs);
var fileName = $"jobs_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; var fileName = $"jobs_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -2433,7 +2246,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index)); 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); a => a.Customer, a => a.AppointmentType, a => a.AppointmentStatus);
var csv = GenerateAppointmentsCsv(appointments); var csv = GenerateAppointmentsCsv(appointments);
var fileName = $"appointments_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; var fileName = $"appointments_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -2503,7 +2316,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index)); 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 csv = GenerateInventoryCsv(inventoryItems);
var fileName = $"inventory_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; var fileName = $"inventory_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -2570,7 +2383,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index)); 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 csv = GenerateMaintenanceCsv(maintenance);
var fileName = $"maintenance_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; var fileName = $"maintenance_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -2658,7 +2471,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index)); 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 csv = GenerateInvoicesCsv(invoices);
var fileName = $"invoices_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; var fileName = $"invoices_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -2691,7 +2504,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index)); 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 csv = GeneratePaymentsCsv(payments);
var fileName = $"payments_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; 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> /// <summary>
/// Exports all purchase orders for the current company as a CSV file, including the vendor /// 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. /// 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)); 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 csv = GeneratePurchaseOrdersCsv(purchaseOrders);
var fileName = $"purchase_orders_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; var fileName = $"purchase_orders_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -4318,35 +3973,6 @@ public class ToolsController : Controller
return sb.ToString(); 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> /// <summary>
/// Builds a CSV string for the given invoice payment records. The parent invoice number is /// 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 /// 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) private string GeneratePaymentsCsv(IEnumerable<Core.Entities.Payment> payments)
{ {
var sb = new System.Text.StringBuilder(); 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) foreach (var payment in payments)
{ {
sb.AppendLine($"{EscapeCsv(payment.Invoice?.InvoiceNumber)}," + sb.AppendLine($"{EscapeCsv(payment.Invoice?.InvoiceNumber)}," +
$"{payment.Amount},{payment.PaymentDate:yyyy-MM-dd}," + $"{payment.Amount},{payment.PaymentDate:yyyy-MM-dd}," +
$"{payment.PaymentMethod},{EscapeCsv(payment.DepositAccount?.AccountNumber)}," + $"{payment.PaymentMethod},{EscapeCsv(payment.Reference)},{EscapeCsv(payment.Notes)}");
$"{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}");
}
} }
return sb.ToString(); return sb.ToString();
@@ -4615,7 +4143,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index)); 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.ExpenseAccount,
e => e.PaymentAccount, e => e.PaymentAccount,
e => e.Vendor, e => e.Vendor,
@@ -132,7 +132,7 @@ public class VendorCreditsController : Controller
.Select(l => l.AccountId!.Value) .Select(l => l.AccountId!.Value)
.Distinct() .Distinct()
.ToList(); .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}"); ViewBag.AccountMap = accounts.ToDictionary(a => a.Id, a => $"{a.AccountNumber} {a.Name}");
// Load bills referenced by applications // Load bills referenced by applications
@@ -357,9 +357,8 @@ public class VendorCreditsController : Controller
private async Task PopulateDropdownsAsync() private async Task PopulateDropdownsAsync()
{ {
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var vendors = await _unitOfWork.Vendors.FindAsync(v => v.IsActive);
var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId && v.IsActive); var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
ViewBag.VendorList = vendors ViewBag.VendorList = vendors
.OrderBy(v => v.CompanyName) .OrderBy(v => v.CompanyName)
@@ -463,9 +463,8 @@ public class VendorsController : Controller
private async Task PopulateExpenseAccountsAsync() private async Task PopulateExpenseAccountsAsync()
{ {
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var accounts = (await _unitOfWork.Accounts.FindAsync( 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.CostOfGoods ||
a.AccountType == AccountType.Asset))) a.AccountType == AccountType.Asset)))
.OrderBy(a => a.AccountNumber) .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);
}
}

Some files were not shown because too many files have changed in this diff Show More