tango-app-api-payment-subscription 3.5.27 → 3.5.28
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 +1 -1
- package/scripts/test-advanceNewStore.mjs +70 -0
- package/scripts/test-outstandingLines.mjs +36 -0
- package/src/controllers/advanceNewStoreExport.controller.js +58 -0
- package/src/controllers/invoice.controller.js +40 -0
- package/src/routes/billing.routes.js +3 -0
- package/src/services/outstandingStores.service.js +125 -0
- package/src/utils/advanceNewStore.util.js +89 -0
package/package.json
CHANGED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import assert from 'node:assert';
|
|
2
|
+
import dayjs from 'dayjs';
|
|
3
|
+
import {
|
|
4
|
+
previousPeriodMonths, isNewStore, proratedAmount,
|
|
5
|
+
standardPriceFor, buildStepTiersByProduct, stepPriceForPosition,
|
|
6
|
+
} from '../src/utils/advanceNewStore.util.js';
|
|
7
|
+
|
|
8
|
+
// previousPeriodMonths: quarterly clicked in July 2026 → Apr, May, Jun 2026
|
|
9
|
+
{
|
|
10
|
+
const today = dayjs('2026-07-15');
|
|
11
|
+
const r = previousPeriodMonths('quarterly', today);
|
|
12
|
+
assert.deepStrictEqual(r.months.map((m) => m.label), ['Apr 2026', 'May 2026', 'Jun 2026']);
|
|
13
|
+
assert.strictEqual(r.months[0].daysInMonth, 30); // April
|
|
14
|
+
assert.strictEqual(dayjs(r.periodStart).format('YYYY-MM-DD'), '2026-04-01');
|
|
15
|
+
assert.strictEqual(dayjs(r.periodEnd).format('YYYY-MM-DD'), '2026-06-30');
|
|
16
|
+
}
|
|
17
|
+
// halfyearly → last 6 completed months
|
|
18
|
+
{
|
|
19
|
+
const r = previousPeriodMonths('halfyearly', dayjs('2026-07-15'));
|
|
20
|
+
assert.deepStrictEqual(r.months.map((m) => m.label),
|
|
21
|
+
['Jan 2026', 'Feb 2026', 'Mar 2026', 'Apr 2026', 'May 2026', 'Jun 2026']);
|
|
22
|
+
}
|
|
23
|
+
// yearly, year-boundary: clicked Feb 2026 → prior 12 months Feb 2025 .. Jan 2026
|
|
24
|
+
{
|
|
25
|
+
const r = previousPeriodMonths('yearly', dayjs('2026-02-10'));
|
|
26
|
+
assert.strictEqual(r.months[0].label, 'Feb 2025');
|
|
27
|
+
assert.strictEqual(r.months[r.months.length - 1].label, 'Jan 2026');
|
|
28
|
+
assert.strictEqual(r.months.length, 12);
|
|
29
|
+
}
|
|
30
|
+
// quarterly across year boundary: clicked Feb 2026 → Nov 2025, Dec 2025, Jan 2026
|
|
31
|
+
{
|
|
32
|
+
const r = previousPeriodMonths('quarterly', dayjs('2026-02-10'));
|
|
33
|
+
assert.deepStrictEqual(r.months.map((m) => m.label), ['Nov 2025', 'Dec 2025', 'Jan 2026']);
|
|
34
|
+
}
|
|
35
|
+
// isNewStore inclusivity
|
|
36
|
+
{
|
|
37
|
+
const start = dayjs('2026-04-01').toDate();
|
|
38
|
+
const end = dayjs('2026-06-30').toDate();
|
|
39
|
+
assert.strictEqual(isNewStore(dayjs('2026-04-01').toDate(), start, end), true);
|
|
40
|
+
assert.strictEqual(isNewStore(dayjs('2026-06-30').toDate(), start, end), true);
|
|
41
|
+
assert.strictEqual(isNewStore(dayjs('2026-03-31').toDate(), start, end), false);
|
|
42
|
+
assert.strictEqual(isNewStore(dayjs('2026-07-01').toDate(), start, end), false);
|
|
43
|
+
assert.strictEqual(isNewStore(null, start, end), false);
|
|
44
|
+
assert.strictEqual(isNewStore(undefined, start, end), false);
|
|
45
|
+
}
|
|
46
|
+
// proratedAmount matches annexure math: 3000/31*15 = 1451.6 → 1452
|
|
47
|
+
{
|
|
48
|
+
assert.strictEqual(proratedAmount(3000, 31, 15), 1452);
|
|
49
|
+
assert.strictEqual(proratedAmount(3000, 30, 30), 3000); // full month
|
|
50
|
+
assert.strictEqual(proratedAmount(0, 31, 15), 0);
|
|
51
|
+
}
|
|
52
|
+
// standardPriceFor
|
|
53
|
+
{
|
|
54
|
+
const std = [{ productName: 'Tango Traffic', negotiatePrice: 1499, basePrice: 1570 }];
|
|
55
|
+
assert.strictEqual(standardPriceFor(std, 'Tango Traffic'), 1499);
|
|
56
|
+
assert.strictEqual(standardPriceFor(std, 'Tango Zone'), 0);
|
|
57
|
+
assert.strictEqual(standardPriceFor([{ productName: 'X', basePrice: 500 }], 'X'), 500); // falls back to basePrice
|
|
58
|
+
}
|
|
59
|
+
// step tiers: position resolution + beyond-range fallback to last tier
|
|
60
|
+
{
|
|
61
|
+
const tiers = buildStepTiersByProduct([
|
|
62
|
+
{ productName: 'Tango Traffic', storeRange: '101-200', negotiatePrice: 900 },
|
|
63
|
+
{ productName: 'Tango Traffic', storeRange: '1-100', negotiatePrice: 1000 },
|
|
64
|
+
]);
|
|
65
|
+
assert.strictEqual(stepPriceForPosition(tiers, 'Tango Traffic', 50), 1000); // tier 1
|
|
66
|
+
assert.strictEqual(stepPriceForPosition(tiers, 'Tango Traffic', 150), 900); // tier 2
|
|
67
|
+
assert.strictEqual(stepPriceForPosition(tiers, 'Tango Traffic', 250), 900); // beyond → last tier
|
|
68
|
+
assert.strictEqual(stepPriceForPosition(tiers, 'Missing', 1), 0);
|
|
69
|
+
}
|
|
70
|
+
console.log('ALL ADVANCE-NEWSTORE UTIL TESTS PASSED');
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import assert from 'node:assert';
|
|
2
|
+
import { groupOutstandingRowsToLines } from '../src/services/outstandingStores.service.js';
|
|
3
|
+
|
|
4
|
+
// Rows for same product+month sum into one line; storeCount counts the rows,
|
|
5
|
+
// price carries the per-store unit rate.
|
|
6
|
+
{
|
|
7
|
+
const rows = [
|
|
8
|
+
{ product: 'Tango Traffic', month: 'Apr 2026', amount: 1000, unitPrice: 1499 },
|
|
9
|
+
{ product: 'Tango Traffic', month: 'Apr 2026', amount: 500, unitPrice: 1499 },
|
|
10
|
+
{ product: 'Tango Traffic', month: 'May 2026', amount: 700, unitPrice: 1499 },
|
|
11
|
+
{ product: 'Tango Zone', month: 'Apr 2026', amount: 300, unitPrice: 350 },
|
|
12
|
+
];
|
|
13
|
+
const lines = groupOutstandingRowsToLines(rows);
|
|
14
|
+
// 3 distinct (product, month) groups.
|
|
15
|
+
assert.strictEqual(lines.length, 3);
|
|
16
|
+
const traApril = lines.find((l) => l.productName === 'Tango Traffic' && l.month === 'Apr 2026');
|
|
17
|
+
assert.strictEqual(traApril.amount, 1500);
|
|
18
|
+
assert.strictEqual(traApril.storeCount, 2); // two rows merged
|
|
19
|
+
assert.strictEqual(traApril.price, 750); // AVERAGE per store: 1500 / 2
|
|
20
|
+
assert.strictEqual(traApril.description, 'Outstanding Apr 2026');
|
|
21
|
+
assert.strictEqual(traApril.HsnNumber, '998314');
|
|
22
|
+
const traMay = lines.find((l) => l.productName === 'Tango Traffic' && l.month === 'May 2026');
|
|
23
|
+
assert.strictEqual(traMay.amount, 700);
|
|
24
|
+
assert.strictEqual(traMay.storeCount, 1);
|
|
25
|
+
assert.strictEqual(traMay.price, 700); // single store: avg == amount
|
|
26
|
+
const zoneApr = lines.find((l) => l.productName === 'Tango Zone' && l.month === 'Apr 2026');
|
|
27
|
+
assert.strictEqual(zoneApr.amount, 300);
|
|
28
|
+
assert.strictEqual(zoneApr.storeCount, 1);
|
|
29
|
+
assert.strictEqual(zoneApr.price, 300); // single store: avg == amount
|
|
30
|
+
}
|
|
31
|
+
// Empty input → [].
|
|
32
|
+
{
|
|
33
|
+
assert.deepStrictEqual(groupOutstandingRowsToLines([]), []);
|
|
34
|
+
assert.deepStrictEqual(groupOutstandingRowsToLines(null), []);
|
|
35
|
+
}
|
|
36
|
+
console.log('ALL OUTSTANDING-LINES TESTS PASSED');
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import * as billingService from '../services/billing.service.js';
|
|
2
|
+
import dayjs from 'dayjs';
|
|
3
|
+
import { logger } from 'tango-app-api-middleware';
|
|
4
|
+
import ExcelJS from 'exceljs';
|
|
5
|
+
import { computeOutstandingRows } from '../services/outstandingStores.service.js';
|
|
6
|
+
import { previousPeriodMonths } from '../utils/advanceNewStore.util.js';
|
|
7
|
+
|
|
8
|
+
const COLUMNS = [
|
|
9
|
+
{ header: 'Store Name', key: 'storeName', width: 28 },
|
|
10
|
+
{ header: 'Store ID', key: 'storeId', width: 16 },
|
|
11
|
+
{ header: 'Product', key: 'product', width: 20 },
|
|
12
|
+
{ header: 'Month', key: 'month', width: 14 },
|
|
13
|
+
{ header: 'Working Days', key: 'workingDays', width: 14 },
|
|
14
|
+
{ header: 'Deployment Date', key: 'deploymentDate', width: 16 },
|
|
15
|
+
{ header: 'Unit Price', key: 'unitPrice', width: 14 },
|
|
16
|
+
{ header: 'Amount', key: 'amount', width: 14 },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
function isPeriodAllowed( advancePeriod ) {
|
|
20
|
+
return [ 'quarterly', 'halfyearly', 'yearly' ].includes( advancePeriod );
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function advanceNewStoreExport( req, res ) {
|
|
24
|
+
try {
|
|
25
|
+
const groupId = req.query?.groupId;
|
|
26
|
+
if ( !groupId ) {
|
|
27
|
+
return res.sendError( 'groupId query parameter is required', 400 );
|
|
28
|
+
}
|
|
29
|
+
const group = await billingService.findOne( { _id: groupId } );
|
|
30
|
+
if ( !group ) {
|
|
31
|
+
return res.sendError( 'Billing group not found', 404 );
|
|
32
|
+
}
|
|
33
|
+
if ( !group.advanceInvoice || !isPeriodAllowed( group.advancePeriod ) ) {
|
|
34
|
+
return res.sendError( 'Group is not on quarterly/halfyearly/yearly advance invoicing', 400 );
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const { months } = previousPeriodMonths( group.advancePeriod, dayjs() );
|
|
38
|
+
const rows = await computeOutstandingRows( group, dayjs() );
|
|
39
|
+
|
|
40
|
+
const wb = new ExcelJS.Workbook();
|
|
41
|
+
const ws = wb.addWorksheet( 'New Stores' );
|
|
42
|
+
ws.columns = COLUMNS;
|
|
43
|
+
ws.getRow( 1 ).font = { bold: true };
|
|
44
|
+
for ( const r of rows ) {
|
|
45
|
+
ws.addRow( r );
|
|
46
|
+
}
|
|
47
|
+
const buf = await wb.xlsx.writeBuffer();
|
|
48
|
+
|
|
49
|
+
const periodLabel = months.length ? `${months[0].label}_to_${months[months.length - 1].label}`.replace( / /g, '' ) : 'period';
|
|
50
|
+
const filename = `new-stores-${( group.groupName || 'group' ).replace( /[^a-z0-9]+/gi, '-' )}-${periodLabel}.xlsx`;
|
|
51
|
+
res.set( 'Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' );
|
|
52
|
+
res.set( 'Content-Disposition', `attachment; filename="${filename}"` );
|
|
53
|
+
return res.send( buf );
|
|
54
|
+
} catch ( error ) {
|
|
55
|
+
logger.error( { error, function: 'advanceNewStoreExport' } );
|
|
56
|
+
return res.sendError( error, 500 );
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -20,6 +20,7 @@ import * as assignedStoreService from '../services/assignedStore.service.js';
|
|
|
20
20
|
import * as bankTransactionService from '../services/bankTransaction.service.js';
|
|
21
21
|
import { getInrRate, getAdditionalProducts } from './brandsBilling.controller.js';
|
|
22
22
|
import { applyInvoiceToPurchaseOrder } from './purchaseOrder.controller.js';
|
|
23
|
+
import { computeOutstandingRows, groupOutstandingRowsToLines } from '../services/outstandingStores.service.js';
|
|
23
24
|
|
|
24
25
|
// Pulls CSM + Finance head emails (stored under applicationDefault
|
|
25
26
|
// type=invoice, subType=heads) AND the per-client CSMs assigned via
|
|
@@ -418,6 +419,45 @@ export async function createInvoice( req, res ) {
|
|
|
418
419
|
logger.error( { error: oneTimeErr, function: 'createInvoice.oneTimeFee', clientId: group.clientId } );
|
|
419
420
|
}
|
|
420
421
|
|
|
422
|
+
// Outstanding stores: when this group was flagged in the popup, append the
|
|
423
|
+
// previous-period new-store charges as grouped line items (one per product
|
|
424
|
+
// per month, tagged "Outstanding {Month}") so they're billed on this same
|
|
425
|
+
// invoice and taxed with the rest. Same dataset as the download export.
|
|
426
|
+
const outstandingGroupIds = ( req.body.outstandingGroupIds || [] ).map( String );
|
|
427
|
+
// Only append on a genuine ADVANCE invoice run (isAdvance). When an
|
|
428
|
+
// advance already covers this month we generate a normal monthly REUSE
|
|
429
|
+
// invoice (advanceCoverProducts set, isAdvance false) — appending here
|
|
430
|
+
// too would double-bill the same outstanding stores.
|
|
431
|
+
if ( isAdvance &&
|
|
432
|
+
outstandingGroupIds.includes( String( group._id ) ) &&
|
|
433
|
+
[ 'quarterly', 'halfyearly', 'yearly' ].includes( group.advancePeriod ) ) {
|
|
434
|
+
try {
|
|
435
|
+
const outRows = await computeOutstandingRows( group, dayjs() );
|
|
436
|
+
const outLines = groupOutstandingRowsToLines( outRows );
|
|
437
|
+
for ( const line of outLines ) {
|
|
438
|
+
products.push( {
|
|
439
|
+
productName: line.productName,
|
|
440
|
+
description: line.description,
|
|
441
|
+
month: line.month,
|
|
442
|
+
amount: line.amount,
|
|
443
|
+
// Per-store rate + outstanding store count so the invoice's Rate
|
|
444
|
+
// and Quantity/Stores columns render (amount stays authoritative —
|
|
445
|
+
// it's the summed prorated total, not price × count).
|
|
446
|
+
price: line.price,
|
|
447
|
+
storeCount: line.storeCount,
|
|
448
|
+
HsnNumber: line.HsnNumber,
|
|
449
|
+
totalZoneCount: 0,
|
|
450
|
+
totalTrafficCameraCount: 0,
|
|
451
|
+
totalZoneCameraCount: 0,
|
|
452
|
+
outstanding: true,
|
|
453
|
+
} );
|
|
454
|
+
}
|
|
455
|
+
} catch ( outErr ) {
|
|
456
|
+
logger.error( { error: outErr, function: 'createInvoice.outstanding', groupId: group._id } );
|
|
457
|
+
// Non-fatal: never block invoice generation over the outstanding add-on.
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
421
461
|
let amount = products.reduce( ( sum, product ) => sum + product.amount, 0 );
|
|
422
462
|
let taxList = [];
|
|
423
463
|
let totalAmount = 0;
|
|
@@ -6,12 +6,15 @@ import { triggerPaymentReminders } from '../controllers/paymentReminderTrigger.c
|
|
|
6
6
|
import { createPurchaseOrder, getPurchaseOrders, updatePurchaseOrder, deletePurchaseOrder } from '../controllers/purchaseOrder.controller.js';
|
|
7
7
|
import { createBillingGroup, deleteBillingGroup, getAllBillingGroups, getBillingGroups, getClientProducts, getInvoices, getLeadProducts, getBaseProducts, onetimePayment, subscribedStoreList, updateBillingGroup, gstinLookup } from '../controllers/billing.controllers.js';
|
|
8
8
|
import { billingGroupSchema, clientProductsValid, createBillingGroupsSchema, deleteBillingGroupsSchema, getBillingGroupsSchema, getInvoiceSchema, leadProductsValid, onetimeFeeValid, subscribedStoreListSchema, updateBillingGroupsSchema } from '../dtos/validation.dtos.js';
|
|
9
|
+
import { advanceNewStoreExport } from '../controllers/advanceNewStoreExport.controller.js';
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
billingRouter.post( '/get-subscribed-store-list', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isAdd' ] } ] } ), validate( subscribedStoreListSchema ), subscribedStoreList );
|
|
12
13
|
|
|
13
14
|
billingRouter.get( '/get-all-billing-groups', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isAdd' ] } ] } ), validate( getBillingGroupsSchema ), getAllBillingGroups );
|
|
14
15
|
|
|
16
|
+
billingRouter.get( '/advance-newstore-export', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isAdd' ] } ] } ), advanceNewStoreExport );
|
|
17
|
+
|
|
15
18
|
billingRouter.post( '/create-group', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isAdd' ] } ] } ), validate( createBillingGroupsSchema ), createBillingGroup );
|
|
16
19
|
|
|
17
20
|
billingRouter.put( '/update-group', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isAdd' ] } ] } ), validate( updateBillingGroupsSchema ), updateBillingGroup );
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import * as clientService from './clientPayment.services.js';
|
|
2
|
+
import * as dailyPriceService from './dailyPrice.service.js';
|
|
3
|
+
import * as storeService from './store.service.js';
|
|
4
|
+
import * as basePriceService from './basePrice.service.js';
|
|
5
|
+
import dayjs from 'dayjs';
|
|
6
|
+
import { logger } from 'tango-app-api-middleware';
|
|
7
|
+
import {
|
|
8
|
+
previousPeriodMonths, isNewStore, proratedAmount,
|
|
9
|
+
standardPriceFor, buildStepTiersByProduct, stepPriceForPosition,
|
|
10
|
+
} from '../utils/advanceNewStore.util.js';
|
|
11
|
+
|
|
12
|
+
function isPeriodAllowed( advancePeriod ) {
|
|
13
|
+
return [ 'quarterly', 'halfyearly', 'yearly' ].includes( advancePeriod );
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Per-(store × product × month) outstanding rows for a group's previous period.
|
|
17
|
+
// This is the SAME dataset the Excel export renders — kept here so the export
|
|
18
|
+
// and invoice generation share one source of truth.
|
|
19
|
+
export async function computeOutstandingRows( group, today = dayjs() ) {
|
|
20
|
+
if ( !group || !group.advanceInvoice || !isPeriodAllowed( group.advancePeriod ) ) {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const { months, periodStart, periodEnd } = previousPeriodMonths( group.advancePeriod, today );
|
|
25
|
+
const groupStoreIds = ( group.stores || [] ).map( String );
|
|
26
|
+
|
|
27
|
+
// New stores: group stores whose edge.firstFileDate is within the period.
|
|
28
|
+
const storeDocs = groupStoreIds.length ?
|
|
29
|
+
await storeService.find(
|
|
30
|
+
{ storeId: { $in: groupStoreIds } },
|
|
31
|
+
{ 'storeId': 1, 'storeName': 1, 'edge.firstFileDate': 1 },
|
|
32
|
+
) : [];
|
|
33
|
+
const newStores = storeDocs.filter(
|
|
34
|
+
( s ) => isNewStore( s?.edge?.firstFileDate, periodStart, periodEnd ) );
|
|
35
|
+
const newStoreById = new Map( newStores.map( ( s ) => [ String( s.storeId ), s ] ) );
|
|
36
|
+
|
|
37
|
+
// Pricing: standard flat + step tiers, keyed by product. priceType from client.
|
|
38
|
+
const bp = await basePriceService.findOne(
|
|
39
|
+
{ clientId: group.clientId }, { standard: 1, step: 1, currency: 1 } );
|
|
40
|
+
const client = await clientService.findOne( { clientId: group.clientId }, { priceType: 1 } );
|
|
41
|
+
const isStep = client?.priceType === 'step';
|
|
42
|
+
const stepTiers = isStep ? buildStepTiersByProduct( bp?.step ) : {};
|
|
43
|
+
|
|
44
|
+
const rows = [];
|
|
45
|
+
for ( const m of months ) {
|
|
46
|
+
// Last dailyPricing snapshot within this month for the client.
|
|
47
|
+
const snap = ( await dailyPriceService.find(
|
|
48
|
+
{ clientId: group.clientId, dateISO: { $gte: m.start, $lte: m.end } },
|
|
49
|
+
) ).sort( ( a, b ) => new Date( b.dateISO ) - new Date( a.dateISO ) )[0];
|
|
50
|
+
if ( !snap ) {
|
|
51
|
+
logger.error( { function: 'computeOutstandingRows', msg: 'no snapshot', month: m.label } );
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
// Step-tier position is 1-based PER PRODUCT, counting only stores that run
|
|
55
|
+
// that product, in snapshot iteration order — mirrors the per-product
|
|
56
|
+
// counter in brandsBilling.controller.js (positionByProduct++), not a
|
|
57
|
+
// fixed group-array index. Reset per month/snapshot.
|
|
58
|
+
const positionByProduct = {};
|
|
59
|
+
for ( const store of ( snap.stores || [] ) ) {
|
|
60
|
+
const ns = newStoreById.get( String( store.storeId ) );
|
|
61
|
+
for ( const p of ( store.products || [] ) ) {
|
|
62
|
+
// Advance the per-product position for EVERY store carrying the
|
|
63
|
+
// product (new or not) so tier positions match the full store set.
|
|
64
|
+
const pos = ( positionByProduct[p.productName] = ( positionByProduct[p.productName] || 0 ) + 1 );
|
|
65
|
+
if ( !ns ) {
|
|
66
|
+
continue; // count position, but only new stores get rows
|
|
67
|
+
}
|
|
68
|
+
const workingDays = Number( p.workingdays ) || 0;
|
|
69
|
+
if ( workingDays <= 0 ) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const unitPrice = isStep ?
|
|
73
|
+
stepPriceForPosition( stepTiers, p.productName, pos ) :
|
|
74
|
+
standardPriceFor( bp?.standard, p.productName );
|
|
75
|
+
rows.push( {
|
|
76
|
+
storeName: ns.storeName || store.storeName || '',
|
|
77
|
+
storeId: String( store.storeId ),
|
|
78
|
+
product: p.productName || '',
|
|
79
|
+
month: m.label,
|
|
80
|
+
workingDays,
|
|
81
|
+
deploymentDate: ns?.edge?.firstFileDate ? dayjs( ns.edge.firstFileDate ).format( 'YYYY-MM-DD' ) : '',
|
|
82
|
+
unitPrice,
|
|
83
|
+
amount: proratedAmount( unitPrice, m.daysInMonth, workingDays ),
|
|
84
|
+
} );
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return rows;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Collapse per-(store × product × month) rows into invoice line items:
|
|
92
|
+
// one line per (productName, month). Amount is the summed prorated total;
|
|
93
|
+
// storeCount is the number of outstanding stores; price is the AVERAGE per-store
|
|
94
|
+
// amount (amount / storeCount), shown in the "Rate" column. Tagged
|
|
95
|
+
// "Outstanding {Month}". Amount is the authoritative billed value; because it
|
|
96
|
+
// sums per-store PRORATED amounts (stores have differing working days), the
|
|
97
|
+
// average rate is a blended figure rather than the flat unit price.
|
|
98
|
+
export function groupOutstandingRowsToLines( rows ) {
|
|
99
|
+
const byKey = new Map();
|
|
100
|
+
for ( const r of ( rows || [] ) ) {
|
|
101
|
+
const key = `${r.product}||${r.month}`;
|
|
102
|
+
const existing = byKey.get( key );
|
|
103
|
+
if ( existing ) {
|
|
104
|
+
existing.amount += Number( r.amount ) || 0;
|
|
105
|
+
existing.storeCount += 1;
|
|
106
|
+
} else {
|
|
107
|
+
byKey.set( key, {
|
|
108
|
+
productName: r.product,
|
|
109
|
+
month: r.month,
|
|
110
|
+
amount: Number( r.amount ) || 0,
|
|
111
|
+
// Number of outstanding stores billed on this line (the "Quantity"/
|
|
112
|
+
// "Stores" column).
|
|
113
|
+
storeCount: 1,
|
|
114
|
+
description: `Outstanding ${r.month}`,
|
|
115
|
+
HsnNumber: '998314',
|
|
116
|
+
} );
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// Rate column = average per-store amount (blended). Computed after summing.
|
|
120
|
+
const lines = Array.from( byKey.values() );
|
|
121
|
+
for ( const line of lines ) {
|
|
122
|
+
line.price = line.storeCount > 0 ? Math.round( line.amount / line.storeCount ) : 0;
|
|
123
|
+
}
|
|
124
|
+
return lines;
|
|
125
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import dayjs from 'dayjs';
|
|
2
|
+
|
|
3
|
+
export const PERIOD_MONTHS = { monthly: 1, quarterly: 3, halfyearly: 6, yearly: 12 };
|
|
4
|
+
|
|
5
|
+
// The last N completed months ending LAST month (relative to `today`), where N
|
|
6
|
+
// is driven by the advance period. Returns month labels ('MMM YYYY') plus each
|
|
7
|
+
// month's start/end Date and actual day count, and the overall period range.
|
|
8
|
+
export function previousPeriodMonths( advancePeriod, today = dayjs() ) {
|
|
9
|
+
const n = PERIOD_MONTHS[advancePeriod] || 1;
|
|
10
|
+
const base = dayjs( today );
|
|
11
|
+
const months = [];
|
|
12
|
+
// i = n (oldest) .. 1 (last month). month = base minus i months.
|
|
13
|
+
for ( let i = n; i >= 1; i-- ) {
|
|
14
|
+
const m = base.subtract( i, 'month' );
|
|
15
|
+
months.push( {
|
|
16
|
+
label: m.format( 'MMM YYYY' ),
|
|
17
|
+
start: m.startOf( 'month' ).toDate(),
|
|
18
|
+
end: m.endOf( 'month' ).toDate(),
|
|
19
|
+
daysInMonth: m.daysInMonth(),
|
|
20
|
+
} );
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
months,
|
|
24
|
+
periodStart: months.length ? months[0].start : null,
|
|
25
|
+
periodEnd: months.length ? months[months.length - 1].end : null,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isNewStore( firstFileDate, periodStart, periodEnd ) {
|
|
30
|
+
if ( !firstFileDate ) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
const d = dayjs( firstFileDate );
|
|
34
|
+
return ( d.isSame( periodStart ) || d.isAfter( periodStart ) ) &&
|
|
35
|
+
( d.isSame( periodEnd ) || d.isBefore( periodEnd ) );
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function proratedAmount( unitPrice, daysInMonth, workingDays ) {
|
|
39
|
+
const price = Number( unitPrice ) || 0;
|
|
40
|
+
const days = Number( daysInMonth ) || 0;
|
|
41
|
+
const wd = Number( workingDays ) || 0;
|
|
42
|
+
if ( !price || !days ) {
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
return Math.round( ( price / days ) * wd );
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function standardPriceFor( standardArr, productName ) {
|
|
49
|
+
const p = ( standardArr || [] ).find( ( x ) => x.productName === productName );
|
|
50
|
+
if ( !p ) {
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
return Number( p.negotiatePrice ) || Number( p.basePrice ) || 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function buildStepTiersByProduct( stepArr ) {
|
|
57
|
+
const byProduct = {};
|
|
58
|
+
for ( const p of ( stepArr || [] ) ) {
|
|
59
|
+
if ( !p.productName ) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
( byProduct[p.productName] = byProduct[p.productName] || [] ).push( p );
|
|
63
|
+
}
|
|
64
|
+
for ( const name of Object.keys( byProduct ) ) {
|
|
65
|
+
byProduct[name].sort( ( a, b ) => {
|
|
66
|
+
const aStart = parseInt( String( a.storeRange || '0' ).split( '-' )[0], 10 ) || 0;
|
|
67
|
+
const bStart = parseInt( String( b.storeRange || '0' ).split( '-' )[0], 10 ) || 0;
|
|
68
|
+
return aStart - bStart;
|
|
69
|
+
} );
|
|
70
|
+
}
|
|
71
|
+
return byProduct;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 1-based position within the product's stores → tier rate; beyond the defined
|
|
75
|
+
// ranges falls back to the last tier (mirrors invoice/brandsBilling stepPrice).
|
|
76
|
+
export function stepPriceForPosition( stepTiersByProduct, productName, positionInProduct ) {
|
|
77
|
+
const tiers = ( stepTiersByProduct || {} )[productName] || [];
|
|
78
|
+
if ( !tiers.length ) {
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
for ( const tier of tiers ) {
|
|
82
|
+
const [ min, max ] = String( tier.storeRange || '' ).split( '-' ).map( Number );
|
|
83
|
+
if ( positionInProduct >= min && positionInProduct <= max ) {
|
|
84
|
+
return Number( tier.negotiatePrice ) || Number( tier.basePrice ) || 0;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const last = tiers[tiers.length - 1];
|
|
88
|
+
return Number( last.negotiatePrice ) || Number( last.basePrice ) || 0;
|
|
89
|
+
}
|