tango-app-api-payment-subscription 3.5.27 → 3.5.29

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.
@@ -0,0 +1,108 @@
1
+ # Quarterly Invoice Creation Script — clientId 449 (Apr/May/Jun 2025)
2
+
3
+ **Date:** 2026-07-09
4
+ **File to create:** `scripts/create-quarterly-invoices-449.js`
5
+
6
+ ## Goal
7
+
8
+ Create **3 invoices** for clientId `449`, one per billing group, each covering the
9
+ **Apr–Jun 2025 quarter (FY 25-26)** as three per-month line-item blocks. This is a
10
+ one-off backfill of quarterly billing.
11
+
12
+ Target groups (all confirmed to exist on clientId 449, all INR, all GST prefix `33`):
13
+
14
+ | Group name | _id | GST | stores |
15
+ |-------------------------------|----------------------------|-----------------|--------|
16
+ | LILLE BY CHALLANI | `6a4f37925d8071b6ae34d1a2` | 33AALCC7452J1ZF | 6 |
17
+ | FEDHA JEWELS PRIVATE LIMITED | `6a4f37298b8646bec2a1f630` | 33AAFCF8793V1ZL | 7 |
18
+ | CHALLANI JEWELLERY MART | `6a4f32644cde0c2869be2e52` | 33AAIFC5600Q1ZC | 2 |
19
+
20
+ Client 449: `priceType: standard`, `status: active`.
21
+
22
+ ## Invoice series & shape
23
+
24
+ - **Series: `INV-`** (normal series), NOT TINV-. Number format `INV-25-26-NNNNN`,
25
+ continuing the highest `invoiceIndex` for `^INV-25-26-` (matches controller logic).
26
+ - `advanceInvoice: false`, `advancePeriod` omitted.
27
+ - **One invoice document per group** whose `products` array contains three month-blocks
28
+ (Apr 2025, May 2025, Jun 2025), each line tagged `month: 'MMM YYYY'`.
29
+ - `advanceMonths: 3` recorded for reference (bundled quarter), but the invoice is a normal
30
+ INV- invoice — the quarterly nature lives in the line items, not the advance flag.
31
+ - No `storeDetails` block (that is TINV-only in the controller).
32
+
33
+ ## Pricing — actual per-month snapshots, prorated
34
+
35
+ Client 449 is `priceType: standard`, so we replicate the **standard-price** path from
36
+ `createInvoice` → `standardPrice()`. For each group, for each of Apr/May/Jun 2025:
37
+
38
+ 1. **Snapshot selection:** pick the latest daily-pricing doc with
39
+ `dateISO <= end-of-month`, sorted `dateISO: -1`, `limit 1` — the actual snapshot for
40
+ that month (differs from the controller, which always takes the newest snapshot).
41
+ 2. **Days basis:** `daysInMonth` of that month (Apr=30, May=31, Jun=30).
42
+ 3. **Basepricing doc:** resolved via the same rule as `resolveBasePricingScope` —
43
+ group-wise doc when `client.billingGroupWisePricing` is on AND a group doc exists,
44
+ else the brand-level doc (`groupId` unset). Read `negotiatePrice` from `standard[]`.
45
+ 4. **overallStore products:** filter to `group.stores`, keep `workingdays > 0`, group by
46
+ (product, workingdays), sum store counts. Prorate:
47
+ `workingdays >= daysInMonth` → `price * count` (full month);
48
+ else `(price / daysInMonth) * workingdays * count`. `proRata: 'flat'` on the group
49
+ forces full-month working days.
50
+ 5. **billingType** perZone / perCamera for `tangoZone` / `tangoTraffic`: multiply by zone
51
+ / camera counts exactly as the controller does.
52
+ 6. **billingMethod `eachStore`** products: emit one line per store (per-store proration),
53
+ as in the controller.
54
+ 7. Tag every produced line with `month: 'MMM YYYY'` for that month.
55
+
56
+ The three months' line arrays are concatenated into the single `products` array.
57
+
58
+ **Empty-month handling:** if a month has no snapshot `<=` its end, or the group has no
59
+ running stores that month, that month contributes zero lines. The script logs this
60
+ **loudly** (WARN per group/month) so a short invoice is never produced silently.
61
+
62
+ ## Invoice document fields (mirrors controller `data`)
63
+
64
+ - `invoice: INV-25-26-NNNNN`, `invoiceIndex`
65
+ - `groupId`, `groupName`, `clientId: '449'`
66
+ - `companyName` = uppercased `registeredCompanyName`; `companyAddress` built from group
67
+ address fields (`addressLineOne+addressLineTwo+city,state,country -pinCode`), matching
68
+ the controller
69
+ - `GSTNumber` = group.gst, `PlaceOfSupply` = group.placeOfSupply
70
+ - `products` = concatenated month-blocks
71
+ - `amount` = Σ line amounts (roundAmount, inr); `tax` = CGST 9% + SGST 9% (GST prefix `33`);
72
+ `totalAmount` = roundAmount(amount + tax)
73
+ - `currency: 'inr'`, `stores` = running-store count from the latest snapshot within Jun 2025
74
+ - `billingDate` = today (creation date, per controller convention), `monthOfbilling: '04'`
75
+ - `dueDate` = billingDate + (group.paymentTerm || 30) days
76
+ - `status: 'pendingCsm'`, `paymentStatus: 'unpaid'`, `paymentMethod: 'Online'`
77
+ - `advanceInvoice: false`, `advanceMonths: 3`
78
+
79
+ ## Number allocation across the 3 invoices
80
+
81
+ Compute the starting index once (max `invoiceIndex` for `^INV-25-26-`), then increment
82
+ locally per group so the three invoices get consecutive numbers within one run. In
83
+ dry-run, the numbers shown are provisional (no write, so the counter is not advanced).
84
+
85
+ ## Safety & idempotency
86
+
87
+ - **Dry-run by default.** Prints, per group: invoice number, per-month per-product
88
+ breakdown (product, storeCount, price, working-days/period, amount), subtotal, CGST,
89
+ SGST, total, and any empty-month WARNs. Writes nothing.
90
+ - **`--apply`** performs the inserts via `invoiceModel.create`.
91
+ - **Duplicate guard:** before creating a group's invoice, check for an existing
92
+ `INV-`-series invoice for that `groupId` already carrying a `products.month = 'Apr 2025'`
93
+ line. If found, SKIP that group with a warning (prevents double-billing on re-run).
94
+ - **No side effects:** no PDF, no email, no OpenSearch activity log, no PO mapping.
95
+ - **Connection:** reuse `getConnection()` from `config/database/database.js` (or
96
+ `MONGO_URI` override), and the `tango-api-schema` models — same as existing scripts.
97
+
98
+ ## Flags
99
+
100
+ - `node scripts/create-quarterly-invoices-449.js` → DRY RUN
101
+ - `node scripts/create-quarterly-invoices-449.js --apply` → write
102
+ - `--clientId=449` (default 449), `--year=2025` (Q1 = Apr/May/Jun of this calendar year;
103
+ FY label derived as `YY-(YY+1)`)
104
+
105
+ ## Out of scope
106
+
107
+ TINV/advance series, multi-client support, PDF/email generation, non-standard (step)
108
+ pricing beyond what `standardPrice` covers for this client.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tango-app-api-payment-subscription",
3
- "version": "3.5.27",
3
+ "version": "3.5.29",
4
4
  "description": "paymentSubscription",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -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;
@@ -2038,38 +2078,54 @@ export async function clientInvoiceList( req, res ) {
2038
2078
  let filterEndDate = '';
2039
2079
 
2040
2080
  if ( !hasMonthYear ) {
2081
+ // Start = start-of-month (00:00:00); End = end-of-month (23:59:59.999)
2082
+ // via .toDate() so invoices on the last day (with any time) are included.
2041
2083
  if ( req.body?.filter && req.body.filter == 'current' ) {
2042
- filterStartDate = new Date( dayjs().startOf( 'month' ).format( 'YYYY-MM-DD' ) );
2043
- filterEndDate = new Date( dayjs().endOf( 'month' ).format( 'YYYY-MM-DD' ) );
2084
+ filterStartDate = dayjs().startOf( 'month' ).toDate();
2085
+ filterEndDate = dayjs().endOf( 'month' ).toDate();
2044
2086
  }
2045
2087
  if ( req.body?.filter && req.body.filter == 'prev' ) {
2046
- filterStartDate = new Date( dayjs().subtract( 1, 'month' ).startOf( 'month' ).format( 'YYYY-MM-DD' ) );
2047
- filterEndDate = new Date( dayjs().subtract( 1, 'month' ).endOf( 'month' ).format( 'YYYY-MM-DD' ) );
2088
+ filterStartDate = dayjs().subtract( 1, 'month' ).startOf( 'month' ).toDate();
2089
+ filterEndDate = dayjs().subtract( 1, 'month' ).endOf( 'month' ).toDate();
2048
2090
  }
2049
2091
  // Rolling windows. 'last3' = the prototype's "Last 3 Months" (was the
2050
2092
  // legacy 'last' id, which silently meant 12 months and made every other
2051
2093
  // filter look broken). 'last' is kept as an alias of 'last3' so older
2052
2094
  // clients don't break mid-deploy.
2053
2095
  if ( req.body?.filter && ( req.body.filter == 'last3' || req.body.filter == 'last' ) ) {
2054
- filterStartDate = new Date( dayjs().subtract( 3, 'month' ).startOf( 'month' ).format( 'YYYY-MM-DD' ) );
2055
- filterEndDate = new Date( dayjs().endOf( 'month' ).format( 'YYYY-MM-DD' ) );
2096
+ filterStartDate = dayjs().subtract( 3, 'month' ).startOf( 'month' ).toDate();
2097
+ filterEndDate = dayjs().endOf( 'month' ).toDate();
2056
2098
  }
2057
2099
  if ( req.body?.filter && req.body.filter == 'last6' ) {
2058
- filterStartDate = new Date( dayjs().subtract( 6, 'month' ).startOf( 'month' ).format( 'YYYY-MM-DD' ) );
2059
- filterEndDate = new Date( dayjs().endOf( 'month' ).format( 'YYYY-MM-DD' ) );
2100
+ filterStartDate = dayjs().subtract( 6, 'month' ).startOf( 'month' ).toDate();
2101
+ filterEndDate = dayjs().endOf( 'month' ).toDate();
2060
2102
  }
2061
2103
  if ( req.body?.filter && req.body.filter == 'last12' ) {
2062
- filterStartDate = new Date( dayjs().subtract( 12, 'month' ).startOf( 'month' ).format( 'YYYY-MM-DD' ) );
2063
- filterEndDate = new Date( dayjs().endOf( 'month' ).format( 'YYYY-MM-DD' ) );
2104
+ filterStartDate = dayjs().subtract( 12, 'month' ).startOf( 'month' ).toDate();
2105
+ filterEndDate = dayjs().endOf( 'month' ).toDate();
2064
2106
  }
2065
2107
 
2066
2108
  if ( req.body?.filter ) {
2109
+ // billingDate may be stored as a Date OR a string on legacy rows. A raw
2110
+ // { billingDate: { $gte: <Date> } } range compares across BSON types for
2111
+ // string rows and silently fails to filter — so the Duration filter
2112
+ // appeared to "show all". Coerce to a Date in an $expr, mirroring the
2113
+ // Month/Year filter below.
2114
+ const billingDateExpr = {
2115
+ $cond: [
2116
+ { $eq: [ { $type: '$billingDate' }, 'date' ] },
2117
+ '$billingDate',
2118
+ { $toDate: '$billingDate' },
2119
+ ],
2120
+ };
2067
2121
  query.push( {
2068
2122
  $match: {
2069
- $and: [
2070
- { billingDate: { $gte: filterStartDate } },
2071
- { billingDate: { $lte: filterEndDate } },
2072
- ],
2123
+ $expr: {
2124
+ $and: [
2125
+ { $gte: [ billingDateExpr, filterStartDate ] },
2126
+ { $lte: [ billingDateExpr, filterEndDate ] },
2127
+ ],
2128
+ },
2073
2129
  },
2074
2130
  } );
2075
2131
  }
@@ -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 );
@@ -21,47 +21,47 @@ function superadminBypass( accessConfig ) {
21
21
  }
22
22
 
23
23
 
24
- invoiceRouter.post( '/createInvoice', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), createInvoice );
25
- invoiceRouter.post( '/regerateInvoice', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), createInvoice );
26
- invoiceRouter.post( '/invoiceDownload/bulk', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), invoiceDownloadBulk );
24
+ invoiceRouter.post( '/createInvoice', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isEdit' ] } ] } ), createInvoice );
25
+ invoiceRouter.post( '/regerateInvoice', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isEdit' ] } ] } ), createInvoice );
26
+ invoiceRouter.post( '/invoiceDownload/bulk', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [] } ] } ), invoiceDownloadBulk );
27
27
  invoiceRouter.post( '/invoiceDownload/:invoiceId', isAllowedSessionHandler, invoiceDownload );
28
- invoiceRouter.post( '/clientInvoiceList', isAllowedSessionHandler, accessVerification( { userType: [ 'tango', 'client' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), clientInvoiceList );
29
- invoiceRouter.post( '/creditTransactionlist', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), creditTransactionlist );
28
+ invoiceRouter.post( '/clientInvoiceList', isAllowedSessionHandler, accessVerification( { userType: [ 'tango', 'client' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [] } ] } ), clientInvoiceList );
29
+ invoiceRouter.post( '/creditTransactionlist', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [] } ] } ), creditTransactionlist );
30
30
  invoiceRouter.post( '/pendingInvoices', isAllowedSessionHandler, pendingInvoices );
31
- invoiceRouter.post( '/applyDiscount', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), applyDiscount );
31
+ invoiceRouter.post( '/applyDiscount', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isEdit' ] } ] } ), applyDiscount );
32
32
  invoiceRouter.post( '/migrateInvoice', migrateInvoice );
33
- invoiceRouter.post( '/PaymentStatusChange', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), PaymentStatusChange );
34
- invoiceRouter.post( '/recordPayment', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), recordPayment );
33
+ invoiceRouter.post( '/PaymentStatusChange', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isEdit' ] } ] } ), PaymentStatusChange );
34
+ invoiceRouter.post( '/recordPayment', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isEdit' ] } ] } ), recordPayment );
35
35
  invoiceRouter.post( '/checkPaymentStatus', checkPaymentStatus );
36
36
  invoiceRouter.get( '/getInvoice/:invoiceId', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango', 'client' ], access: [ ] } ), getInvoice );
37
- invoiceRouter.get( '/invoiceAnnexure/:invoiceId', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango', 'client' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), invoiceAnnexure );
38
- invoiceRouter.get( '/invoiceBankDetails/:invoiceId', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango', 'client' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), invoiceBankDetails );
39
- invoiceRouter.put( '/updateInvoice', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), updateInvoice );
37
+ invoiceRouter.get( '/invoiceAnnexure/:invoiceId', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango', 'client' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [] } ] } ), invoiceAnnexure );
38
+ invoiceRouter.get( '/invoiceBankDetails/:invoiceId', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango', 'client' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [] } ] } ), invoiceBankDetails );
39
+ invoiceRouter.put( '/updateInvoice', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isEdit' ] } ] } ), updateInvoice );
40
40
  invoiceRouter.get( '/getClientBasePricing/:clientId', isAllowedSessionHandler, getClientBasePricing );
41
- invoiceRouter.delete( '/deleteInvoice/:invoiceId', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), deleteInvoice );
41
+ invoiceRouter.delete( '/deleteInvoice/:invoiceId', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isEdit' ] } ] } ), deleteInvoice );
42
42
 
43
43
  invoiceRouter.post( '/approveInvoiceCsm', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'csmApproval', permissions: [ 'isEdit' ] } ] } ), approveInvoiceCsm );
44
44
  invoiceRouter.post( '/approveInvoiceFinance', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'financeApproval', permissions: [ 'isEdit' ] } ] } ), approveInvoiceFinance );
45
- invoiceRouter.post( '/approveInvoiceApproval', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), approveInvoiceApproval );
45
+ invoiceRouter.post( '/approveInvoiceApproval', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isEdit' ] } ] } ), approveInvoiceApproval );
46
46
 
47
47
  invoiceRouter.get( '/getInvoiceHeads', isAllowedSessionHandler, getInvoiceHeads );
48
48
  invoiceRouter.post( '/updateInvoiceHeads', isAllowedSessionHandler, validate( updateInvoiceHeadsSchema ), updateInvoiceHeads );
49
49
 
50
50
  // Bank-statement reconciliation (billing "Transactions" tab). Upload mutates
51
51
  // the banktransaction collection so it needs edit rights; list is read-only.
52
- invoiceRouter.post( '/bankStatement/upload', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), uploadBankStatement );
53
- invoiceRouter.post( '/bankStatement/list', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), bankTransactionList );
54
- invoiceRouter.get( '/bankStatement/resolveOptions', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), resolveOptions );
55
- invoiceRouter.post( '/bankStatement/resolve', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), resolveBankTransaction );
52
+ invoiceRouter.post( '/bankStatement/upload', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isEdit' ] } ] } ), uploadBankStatement );
53
+ invoiceRouter.post( '/bankStatement/list', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [] } ] } ), bankTransactionList );
54
+ invoiceRouter.get( '/bankStatement/resolveOptions', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [] } ] } ), resolveOptions );
55
+ invoiceRouter.post( '/bankStatement/resolve', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isEdit' ] } ] } ), resolveBankTransaction );
56
56
 
57
57
  // Estimates (quotations) — per-brand quotation documents with their own
58
58
  // lifecycle. List/get are read; create/status/delete need edit rights.
59
- invoiceRouter.post( '/estimate/list', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), estimateList );
60
- invoiceRouter.post( '/estimate/create', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), createEstimate );
59
+ invoiceRouter.post( '/estimate/list', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [] } ] } ), estimateList );
60
+ invoiceRouter.post( '/estimate/create', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isEdit' ] } ] } ), createEstimate );
61
61
  invoiceRouter.post( '/estimate/approveCsm', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'csmApproval', permissions: [ 'isEdit' ] } ] } ), approveEstimateCsm );
62
62
  invoiceRouter.post( '/estimate/approveFinance', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'financeApproval', permissions: [ 'isEdit' ] } ] } ), approveEstimateFinance );
63
- invoiceRouter.post( '/estimate/approveApproval', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), approveEstimateApproval );
64
- invoiceRouter.post( '/estimate/update', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), updateEstimate );
65
- invoiceRouter.post( '/estimate/download/:estimateId', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), downloadEstimate );
66
- invoiceRouter.get( '/estimate/:estimateId', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), getEstimate );
67
- invoiceRouter.delete( '/estimate/:estimateId', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), deleteEstimate );
63
+ invoiceRouter.post( '/estimate/approveApproval', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isEdit' ] } ] } ), approveEstimateApproval );
64
+ invoiceRouter.post( '/estimate/update', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isEdit' ] } ] } ), updateEstimate );
65
+ invoiceRouter.post( '/estimate/download/:estimateId', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [] } ] } ), downloadEstimate );
66
+ invoiceRouter.get( '/estimate/:estimateId', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [] } ] } ), getEstimate );
67
+ invoiceRouter.delete( '/estimate/:estimateId', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isEdit' ] } ] } ), deleteEstimate );
@@ -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
+ }