tango-app-api-payment-subscription 3.5.21 → 3.5.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/controllers/brandsBilling.controller.js +263 -27
- package/src/controllers/estimate.controller.js +16 -10
- package/src/controllers/invoice.controller.js +38 -3
- package/src/controllers/paymentSubscription.controllers.js +108 -3
- package/src/controllers/purchaseOrder.controller.js +112 -4
- package/src/hbs/estimatePdf.hbs +0 -1
- package/src/hbs/invoicePdf.hbs +6 -0
- package/src/hbs/invoicePdf1.hbs +6 -0
- package/src/hbs/invoicepaymentemail.hbs +4 -0
- package/src/routes/billing.routes.js +7 -8
- package/src/routes/paymentSubscription.routes.js +5 -7
- package/src/services/clientPayment.services.js +21 -0
- package/src/services/purchaseOrder.service.js +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tango-app-api-payment-subscription",
|
|
3
|
-
"version": "3.5.
|
|
3
|
+
"version": "3.5.23",
|
|
4
4
|
"description": "paymentSubscription",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -20,12 +20,12 @@
|
|
|
20
20
|
"dotenv": "^16.4.5",
|
|
21
21
|
"exceljs": "^4.4.0",
|
|
22
22
|
"express": "^4.18.3",
|
|
23
|
+
"express-fileupload": "^1.5.2",
|
|
23
24
|
"handlebars": "^4.7.8",
|
|
24
25
|
"html-pdf-node": "^1.0.8",
|
|
25
26
|
"joi-to-swagger": "^6.2.0",
|
|
26
27
|
"jsdom": "^24.0.0",
|
|
27
28
|
"mongodb": "^6.4.0",
|
|
28
|
-
"multer": "^1.4.5-lts.1",
|
|
29
29
|
"nodemon": "^3.1.0",
|
|
30
30
|
"puppeteer": "^24.41.0",
|
|
31
31
|
"swagger-ui-express": "^5.0.0",
|
|
@@ -8,7 +8,7 @@ import * as basePriceService from '../services/basePrice.service.js';
|
|
|
8
8
|
import * as cameraService from '../services/camera.service.js';
|
|
9
9
|
import * as planogramService from '../services/planogram.service.js';
|
|
10
10
|
import dayjs from 'dayjs';
|
|
11
|
-
import { logger, checkFileExist, signedUrl, download, insertOpenSearchData, getOpenSearchData } from 'tango-app-api-middleware';
|
|
11
|
+
import { logger, checkFileExist, signedUrl, download, insertOpenSearchData, getOpenSearchData, searchOpenSearchData, scrollResponse, clearScroll } from 'tango-app-api-middleware';
|
|
12
12
|
import * as XLSX from 'xlsx';
|
|
13
13
|
import ExcelJS from 'exceljs';
|
|
14
14
|
import { bulkUpdateBillingGroupRowSchema } from '../dtos/validation.dtos.js';
|
|
@@ -1944,7 +1944,11 @@ export async function billingSummary( req, res ) {
|
|
|
1944
1944
|
csm: [ ...( csmByClient.get( key ) || [] ) ].join( ', ' ),
|
|
1945
1945
|
revenueMonths: {},
|
|
1946
1946
|
billedStoresMonths: {},
|
|
1947
|
-
|
|
1947
|
+
// Installation fee per month (keyed YYYY-MM). The summary surfaces
|
|
1948
|
+
// ONLY the current month's value — see installationOut below — so the
|
|
1949
|
+
// column stays hidden until the current-month invoice is generated
|
|
1950
|
+
// with an installation fee.
|
|
1951
|
+
installationMonths: {},
|
|
1948
1952
|
invDollar: 0,
|
|
1949
1953
|
invInr: 0,
|
|
1950
1954
|
} );
|
|
@@ -1956,7 +1960,8 @@ export async function billingSummary( req, res ) {
|
|
|
1956
1960
|
const r = rowOf( inv._id.c );
|
|
1957
1961
|
r.revenueMonths[inv._id.ym] = Math.round( ( ( inv.revenueInr || 0 ) + ( inv.revenueUsd || 0 ) * usdRate ) * 100 ) / 100;
|
|
1958
1962
|
r.billedStoresMonths[inv._id.ym] = inv.stores || 0;
|
|
1959
|
-
r.
|
|
1963
|
+
r.installationMonths[inv._id.ym] = ( r.installationMonths[inv._id.ym] || 0 ) +
|
|
1964
|
+
( inv.installationInr || 0 ) + ( inv.installationUsd || 0 ) * usdRate;
|
|
1960
1965
|
r.invDollar += ( inv.dollarInvoices || 0 );
|
|
1961
1966
|
r.invInr += ( inv.inrInvoices || 0 );
|
|
1962
1967
|
if ( inv.companyName ) {
|
|
@@ -2074,7 +2079,11 @@ export async function billingSummary( req, res ) {
|
|
|
2074
2079
|
// in native USD for dollar clients.
|
|
2075
2080
|
revCur = revCur == null ? null : Math.round( revCur );
|
|
2076
2081
|
const revPrevOut = revPrev == null ? null : Math.round( revPrev );
|
|
2077
|
-
|
|
2082
|
+
// Show the installation fee ONLY for the current month, and only once
|
|
2083
|
+
// that month's invoice (which carries the installationFee line) exists.
|
|
2084
|
+
// Until then it's null so the UI hides the whole column.
|
|
2085
|
+
const curInstallation = r.installationMonths[curKey] || 0;
|
|
2086
|
+
const installationOut = curInstallation ? Math.round( curInstallation ) : null;
|
|
2078
2087
|
// Registered Entity from the billings collection (distinct group names).
|
|
2079
2088
|
// First name shown in the column; the full list is sent so the UI can
|
|
2080
2089
|
// reveal the others on hover. Fall back to the invoice company name when
|
|
@@ -2176,6 +2185,11 @@ const ADDITIONAL_PRODUCT_PRICES = {
|
|
|
2176
2185
|
planogram: 770,
|
|
2177
2186
|
aiManager: 500,
|
|
2178
2187
|
};
|
|
2188
|
+
// Product Lambda Function URLs (VMS / Run AI). Each returns the list of billable
|
|
2189
|
+
// store IDs for a client; used both for the detailed export and for the invoice
|
|
2190
|
+
// additional-products quantity (scoped to a billing group's stores).
|
|
2191
|
+
const RUN_AI_LAMBDA_URL = 'https://mdm3mf7wuficgv3jjspkws2nu40azlsc.lambda-url.ap-south-1.on.aws/';
|
|
2192
|
+
const VMS_LAMBDA_URL = 'https://ppf3l3mxc2lorh5hkrsj6zwyim0bupxw.lambda-url.ap-south-1.on.aws/';
|
|
2179
2193
|
// OpenSearch index holding extra billed products (VMS, Run AI, etc.). Each
|
|
2180
2194
|
// record is a daily snapshot carrying its own quantity + price per product.
|
|
2181
2195
|
const BILLING_DETAILS_INDEX = ( () => {
|
|
@@ -2190,12 +2204,37 @@ const BILLING_DETAILS_INDEX = ( () => {
|
|
|
2190
2204
|
// that has none (currently only clientId 11). Shared by the Billing Breakdown
|
|
2191
2205
|
// table (additionalProducts controller) AND invoice generation, so both stay in
|
|
2192
2206
|
// sync. Each entry: { productName, quantity, price, total }.
|
|
2193
|
-
|
|
2207
|
+
//
|
|
2208
|
+
// storeIds (optional): when a non-empty array is passed, the Eyetest / Planogram
|
|
2209
|
+
// / AI Manager quantities are restricted to those stores only — used by invoice
|
|
2210
|
+
// generation so a billing group is billed for its assigned stores, not the whole
|
|
2211
|
+
// brand. When omitted (e.g. the brand-wide Billing Breakdown endpoint) the counts
|
|
2212
|
+
// stay client-wide.
|
|
2213
|
+
//
|
|
2214
|
+
// Run AI / VMS are sourced by caller: invoice generation (storeIds passed) uses
|
|
2215
|
+
// the product Lambdas (called brand-wide, then intersected with the group's
|
|
2216
|
+
// stores locally); the brand-wide Billing Breakdown (no storeIds) uses the
|
|
2217
|
+
// billing_details snapshot instead.
|
|
2218
|
+
//
|
|
2219
|
+
// Remote Optum is store-scoped for invoices too: its quantity becomes the count
|
|
2220
|
+
// of remote_optom_steps_summary records for the group over the latest inserted
|
|
2221
|
+
// month in that index (see remoteOptumRecordCount). Factory is dropped from
|
|
2222
|
+
// invoices entirely. The brand-wide Billing Breakdown keeps taking Remote Optum /
|
|
2223
|
+
// Factory from the billing_details snapshot as-is.
|
|
2224
|
+
export async function getAdditionalProducts( clientId, storeIds ) {
|
|
2194
2225
|
clientId = String( clientId ?? '' );
|
|
2195
2226
|
if ( clientId !== ADDITIONAL_PRODUCTS_CLIENT_ID ) {
|
|
2196
2227
|
return [];
|
|
2197
2228
|
}
|
|
2198
2229
|
|
|
2230
|
+
// Restrict the Mongo aggregations to the billing group's stores when provided.
|
|
2231
|
+
// group.stores holds storeIds, matched via $in against each collection's
|
|
2232
|
+
// storeId field (top-level for cameras/planograms, stores.storeId once
|
|
2233
|
+
// unwound for daily-pricing).
|
|
2234
|
+
const scopeStores = Array.isArray( storeIds ) && storeIds.length;
|
|
2235
|
+
const storeMatch = scopeStores ? { storeId: { $in: storeIds } } : {};
|
|
2236
|
+
const unwoundStoreMatch = scopeStores ? { 'stores.storeId': { $in: storeIds } } : {};
|
|
2237
|
+
|
|
2199
2238
|
// Eyetest: distinct stores in the cameras collection that have an eye-test
|
|
2200
2239
|
// stream with a QR code.
|
|
2201
2240
|
const eyetestRows = await cameraService.aggregate( [
|
|
@@ -2203,6 +2242,7 @@ export async function getAdditionalProducts( clientId ) {
|
|
|
2203
2242
|
{ clientId: clientId },
|
|
2204
2243
|
{ isEyeTestStream: true },
|
|
2205
2244
|
{ qrCode: { $exists: true } },
|
|
2245
|
+
storeMatch,
|
|
2206
2246
|
] } },
|
|
2207
2247
|
{ $group: { _id: '$storeId' } },
|
|
2208
2248
|
{ $count: 'stores' },
|
|
@@ -2211,7 +2251,7 @@ export async function getAdditionalProducts( clientId ) {
|
|
|
2211
2251
|
|
|
2212
2252
|
// Planogram: distinct storeName in the planograms collection.
|
|
2213
2253
|
const planogramRows = await planogramService.aggregate( [
|
|
2214
|
-
{ $match: { clientId: clientId } },
|
|
2254
|
+
{ $match: { clientId: clientId, ...storeMatch } },
|
|
2215
2255
|
{ $group: { _id: null, storecount: { $addToSet: '$storeName' } } },
|
|
2216
2256
|
{ $project: { count: { $size: '$storecount' } } },
|
|
2217
2257
|
] );
|
|
@@ -2225,6 +2265,7 @@ export async function getAdditionalProducts( clientId ) {
|
|
|
2225
2265
|
{ $sort: { dateISO: -1 } },
|
|
2226
2266
|
{ $limit: 1 },
|
|
2227
2267
|
{ $unwind: '$stores' },
|
|
2268
|
+
...( scopeStores ? [ { $match: unwoundStoreMatch } ] : [] ),
|
|
2228
2269
|
{ $unwind: '$stores.products' },
|
|
2229
2270
|
{ $match: { 'stores.products.productName': 'tangoTraffic', 'stores.products.workingdays': { $gt: 1 } } },
|
|
2230
2271
|
{ $group: { _id: '$stores.storeId' } },
|
|
@@ -2245,10 +2286,45 @@ export async function getAdditionalProducts( clientId ) {
|
|
|
2245
2286
|
build( 'AI Manager', aiManagerQty, ADDITIONAL_PRODUCT_PRICES.aiManager ),
|
|
2246
2287
|
];
|
|
2247
2288
|
|
|
2289
|
+
// Run AI and VMS quantities come from two different sources depending on the
|
|
2290
|
+
// caller:
|
|
2291
|
+
// - Invoice generation (storeIds passed): use the product Lambdas. The
|
|
2292
|
+
// Lambda's own store_ids filter is unreliable, so lambdaStoreCount calls it
|
|
2293
|
+
// brand-wide and intersects the returned store list with the group's stores
|
|
2294
|
+
// locally — giving a per-group scoped count. Unit price from billing_details.
|
|
2295
|
+
// - Brand-wide Billing Breakdown (no storeIds): use the billing_details
|
|
2296
|
+
// OpenSearch snapshot instead (handled by the loop below), same as Remote
|
|
2297
|
+
// Optum / Factory.
|
|
2298
|
+
// lambdaSourced marks which productNames the loop below must skip so they are
|
|
2299
|
+
// not counted twice — only populated in the invoice (scoped) case.
|
|
2300
|
+
const lambdaSourced = new Set();
|
|
2301
|
+
if ( scopeStores ) {
|
|
2302
|
+
const [ runAiQty, vmsQty, runAiPrice, vmsPrice, optumQty, optumPrice ] = await Promise.all( [
|
|
2303
|
+
lambdaStoreCount( clientId, RUN_AI_LAMBDA_URL, storeIds ),
|
|
2304
|
+
lambdaStoreCount( clientId, VMS_LAMBDA_URL, storeIds ),
|
|
2305
|
+
billingDetailsPrice( clientId, 'Run AI' ),
|
|
2306
|
+
billingDetailsPrice( clientId, 'VMS' ),
|
|
2307
|
+
remoteOptumRecordCount( clientId, storeIds ),
|
|
2308
|
+
billingDetailsPrice( clientId, 'Remote Optum' ),
|
|
2309
|
+
] );
|
|
2310
|
+
products.push( build( 'Run AI', runAiQty, runAiPrice ) );
|
|
2311
|
+
products.push( build( 'VMS', vmsQty, vmsPrice ) );
|
|
2312
|
+
products.push( build( 'Remote Optum', optumQty, optumPrice ) );
|
|
2313
|
+
// Skipped in the billing_details loop below so they are not counted twice.
|
|
2314
|
+
lambdaSourced.add( 'run ai' );
|
|
2315
|
+
lambdaSourced.add( 'vms' );
|
|
2316
|
+
lambdaSourced.add( 'remote optum' );
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2248
2319
|
// Extra products from the billing_details OpenSearch index — the LATEST daily
|
|
2249
2320
|
// snapshot for this client. quantity/price are stored as strings, so coerce
|
|
2250
|
-
// them.
|
|
2251
|
-
//
|
|
2321
|
+
// them. In the invoice (scoped) case VMS / Run AI / Remote Optum are skipped
|
|
2322
|
+
// here (already added store-scoped above) to avoid double-counting; in the
|
|
2323
|
+
// brand-wide case they are taken from this snapshot like the other extra
|
|
2324
|
+
// products. If OpenSearch is unreachable we just skip these rows rather than
|
|
2325
|
+
// failing the whole list. Factory is excluded from invoices (scoped) but still
|
|
2326
|
+
// shown in the brand-wide Billing Breakdown table.
|
|
2327
|
+
const invoiceExcluded = scopeStores ? new Set( [ 'factory' ] ) : new Set();
|
|
2252
2328
|
try {
|
|
2253
2329
|
const osRes = await getOpenSearchData( BILLING_DETAILS_INDEX, {
|
|
2254
2330
|
size: 1,
|
|
@@ -2258,6 +2334,10 @@ export async function getAdditionalProducts( clientId ) {
|
|
|
2258
2334
|
const hits = osRes?.body?.hits?.hits || osRes?.hits?.hits || [];
|
|
2259
2335
|
const osProducts = hits[0]?._source?.products || [];
|
|
2260
2336
|
for ( const p of osProducts ) {
|
|
2337
|
+
const nameKey = String( p.productName ).toLowerCase();
|
|
2338
|
+
if ( lambdaSourced.has( nameKey ) || invoiceExcluded.has( nameKey ) ) {
|
|
2339
|
+
continue;
|
|
2340
|
+
}
|
|
2261
2341
|
const quantity = Number( p.quantity ) || 0;
|
|
2262
2342
|
const price = Number( p.price ) || 0;
|
|
2263
2343
|
products.push( build( p.productName, quantity, price ) );
|
|
@@ -2272,8 +2352,13 @@ export async function getAdditionalProducts( clientId ) {
|
|
|
2272
2352
|
export async function additionalProducts( req, res ) {
|
|
2273
2353
|
try {
|
|
2274
2354
|
const clientId = String( req.query?.clientId ?? req.body?.clientId ?? '' );
|
|
2275
|
-
const products = await
|
|
2276
|
-
|
|
2355
|
+
const [ products, billingDate ] = await Promise.all( [
|
|
2356
|
+
getAdditionalProducts( clientId ),
|
|
2357
|
+
billingDetailsDate( clientId ),
|
|
2358
|
+
] );
|
|
2359
|
+
// billingDate (YYYY-MM-DD) is the billing_details snapshot behind the counts;
|
|
2360
|
+
// the UI passes it back to the export endpoint so exports match the cards.
|
|
2361
|
+
return res.sendSuccess( { currency: 'inr', products, billingDate } );
|
|
2277
2362
|
} catch ( error ) {
|
|
2278
2363
|
logger.error( { error: error, function: 'additionalProducts' } );
|
|
2279
2364
|
return res.sendError( error, 500 );
|
|
@@ -2300,6 +2385,35 @@ async function lamdaServiceCall( url, data ) {
|
|
|
2300
2385
|
}
|
|
2301
2386
|
}
|
|
2302
2387
|
|
|
2388
|
+
// Date (YYYY-MM-DD) of the latest billing_details OpenSearch snapshot for a
|
|
2389
|
+
// client — the snapshot whose product counts back the Additional Products cards.
|
|
2390
|
+
// The UI passes this back to the export endpoint so the export reflects the same
|
|
2391
|
+
// day as the card. Returns '' if unavailable; date_string is normalised to
|
|
2392
|
+
// YYYY-MM-DD (it may be stored as DD-MM-YYYY).
|
|
2393
|
+
async function billingDetailsDate( clientId ) {
|
|
2394
|
+
try {
|
|
2395
|
+
const osRes = await getOpenSearchData( BILLING_DETAILS_INDEX, {
|
|
2396
|
+
size: 1,
|
|
2397
|
+
query: { term: { 'client_id': clientId } },
|
|
2398
|
+
sort: [ { date_string: { order: 'desc' } } ],
|
|
2399
|
+
} );
|
|
2400
|
+
const hits = osRes?.body?.hits?.hits || osRes?.hits?.hits || [];
|
|
2401
|
+
const raw = String( hits[0]?._source?.date_string || '' ).trim();
|
|
2402
|
+
if ( !raw ) {
|
|
2403
|
+
return '';
|
|
2404
|
+
}
|
|
2405
|
+
// Accept DD-MM-YYYY or YYYY-MM-DD; return YYYY-MM-DD.
|
|
2406
|
+
const ddmmyyyy = raw.match( /^(\d{2})-(\d{2})-(\d{4})$/ );
|
|
2407
|
+
if ( ddmmyyyy ) {
|
|
2408
|
+
return `${ddmmyyyy[3]}-${ddmmyyyy[2]}-${ddmmyyyy[1]}`;
|
|
2409
|
+
}
|
|
2410
|
+
return raw;
|
|
2411
|
+
} catch ( osErr ) {
|
|
2412
|
+
logger.error( { error: osErr, function: 'billingDetailsDate', clientId } );
|
|
2413
|
+
return '';
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2303
2417
|
// Unit price for a product from the latest billing_details OpenSearch record.
|
|
2304
2418
|
// productName match is case-insensitive. Returns 0 if not found / unavailable.
|
|
2305
2419
|
async function billingDetailsPrice( clientId, productName ) {
|
|
@@ -2319,13 +2433,82 @@ async function billingDetailsPrice( clientId, productName ) {
|
|
|
2319
2433
|
}
|
|
2320
2434
|
}
|
|
2321
2435
|
|
|
2436
|
+
// Date (YYYY-MM-DD) of the most recent record in the remote_optom_steps_summary
|
|
2437
|
+
// index — the latest day audit data was inserted. Date is a keyword so it sorts
|
|
2438
|
+
// lexically. Returns '' if the index is empty / unreachable.
|
|
2439
|
+
async function remoteOptumLatestDate() {
|
|
2440
|
+
try {
|
|
2441
|
+
const osRes = await getOpenSearchData( 'remote_optom_steps_summary', {
|
|
2442
|
+
size: 1,
|
|
2443
|
+
_source: [ 'Date' ],
|
|
2444
|
+
sort: [ { 'Date': { order: 'desc' } } ],
|
|
2445
|
+
} );
|
|
2446
|
+
const hits = osRes?.body?.hits?.hits || osRes?.hits?.hits || [];
|
|
2447
|
+
return String( hits[0]?._source?.Date || '' ).trim();
|
|
2448
|
+
} catch ( osErr ) {
|
|
2449
|
+
logger.error( { error: osErr, function: 'remoteOptumLatestDate' } );
|
|
2450
|
+
return '';
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
// Remote Optum billed quantity for a billing group = number of audit records in
|
|
2455
|
+
// the remote_optom_steps_summary index that belong to the group's stores, over
|
|
2456
|
+
// the range [to_date's month-start .. to_date], where to_date is the latest
|
|
2457
|
+
// record date in the index (e.g. to_date 2026-06-30 → from_date 2026-06-01). That
|
|
2458
|
+
// index keys by store NAME (no storeId), so group.stores (storeIds) are first
|
|
2459
|
+
// mapped to names via the stores collection, then matched with a terms filter on
|
|
2460
|
+
// storeName.keyword. Date is a keyword (YYYY-MM-DD per record), so the range is
|
|
2461
|
+
// lexical. size:0 + track_total_hits gives the exact count without paging past
|
|
2462
|
+
// the 10k window. Returns 0 on any failure or when the group has no matching
|
|
2463
|
+
// store names / the index is empty.
|
|
2464
|
+
async function remoteOptumRecordCount( clientId, storeIds ) {
|
|
2465
|
+
try {
|
|
2466
|
+
if ( !Array.isArray( storeIds ) || !storeIds.length ) {
|
|
2467
|
+
return 0;
|
|
2468
|
+
}
|
|
2469
|
+
const toDate = await remoteOptumLatestDate();
|
|
2470
|
+
if ( !toDate ) {
|
|
2471
|
+
return 0;
|
|
2472
|
+
}
|
|
2473
|
+
const fromDate = dayjs( toDate ).startOf( 'month' ).format( 'YYYY-MM-DD' );
|
|
2474
|
+
const stores = await storeService.find(
|
|
2475
|
+
{ clientId: clientId, storeId: { $in: storeIds } },
|
|
2476
|
+
{ storeName: 1 },
|
|
2477
|
+
);
|
|
2478
|
+
const storeNames = [ ...new Set(
|
|
2479
|
+
stores.map( ( s ) => String( s.storeName || '' ).trim() ).filter( Boolean ),
|
|
2480
|
+
) ];
|
|
2481
|
+
if ( !storeNames.length ) {
|
|
2482
|
+
return 0;
|
|
2483
|
+
}
|
|
2484
|
+
const osRes = await getOpenSearchData( 'remote_optom_steps_summary', {
|
|
2485
|
+
size: 0,
|
|
2486
|
+
track_total_hits: true,
|
|
2487
|
+
query: { bool: { filter: [
|
|
2488
|
+
{ range: { 'Date': { gte: fromDate, lte: toDate } } },
|
|
2489
|
+
{ terms: { 'storeName.keyword': storeNames } },
|
|
2490
|
+
] } },
|
|
2491
|
+
} );
|
|
2492
|
+
const total = osRes?.body?.hits?.total?.value ?? osRes?.hits?.total?.value ?? 0;
|
|
2493
|
+
return Number( total ) || 0;
|
|
2494
|
+
} catch ( osErr ) {
|
|
2495
|
+
logger.error( { error: osErr, function: 'remoteOptumRecordCount', clientId } );
|
|
2496
|
+
return 0;
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2322
2500
|
// Build the Lambda-backed export rows (VMS / Run AI / etc.). Calls the product's
|
|
2323
2501
|
// Lambda for store_names (storeIds), maps them to the stores collection for
|
|
2324
2502
|
// name + country (Zone), with current-month days and the billing_details price.
|
|
2325
|
-
|
|
2326
|
-
|
|
2503
|
+
// reqDate (YYYY-MM-DD, optional) is the billing_details snapshot date the UI
|
|
2504
|
+
// picked so the export matches the card count; it is sent to the Lambda as
|
|
2505
|
+
// to_date (DD-MM-YYYY). Defaults to today when absent.
|
|
2506
|
+
async function lambdaStoreExport( clientId, lambdaUrl, billingProductName, reqDate ) {
|
|
2507
|
+
// reqDate is ISO YYYY-MM-DD, which dayjs parses natively; reformat to the
|
|
2508
|
+
// Lambda's DD-MM-YYYY. Fall back to today when absent / unparseable.
|
|
2509
|
+
const parsed = reqDate ? dayjs( reqDate ) : null;
|
|
2510
|
+
const toDate = parsed && parsed.isValid() ? parsed.format( 'DD-MM-YYYY' ) : dayjs().format( 'DD-MM-YYYY' );
|
|
2327
2511
|
const lambdaResult = await lamdaServiceCall( lambdaUrl, { to_date: toDate, client_id: clientId } );
|
|
2328
|
-
console.log( '🚀 ~ lambdaStoreExport ~ lambdaResult:', lambdaResult );
|
|
2329
2512
|
// These Lambdas return the storeIds under either `store_ids` (Run AI) or
|
|
2330
2513
|
// `store_names` (VMS) with no status_code. Accept whichever is present; data
|
|
2331
2514
|
// availability = a non-empty array.
|
|
@@ -2353,6 +2536,33 @@ async function lambdaStoreExport( clientId, lambdaUrl, billingProductName ) {
|
|
|
2353
2536
|
} );
|
|
2354
2537
|
}
|
|
2355
2538
|
|
|
2539
|
+
// Count the billable stores a product Lambda (VMS / Run AI) reports, optionally
|
|
2540
|
+
// scoped to a billing group's stores. The Lambda's own store_ids request filter
|
|
2541
|
+
// is unreliable — passing store_ids returns 0 even for IDs it itself reports as
|
|
2542
|
+
// billable — so we always call it BRAND-WIDE (client only) to get the true store
|
|
2543
|
+
// list, then intersect that list with the group's stores locally. Both sides use
|
|
2544
|
+
// the same `clientId-n` storeId format (e.g. `11-2911`), matching how the Mongo
|
|
2545
|
+
// aggregations scope by group.stores. Returns 0 on any failure (Lambda
|
|
2546
|
+
// unreachable / empty), so the caller just omits the line rather than failing the
|
|
2547
|
+
// whole invoice.
|
|
2548
|
+
async function lambdaStoreCount( clientId, lambdaUrl, storeIds ) {
|
|
2549
|
+
const toDate = dayjs().format( 'DD-MM-YYYY' );
|
|
2550
|
+
const lambdaResult = await lamdaServiceCall( lambdaUrl, { to_date: toDate, client_id: clientId } );
|
|
2551
|
+
// The Lambdas return the storeIds under either `store_ids` (Run AI) or
|
|
2552
|
+
// `store_names` (VMS); accept whichever is present.
|
|
2553
|
+
const rawIds = ( lambdaResult && Array.isArray( lambdaResult.store_ids ) ) ? lambdaResult.store_ids :
|
|
2554
|
+
( ( lambdaResult && Array.isArray( lambdaResult.store_names ) ) ? lambdaResult.store_names : [] );
|
|
2555
|
+
const ids = rawIds.map( ( s ) => String( s ) );
|
|
2556
|
+
// Scope to the billing group's stores when provided; otherwise (brand-wide
|
|
2557
|
+
// Billing Breakdown) count every store the Lambda reports.
|
|
2558
|
+
const scope = Array.isArray( storeIds ) && storeIds.length ? storeIds.map( ( s ) => String( s ) ) : null;
|
|
2559
|
+
if ( scope ) {
|
|
2560
|
+
const allowed = new Set( scope );
|
|
2561
|
+
return ids.filter( ( id ) => allowed.has( id ) ).length;
|
|
2562
|
+
}
|
|
2563
|
+
return ids.length;
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2356
2566
|
// tangoTraffic working days per store, from the latest daily-pricing doc.
|
|
2357
2567
|
// Map<storeId, workingdays>; stores with no tangoTraffic record are absent.
|
|
2358
2568
|
async function tangoTrafficDaysByStore( clientId ) {
|
|
@@ -2522,8 +2732,9 @@ export async function additionalProductExport( req, res ) {
|
|
|
2522
2732
|
if ( product === 'vms' ) {
|
|
2523
2733
|
const exportData = await lambdaStoreExport(
|
|
2524
2734
|
clientId,
|
|
2525
|
-
|
|
2735
|
+
VMS_LAMBDA_URL,
|
|
2526
2736
|
'VMS',
|
|
2737
|
+
reqDate,
|
|
2527
2738
|
);
|
|
2528
2739
|
if ( !exportData.length ) {
|
|
2529
2740
|
return res.sendError( 'No data', 204 );
|
|
@@ -2535,10 +2746,10 @@ export async function additionalProductExport( req, res ) {
|
|
|
2535
2746
|
if ( product === 'runai' ) {
|
|
2536
2747
|
const exportData = await lambdaStoreExport(
|
|
2537
2748
|
clientId,
|
|
2538
|
-
|
|
2749
|
+
RUN_AI_LAMBDA_URL,
|
|
2539
2750
|
'Run AI',
|
|
2751
|
+
reqDate,
|
|
2540
2752
|
);
|
|
2541
|
-
console.log( '🚀 ~ additionalProductExport ~ exportData:', exportData );
|
|
2542
2753
|
if ( !exportData.length ) {
|
|
2543
2754
|
return res.sendError( 'No data', 204 );
|
|
2544
2755
|
}
|
|
@@ -2547,29 +2758,54 @@ export async function additionalProductExport( req, res ) {
|
|
|
2547
2758
|
}
|
|
2548
2759
|
|
|
2549
2760
|
if ( product === 'remoteoptum' ) {
|
|
2550
|
-
// Detailed optom-audit export from the remote_optom_steps_summary index
|
|
2551
|
-
//
|
|
2552
|
-
//
|
|
2553
|
-
//
|
|
2761
|
+
// Detailed optom-audit export from the remote_optom_steps_summary index.
|
|
2762
|
+
// to_date is the date from the payload (reqDate); from_date is the first
|
|
2763
|
+
// day of that date's month — so the export covers month-start through the
|
|
2764
|
+
// requested date. Each record is one row; the nested `steps` object is
|
|
2765
|
+
// flattened into TRUE/FALSE columns in a fixed (alphabetical) order.
|
|
2554
2766
|
const STEP_COLUMNS = [
|
|
2555
2767
|
'Adjust-Phoropter', 'DuoChrome-Test', 'Explanation', 'Final-Prescription',
|
|
2556
2768
|
'Handover', 'History-Taking', 'JCC', 'Near-Vision', 'Personal-Intro',
|
|
2557
2769
|
'Subjective-Refraction', 'Trial-Frame', 'VA-Check',
|
|
2558
2770
|
];
|
|
2771
|
+
const fromDate = dayjs( reqDate ).startOf( 'month' ).format( 'YYYY-MM-DD' );
|
|
2559
2772
|
let optomHits = [];
|
|
2773
|
+
let scrollId = null;
|
|
2560
2774
|
try {
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2775
|
+
// A month-to-date span can exceed the 10k max_result_window, so scroll
|
|
2776
|
+
// through all pages. Date is a keyword (YYYY-MM-DD) → the span is a lexical
|
|
2777
|
+
// range. NOTE: do NOT sort on StartTime — it's an analyzed text field, and
|
|
2778
|
+
// sorting on it makes OpenSearch return ZERO hits. The range filter is
|
|
2779
|
+
// enough; rows come back in index order.
|
|
2780
|
+
let osRes = await searchOpenSearchData( 'remote_optom_steps_summary', {
|
|
2566
2781
|
size: 10000,
|
|
2567
|
-
query: {
|
|
2782
|
+
query: { range: { 'Date': { gte: fromDate, lte: reqDate } } },
|
|
2568
2783
|
} );
|
|
2569
|
-
|
|
2784
|
+
let body = osRes?.body || osRes;
|
|
2785
|
+
let hits = body?.hits?.hits || [];
|
|
2786
|
+
scrollId = body?._scroll_id;
|
|
2787
|
+
while ( hits.length ) {
|
|
2788
|
+
optomHits.push( ...hits );
|
|
2789
|
+
if ( !scrollId ) {
|
|
2790
|
+
break;
|
|
2791
|
+
}
|
|
2792
|
+
osRes = await scrollResponse( scrollId );
|
|
2793
|
+
body = osRes?.body || osRes;
|
|
2794
|
+
hits = body?.hits?.hits || [];
|
|
2795
|
+
scrollId = body?._scroll_id || scrollId;
|
|
2796
|
+
}
|
|
2570
2797
|
} catch ( osErr ) {
|
|
2571
2798
|
logger.error( { error: osErr, function: 'additionalProductExport.remoteOptum', clientId } );
|
|
2572
2799
|
return res.sendError( 'Failed to fetch Remote Optum data', 502 );
|
|
2800
|
+
} finally {
|
|
2801
|
+
// Free the scroll context; ignore failures (context may already be gone).
|
|
2802
|
+
if ( scrollId ) {
|
|
2803
|
+
try {
|
|
2804
|
+
await clearScroll( scrollId );
|
|
2805
|
+
} catch ( clearErr ) {
|
|
2806
|
+
logger.error( { error: clearErr, function: 'additionalProductExport.remoteOptum.clearScroll' } );
|
|
2807
|
+
}
|
|
2808
|
+
}
|
|
2573
2809
|
}
|
|
2574
2810
|
|
|
2575
2811
|
const exportData = optomHits.map( ( h ) => {
|
|
@@ -7,7 +7,7 @@ import Handlebars from '../utils/validations/helper/handlebar.helper.js';
|
|
|
7
7
|
import fs from 'fs';
|
|
8
8
|
import path from 'path';
|
|
9
9
|
import htmlpdf from 'html-pdf-node';
|
|
10
|
-
import { symbolFor } from '../utils/currency.js';
|
|
10
|
+
import { symbolFor, roundAmount } from '../utils/currency.js';
|
|
11
11
|
import { getInvoiceCcEmails } from './invoice.controller.js';
|
|
12
12
|
|
|
13
13
|
// Build the estimate PDF buffer + the template data + a safe filename. Shared
|
|
@@ -45,7 +45,9 @@ async function buildEstimatePdf( estimate ) {
|
|
|
45
45
|
companyAddress: e.companyAddress || '',
|
|
46
46
|
GSTNumber: e.GSTNumber || '',
|
|
47
47
|
PlaceOfSupply: e.PlaceOfSupply || '',
|
|
48
|
-
|
|
48
|
+
// "Default Group" is the placeholder used when no real billing group was
|
|
49
|
+
// chosen — don't surface it on the estimate (the template hides empty).
|
|
50
|
+
groupName: ( e.groupName && e.groupName !== 'Default Group' ) ? e.groupName : '',
|
|
49
51
|
period: e.period || '',
|
|
50
52
|
createdDate: e.createdDate ? dayjs( e.createdDate ).format( 'DD/MM/YYYY' ) : '',
|
|
51
53
|
validTill: e.validTill ? dayjs( e.validTill ).format( 'DD/MM/YYYY' ) : '',
|
|
@@ -211,11 +213,13 @@ export async function createEstimate( req, res ) {
|
|
|
211
213
|
{ 'clientName': 1, 'paymentInvoice.currencyType': 1 },
|
|
212
214
|
);
|
|
213
215
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
+
// INR rounds to whole; other currencies keep 2 decimals.
|
|
217
|
+
const estimateCurrency = b.currency || ( client?.paymentInvoice?.currencyType === 'dollar' ? 'dollar' : 'inr' );
|
|
218
|
+
const amount = roundAmount( Number( b.amount ) || 0, estimateCurrency );
|
|
219
|
+
let totalAmount = roundAmount( Number( b.totalAmount ) || 0, estimateCurrency );
|
|
216
220
|
if ( !totalAmount && amount ) {
|
|
217
221
|
// Default to 18% GST when caller sends only the pre-tax amount.
|
|
218
|
-
totalAmount =
|
|
222
|
+
totalAmount = roundAmount( amount * 1.18, estimateCurrency );
|
|
219
223
|
}
|
|
220
224
|
|
|
221
225
|
const { estimate, estimateIndex } = await nextEstimateNumber();
|
|
@@ -239,7 +243,7 @@ export async function createEstimate( req, res ) {
|
|
|
239
243
|
tax: Array.isArray( b.tax ) ? b.tax : [],
|
|
240
244
|
amount,
|
|
241
245
|
totalAmount,
|
|
242
|
-
currency:
|
|
246
|
+
currency: estimateCurrency,
|
|
243
247
|
status: b.status === 'sent' ? 'sent' : 'pending',
|
|
244
248
|
createdDate,
|
|
245
249
|
validTill,
|
|
@@ -273,10 +277,12 @@ export async function updateEstimate( req, res ) {
|
|
|
273
277
|
return res.sendError( 'An accepted estimate cannot be edited.', 409 );
|
|
274
278
|
}
|
|
275
279
|
|
|
276
|
-
|
|
277
|
-
|
|
280
|
+
// INR rounds to whole; other currencies keep 2 decimals.
|
|
281
|
+
const estimateCurrency = b.currency || existing.currency;
|
|
282
|
+
const amount = roundAmount( Number( b.amount ) || 0, estimateCurrency );
|
|
283
|
+
let totalAmount = roundAmount( Number( b.totalAmount ) || 0, estimateCurrency );
|
|
278
284
|
if ( !totalAmount && amount ) {
|
|
279
|
-
totalAmount =
|
|
285
|
+
totalAmount = roundAmount( amount * 1.18, estimateCurrency );
|
|
280
286
|
}
|
|
281
287
|
|
|
282
288
|
// Only the editable fields are updated. The estimate number, index,
|
|
@@ -294,7 +300,7 @@ export async function updateEstimate( req, res ) {
|
|
|
294
300
|
tax: Array.isArray( b.tax ) ? b.tax : existing.tax,
|
|
295
301
|
amount,
|
|
296
302
|
totalAmount,
|
|
297
|
-
currency:
|
|
303
|
+
currency: estimateCurrency,
|
|
298
304
|
notes: b.notes ?? existing.notes,
|
|
299
305
|
updatedBy: req.user?.email || req.user?.userName || '',
|
|
300
306
|
};
|
|
@@ -312,8 +312,17 @@ export async function createInvoice( req, res ) {
|
|
|
312
312
|
// Skip when reusing advance-invoice line items — those already include any
|
|
313
313
|
// additional products from when the advance invoice was generated.
|
|
314
314
|
if ( !advanceCoverProducts ) {
|
|
315
|
-
|
|
315
|
+
// Scope additional-product quantities to this billing group's assigned
|
|
316
|
+
// stores (group.stores). Without this the counts span the whole brand
|
|
317
|
+
// and every group's invoice would be billed for all stores.
|
|
318
|
+
const extraProducts = await getAdditionalProducts( group.clientId, group.stores );
|
|
316
319
|
for ( const ep of extraProducts ) {
|
|
320
|
+
// Skip additional products with no billable quantity — an Eyetest /
|
|
321
|
+
// Planogram / AI Manager / VMS line that resolved to 0 stores for this
|
|
322
|
+
// billing group should not appear as a ₹0 row on the invoice.
|
|
323
|
+
if ( ( Number( ep.quantity ) || 0 ) <= 0 ) {
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
317
326
|
products.push( {
|
|
318
327
|
productName: ep.productName,
|
|
319
328
|
period: 'fullmonth',
|
|
@@ -805,7 +814,7 @@ export async function invoiceDownload( req, res ) {
|
|
|
805
814
|
let invoiceDate = dayjs( invoiceInfo.billingDate ).format( 'DD/MM/YYYY' );
|
|
806
815
|
|
|
807
816
|
invoiceInfo.totalAmount = roundAmount( invoiceInfo.totalAmount, invoiceInfo.currency );
|
|
808
|
-
let AmountinWords =
|
|
817
|
+
let AmountinWords = amountToWords( invoiceInfo.totalAmount, invoiceInfo.currency );
|
|
809
818
|
let getgroup;
|
|
810
819
|
let days = getgroup?.paymentTerm ? getgroup?.paymentTerm : '30';
|
|
811
820
|
let dueDate = invoiceInfo?.dueDate ? dayjs( invoiceInfo?.dueDate ).format( 'DD/MM/YYYY' ) : dayjs().add( days, 'days' ).format( 'DD/MM/YYYY' );
|
|
@@ -1006,7 +1015,7 @@ async function buildInvoicePdfBuffer( invoiceId ) {
|
|
|
1006
1015
|
|
|
1007
1016
|
let invoiceDate = dayjs( invoiceInfo.billingDate ).format( 'DD/MM/YYYY' );
|
|
1008
1017
|
invoiceInfo.totalAmount = roundAmount( invoiceInfo.totalAmount, invoiceInfo.currency );
|
|
1009
|
-
let AmountinWords =
|
|
1018
|
+
let AmountinWords = amountToWords( invoiceInfo.totalAmount, invoiceInfo.currency );
|
|
1010
1019
|
let getgroup;
|
|
1011
1020
|
if ( invoiceInfo.groupId ) {
|
|
1012
1021
|
getgroup = await billingService.findOne( { _id: invoiceInfo.groupId } );
|
|
@@ -1218,6 +1227,32 @@ function inWords( num ) {
|
|
|
1218
1227
|
return str.toLowerCase().split( ' ' ).map( ( word ) => word.charAt( 0 ).toUpperCase() + word.slice( 1 ) ).join( ' ' );
|
|
1219
1228
|
}
|
|
1220
1229
|
|
|
1230
|
+
// Sub-unit (fraction) name per currency for the "Total In Words" cents part.
|
|
1231
|
+
// INR is whole-number only, so it has no fraction.
|
|
1232
|
+
const CURRENCY_FRACTION_WORDS = {
|
|
1233
|
+
dollar: 'Cents',
|
|
1234
|
+
singaporedollar: 'Cents',
|
|
1235
|
+
euro: 'Cents',
|
|
1236
|
+
aed: 'Fils',
|
|
1237
|
+
};
|
|
1238
|
+
|
|
1239
|
+
// Spell out an amount. INR -> whole number only. Other currencies keep 2
|
|
1240
|
+
// decimals, so the cents are spelled out too when non-zero
|
|
1241
|
+
// (e.g. 3191.10 -> "Three Thousand One Hundred And Ninety One and Ten Cents").
|
|
1242
|
+
function amountToWords( value, currency ) {
|
|
1243
|
+
const n = Number( value ) || 0;
|
|
1244
|
+
if ( currency === 'inr' ) {
|
|
1245
|
+
return inWords( Math.round( n ) );
|
|
1246
|
+
}
|
|
1247
|
+
const whole = Math.trunc( n );
|
|
1248
|
+
const cents = Math.round( ( n - whole ) * 100 );
|
|
1249
|
+
const fraction = CURRENCY_FRACTION_WORDS[currency] || 'Cents';
|
|
1250
|
+
if ( cents > 0 ) {
|
|
1251
|
+
return `${inWords( whole )} and ${inWords( cents )} ${fraction}`;
|
|
1252
|
+
}
|
|
1253
|
+
return inWords( whole );
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1221
1256
|
|
|
1222
1257
|
// Resolve which basepricing doc applies to a billing group. When the client
|
|
1223
1258
|
// has billingGroupWisePricing enabled AND a doc exists for this group, returns
|
|
@@ -3125,7 +3125,7 @@ export const invoiceDownload = async ( req, res ) => {
|
|
|
3125
3125
|
let dueDate = invoiceInfo?.dueDate ? dayjs( invoiceInfo?.dueDate ).format( 'DD/MM/YYYY' ) : dayjs().add( days, 'days' ).format( 'DD/MM/YYYY' );
|
|
3126
3126
|
|
|
3127
3127
|
invoiceInfo.totalAmount = invoiceInfo.totalAmount;
|
|
3128
|
-
let AmountinWords =
|
|
3128
|
+
let AmountinWords = amountToWords( invoiceInfo.totalAmount, invoicePdfCurrency );
|
|
3129
3129
|
invoiceData = {
|
|
3130
3130
|
...invoiceInfo._doc,
|
|
3131
3131
|
clientName: clientDetails.clientName,
|
|
@@ -3254,6 +3254,31 @@ function inWords( num ) {
|
|
|
3254
3254
|
return str.toLowerCase().split( ' ' ).map( ( word ) => word.charAt( 0 ).toUpperCase() + word.slice( 1 ) ).join( ' ' );
|
|
3255
3255
|
}
|
|
3256
3256
|
|
|
3257
|
+
// Sub-unit (fraction) name per currency for the "Total In Words" cents part.
|
|
3258
|
+
const CURRENCY_FRACTION_WORDS = {
|
|
3259
|
+
dollar: 'Cents',
|
|
3260
|
+
singaporedollar: 'Cents',
|
|
3261
|
+
euro: 'Cents',
|
|
3262
|
+
aed: 'Fils',
|
|
3263
|
+
};
|
|
3264
|
+
|
|
3265
|
+
// Spell out an amount. INR -> whole number only. Other currencies keep 2
|
|
3266
|
+
// decimals, so the cents are spelled out too when non-zero
|
|
3267
|
+
// (e.g. 383.04 -> "Three Hundred And Eighty Three and Four Cents").
|
|
3268
|
+
function amountToWords( value, currency ) {
|
|
3269
|
+
const n = Number( value ) || 0;
|
|
3270
|
+
if ( currency === 'inr' ) {
|
|
3271
|
+
return inWords( Math.round( n ) );
|
|
3272
|
+
}
|
|
3273
|
+
const whole = Math.trunc( n );
|
|
3274
|
+
const cents = Math.round( ( n - whole ) * 100 );
|
|
3275
|
+
const fraction = CURRENCY_FRACTION_WORDS[currency] || 'Cents';
|
|
3276
|
+
if ( cents > 0 ) {
|
|
3277
|
+
return `${inWords( whole )} and ${inWords( cents )} ${fraction}`;
|
|
3278
|
+
}
|
|
3279
|
+
return inWords( whole );
|
|
3280
|
+
}
|
|
3281
|
+
|
|
3257
3282
|
export const updateInvoiceStatus = async ( req, res ) => {
|
|
3258
3283
|
try {
|
|
3259
3284
|
if ( !req.params?.invoiceId ) {
|
|
@@ -4225,7 +4250,7 @@ export async function uploadClientDocument( req, res ) {
|
|
|
4225
4250
|
const clientId = String( req.body?.clientId || '' );
|
|
4226
4251
|
const documentName = String( req.body?.documentName || '' ).trim();
|
|
4227
4252
|
const expiryDateRaw = req.body?.expiryDate;
|
|
4228
|
-
const file = req.file; //
|
|
4253
|
+
const file = req.files?.file; // express-fileupload → req.files.file
|
|
4229
4254
|
|
|
4230
4255
|
if ( !clientId ) {
|
|
4231
4256
|
return res.sendError( 'clientId is required', 400 );
|
|
@@ -4257,7 +4282,7 @@ export async function uploadClientDocument( req, res ) {
|
|
|
4257
4282
|
Key: key,
|
|
4258
4283
|
fileName: fileName,
|
|
4259
4284
|
ContentType: file.mimetype,
|
|
4260
|
-
body: file.
|
|
4285
|
+
body: file.data,
|
|
4261
4286
|
} );
|
|
4262
4287
|
|
|
4263
4288
|
const storedPath = `${key}${fileName}`;
|
|
@@ -4307,6 +4332,86 @@ export async function getClientDocuments( req, res ) {
|
|
|
4307
4332
|
}
|
|
4308
4333
|
}
|
|
4309
4334
|
|
|
4335
|
+
// Update a client document (name / expiry, and optionally replace the PDF).
|
|
4336
|
+
export async function updateClientDocument( req, res ) {
|
|
4337
|
+
try {
|
|
4338
|
+
const clientId = String( req.body?.clientId || '' );
|
|
4339
|
+
const documentId = String( req.body?.documentId || '' );
|
|
4340
|
+
const documentName = String( req.body?.documentName || '' ).trim();
|
|
4341
|
+
const expiryDateRaw = req.body?.expiryDate;
|
|
4342
|
+
const file = req.files?.file; // optional PDF replacement
|
|
4343
|
+
|
|
4344
|
+
if ( !clientId ) {
|
|
4345
|
+
return res.sendError( 'clientId is required', 400 );
|
|
4346
|
+
}
|
|
4347
|
+
if ( !documentId ) {
|
|
4348
|
+
return res.sendError( 'documentId is required', 400 );
|
|
4349
|
+
}
|
|
4350
|
+
if ( !documentName ) {
|
|
4351
|
+
return res.sendError( 'documentName is required', 400 );
|
|
4352
|
+
}
|
|
4353
|
+
|
|
4354
|
+
const client = await paymentService.findOneClient(
|
|
4355
|
+
{ clientId, 'additionalDocuments._id': documentId },
|
|
4356
|
+
{ clientId: 1 },
|
|
4357
|
+
);
|
|
4358
|
+
if ( !client ) {
|
|
4359
|
+
return res.sendError( 'Document not found', 404 );
|
|
4360
|
+
}
|
|
4361
|
+
|
|
4362
|
+
const expiryDate = expiryDateRaw ? new Date( expiryDateRaw ) : null;
|
|
4363
|
+
const fields = {
|
|
4364
|
+
documentName: documentName,
|
|
4365
|
+
expiryDate: ( expiryDate && !isNaN( expiryDate.getTime() ) ) ? expiryDate : null,
|
|
4366
|
+
};
|
|
4367
|
+
|
|
4368
|
+
// Optional new file → upload and point the subdoc at the new path.
|
|
4369
|
+
if ( file ) {
|
|
4370
|
+
if ( file.mimetype !== 'application/pdf' ) {
|
|
4371
|
+
return res.sendError( 'Only PDF files are allowed', 400 );
|
|
4372
|
+
}
|
|
4373
|
+
const bucket = JSON.parse( process.env.BUCKET );
|
|
4374
|
+
const safeName = documentName.replace( /[^a-zA-Z0-9-_]/g, '_' );
|
|
4375
|
+
const fileName = `${safeName}_${Date.now()}.pdf`;
|
|
4376
|
+
const key = `${clientId}/documents/`;
|
|
4377
|
+
await fileUpload( {
|
|
4378
|
+
Bucket: bucket.assets,
|
|
4379
|
+
Key: key,
|
|
4380
|
+
fileName: fileName,
|
|
4381
|
+
ContentType: file.mimetype,
|
|
4382
|
+
body: file.data,
|
|
4383
|
+
} );
|
|
4384
|
+
fields.path = `${key}${fileName}`;
|
|
4385
|
+
}
|
|
4386
|
+
|
|
4387
|
+
await paymentService.updateAdditionalDocument( clientId, documentId, fields );
|
|
4388
|
+
return res.sendSuccess( { message: 'Document updated', documentName, expiryDate: fields.expiryDate } );
|
|
4389
|
+
} catch ( error ) {
|
|
4390
|
+
logger.error( { error: error, function: 'updateClientDocument' } );
|
|
4391
|
+
return res.sendError( error, 500 );
|
|
4392
|
+
}
|
|
4393
|
+
}
|
|
4394
|
+
|
|
4395
|
+
// Delete a client document (removes the subdoc; the S3 object is left as-is,
|
|
4396
|
+
// matching the app's existing replace-without-delete behavior).
|
|
4397
|
+
export async function deleteClientDocument( req, res ) {
|
|
4398
|
+
try {
|
|
4399
|
+
const clientId = String( req.body?.clientId || req.query?.clientId || '' );
|
|
4400
|
+
const documentId = String( req.params?.documentId || req.body?.documentId || '' );
|
|
4401
|
+
if ( !clientId ) {
|
|
4402
|
+
return res.sendError( 'clientId is required', 400 );
|
|
4403
|
+
}
|
|
4404
|
+
if ( !documentId ) {
|
|
4405
|
+
return res.sendError( 'documentId is required', 400 );
|
|
4406
|
+
}
|
|
4407
|
+
await paymentService.pullAdditionalDocument( clientId, documentId );
|
|
4408
|
+
return res.sendSuccess( { message: 'Document deleted' } );
|
|
4409
|
+
} catch ( error ) {
|
|
4410
|
+
logger.error( { error: error, function: 'deleteClientDocument' } );
|
|
4411
|
+
return res.sendError( error, 500 );
|
|
4412
|
+
}
|
|
4413
|
+
}
|
|
4414
|
+
|
|
4310
4415
|
// Toggle billing-group-wise pricing on the client. When enabled, pricing is
|
|
4311
4416
|
// maintained per billing group (separate basepricing docs keyed by groupId).
|
|
4312
4417
|
export async function setBillingGroupWisePricing( req, res ) {
|
|
@@ -57,10 +57,10 @@ export async function createPurchaseOrder( req, res ) {
|
|
|
57
57
|
return res.sendError( 'A purchase order with this number already exists', 409 );
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
// Optional PO PDF upload (
|
|
61
|
-
// brand document upload. PDF only.
|
|
60
|
+
// Optional PO PDF upload (express-fileupload → req.files.file) → assets
|
|
61
|
+
// bucket, same as brand document upload. PDF only.
|
|
62
62
|
let pdfPath = '';
|
|
63
|
-
const file = req.file;
|
|
63
|
+
const file = req.files?.file;
|
|
64
64
|
if ( file ) {
|
|
65
65
|
if ( file.mimetype !== 'application/pdf' ) {
|
|
66
66
|
return res.sendError( 'Only PDF files are allowed', 400 );
|
|
@@ -74,7 +74,7 @@ export async function createPurchaseOrder( req, res ) {
|
|
|
74
74
|
Key: key,
|
|
75
75
|
fileName: fileName,
|
|
76
76
|
ContentType: file.mimetype,
|
|
77
|
-
body: file.
|
|
77
|
+
body: file.data,
|
|
78
78
|
} );
|
|
79
79
|
pdfPath = `${key}${fileName}`;
|
|
80
80
|
console.log( '🚀 ~ createPurchaseOrder ~ result:', result );
|
|
@@ -132,6 +132,114 @@ export async function getPurchaseOrders( req, res ) {
|
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
// Update a purchase order. Editable: companyName, purchaseOrderNumber,
|
|
136
|
+
// totalAmount, date and (optionally) the PO PDF. Changing totalAmount keeps the
|
|
137
|
+
// already-used amount intact and recomputes remainingAmount + status.
|
|
138
|
+
export async function updatePurchaseOrder( req, res ) {
|
|
139
|
+
try {
|
|
140
|
+
const id = String( req.params?.id || req.body?._id || '' );
|
|
141
|
+
if ( !id ) {
|
|
142
|
+
return res.sendError( 'purchase order id is required', 400 );
|
|
143
|
+
}
|
|
144
|
+
const existing = await purchaseOrderService.findOne( { _id: id } );
|
|
145
|
+
if ( !existing ) {
|
|
146
|
+
return res.sendError( 'Purchase order not found', 404 );
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const clientId = existing.clientId;
|
|
150
|
+
const companyName = String( req.body?.companyName || '' ).trim();
|
|
151
|
+
const purchaseOrderNumber = String( req.body?.purchaseOrderNumber || '' ).trim();
|
|
152
|
+
const totalAmount = Number( req.body?.totalAmount );
|
|
153
|
+
const date = req.body?.date ? new Date( req.body.date ) : existing.date;
|
|
154
|
+
|
|
155
|
+
if ( !purchaseOrderNumber ) {
|
|
156
|
+
return res.sendError( 'purchaseOrderNumber is required', 400 );
|
|
157
|
+
}
|
|
158
|
+
if ( !( totalAmount > 0 ) ) {
|
|
159
|
+
return res.sendError( 'totalAmount must be greater than 0', 400 );
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Reject a number that already belongs to a DIFFERENT PO for this client.
|
|
163
|
+
if ( purchaseOrderNumber !== existing.purchaseOrderNumber ) {
|
|
164
|
+
const clash = await purchaseOrderService.findOne( { clientId, purchaseOrderNumber } );
|
|
165
|
+
if ( clash && String( clash._id ) !== id ) {
|
|
166
|
+
return res.sendError( 'A purchase order with this number already exists', 409 );
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Preserve the amount already consumed by mapped invoices; the new total
|
|
171
|
+
// can't drop below it.
|
|
172
|
+
const usedAmount = Number( existing.totalAmount ) - Number( existing.remainingAmount );
|
|
173
|
+
if ( totalAmount < usedAmount ) {
|
|
174
|
+
return res.sendError(
|
|
175
|
+
`totalAmount cannot be less than the amount already used (${usedAmount})`,
|
|
176
|
+
400,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
const remainingAmount = totalAmount - usedAmount;
|
|
180
|
+
|
|
181
|
+
const update = {
|
|
182
|
+
companyName,
|
|
183
|
+
purchaseOrderNumber,
|
|
184
|
+
totalAmount,
|
|
185
|
+
remainingAmount,
|
|
186
|
+
date,
|
|
187
|
+
status: statusFor( totalAmount, remainingAmount ),
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// Optional PDF replacement (express-fileupload → req.files.file). PDF only.
|
|
191
|
+
const file = req.files?.file;
|
|
192
|
+
if ( file ) {
|
|
193
|
+
if ( file.mimetype !== 'application/pdf' ) {
|
|
194
|
+
return res.sendError( 'Only PDF files are allowed', 400 );
|
|
195
|
+
}
|
|
196
|
+
const bucket = JSON.parse( process.env.BUCKET );
|
|
197
|
+
const safeName = purchaseOrderNumber.replace( /[^a-zA-Z0-9-_]/g, '_' );
|
|
198
|
+
const fileName = `${safeName}_${Date.now()}.pdf`;
|
|
199
|
+
const key = `${clientId}/purchase-orders/`;
|
|
200
|
+
await fileUpload( {
|
|
201
|
+
Bucket: bucket.assets,
|
|
202
|
+
Key: key,
|
|
203
|
+
fileName: fileName,
|
|
204
|
+
ContentType: file.mimetype,
|
|
205
|
+
body: file.data,
|
|
206
|
+
} );
|
|
207
|
+
update.pdfPath = `${key}${fileName}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
await purchaseOrderService.updateOne( { _id: id }, update );
|
|
211
|
+
logPO( req, clientId, 'purchaseOrderUpdated', [ `PO ${purchaseOrderNumber} updated (total ${totalAmount})` ] );
|
|
212
|
+
return res.sendSuccess( { message: 'Purchase order updated' } );
|
|
213
|
+
} catch ( error ) {
|
|
214
|
+
logger.error( { error: error, function: 'updatePurchaseOrder' } );
|
|
215
|
+
return res.sendError( error, 500 );
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Delete a purchase order. Blocked when invoices are already mapped to it
|
|
220
|
+
// (deleting would orphan those invoice → PO links).
|
|
221
|
+
export async function deletePurchaseOrder( req, res ) {
|
|
222
|
+
try {
|
|
223
|
+
const id = String( req.params?.id || '' );
|
|
224
|
+
if ( !id ) {
|
|
225
|
+
return res.sendError( 'purchase order id is required', 400 );
|
|
226
|
+
}
|
|
227
|
+
const existing = await purchaseOrderService.findOne( { _id: id } );
|
|
228
|
+
if ( !existing ) {
|
|
229
|
+
return res.sendError( 'Purchase order not found', 404 );
|
|
230
|
+
}
|
|
231
|
+
if ( ( existing.usage || [] ).length > 0 ) {
|
|
232
|
+
return res.sendError( 'Cannot delete a purchase order with mapped invoices', 409 );
|
|
233
|
+
}
|
|
234
|
+
await purchaseOrderService.deleteRecord( { _id: id } );
|
|
235
|
+
logPO( req, existing.clientId, 'purchaseOrderDeleted', [ `PO ${existing.purchaseOrderNumber} deleted` ] );
|
|
236
|
+
return res.sendSuccess( { message: 'Purchase order deleted' } );
|
|
237
|
+
} catch ( error ) {
|
|
238
|
+
logger.error( { error: error, function: 'deletePurchaseOrder' } );
|
|
239
|
+
return res.sendError( error, 500 );
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
135
243
|
// Map an invoice to a PO: deduct the invoice amount from the PO's remaining
|
|
136
244
|
// balance, record the usage, update status, and log it. Shared with invoice
|
|
137
245
|
// generation/edit so the deduction happens wherever an invoice gets a PO.
|
package/src/hbs/estimatePdf.hbs
CHANGED
package/src/hbs/invoicePdf.hbs
CHANGED
|
@@ -881,6 +881,10 @@
|
|
|
881
881
|
align-self: stretch;
|
|
882
882
|
flex-shrink: 0;
|
|
883
883
|
position: relative;
|
|
884
|
+
/* Keep the whole totals block (Sub Total ... Total In Words) on a
|
|
885
|
+
single page so the amount-in-words can't be split across pages. */
|
|
886
|
+
break-inside: avoid;
|
|
887
|
+
page-break-inside: avoid;
|
|
884
888
|
}
|
|
885
889
|
|
|
886
890
|
.frame-2394 {
|
|
@@ -1011,6 +1015,8 @@
|
|
|
1011
1015
|
justify-content: flex-start;
|
|
1012
1016
|
flex-shrink: 0;
|
|
1013
1017
|
position: relative;
|
|
1018
|
+
break-inside: avoid;
|
|
1019
|
+
page-break-inside: avoid;
|
|
1014
1020
|
}
|
|
1015
1021
|
|
|
1016
1022
|
.text13 {
|
package/src/hbs/invoicePdf1.hbs
CHANGED
|
@@ -844,6 +844,10 @@
|
|
|
844
844
|
align-self: stretch;
|
|
845
845
|
flex-shrink: 0;
|
|
846
846
|
position: relative;
|
|
847
|
+
/* Keep the whole totals block (Sub Total ... Total In Words) on a
|
|
848
|
+
single page so the amount-in-words can't be split across pages. */
|
|
849
|
+
break-inside: avoid;
|
|
850
|
+
page-break-inside: avoid;
|
|
847
851
|
}
|
|
848
852
|
|
|
849
853
|
.frame-2394 {
|
|
@@ -974,6 +978,8 @@
|
|
|
974
978
|
justify-content: flex-start;
|
|
975
979
|
flex-shrink: 0;
|
|
976
980
|
position: relative;
|
|
981
|
+
break-inside: avoid;
|
|
982
|
+
page-break-inside: avoid;
|
|
977
983
|
}
|
|
978
984
|
|
|
979
985
|
.text13 {
|
|
@@ -652,6 +652,7 @@ img {
|
|
|
652
652
|
</tbody></table>
|
|
653
653
|
</td>
|
|
654
654
|
</tr>
|
|
655
|
+
{{#gtZero tax.0.value}}
|
|
655
656
|
<tr>
|
|
656
657
|
<td height="12" style="height:12px; min-height:12px; line-height:12px;"></td>
|
|
657
658
|
</tr>
|
|
@@ -673,6 +674,7 @@ img {
|
|
|
673
674
|
<table width="100%" border="0" cellpadding="0" cellspacing="0">
|
|
674
675
|
<tbody>
|
|
675
676
|
{{#each tax }}
|
|
677
|
+
{{#gtZero value}}
|
|
676
678
|
<tr>
|
|
677
679
|
<td>
|
|
678
680
|
<div style="line-height:24px;text-align:right;">
|
|
@@ -680,6 +682,7 @@ img {
|
|
|
680
682
|
</div>
|
|
681
683
|
</td>
|
|
682
684
|
</tr>
|
|
685
|
+
{{/gtZero}}
|
|
683
686
|
{{/each}}
|
|
684
687
|
|
|
685
688
|
|
|
@@ -689,6 +692,7 @@ img {
|
|
|
689
692
|
</tbody></table>
|
|
690
693
|
</td>
|
|
691
694
|
</tr>
|
|
695
|
+
{{/gtZero}}
|
|
692
696
|
<tr>
|
|
693
697
|
<td height="12" style="height:12px; min-height:12px; line-height:12px;"></td>
|
|
694
698
|
</tr>
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
|
-
import multer from 'multer';
|
|
3
2
|
export const billingRouter = express.Router();
|
|
4
|
-
const poUpload = multer( { storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } } );
|
|
5
3
|
import { accessVerification, isAllowedSessionHandler, validate } from 'tango-app-api-middleware';
|
|
6
4
|
import { getPaymentReminder, savePaymentReminder } from '../controllers/paymentReminder.controller.js';
|
|
7
5
|
import { triggerPaymentReminders } from '../controllers/paymentReminderTrigger.controller.js';
|
|
8
|
-
import { createPurchaseOrder, getPurchaseOrders } from '../controllers/purchaseOrder.controller.js';
|
|
6
|
+
import { createPurchaseOrder, getPurchaseOrders, updatePurchaseOrder, deletePurchaseOrder } from '../controllers/purchaseOrder.controller.js';
|
|
9
7
|
import { createBillingGroup, deleteBillingGroup, getAllBillingGroups, getBillingGroups, getClientProducts, getInvoices, getLeadProducts, getBaseProducts, onetimePayment, subscribedStoreList, updateBillingGroup, gstinLookup } from '../controllers/billing.controllers.js';
|
|
10
8
|
import { billingGroupSchema, clientProductsValid, createBillingGroupsSchema, deleteBillingGroupsSchema, getBillingGroupsSchema, getInvoiceSchema, leadProductsValid, onetimeFeeValid, subscribedStoreListSchema, updateBillingGroupsSchema } from '../dtos/validation.dtos.js';
|
|
11
9
|
|
|
@@ -42,11 +40,12 @@ billingRouter.post( '/payment-reminder', isAllowedSessionHandler, accessVerifica
|
|
|
42
40
|
|
|
43
41
|
// Purchase Orders (brand-view Purchase Order tab).
|
|
44
42
|
billingRouter.get( '/purchase-orders', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isAdd' ] } ] } ), getPurchaseOrders );
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
billingRouter.
|
|
43
|
+
// The multipart body is parsed app-wide by express-fileupload (see app.js), so
|
|
44
|
+
// the uploaded PDF is on req.files.file and text fields on req.body regardless
|
|
45
|
+
// of middleware order — no per-route upload middleware needed.
|
|
46
|
+
billingRouter.post( '/purchase-orders', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isAdd' ] } ] } ), createPurchaseOrder );
|
|
47
|
+
billingRouter.put( '/purchase-orders/:id', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isAdd' ] } ] } ), updatePurchaseOrder );
|
|
48
|
+
billingRouter.delete( '/purchase-orders/:id', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isAdd' ] } ] } ), deletePurchaseOrder );
|
|
50
49
|
|
|
51
50
|
// Cron-triggered: sends the configured payment reminder emails. Unauthenticated
|
|
52
51
|
// like the other cron endpoints; protect at the network / scheduler layer.
|
|
@@ -1,16 +1,11 @@
|
|
|
1
1
|
|
|
2
2
|
import express from 'express';
|
|
3
|
-
import multer from 'multer';
|
|
4
3
|
import * as paymentController from '../controllers/paymentSubscription.controllers.js';
|
|
5
4
|
import { validate, isAllowedSessionHandler, accessVerification } from 'tango-app-api-middleware';
|
|
6
5
|
import * as validationDtos from '../dtos/validation.dtos.js';
|
|
7
6
|
import { validateClient } from '../utils/validations/client.validation.js';
|
|
8
7
|
export const paymentSubscriptionRouter = express.Router();
|
|
9
8
|
|
|
10
|
-
// PDF document upload (Plans & Subscription > Documents). In-memory storage,
|
|
11
|
-
// 10MB cap; the controller streams the buffer to the assets S3 bucket.
|
|
12
|
-
const documentUpload = multer( { storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } } );
|
|
13
|
-
|
|
14
9
|
paymentSubscriptionRouter.post( '/addBilling', isAllowedSessionHandler, accessVerification( {
|
|
15
10
|
userType: [ 'tango', 'client' ], access: [
|
|
16
11
|
{ featureName: 'Global', name: 'Subscription', permissions: [ 'isAdd' ] },
|
|
@@ -151,9 +146,12 @@ paymentSubscriptionRouter.put( '/pushNotification/update/:notificationId', isAll
|
|
|
151
146
|
paymentSubscriptionRouter.post( '/updateRemind/:notificationId', isAllowedSessionHandler, validate( validationDtos.validateId ), paymentController.updateRemind );
|
|
152
147
|
paymentSubscriptionRouter.post( '/createDefaultbillings', paymentController.createDefaultbillings );
|
|
153
148
|
|
|
154
|
-
// Brand documents (Plans & Subscription > Documents accordion).
|
|
155
|
-
|
|
149
|
+
// Brand documents (Plans & Subscription > Documents accordion). The multipart
|
|
150
|
+
// body is parsed app-wide by express-fileupload (see app.js) → req.files.file.
|
|
151
|
+
paymentSubscriptionRouter.post( '/client-document/upload', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Subscription', permissions: [ 'isEdit' ] } ] } ), paymentController.uploadClientDocument );
|
|
156
152
|
paymentSubscriptionRouter.get( '/client-document/list', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Subscription', permissions: [] } ] } ), paymentController.getClientDocuments );
|
|
153
|
+
paymentSubscriptionRouter.put( '/client-document/update', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Subscription', permissions: [ 'isEdit' ] } ] } ), paymentController.updateClientDocument );
|
|
154
|
+
paymentSubscriptionRouter.delete( '/client-document/:documentId', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Subscription', permissions: [ 'isEdit' ] } ] } ), paymentController.deleteClientDocument );
|
|
157
155
|
|
|
158
156
|
// Toggle billing-group-wise pricing for a client (tango only, edit perm).
|
|
159
157
|
paymentSubscriptionRouter.post( '/billingGroupWisePricing', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Subscription', permissions: [ 'isEdit' ] } ] } ), paymentController.setBillingGroupWisePricing );
|
|
@@ -21,6 +21,27 @@ export const pushAdditionalDocument = ( query = {}, document = {} ) => {
|
|
|
21
21
|
return model.clientModel.updateOne( query, { $push: { additionalDocuments: document } } );
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
+
// Update fields of a single additionalDocuments subdoc (matched by _id). `fields`
|
|
25
|
+
// keys are the subdoc field names, e.g. { documentName, expiryDate, path }.
|
|
26
|
+
export const updateAdditionalDocument = ( clientId, documentId, fields = {} ) => {
|
|
27
|
+
const set = {};
|
|
28
|
+
for ( const [ k, v ] of Object.entries( fields ) ) {
|
|
29
|
+
set[`additionalDocuments.$.${k}`] = v;
|
|
30
|
+
}
|
|
31
|
+
return model.clientModel.updateOne(
|
|
32
|
+
{ clientId, 'additionalDocuments._id': documentId },
|
|
33
|
+
{ $set: set },
|
|
34
|
+
);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Remove a single additionalDocuments subdoc (matched by _id).
|
|
38
|
+
export const pullAdditionalDocument = ( clientId, documentId ) => {
|
|
39
|
+
return model.clientModel.updateOne(
|
|
40
|
+
{ clientId },
|
|
41
|
+
{ $pull: { additionalDocuments: { _id: documentId } } },
|
|
42
|
+
);
|
|
43
|
+
};
|
|
44
|
+
|
|
24
45
|
export const aggregate = ( query = [] ) => {
|
|
25
46
|
return model.clientModel.aggregate( query );
|
|
26
47
|
};
|
|
@@ -16,6 +16,10 @@ export const updateOne = ( query, record ) => {
|
|
|
16
16
|
return model.purchaseOrderModel.updateOne( query, { $set: record } );
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
+
export const deleteRecord = ( query ) => {
|
|
20
|
+
return model.purchaseOrderModel.deleteOne( query );
|
|
21
|
+
};
|
|
22
|
+
|
|
19
23
|
export const aggregate = ( query = [] ) => {
|
|
20
24
|
return model.purchaseOrderModel.aggregate( query );
|
|
21
25
|
};
|