A sweep of every CRM/accounting toggle found surfaces still reachable
with their flag OFF. Adds a shared requireFeatureFlag middleware (the two
existing per-file copies predate it) and closes the gaps:
- Hours logging: only createEntry checked the flag — edit/delete/bill and
the list/summary routes were permission-only. Gate all six
/hour-entries routes on the hoursLogging master so a disabled feature
can't be read, mutated, or invoiced via a direct API hit.
- Installment plans: PUT /deals/:uuid/installment-plan mutates invoices
but wasn't bills-gated; add requireFeatureFlag('bills').
- Customer invoice PDF: /invoices/:id/pdf lacked the feature_bills check
the list + quotes routes have. Also fixes the quotes-PDF gate, which
read req.customer.feature_quotes (never populated → silent no-op).
- Customer contracts: /contracts + /contracts/:id/pdf were gated by
neither the master nor a per-customer column.
Per-customer contracts override (the missing counterpart):
- Migration 131 adds customer_accounts.feature_contracts, default TRUE so
existing customers keep their Contracts tab (preserve-visuals).
- Effective resolver now contractsMaster AND feature_contracts; admin
detail page gains the toggle; service/validator/serializer wired.
Cleanups:
- Drop stale `taxReport` from the sidebar's Clients-reveal list (Tax moved
to Accounting); add the missing `projects` so it mirrors the context
derivation.
- SettingsPage tab-snap effect now depends on flags.accounting.
- Fix stale taxReport "forced off when bills off" comment (it's accounting).
88 lines
3.5 KiB
JavaScript
88 lines
3.5 KiB
JavaScript
/**
|
||
* Admin → deals lineage endpoint.
|
||
*
|
||
* One UUID per customer engagement spans every quote, contract, and
|
||
* invoice (migration 140). This route exposes the union: given a
|
||
* deal_uuid, return every related document so the frontend's
|
||
* DocumentLineageCard can render the full chain with a single query
|
||
* instead of walking the legacy point-to-point FKs in JS.
|
||
*
|
||
* Read-only. The same `customers.view` permission used elsewhere for
|
||
* lineage display is the gate here — anyone who can read a quote or
|
||
* invoice detail page can read its deal lineage.
|
||
*
|
||
* Sibling routes (`/api/admin/quotes/:id/lineage`,
|
||
* `/api/admin/contracts/:id/lineage`, `/api/admin/invoices/:id/lineage`)
|
||
* also exist as conveniences so the frontend doesn't have to fetch
|
||
* the deal_uuid first; they resolve and delegate to the same service.
|
||
*/
|
||
|
||
const express = require('express');
|
||
const { param, body } = require('express-validator');
|
||
const { adminAuth } = require('../middleware/auth');
|
||
const { requirePermission } = require('../middleware/permissions');
|
||
const { requireFeatureFlag } = require('../middleware/requireFeatureFlag');
|
||
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||
const dealsService = require('../services/dealsService');
|
||
const invoiceService = require('../services/invoiceService');
|
||
const { db } = require('../database/db');
|
||
|
||
const router = express.Router();
|
||
router.use(adminAuth);
|
||
|
||
router.get(
|
||
'/:uuid/documents',
|
||
requirePermission('customers.view'),
|
||
// UUID v4 format check — adminCalendar uses a similar pattern.
|
||
// Length window 32–36 covers both hyphenated and non-hyphenated
|
||
// forms; the service does the actual lookup.
|
||
[param('uuid').isString().isLength({ min: 32, max: 36 })],
|
||
handleAsync(async (req, res) => {
|
||
validateRequest(req);
|
||
const result = await dealsService.getDealDocuments(req.params.uuid);
|
||
return successResponse(res, result);
|
||
}),
|
||
);
|
||
|
||
/**
|
||
* Atomically reshape an installment plan after siblings have spawned.
|
||
* Delegates to invoiceService.updateInstallmentPlan inside a transaction.
|
||
* See that service function for the guard/reuse/grow/trim semantics.
|
||
*
|
||
* 400 — invalid input (validator or service-side percent sum / unknown
|
||
* trigger / single-invoice deal).
|
||
* 404 — deal_uuid owns no invoices.
|
||
* 409 — at least one sibling is past `scheduled`/`pending_delivery`, or
|
||
* the deal contains a Storno.
|
||
*/
|
||
router.put(
|
||
'/:uuid/installment-plan',
|
||
// Mutates invoices — gate on the bills flag like every other invoice
|
||
// write path, so installment plans can't be reshaped with Bills off.
|
||
requireFeatureFlag('bills', 'BILLS_DISABLED'),
|
||
requirePermission('bills.manage'),
|
||
[
|
||
param('uuid').isString().isLength({ min: 32, max: 36 }),
|
||
body('installments').isArray({ min: 1 }),
|
||
body('installments.*.percent').isFloat({ min: 0, max: 100 }),
|
||
body('installments.*.trigger').isIn([
|
||
'quote_accepted', 'before_event', 'after_event', 'after_delivery', 'fixed_date',
|
||
]),
|
||
body('installments.*.offset_days').isInt(),
|
||
body('installments.*.label').optional({ values: 'falsy' }).isString().isLength({ max: 200 }),
|
||
],
|
||
handleAsync(async (req, res) => {
|
||
validateRequest(req);
|
||
const adminId = req.admin?.id;
|
||
const result = await db.transaction((trx) => invoiceService.updateInstallmentPlan({
|
||
trx,
|
||
dealUuid: req.params.uuid,
|
||
installments: req.body.installments,
|
||
adminId,
|
||
}));
|
||
return successResponse(res, result);
|
||
}),
|
||
);
|
||
|
||
module.exports = router;
|