Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f671f7e62e |
@@ -1,285 +0,0 @@
|
|||||||
# Accounting System Audit
|
|
||||||
|
|
||||||
> Living record of the accounting/GL audit and its remediation status.
|
|
||||||
> **Keep this file updated** whenever an accounting finding is opened or closed — the
|
|
||||||
> original audit was lost once because it was never written down. Don't let that happen again.
|
|
||||||
|
|
||||||
Last reconciled against code: **2026-06-19** (branch `dev`, commits through `9532812`).
|
|
||||||
Verification at that point: `dotnet build` clean (0 errors); `dotnet test tests/PowderCoating.UnitTests` → **290 passed, 0 failed**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## How the GL is modeled (context for the findings)
|
|
||||||
|
|
||||||
Balances are computed two different ways, and they must agree:
|
|
||||||
|
|
||||||
1. **At posting time** — controllers call `AccountBalanceService.DebitAsync/CreditAsync`, which
|
|
||||||
mutate the denormalized `Account.CurrentBalance`.
|
|
||||||
2. **At report time** — `FinancialReportService` (Trial Balance, Balance Sheet, P&L, AR/AP aging,
|
|
||||||
statements) and `LedgerService` (per-account ledger + `RecalculateBalances`) recompute balances
|
|
||||||
from source documents.
|
|
||||||
|
|
||||||
`LedgerService` also backs the **Balance Reconciliation report** (the detective control added in
|
|
||||||
`c2cd19e`), which compares stored `CurrentBalance` vs the ledger-recomputed balance. Any account
|
|
||||||
whose recompute path is incomplete will (a) be silently corrupted by a "Recalculate Balances" run
|
|
||||||
and (b) produce misleading output in the reconciliation report.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Resolved findings (this audit batch)
|
|
||||||
|
|
||||||
| # | Finding | Fix | Commit | Verified |
|
|
||||||
|---|---------|-----|--------|----------|
|
|
||||||
| **#4** | `Sales Tax Payable` (2200) was credited on every invoice but never relieved — the liability grew forever (no remittance flow). | `JournalEntries/SalesTaxPayment` GET+POST: DR 2200 / CR bank, balanced, in a transaction, honors the period lock and validates amount + bank-account tenancy. | `0c921ba` | Posting reviewed `JournalEntriesController:235-296`. Reporting needs no change (posted JE lines already flow through TB/BS/ledger). |
|
|
||||||
| **#7** | Most report queries relied on the global tenant filter, which is **bypassed for SuperAdmin** → cross-company leakage on P&L / Balance Sheet / Trial Balance / aging / statements. | Explicit `CompanyId == companyId` predicate added to every query in `FinancialReportService`. | `9532812` | Confirmed: every query in `FinancialReportService.cs` carries an explicit `CompanyId` predicate. |
|
|
||||||
| **#9a** | A cash refund posted **DR AR** (control up) while the customer subledger went down — opposite directions → guaranteed AR drift. | Refund now **reverses the sale**: DR Sales Returns (4960) + DR Sales Tax Payable (tax portion) / CR bank; AR untouched. Split centralized in `Core/Accounting/RefundAllocation.Split`. | `2a82a1d` | Posting (`InvoicesController.IssueRefund`/`CancelRefund`) and both recompute paths (`LedgerService` §5b, `FinancialReportService` TB/BS/P&L) all use `RefundAllocation.Split` — they agree by construction. |
|
|
||||||
| **#9b** | Store-credit refunds & credit memos posted nothing to the GL on issue (only a `CreditMemo` + `Customer.CreditBalance`) → outstanding store credit invisible on the balance sheet. | New **2350 Customer Credits** liability. Issue: DR 4950 / CR 2350. Apply: DR 2350 / CR AR. Void remainder: DR 2350 / CR 4950. Updated in all 8 posting sites + TB/BS/P&L + ledger §12b. | `9ce3612` | Posting sites confirmed (`CreditMemosController` 180-185/262-270/314-320; `InvoicesController` store-credit path). Reporting `cmContraRevenue`/`cmIssuedNonVoided` math reviewed. |
|
|
||||||
| — | No detective control to catch denormalized-balance drift. | **Balance Reconciliation report** (`Reports/Reconciliation`): per-account stored vs recomputed, plus AR/AP control-vs-subledger. | `c2cd19e` | Reviewed `FinancialReportService.GetBalanceReconciliationAsync`. NOTE: its usefulness is limited by O2 below. |
|
|
||||||
|
|
||||||
### Original numbering gap
|
|
||||||
The commits reference findings **#4, #7, #9a, #9b** — implying a list of at least #1–#9. The original
|
|
||||||
audit document was never persisted and is unrecoverable. Findings **#1, #2, #3, #5, #6, #8** cannot be
|
|
||||||
matched to commits and their status is **unknown**. If the original list resurfaces, reconcile it here.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Resolved findings (2026-06-19 remediation)
|
|
||||||
|
|
||||||
### O1 — Account 2300 mislabeled "Payroll Liabilities" while used as Customer Deposits — **RESOLVED**
|
|
||||||
- **Root cause refined:** there is no payroll feature posting to 2300; the deposit GL code has always used
|
|
||||||
2300 (resolved by number) as the Customer Deposits liability. Migration `AccountingDepositsGL` already
|
|
||||||
renamed it to "Customer Deposits" for tenants that lacked a 2300, but its `NOT EXISTS` guard **skipped**
|
|
||||||
pre-existing tenants, leaving them mislabeled. The two seed files still created new tenants with the
|
|
||||||
wrong name.
|
|
||||||
- **Fix (chosen approach: rename 2300, move payroll to a new 2400):**
|
|
||||||
- Seed files now create **2300 = "Customer Deposits" (IsSystem)** and a separate **2400 = "Payroll
|
|
||||||
Liabilities"** — `SeedDataService.Accounts.cs`, `SeedData.cs`.
|
|
||||||
- `EnsureSystemAccountsAsync` self-heals: renames any 2300 still named "Payroll Liabilities" → "Customer
|
|
||||||
Deposits" (preserving user renames) and ensures 2400 exists.
|
|
||||||
- Migration `20260619233108_RenameDepositsAccountAddPayroll` does the same for existing tenants (rename
|
|
||||||
only where Name is still the default; insert 2400 where missing). Down is best-effort (soft-deletes
|
|
||||||
only empty 2400s; does not re-introduce the mislabel).
|
|
||||||
- Account **number 2300 is unchanged**, so the deposit posting code (`DepositsController`,
|
|
||||||
`InvoicesController`) needed no changes — its lookups are by `"2300"`.
|
|
||||||
|
|
||||||
### O2 — `LedgerService` never recomputed 4950 Sales Discounts → recalc corrupted it — **RESOLVED**
|
|
||||||
- **Fix:** added a 4950 section to both `LedgerService.GetAccountLedgerAsync` and `ComputePriorBalanceAsync`
|
|
||||||
that reproduces the *actual postings* so a balance recalc matches the stored balance:
|
|
||||||
invoice discounts (DR at invoice date) + credit-memo issuance (DR full amount at issue) − the unapplied
|
|
||||||
remainder of voided memos (CR at void). This mirrors what `AccountBalanceService` posts at
|
|
||||||
`InvoicesController:849/1108/1629` and `CreditMemosController`, so the Balance Reconciliation report no
|
|
||||||
longer shows false drift on 4950.
|
|
||||||
- **Note / micro-discrepancy:** the ledger uses `memo.AmountApplied` for the voided remainder (matching the
|
|
||||||
true posting), whereas `FinancialReportService` TB/BS derive the voided portion from applications against
|
|
||||||
non-voided invoices. These differ only in the rare case of a voided memo applied to a since-voided
|
|
||||||
invoice. Left as-is (report-side nuance, not O2's target); documented here so it isn't mistaken for a bug.
|
|
||||||
- Regression test: `LedgerServiceTests.GetAccountLedgerAsync_SalesDiscounts4950_IncludesInvoiceDiscountsAndCreditMemoContraRevenue`.
|
|
||||||
|
|
||||||
Verification of O1+O2: `dotnet build` clean; `dotnet test tests/PowderCoating.UnitTests` → **291 passed**;
|
|
||||||
migration applied to the dev database successfully.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### O3 — Write-path account lookups omitted explicit `CompanyId` — **RESOLVED**
|
|
||||||
- Added `CompanyId` to every **GL posting-path** account lookup that determines where money posts:
|
|
||||||
- `InvoicesController` — all account-resolver helpers (`GetCheckingAccountIdAsync`,
|
|
||||||
`GetCustomerDepositsAccountIdAsync`, `GetSalesReturnsAccountIdAsync`, `GetCustomerCreditsAccountIdAsync`,
|
|
||||||
`GetArAccountIdAsync`, `GetBadDebtAccountIdAsync`, `ResolveSalesTaxAccountIdAsync`,
|
|
||||||
`GetSalesDiscountAccountIdAsync`, `GetGcLiabilityAccountIdAsync`) plus the bank-account and write-off
|
|
||||||
expense dropdowns (scoped via `_tenantContext`).
|
|
||||||
- `CreditMemosController` — Create/Apply/Void GL lookups (scoped via the in-scope `customer`/`invoice`/`memo`).
|
|
||||||
- `GiftCertificatesController` — Create, BulkCreate, Void GL lookups + `GetGcLiabilityAccountIdAsync`.
|
|
||||||
- `BillsController` — AP/expense account resolution that pre-fills `APAccountId` (Create + CreateFromPO).
|
|
||||||
- `DepositsController` and `JournalEntriesController.SalesTaxPayment` already scoped correctly.
|
|
||||||
|
|
||||||
### O4 — Sales-tax remittance could over-remit (drive 2200 negative) — **RESOLVED**
|
|
||||||
- `JournalEntriesController.SalesTaxPayment` (POST) now rejects any amount exceeding `taxAcct.CurrentBalance`
|
|
||||||
(0.005 rounding tolerance), so a typo can no longer push Sales Tax Payable into an abnormal debit balance.
|
|
||||||
|
|
||||||
Verification of O3+O4: `dotnet build` clean; `dotnet test tests/PowderCoating.UnitTests` → **291 passed**.
|
|
||||||
|
|
||||||
### O5 — Gift Certificate Liability 2500 missing for new tenants / mislabeled on default company — **RESOLVED**
|
|
||||||
- **Root cause (same shape as O1):** 2500 is resolved by number as the GC liability
|
|
||||||
(`GiftCertificatesController`). The `AccountingGapsPhase2` migration seeded it for tenants existing at
|
|
||||||
deploy, but (a) the per-tenant seeder `SeedDataService.Accounts.cs` never created a 2500, so tenants
|
|
||||||
onboarded afterward had **no GC liability account** and GC GL postings silently no-op'd; and (b) the
|
|
||||||
default-company seeder `SeedData.cs` created 2500 as **"Long-Term Loan"**, so that company's GC
|
|
||||||
obligations were mislabeled (and the migration's `NOT EXISTS` guard skipped it).
|
|
||||||
- **Fix:**
|
|
||||||
- `SeedDataService.Accounts.cs` now seeds **2500 "Gift Certificate Liability" (IsSystem)**.
|
|
||||||
- `SeedData.cs` now seeds 2500 as GC liability and moves the long-term loan to **2900**.
|
|
||||||
- `EnsureSystemAccountsAsync` self-heals: renames any 2500 still named "Long-Term Loan" → "Gift
|
|
||||||
Certificate Liability" (preserving user renames) and ensures a 2500 exists.
|
|
||||||
- Migration `20260620002950_FixGiftCertificateLiabilityAccount`: moves long-term loan to 2900 where a
|
|
||||||
2500="Long-Term Loan" exists and no 2900 is present; relabels the mislabeled 2500; safety-net inserts a
|
|
||||||
2500 for any company lacking one. Non-destructive (no Id/number/balance changes); Down is best-effort.
|
|
||||||
- Verified on the dev DB: existing 2500 GC-liability rows preserved; no spurious accounts added; build
|
|
||||||
clean; migration applied; **291 unit tests pass**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Read-path account lookup sweep (Tier 1–3) — **RESOLVED**
|
|
||||||
Completed the app-wide defense-in-depth pass: every `Accounts` lookup in a controller now carries an
|
|
||||||
explicit `CompanyId` predicate, matching the standing rule in CLAUDE.md. ~19 lookups across 12 controllers:
|
|
||||||
- **Tier 1 (write-path validation):** `AccountsController` duplicate account-number check on Create/Edit.
|
|
||||||
- **Tier 2 (dropdowns/lists):** `AccountsController` (Index/year-end/parent dropdown), `BankReconciliations`,
|
|
||||||
`Bills` (bank list + receipt-scan + suggest), `Budgets`, `CatalogItems`, `Expenses`, `FixedAssets`,
|
|
||||||
`Inventory`, `JournalEntries` (chart dropdown), `Vendors`.
|
|
||||||
- **Tier 3 (`accountIds.Contains` display maps):** `JournalEntries` Details, `Reports` budget vs actual,
|
|
||||||
`VendorCredits` Details — scoped via the in-scope entity's `CompanyId` for uniformity.
|
|
||||||
`companyId` source per controller: `_tenantContext.GetCurrentCompanyId()` where available, else the
|
|
||||||
in-scope entity's `CompanyId`, else `_userManager` current user. Build clean; 291 unit tests pass.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Whole-system re-audit (2026-06-19) — NEW findings, OPEN
|
|
||||||
|
|
||||||
Root cause shared by all three: the GL is kept two ways — **direct postings** via
|
|
||||||
`AccountBalanceService.Debit/CreditAsync` (authoritative `Account.CurrentBalance`) and **recomputes**
|
|
||||||
via `LedgerService` (drives `RecalculateAllAsync`) and `FinancialReportService` (drives the reports).
|
|
||||||
Every posting type must be mirrored in *both* recompute engines or balances diverge. Several postings
|
|
||||||
are **not** mirrored, so: (a) "Recalculate Balances" corrupts those accounts, (b) the Balance
|
|
||||||
Reconciliation report shows false drift, and in one case (O7) the **Trial Balance does not balance**.
|
|
||||||
O2 (Sales Discounts 4950) was the first instance found and fixed; these are the rest.
|
|
||||||
|
|
||||||
### O6 — Inventory consumption COGS not in the recompute — **RESOLVED (recompute approach)**
|
|
||||||
- `JobsController` and `InventoryController` post **DR COGS / CR Inventory** when an item with both
|
|
||||||
`CogsAccountId` and `InventoryAccountId` is consumed. The COGS posting fires only for `JobUsage` and
|
|
||||||
`Waste` transaction types, and those types are created **only** at the two COGS-posting sites — so the
|
|
||||||
consumption is exactly identifiable from `InventoryTransaction`. (This is why the recompute approach was
|
|
||||||
used instead of the originally-planned JE+backfill: it reads existing transactions, so historical data is
|
|
||||||
covered automatically with no fuzzy backfill.)
|
|
||||||
- **Fix applied:**
|
|
||||||
- Both posting sites now record the consumption at the effective (weighted-average) unit cost, so the
|
|
||||||
transaction's `TotalCost` equals the COGS actually posted (the recompute reads `TotalCost`).
|
|
||||||
- `LedgerService` (dated rows + prior balance): new section 12d — for a COGS or Inventory account, sum
|
|
||||||
`TotalCost` of JobUsage/Waste transactions on items with both accounts (DR COGS / CR Inventory).
|
|
||||||
- `FinancialReportService` **Trial Balance** (`cogsConsumptionByAcct` DR / `invConsumptionByAcct` CR) and
|
|
||||||
**P&L** (accrual COGS) include consumption. Cash-basis P&L excludes it (cash recognises cost at purchase).
|
|
||||||
- Regression test `GetAccountLedgerAsync_InventoryConsumption_DebitsCogsAndCreditsInventory`.
|
|
||||||
- **Deliberately NOT changed — see O9:** the **Balance Sheet** inventory-asset line is left as-is because it
|
|
||||||
already does not track inventory *purchases* (it expenses them through retained earnings), so relieving it
|
|
||||||
for consumption alone would drive inventory negative or unbalance the sheet. That's a separate inventory
|
|
||||||
*capitalization* policy decision (O9). TB and recalc are now correct and balanced; build clean; 293 tests pass.
|
|
||||||
|
|
||||||
### O7 — Gift-certificate redemptions credit AR but AR recompute omitted it — **RESOLVED**
|
|
||||||
- `InvoicesController.ApplyGiftCertificate:3137` posts **DR 2500 GC Liability / CR AR** (no Payment row,
|
|
||||||
no `AmountPaid` change — only `invoice.GiftCertificateRedeemed`). The **2500 debit IS** recomputed
|
|
||||||
(GC redemptions), but the **AR credit is NOT**: AR recompute = `sum(Total) − Payments − CreditMemoApplications`
|
|
||||||
in both `FinancialReportService` (BS line 358 / TB line 1129) and `LedgerService` (AR section).
|
|
||||||
- **Impact:** because only one side of the entry is recomputed, the **Trial Balance is out of balance by the
|
|
||||||
total GC redeemed**; Balance Sheet AR is overstated; recalc corrupts AR. (AR **Aging** is correct — it uses
|
|
||||||
`BalanceDue`, which includes `GiftCertificateRedeemed`.) Active for any company that redeems GCs on invoices.
|
|
||||||
- **Fix applied:** GC redemptions now subtracted from AR in both recompute engines — `FinancialReportService`
|
|
||||||
Balance Sheet (`gcRedeemedBs`) and Trial Balance (`gcRedeemedTb`), and `LedgerService` AR section + prior
|
|
||||||
balance. Mirrors the `cmApplied` treatment. Regression test
|
|
||||||
`GetAccountLedgerAsync_AR_GiftCertificateRedemption_CreditsAccountsReceivable`. Build clean; 292 tests pass.
|
|
||||||
|
|
||||||
### O8 — Written-off invoices misstated in AR recompute — **RESOLVED**
|
|
||||||
- `InvoicesController.WriteOff` already posts **DR Bad Debt / CR AR** *and creates a balanced posted
|
|
||||||
JournalEntry* — so both recompute engines already pick the entry up via their JE-line sections. The real
|
|
||||||
defect was narrower: `FinancialReportService` **excluded payments on written-off invoices** from AR credits
|
|
||||||
and bank debits (4× `Status != InvoiceStatus.WrittenOff` filters). Because the write-off JE only credits the
|
|
||||||
*unpaid balance*, excluding the earlier payments left the **paid** portion dangling as open AR (and
|
|
||||||
understated the bank). `LedgerService` had no such filter, so the two engines disagreed.
|
|
||||||
- **Fix applied:** removed all four `WrittenOff` exclusion filters in `FinancialReportService` (Balance Sheet
|
|
||||||
+ Trial Balance, both the bank-deposit and AR-credit queries). Now: invoice `Total` (debit) − payments
|
|
||||||
(credit) − write-off JE (credit) nets AR to zero, the payment counts in the bank, and bad debt is the JE
|
|
||||||
debit — trial balance balances, and the two engines agree. No backfill needed (the write-off JE already
|
|
||||||
exists for historical write-offs). AR Aging was already correct.
|
|
||||||
|
|
||||||
**Architectural note:** O2/O6/O7/O8 all stem from reports re-deriving balances from source documents in
|
|
||||||
parallel with the live postings. The durable fix is to make **every** financial event post `JournalEntry`
|
|
||||||
lines and drive all reports/recompute from those lines alone (single source of truth) — O8's write-off already
|
|
||||||
does exactly this, which is why it was the cleanest. Worth considering before the ledger grows further.
|
|
||||||
|
|
||||||
### O9 — Inventory accounting policy — **RESOLVED (policy decision: expense at purchase / periodic)**
|
|
||||||
- **Decision (owner, 2026-06-19):** materials (powder, consumables) are **expensed at purchase**, not
|
|
||||||
capitalized as inventory-for-resale — this is a service business that *uses* materials to deliver a
|
|
||||||
service, it does not sell inventory. So the **periodic** model is correct.
|
|
||||||
- **Implication — no code change needed:**
|
|
||||||
- The Balance Sheet correctly does **not** capitalize inventory (cost hits the P&L at purchase via the
|
|
||||||
bill line categorized to a COGS/expense account). The earlier decision to leave the BS untouched stands.
|
|
||||||
- Purchase receiving creates a `Purchase`-type `InventoryTransaction` that posts **no** GL — correct.
|
|
||||||
- The **perpetual** consumption-COGS path (O6: `DR COGS / CR Inventory` on JobUsage/Waste) is **opt-in**:
|
|
||||||
it only fires when an item has *both* `CogsAccountId` and `InventoryAccountId`, which are set **only via
|
|
||||||
CSV import** (never by normal item creation). Under this policy those mappings should be left empty, so
|
|
||||||
the path stays dormant. O6's recompute fix remains correct and harmless when unused.
|
|
||||||
- **⚠ Footgun to avoid:** do **not** both expense powder at purchase (bill → COGS account) *and* map an
|
|
||||||
item's `CogsAccountId` + `InventoryAccountId` — that would record the cost twice (once at purchase, once at
|
|
||||||
consumption). Keep item COGS/Inventory account mappings empty under the expense-at-purchase policy.
|
|
||||||
|
|
||||||
#### O9 update (2026-06-20) — default GL accounts feature widened this surface
|
|
||||||
- The original O9 note assumed item `CogsAccountId`/`InventoryAccountId` are "set only via CSV import, never
|
|
||||||
by normal item creation." **That assumption no longer holds.** A new **Default Accounts** feature
|
|
||||||
(Chart of Accounts → "Set Defaults", stored on `CompanyPreferences.Default{Revenue,Cogs,Inventory}AccountId`)
|
|
||||||
pre-fills these fields on **normal** Inventory/Catalog item creation. A company that sets *both* a default
|
|
||||||
COGS and a default Inventory account will have new items post `DR COGS / CR Inventory` on consumption —
|
|
||||||
i.e. it opts the shop into the perpetual path.
|
|
||||||
- **Why it's still safe:** the defaults are **null by default**, so nothing changes until a company
|
|
||||||
deliberately sets them. The footgun (double-counting under expense-at-purchase) is surfaced with an inline
|
|
||||||
warning on the Default Accounts card and in the Settings help article. Posting and balance-recompute both
|
|
||||||
read the item's *stored* accounts, so there is **no recompute drift** — verified during the 2026-06-20 audit.
|
|
||||||
- **Revenue default fallback:** invoice lines now fall back to `DefaultRevenueAccountId` (active only), then
|
|
||||||
to account 4000 (`InvoicesController.Create`). Resolved at invoice-create time and stored on the
|
|
||||||
`InvoiceItem`, so recompute stays consistent.
|
|
||||||
|
|
||||||
### 2026-06-20 audit — dropdown sub-type→type broadening + deposit account picker
|
|
||||||
Reviewed after broadening account dropdowns from sub-type to parent `AccountType` and adding a user-selectable
|
|
||||||
deposit account. **No ledger-drift bugs found.** Notes:
|
|
||||||
- **Deposit account picker ↔ recompute:** consistent. Live posting debits the chosen `DepositAccountId`, and
|
|
||||||
`LedgerService` reproduces the debit by `DepositAccountId == accountId` (lines ~78/724). Picking a non-default
|
|
||||||
deposit account recomputes correctly.
|
|
||||||
- **Bank / "pay-from" / bank-rec pickers — server-side guard added (2026-06-20):** the submitted account is
|
|
||||||
now validated via `AccountGuard.IsValidMoneyAccountAsync` (active, company-owned, AccountType Asset or
|
|
||||||
Liability) before any posting, at bill `RecordPayment` / `Create(payNow)` / `EditPayment`,
|
|
||||||
`BankReconciliation.Create`, and deposit `Record`. Defense in depth against tampered/stale POSTs. Per the
|
|
||||||
"trust the operator" decision this still allows e.g. A/R (an Asset) as a source — it only rejects
|
|
||||||
non-money types (Revenue/Expense/Equity/COGS).
|
|
||||||
- **Latent deposit imbalance — RESOLVED (2026-06-20):** a deposit saved with a null `DepositAccountId` posted
|
|
||||||
`CR 2300` with no offsetting debit → unbalanced. `DepositsController.Record` now blocks recording when the
|
|
||||||
`2300` Customer Deposits account exists but no deposit/bank account resolves (user must pick one). When `2300`
|
|
||||||
doesn't exist (company not using accounting), no GL posts at all, so the deposit is still allowed through.
|
|
||||||
- **Type/sub-type mismatch risk — RESOLVED (2026-06-20):** account `AccountType` is now **derived** from the
|
|
||||||
chosen `AccountSubType` on create/edit via `AccountClassification.TypeForSubType` (single source of truth,
|
|
||||||
also used by the Create pre-select), so the two can never disagree and the sub-type-based sign convention is
|
|
||||||
always consistent with the displayed type. A read-only sweep of the dev DB (109 accounts) found **0** existing
|
|
||||||
mismatches, so no repair tool was needed.
|
|
||||||
|
|
||||||
### 2026-06-20 — GL trial-balance integrity check (audit "#2")
|
|
||||||
Ran an empirical per-company trial-balance net on stored `Account.CurrentBalance`
|
|
||||||
(`SUM(debit-normal) − SUM(credit-normal)`, which must net to 0 for balanced books). **Both tenants are
|
|
||||||
imbalanced**, but the cause is pre-existing data, **not** this session's changes.
|
|
||||||
|
|
||||||
- **Demo Company:** net Dr **$92,653.63** = **$89,500 opening-balance** entry without an offsetting equity
|
|
||||||
line (normal demo-data artifact) + **$3,153.63** from postings.
|
|
||||||
- **SCP Powder Coating** (opening balance $0, clean start): net Dr **$3,079.52**, entirely from postings.
|
|
||||||
|
|
||||||
**Root cause (forensics on SCP):** AR reconciles correctly (~invoices $21,496 − payments $18,314 ≈ stored
|
|
||||||
$3,079), so AR posted on both sides. But **Revenue has $0 GL movement** (the 24 invoices are header-only —
|
|
||||||
**0 line items** — so the per-`InvoiceItem` revenue credit never fires) and the **payment-side bank debit
|
|
||||||
never posted** ($0 bank delta from 22 payments). This is the classic one-sided posting from
|
|
||||||
(a) imported/header-only invoices and (b) postings to unconfigured (null) offset accounts, which
|
|
||||||
`AccountBalanceService` silently skips. Same architectural class as O2/O6/O7/O8.
|
|
||||||
|
|
||||||
**Conclusion:** this session's work (default GL accounts, deposit guard, money-account guards, tenant sweep)
|
|
||||||
**did not introduce the imbalance** — those changes are read-path + validation + defaults and actually
|
|
||||||
*prevent* this bug class going forward (new invoices now fall back to a default revenue account; deposits/
|
|
||||||
payments are guarded against null money accounts). The existing imbalance is legacy/imported data.
|
|
||||||
|
|
||||||
**Remediation options (owner's call — not auto-applied; SCP is live company data):**
|
|
||||||
- `Recalculate Balances` re-derives from source docs but will **not** conjure revenue for header-only
|
|
||||||
invoices (no line items to credit), so it won't fully fix SCP on its own.
|
|
||||||
- Durable fix: the **JournalEntry single-source** refactor (`docs/ACCOUNTING_LEDGER_REFACTOR.md`) forces every
|
|
||||||
event to post balanced lines.
|
|
||||||
- Historical cleanup: correcting journal entries (or backfilling revenue/bank accounts on the imported docs
|
|
||||||
then recomputing) — a deliberate data-remediation task.
|
|
||||||
- Worth considering: surfacing this trial-balance net as a built-in "GL Health" indicator so drift is visible.
|
|
||||||
|
|
||||||
## Status
|
|
||||||
**All findings O1–O9 + the read-path sweep are resolved** on `dev` (O9 by policy decision — expense at
|
|
||||||
purchase — needing no code change). The optional structural follow-up is the JournalEntry single-source
|
|
||||||
refactor (`docs/ACCOUNTING_LEDGER_REFACTOR.md`), which would prevent the O2/O6/O7/O8 bug class from recurring.
|
|
||||||
The 2026-06-20 trial-balance check confirmed a pre-existing GL imbalance from legacy/imported one-sided
|
|
||||||
postings (not from recent changes) — see above for remediation options.
|
|
||||||
Original audit numbering #1–3/#5/#6/#8 remains unrecoverable (see top). Nothing merged to `master` yet.
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
# Accounting Deploy & Verification Checklist
|
|
||||||
|
|
||||||
> Repeatable steps to run whenever accounting changes ship (and a good periodic health check).
|
|
||||||
> Goal: prove the books are internally consistent with **real data**, not just code review.
|
|
||||||
> Companions: `docs/ACCOUNTING_AUDIT.md` (findings/decisions), `docs/ACCOUNTING_LEDGER_REFACTOR.md` (future).
|
|
||||||
|
|
||||||
The decisive invariant: **a Trial Balance whose total debits == total credits, with no drift on the
|
|
||||||
Balance Reconciliation report, for every company.** If that holds, the ledger is sound by construction.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Pre-deploy (against the production DB, read-only)
|
|
||||||
|
|
||||||
Migrations are applied **manually** at deploy (the app does not auto-migrate). Two are pending from the
|
|
||||||
2026-06 audit; apply in this order:
|
|
||||||
|
|
||||||
1. `RenameDepositsAccountAddPayroll` (O1 — renames 2300 → "Customer Deposits", adds 2400 Payroll)
|
|
||||||
2. `FixGiftCertificateLiabilityAccount` (O5 — relabels mislabeled 2500 → GC Liability, adds 2900)
|
|
||||||
|
|
||||||
Both are non-destructive (no account Id / number / balance is changed; only relabels + additive inserts).
|
|
||||||
Preview exactly what they'll touch (read-only — swap account numbers as needed):
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- O1: 2300 rows that will be renamed (only those still named the default), and who gets a new 2400
|
|
||||||
SELECT CompanyId, AccountNumber, Name, CurrentBalance,
|
|
||||||
CASE WHEN Name = 'Payroll Liabilities' THEN 'WILL RENAME -> Customer Deposits' ELSE 'kept as-is' END AS Action
|
|
||||||
FROM Accounts WHERE AccountNumber = '2300' AND IsDeleted = 0 ORDER BY CompanyId;
|
|
||||||
|
|
||||||
-- O5: 2500 rows that will be relabeled (only those still named "Long-Term Loan")
|
|
||||||
SELECT CompanyId, AccountNumber, Name, CurrentBalance,
|
|
||||||
CASE WHEN Name = 'Long-Term Loan' THEN 'WILL RELABEL -> Gift Certificate Liability' ELSE 'kept as-is' END AS Action
|
|
||||||
FROM Accounts WHERE AccountNumber = '2500' AND IsDeleted = 0 ORDER BY CompanyId;
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. Deploy
|
|
||||||
|
|
||||||
1. Merge `dev` → `master`, trigger the Jenkins production job.
|
|
||||||
2. Apply the two migrations above (in order) to the production DB.
|
|
||||||
|
|
||||||
## 3. Post-deploy verification (per company — all of them)
|
|
||||||
|
|
||||||
Run for **every** company (there are ~7). Most is doable from the app UI under Reports / Finance.
|
|
||||||
|
|
||||||
- [ ] **Trial Balance** — open it. **Total Debits must equal Total Credits.** Any difference is a
|
|
||||||
one-sided posting and must be investigated before trusting other reports.
|
|
||||||
- [ ] **Balance Reconciliation report** (`/Reports/Reconciliation`) — every account's *stored* balance
|
|
||||||
should match the *recomputed* balance (no drift highlighted). Also confirm:
|
|
||||||
- AR control account == sum of customer balances (AR subledger).
|
|
||||||
- AP control account == sum of vendor balances (AP subledger).
|
|
||||||
- [ ] **Recalculate Balances**, then re-open the Trial Balance and Balance Reconciliation. This exercises
|
|
||||||
the recompute paths the audit fixed (`LedgerService`). After a recalc:
|
|
||||||
- Trial Balance still balances.
|
|
||||||
- Reconciliation shows no drift (stored now == recomputed by definition; the point is TB stays balanced
|
|
||||||
and the values look sane).
|
|
||||||
|
|
||||||
## 4. Spot-check the accounts the audit touched
|
|
||||||
|
|
||||||
For each company, glance at these on the Trial Balance / chart of accounts:
|
|
||||||
|
|
||||||
- [ ] **2300 Customer Deposits** — named correctly; balance == outstanding (un-applied) customer deposits.
|
|
||||||
- [ ] **2400 Payroll Liabilities** — exists (likely 0 unless payroll is tracked).
|
|
||||||
- [ ] **2500 Gift Certificate Liability** — named correctly; balance == outstanding GC value (issued − redeemed − voided).
|
|
||||||
- [ ] **2900 Long-Term Loan** — present where the old 2500 was relabeled.
|
|
||||||
- [ ] **4950 Sales Discounts / 4960 Sales Returns** — contra-revenue, show as debit-balance.
|
|
||||||
- [ ] **AR** — for any company that uses gift certificates or has written off an invoice, confirm AR is not
|
|
||||||
overstated (these were O7 / O8). Cross-check AR total against the AR Aging report.
|
|
||||||
|
|
||||||
## 5. If something is off
|
|
||||||
|
|
||||||
- A Trial Balance that doesn't balance → a posting hit only one side. Note the company + amount and check it
|
|
||||||
against the findings in `docs/ACCOUNTING_AUDIT.md` (the resolved O2/O6/O7/O8 patterns) before assuming a new bug.
|
|
||||||
- Drift on the Reconciliation report → run **Recalculate Balances**; if it persists, the recompute is missing a
|
|
||||||
posting type (same class as the audit findings).
|
|
||||||
- Do **not** treat a recalc as a fix for a real imbalance — it makes stored == recomputed, which can *hide* a
|
|
||||||
one-sided posting if only one engine is wrong. The Trial Balance balancing is the real test.
|
|
||||||
|
|
||||||
## 6. Policy reminders (from the audit)
|
|
||||||
|
|
||||||
- **Inventory = expensed at purchase (periodic).** Do **not** map an item's `CogsAccountId` + `InventoryAccountId`
|
|
||||||
(set only via CSV import) while also expensing powder at purchase — that double-counts COGS. Keep those empty.
|
|
||||||
- Sales-tax remittance is capped at the outstanding 2200 balance (O4) — you cannot over-remit.
|
|
||||||
|
|
||||||
## When to repeat
|
|
||||||
|
|
||||||
- After any accounting feature change or import.
|
|
||||||
- As a periodic health check (e.g. monthly), run Section 3 — it's cheap and catches drift early.
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
# Accounting Ledger Refactor — Single Source of Truth
|
|
||||||
|
|
||||||
> Status: **PROPOSED / not started.** This is a captured plan, not in-progress work.
|
|
||||||
> Companion to `docs/ACCOUNTING_AUDIT.md`. Resolves the O2/O6/O7/O8 bug *class* and the open
|
|
||||||
> finding **O9** (inventory capitalization). Do this when there's runway + a staging environment —
|
|
||||||
> it is not an emergency; after audit fixes O1–O8 the current books are correct.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Why
|
|
||||||
|
|
||||||
Today every financial event's GL effect is encoded in **three** places:
|
|
||||||
|
|
||||||
1. **`Account.CurrentBalance`** — updated live at each posting site via
|
|
||||||
`AccountBalanceService.DebitAsync/CreditAsync`.
|
|
||||||
2. **`LedgerService`** — re-derives each account's balance by reading *source documents* (Payments,
|
|
||||||
Invoices, Bills, InventoryTransactions, GiftCertificates, CreditMemos, …). Drives
|
|
||||||
`RecalculateAllAsync` and the Balance Reconciliation report.
|
|
||||||
3. **`FinancialReportService`** — *independently* re-derives P&L, Balance Sheet, and Trial Balance from
|
|
||||||
the same documents, with its own per-report logic (largely duplicated across the three reports).
|
|
||||||
|
|
||||||
Adding a transaction type means hand-mirroring its debit and credit into all three engines, in the right
|
|
||||||
direction, in every report method. Missing one spot produced every finding in the audit:
|
|
||||||
|
|
||||||
| Finding | Missed mirror |
|
|
||||||
|---|---|
|
|
||||||
| O2 | 4950 Sales Discounts absent from `LedgerService` → recalc wiped it |
|
|
||||||
| O6 | inventory-consumption COGS absent from both recompute engines |
|
|
||||||
| O7 | GC redemption's AR credit absent → **Trial Balance did not balance** |
|
|
||||||
| O8 | written-off payments filtered inconsistently between the two engines |
|
|
||||||
|
|
||||||
These are one design flaw with four faces: **reports re-interpret documents instead of reading a ledger.**
|
|
||||||
|
|
||||||
## Target
|
|
||||||
|
|
||||||
The journal becomes the only representation of GL effect. The tables already exist
|
|
||||||
(`JournalEntry` + `JournalEntryLine`, with a `Posted` status).
|
|
||||||
|
|
||||||
- **Every** financial event posts a balanced `JournalEntry` (DR == CR enforced once, centrally).
|
|
||||||
- `Account.CurrentBalance`, Trial Balance, Balance Sheet, P&L, and the recompute all derive from
|
|
||||||
**`JournalEntryLine` only** (plus each account's opening balance).
|
|
||||||
- Invoices/Payments/Bills remain *operational* records; they stop carrying implicit GL meaning that every
|
|
||||||
report must re-decode.
|
|
||||||
|
|
||||||
Result: `LedgerService.GetAccountLedgerAsync` (~800 lines of per-document sections) collapses to
|
|
||||||
"sum the JE lines for this account in range." Trial Balance / Balance Sheet become straight per-account JE
|
|
||||||
aggregations. The special cases — `gcRedeemed`, `cmApplied`, refund splits, consumption COGS, discount
|
|
||||||
contra-revenue — all disappear because they are simply journal lines.
|
|
||||||
|
|
||||||
## What changes in the code
|
|
||||||
|
|
||||||
1. **One posting service:** `PostAsync(date, description, lines[])` — validates balanced, assigns the
|
|
||||||
`JE-YYMM-####` number, marks `Posted`, updates `CurrentBalance` from the lines. `AccountBalanceService`
|
|
||||||
becomes a thin wrapper over it.
|
|
||||||
2. **Convert every posting site** to build JE lines instead of ad-hoc Debit/Credit pairs: invoice create,
|
|
||||||
payment, deposit record/apply, refund, credit memo issue/apply/void, GC issue/redeem/void, bill, bill
|
|
||||||
payment, vendor credit, inventory consumption, write-off, sales-tax remittance, year-end close,
|
|
||||||
depreciation, Stripe. Many already call `DebitAsync`; mechanical but touches **every accounting
|
|
||||||
controller**. `InvoicesController.WriteOff` already posts a JE — copy that model everywhere.
|
|
||||||
3. **Edits/voids become reversing entries** (post an opposite JE), never mutate in place.
|
|
||||||
4. **Gut the re-derivation:** `LedgerService` → JE-only; `FinancialReportService` TB/BS/P&L → JE-only.
|
|
||||||
AR/AP **aging** and customer/vendor statements stay document-driven (they answer "which documents are
|
|
||||||
open," not "what is the GL balance"). The AR control account then auto-reconciles to the subledger
|
|
||||||
because both come from the same postings.
|
|
||||||
|
|
||||||
## Migration of historical data
|
|
||||||
|
|
||||||
- **(a) Replay** — re-run every historical document through the new posting logic to emit JEs. Most
|
|
||||||
faithful; must handle edits/voids in chronological order. Complex, higher risk.
|
|
||||||
- **(b) Conversion balances (recommended)** — snapshot each account's current `CurrentBalance` as a single
|
|
||||||
dated "opening balance" JE at a cutover date; only new events post JEs after that. This is how real
|
|
||||||
systems migrate. With ~7 companies it is the pragmatic, low-risk path: pre-cutover per-transaction GL
|
|
||||||
granularity is lost, but every balance is preserved exactly.
|
|
||||||
|
|
||||||
## Phased plan (verifiable, not big-bang)
|
|
||||||
|
|
||||||
- **Phase 0** — build `PostAsync` + balanced-entry invariant + unit tests.
|
|
||||||
- **Phase 1** — route all posting sites through it, creating JEs *alongside* existing behavior (no report
|
|
||||||
changes yet). The **Balance Reconciliation report is the regression harness** — it should show near-zero
|
|
||||||
drift everywhere once every event posts a JE.
|
|
||||||
- **Phase 2** — switch `LedgerService` to JE-only; confirm `RecalculateAllAsync` reproduces balances.
|
|
||||||
- **Phase 3** — switch `FinancialReportService` (TB/BS/P&L) to JE-only; reconcile each report per company
|
|
||||||
against the old numbers using the snapshot harness below.
|
|
||||||
- **Phase 4** — post conversion-balance JEs at the cutover date; delete the dead re-derivation code
|
|
||||||
(~1,000+ lines).
|
|
||||||
- **Phase 5** — **O9 is already decided: expense at purchase (periodic).** Materials are used to deliver a
|
|
||||||
service, not sold, so they are expensed when purchased (bill → COGS/expense account); inventory is **not**
|
|
||||||
capitalized on the Balance Sheet. In the refactor, post material purchases as `DR COGS / CR AP` (expense
|
|
||||||
immediately) and do **not** emit a separate consumption JE. The opt-in perpetual path (item
|
|
||||||
`CogsAccountId` + `InventoryAccountId`) stays unused under this policy — keep those mappings empty to avoid
|
|
||||||
double-counting. No perpetual inventory asset/relief postings.
|
|
||||||
|
|
||||||
## Prerequisites before starting
|
|
||||||
|
|
||||||
- A **report-snapshot/diff harness** to compare each company's P&L / Balance Sheet / Trial Balance before
|
|
||||||
vs after each phase (the safety net for "numbers shifting").
|
|
||||||
- A **staging copy** of production data to run the cutover against first.
|
|
||||||
- A **decision on O9** (the inventory policy) — see `docs/ACCOUNTING_AUDIT.md`.
|
|
||||||
|
|
||||||
## Honest assessment
|
|
||||||
|
|
||||||
- **Effort:** the single biggest change in the accounting area — weeks, not hours, with careful per-company
|
|
||||||
verification.
|
|
||||||
- **Risk:** broad blast radius (every accounting controller); report numbers *will* shift slightly as they
|
|
||||||
become consistent (the point), so it needs per-company sign-off.
|
|
||||||
- **Optional:** after O1–O8 the current system is correct. The refactor buys *prevention* of this bug class,
|
|
||||||
a real audit trail, and ~1,500 fewer lines of parallel logic. Higher ROI the more accounting features you
|
|
||||||
keep adding.
|
|
||||||
- **Safe stopping point:** Phases 0–1 alone (post JEs everywhere, watch the reconciliation report) are
|
|
||||||
low-risk and immediately valuable. You can stop there and continue later.
|
|
||||||
|
|
||||||
## What it fixes
|
|
||||||
|
|
||||||
- Eliminates the O2/O6/O7/O8 bug class (one place to get each event right).
|
|
||||||
- Trial Balance balances by construction.
|
|
||||||
- Every balance is traceable to a journal entry (audit trail).
|
|
||||||
- Resolves **O9** as part of Phase 5.
|
|
||||||
- Turns the Balance Reconciliation report into a true integrity check rather than a drift detector.
|
|
||||||
@@ -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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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 },
|
||||||
|
|||||||
+84
-67
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
-11375
File diff suppressed because it is too large
Load Diff
-141
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Generated
-11375
File diff suppressed because it is too large
Load Diff
-88
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-11376
File diff suppressed because it is too large
Load Diff
-82
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-11382
File diff suppressed because it is too large
Load Diff
-81
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-11382
File diff suppressed because it is too large
Load Diff
-119
@@ -1,119 +0,0 @@
|
|||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace PowderCoating.Infrastructure.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class RenameDepositsAccountAddPayroll : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
// O1 remediation. Account 2300 has always been used by the deposit GL posting code as the
|
|
||||||
// Customer Deposits liability (resolved by number), but pre-migration tenants still had it
|
|
||||||
// seeded/named "Payroll Liabilities" — so the liability was mislabeled on the balance sheet.
|
|
||||||
// Rename it to "Customer Deposits" and mark it system. Only touch accounts still carrying the
|
|
||||||
// old default name, so a tenant's own rename is preserved.
|
|
||||||
migrationBuilder.Sql(@"
|
|
||||||
UPDATE Accounts
|
|
||||||
SET Name = 'Customer Deposits',
|
|
||||||
Description = 'Deposits received from customers before an invoice is created; cleared when applied to an invoice',
|
|
||||||
IsSystem = 1
|
|
||||||
WHERE AccountNumber = '2300'
|
|
||||||
AND IsDeleted = 0
|
|
||||||
AND Name = 'Payroll Liabilities';
|
|
||||||
");
|
|
||||||
|
|
||||||
// Re-home payroll to a dedicated 2400 account for every company that lacks one, so the chart
|
|
||||||
// still offers a payroll liability without colliding with Customer Deposits at 2300.
|
|
||||||
migrationBuilder.Sql(@"
|
|
||||||
INSERT INTO Accounts
|
|
||||||
(AccountNumber, Name, AccountType, AccountSubType,
|
|
||||||
IsSystem, IsActive, Description,
|
|
||||||
CompanyId, CreatedAt, IsDeleted,
|
|
||||||
CurrentBalance, OpeningBalance)
|
|
||||||
SELECT
|
|
||||||
'2400',
|
|
||||||
'Payroll Liabilities',
|
|
||||||
2, -- AccountType.Liability
|
|
||||||
12, -- AccountSubType.OtherCurrentLiability
|
|
||||||
0, -- IsSystem = false
|
|
||||||
1, -- IsActive = true
|
|
||||||
'Payroll taxes and withholdings owed',
|
|
||||||
c.Id,
|
|
||||||
GETUTCDATE(),
|
|
||||||
0, -- IsDeleted = false
|
|
||||||
0, -- CurrentBalance
|
|
||||||
0 -- OpeningBalance
|
|
||||||
FROM Companies c
|
|
||||||
WHERE c.IsDeleted = 0
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM Accounts a
|
|
||||||
WHERE a.CompanyId = c.Id
|
|
||||||
AND a.AccountNumber = '2400'
|
|
||||||
AND a.IsDeleted = 0
|
|
||||||
);
|
|
||||||
");
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "PricingTiers",
|
|
||||||
keyColumn: "Id",
|
|
||||||
keyValue: 1,
|
|
||||||
column: "CreatedAt",
|
|
||||||
value: new DateTime(2026, 6, 19, 23, 31, 4, 905, DateTimeKind.Utc).AddTicks(7611));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "PricingTiers",
|
|
||||||
keyColumn: "Id",
|
|
||||||
keyValue: 2,
|
|
||||||
column: "CreatedAt",
|
|
||||||
value: new DateTime(2026, 6, 19, 23, 31, 4, 905, DateTimeKind.Utc).AddTicks(7618));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "PricingTiers",
|
|
||||||
keyColumn: "Id",
|
|
||||||
keyValue: 3,
|
|
||||||
column: "CreatedAt",
|
|
||||||
value: new DateTime(2026, 6, 19, 23, 31, 4, 905, DateTimeKind.Utc).AddTicks(7619));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
// Best-effort reversal. The 2300 rename is intentionally NOT undone: it corrected a mislabeled
|
|
||||||
// account and reverting would re-introduce the bug. Only the empty 2400 accounts this migration
|
|
||||||
// added are soft-deleted (skip any that already carry a balance, i.e. are in use).
|
|
||||||
migrationBuilder.Sql(@"
|
|
||||||
UPDATE Accounts
|
|
||||||
SET IsDeleted = 1
|
|
||||||
WHERE AccountNumber = '2400'
|
|
||||||
AND IsDeleted = 0
|
|
||||||
AND Name = 'Payroll Liabilities'
|
|
||||||
AND CurrentBalance = 0;
|
|
||||||
");
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "PricingTiers",
|
|
||||||
keyColumn: "Id",
|
|
||||||
keyValue: 1,
|
|
||||||
column: "CreatedAt",
|
|
||||||
value: new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2051));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "PricingTiers",
|
|
||||||
keyColumn: "Id",
|
|
||||||
keyValue: 2,
|
|
||||||
column: "CreatedAt",
|
|
||||||
value: new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2056));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "PricingTiers",
|
|
||||||
keyColumn: "Id",
|
|
||||||
keyValue: 3,
|
|
||||||
column: "CreatedAt",
|
|
||||||
value: new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2057));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-11382
File diff suppressed because it is too large
Load Diff
-123
@@ -1,123 +0,0 @@
|
|||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace PowderCoating.Infrastructure.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class FixGiftCertificateLiabilityAccount : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
// O5 remediation. Account 2500 is resolved by number as the Gift Certificate Liability
|
|
||||||
// (GiftCertificatesController), but the default-company chart seeded it as "Long-Term Loan",
|
|
||||||
// so GC obligations were mislabeled there and the AccountingGapsPhase2 GC-liability seed was
|
|
||||||
// skipped by its NOT EXISTS guard.
|
|
||||||
|
|
||||||
// 1) Preserve the long-term loan account: move it to 2900 for any company whose 2500 is still
|
|
||||||
// named "Long-Term Loan" and that lacks a 2900. (Companies onboarded via the per-tenant
|
|
||||||
// seeder already have a 2900 "Business Loan", so the NOT EXISTS guard leaves them alone.)
|
|
||||||
migrationBuilder.Sql(@"
|
|
||||||
INSERT INTO Accounts
|
|
||||||
(AccountNumber, Name, AccountType, AccountSubType,
|
|
||||||
IsSystem, IsActive, Description,
|
|
||||||
CompanyId, CreatedAt, IsDeleted, CurrentBalance, OpeningBalance)
|
|
||||||
SELECT '2900', 'Long-Term Loan',
|
|
||||||
2, -- AccountType.Liability
|
|
||||||
11, -- AccountSubType.LongTermLiability
|
|
||||||
0, 1, 'Long-term equipment or business loan',
|
|
||||||
c.Id, GETUTCDATE(), 0, 0, 0
|
|
||||||
FROM Companies c
|
|
||||||
WHERE c.IsDeleted = 0
|
|
||||||
AND EXISTS (SELECT 1 FROM Accounts a WHERE a.CompanyId = c.Id
|
|
||||||
AND a.AccountNumber = '2500' AND a.IsDeleted = 0 AND a.Name = 'Long-Term Loan')
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM Accounts a WHERE a.CompanyId = c.Id
|
|
||||||
AND a.AccountNumber = '2900' AND a.IsDeleted = 0);
|
|
||||||
");
|
|
||||||
|
|
||||||
// 2) Relabel the mislabeled 2500 to Gift Certificate Liability (only where it still carries the
|
|
||||||
// old default name, so a user's own rename is preserved). Id / number / balance untouched.
|
|
||||||
migrationBuilder.Sql(@"
|
|
||||||
UPDATE Accounts
|
|
||||||
SET Name = 'Gift Certificate Liability',
|
|
||||||
Description = 'Outstanding gift certificate obligations owed to certificate holders',
|
|
||||||
IsSystem = 1
|
|
||||||
WHERE AccountNumber = '2500' AND IsDeleted = 0 AND Name = 'Long-Term Loan';
|
|
||||||
");
|
|
||||||
|
|
||||||
// 3) Safety net: ensure every company has a 2500 Gift Certificate Liability (covers any tenant
|
|
||||||
// onboarded after AccountingGapsPhase2 ran that never received one — without it GC GL no-ops).
|
|
||||||
migrationBuilder.Sql(@"
|
|
||||||
INSERT INTO Accounts
|
|
||||||
(AccountNumber, Name, AccountType, AccountSubType,
|
|
||||||
IsSystem, IsActive, Description,
|
|
||||||
CompanyId, CreatedAt, IsDeleted, CurrentBalance, OpeningBalance)
|
|
||||||
SELECT '2500', 'Gift Certificate Liability',
|
|
||||||
2, -- AccountType.Liability
|
|
||||||
12, -- AccountSubType.OtherCurrentLiability
|
|
||||||
1, 1, 'Outstanding gift certificate obligations owed to certificate holders',
|
|
||||||
c.Id, GETUTCDATE(), 0, 0, 0
|
|
||||||
FROM Companies c
|
|
||||||
WHERE c.IsDeleted = 0
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM Accounts a WHERE a.CompanyId = c.Id
|
|
||||||
AND a.AccountNumber = '2500' AND a.IsDeleted = 0);
|
|
||||||
");
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "PricingTiers",
|
|
||||||
keyColumn: "Id",
|
|
||||||
keyValue: 1,
|
|
||||||
column: "CreatedAt",
|
|
||||||
value: new DateTime(2026, 6, 20, 0, 29, 46, 909, DateTimeKind.Utc).AddTicks(3976));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "PricingTiers",
|
|
||||||
keyColumn: "Id",
|
|
||||||
keyValue: 2,
|
|
||||||
column: "CreatedAt",
|
|
||||||
value: new DateTime(2026, 6, 20, 0, 29, 46, 909, DateTimeKind.Utc).AddTicks(3981));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "PricingTiers",
|
|
||||||
keyColumn: "Id",
|
|
||||||
keyValue: 3,
|
|
||||||
column: "CreatedAt",
|
|
||||||
value: new DateTime(2026, 6, 20, 0, 29, 46, 909, DateTimeKind.Utc).AddTicks(3982));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
// Best-effort reversal: the 2500 relabel is intentionally NOT undone (reverting would
|
|
||||||
// re-introduce the mislabel), and 2500 rows are left in place since most pre-date this
|
|
||||||
// migration. Only soft-delete the empty 2900 accounts this migration added.
|
|
||||||
migrationBuilder.Sql(@"
|
|
||||||
UPDATE Accounts SET IsDeleted = 1
|
|
||||||
WHERE AccountNumber = '2900' AND IsDeleted = 0 AND Name = 'Long-Term Loan' AND CurrentBalance = 0;
|
|
||||||
");
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "PricingTiers",
|
|
||||||
keyColumn: "Id",
|
|
||||||
keyValue: 1,
|
|
||||||
column: "CreatedAt",
|
|
||||||
value: new DateTime(2026, 6, 19, 23, 31, 4, 905, DateTimeKind.Utc).AddTicks(7611));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "PricingTiers",
|
|
||||||
keyColumn: "Id",
|
|
||||||
keyValue: 2,
|
|
||||||
column: "CreatedAt",
|
|
||||||
value: new DateTime(2026, 6, 19, 23, 31, 4, 905, DateTimeKind.Utc).AddTicks(7618));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "PricingTiers",
|
|
||||||
keyColumn: "Id",
|
|
||||||
keyValue: 3,
|
|
||||||
column: "CreatedAt",
|
|
||||||
value: new DateTime(2026, 6, 19, 23, 31, 4, 905, DateTimeKind.Utc).AddTicks(7619));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-151
@@ -1,151 +0,0 @@
|
|||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace PowderCoating.Infrastructure.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class AddCompanyDefaultGlAccounts : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.AddColumn<int>(
|
|
||||||
name: "DefaultCogsAccountId",
|
|
||||||
table: "CompanyPreferences",
|
|
||||||
type: "int",
|
|
||||||
nullable: true);
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<int>(
|
|
||||||
name: "DefaultInventoryAccountId",
|
|
||||||
table: "CompanyPreferences",
|
|
||||||
type: "int",
|
|
||||||
nullable: true);
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<int>(
|
|
||||||
name: "DefaultRevenueAccountId",
|
|
||||||
table: "CompanyPreferences",
|
|
||||||
type: "int",
|
|
||||||
nullable: true);
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "PricingTiers",
|
|
||||||
keyColumn: "Id",
|
|
||||||
keyValue: 1,
|
|
||||||
column: "CreatedAt",
|
|
||||||
value: new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4507));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "PricingTiers",
|
|
||||||
keyColumn: "Id",
|
|
||||||
keyValue: 2,
|
|
||||||
column: "CreatedAt",
|
|
||||||
value: new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4514));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "PricingTiers",
|
|
||||||
keyColumn: "Id",
|
|
||||||
keyValue: 3,
|
|
||||||
column: "CreatedAt",
|
|
||||||
value: new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4515));
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_CompanyPreferences_DefaultCogsAccountId",
|
|
||||||
table: "CompanyPreferences",
|
|
||||||
column: "DefaultCogsAccountId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_CompanyPreferences_DefaultInventoryAccountId",
|
|
||||||
table: "CompanyPreferences",
|
|
||||||
column: "DefaultInventoryAccountId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_CompanyPreferences_DefaultRevenueAccountId",
|
|
||||||
table: "CompanyPreferences",
|
|
||||||
column: "DefaultRevenueAccountId");
|
|
||||||
|
|
||||||
migrationBuilder.AddForeignKey(
|
|
||||||
name: "FK_CompanyPreferences_Accounts_DefaultCogsAccountId",
|
|
||||||
table: "CompanyPreferences",
|
|
||||||
column: "DefaultCogsAccountId",
|
|
||||||
principalTable: "Accounts",
|
|
||||||
principalColumn: "Id");
|
|
||||||
|
|
||||||
migrationBuilder.AddForeignKey(
|
|
||||||
name: "FK_CompanyPreferences_Accounts_DefaultInventoryAccountId",
|
|
||||||
table: "CompanyPreferences",
|
|
||||||
column: "DefaultInventoryAccountId",
|
|
||||||
principalTable: "Accounts",
|
|
||||||
principalColumn: "Id");
|
|
||||||
|
|
||||||
migrationBuilder.AddForeignKey(
|
|
||||||
name: "FK_CompanyPreferences_Accounts_DefaultRevenueAccountId",
|
|
||||||
table: "CompanyPreferences",
|
|
||||||
column: "DefaultRevenueAccountId",
|
|
||||||
principalTable: "Accounts",
|
|
||||||
principalColumn: "Id");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropForeignKey(
|
|
||||||
name: "FK_CompanyPreferences_Accounts_DefaultCogsAccountId",
|
|
||||||
table: "CompanyPreferences");
|
|
||||||
|
|
||||||
migrationBuilder.DropForeignKey(
|
|
||||||
name: "FK_CompanyPreferences_Accounts_DefaultInventoryAccountId",
|
|
||||||
table: "CompanyPreferences");
|
|
||||||
|
|
||||||
migrationBuilder.DropForeignKey(
|
|
||||||
name: "FK_CompanyPreferences_Accounts_DefaultRevenueAccountId",
|
|
||||||
table: "CompanyPreferences");
|
|
||||||
|
|
||||||
migrationBuilder.DropIndex(
|
|
||||||
name: "IX_CompanyPreferences_DefaultCogsAccountId",
|
|
||||||
table: "CompanyPreferences");
|
|
||||||
|
|
||||||
migrationBuilder.DropIndex(
|
|
||||||
name: "IX_CompanyPreferences_DefaultInventoryAccountId",
|
|
||||||
table: "CompanyPreferences");
|
|
||||||
|
|
||||||
migrationBuilder.DropIndex(
|
|
||||||
name: "IX_CompanyPreferences_DefaultRevenueAccountId",
|
|
||||||
table: "CompanyPreferences");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "DefaultCogsAccountId",
|
|
||||||
table: "CompanyPreferences");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "DefaultInventoryAccountId",
|
|
||||||
table: "CompanyPreferences");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "DefaultRevenueAccountId",
|
|
||||||
table: "CompanyPreferences");
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "PricingTiers",
|
|
||||||
keyColumn: "Id",
|
|
||||||
keyValue: 1,
|
|
||||||
column: "CreatedAt",
|
|
||||||
value: new DateTime(2026, 6, 20, 0, 29, 46, 909, DateTimeKind.Utc).AddTicks(3976));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "PricingTiers",
|
|
||||||
keyColumn: "Id",
|
|
||||||
keyValue: 2,
|
|
||||||
column: "CreatedAt",
|
|
||||||
value: new DateTime(2026, 6, 20, 0, 29, 46, 909, DateTimeKind.Utc).AddTicks(3981));
|
|
||||||
|
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "PricingTiers",
|
|
||||||
keyColumn: "Id",
|
|
||||||
keyValue: 3,
|
|
||||||
column: "CreatedAt",
|
|
||||||
value: new DateTime(2026, 6, 20, 0, 29, 46, 909, DateTimeKind.Utc).AddTicks(3982));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 325–400 °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 325–400 °F.
|
||||||
- cureTimeMinutes: the hold time at cure temperature in minutes (NOT total oven time). Typically 10–20 min.
|
- cureTimeMinutes: the hold time at cure temperature in minutes (NOT total oven time). Typically 10–20 min.
|
||||||
- specificGravity: the specific gravity / density value from the TDS (often labeled ""Specific Gravity"" or ""Density""). Typically 1.2–1.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 > 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 & 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
Reference in New Issue
Block a user