@darkpos/pricing 1.0.146 → 1.0.147

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,239 @@
1
+ const usePricing = require('../../index');
2
+ const mockStores = require('../mocks/stores');
3
+
4
+ const pricing = usePricing({ store: mockStores[0] });
5
+
6
+ const ORDER_ID = 'order-1';
7
+ const OVERPAID_ITEM_ID = 'item-0';
8
+ const UNDERPAID_ITEM_ID = 'item-1';
9
+
10
+ const liveOrder = {
11
+ _id: ORDER_ID,
12
+ items: [
13
+ {
14
+ _id: OVERPAID_ITEM_ID,
15
+ total: 10.5,
16
+ totalPaid: 14.7,
17
+ status: { paid: { value: true }, picked: { value: false } },
18
+ },
19
+ {
20
+ _id: UNDERPAID_ITEM_ID,
21
+ total: 10.5,
22
+ totalPaid: 10.3,
23
+ status: { paid: { value: false }, picked: { value: false } },
24
+ },
25
+ ],
26
+ };
27
+
28
+ const ordersById = { [ORDER_ID]: liveOrder };
29
+
30
+ const overpaidInvoiceItem = {
31
+ orderId: ORDER_ID,
32
+ orderItemId: OVERPAID_ITEM_ID,
33
+ total: 14.7,
34
+ totalPaid: 14.7,
35
+ amount: 14.7,
36
+ status: { paid: { value: true }, picked: { value: false } },
37
+ };
38
+
39
+ const partiallyPaidInvoiceItem = {
40
+ orderId: ORDER_ID,
41
+ orderItemId: UNDERPAID_ITEM_ID,
42
+ total: 13.65,
43
+ totalPaid: 10.3,
44
+ amount: 10.3,
45
+ status: { paid: { value: false }, picked: { value: false } },
46
+ };
47
+
48
+ describe('computeRefundables', () => {
49
+ const overpaidState = pricing.invoice.resolveItemState(
50
+ overpaidInvoiceItem,
51
+ ordersById
52
+ ); // excess +4.2
53
+ const underpaidState = pricing.invoice.resolveItemState(
54
+ partiallyPaidInvoiceItem,
55
+ ordersById
56
+ ); // excess -0.2
57
+
58
+ test('refunds the full excess from the overpaid item and redistributes the surplus to the underpaid item', () => {
59
+ const [overpaidRefundable, underpaidCredit] =
60
+ pricing.invoice.computeRefundables([overpaidState, underpaidState], 4);
61
+ expect(overpaidRefundable).toBe(4.2); // full overpayment cleared
62
+ expect(underpaidCredit).toBe(-0.2); // 0.2 credit applied to cover the gap
63
+ });
64
+
65
+ test('net refund amount across all items equals the requested refund total', () => {
66
+ const [overpaidRefundable, underpaidCredit] =
67
+ pricing.invoice.computeRefundables([overpaidState, underpaidState], 4);
68
+ expect(overpaidRefundable + underpaidCredit).toBe(4);
69
+ });
70
+
71
+ test('result is order-independent — same outcome when items are reversed', () => {
72
+ const [underpaidCredit, overpaidRefundable] =
73
+ pricing.invoice.computeRefundables([underpaidState, overpaidState], 4);
74
+ expect(overpaidRefundable).toBe(4.2);
75
+ expect(underpaidCredit).toBe(-0.2);
76
+ });
77
+
78
+ test('never-paid item receives no credit even when overpaid surplus exists', () => {
79
+ const neverPaidState = {
80
+ ...pricing.invoice.resolveItemState(partiallyPaidInvoiceItem, ordersById),
81
+ itemTotalPaid: 0,
82
+ excess: -10.5,
83
+ };
84
+ const [overpaidRefundable, neverPaidRefundable] =
85
+ pricing.invoice.computeRefundables([overpaidState, neverPaidState], 4);
86
+ expect(overpaidRefundable).toBe(4); // capped — no surplus to redistribute
87
+ expect(neverPaidRefundable).toBe(0); // never-paid item stays untouched
88
+ });
89
+
90
+ test('draws directly from totalPaid when no overpaid items exist', () => {
91
+ const exactlyPaidState = { ...overpaidState, excess: 0 };
92
+ const [refundable] = pricing.invoice.computeRefundables(
93
+ [exactlyPaidState],
94
+ 5
95
+ );
96
+ expect(refundable).toBe(5);
97
+ });
98
+
99
+ test('returns all zeros when the requested refund amount is 0', () => {
100
+ const refundables = pricing.invoice.computeRefundables(
101
+ [overpaidState, underpaidState],
102
+ 0
103
+ );
104
+ expect(refundables).toEqual([0, 0]);
105
+ });
106
+ });
107
+
108
+ describe('applyRefundToInvoices', () => {
109
+ const invoicesForOnePayment = [
110
+ {
111
+ _id: 'inv-1',
112
+ amount: 25,
113
+ paymentOrderItems: [overpaidInvoiceItem, partiallyPaidInvoiceItem],
114
+ },
115
+ ];
116
+
117
+ test('the refund invoice has the correct total amount and both item refunds', () => {
118
+ const refundInvoices = pricing.invoice.applyRefundToInvoices(
119
+ invoicesForOnePayment,
120
+ ordersById,
121
+ 4
122
+ );
123
+ expect(refundInvoices).toHaveLength(1);
124
+ expect(refundInvoices[0].amount).toBe(-4);
125
+ expect(refundInvoices[0].paymentOrderItems).toHaveLength(2);
126
+ expect(refundInvoices[0].paymentOrderItems[0].amount).toBe(-4.2);
127
+ expect(refundInvoices[0].paymentOrderItems[1].amount).toBe(0.2);
128
+ });
129
+
130
+ test('the overpaid item is cleared to its exact total and marked as paid', () => {
131
+ const refundInvoices = pricing.invoice.applyRefundToInvoices(
132
+ invoicesForOnePayment,
133
+ ordersById,
134
+ 4
135
+ );
136
+ expect(refundInvoices[0].paymentOrderItems[0].status.paid.value).toBe(true);
137
+ });
138
+
139
+ test('the underpaid item receives a credit that covers its remaining balance and is marked as paid', () => {
140
+ const refundInvoices = pricing.invoice.applyRefundToInvoices(
141
+ invoicesForOnePayment,
142
+ ordersById,
143
+ 4
144
+ );
145
+ expect(refundInvoices[0].paymentOrderItems[1].status.paid.value).toBe(true);
146
+ });
147
+
148
+ test('returns an empty array when the refund amount is 0', () => {
149
+ const refundInvoices = pricing.invoice.applyRefundToInvoices(
150
+ invoicesForOnePayment,
151
+ ordersById,
152
+ 0
153
+ );
154
+ expect(refundInvoices).toEqual([]);
155
+ });
156
+
157
+ test('skips invoices that are already refunded or have no positive balance', () => {
158
+ const invoicesIncludingAlreadyRefunded = [
159
+ {
160
+ _id: 'inv-already-refunded',
161
+ amount: -5,
162
+ paymentOrderItems: [overpaidInvoiceItem],
163
+ },
164
+ ...invoicesForOnePayment,
165
+ ];
166
+ const refundInvoices = pricing.invoice.applyRefundToInvoices(
167
+ invoicesIncludingAlreadyRefunded,
168
+ ordersById,
169
+ 4
170
+ );
171
+ expect(refundInvoices).toHaveLength(1);
172
+ expect(refundInvoices[0]._id).toBe('inv-1');
173
+ });
174
+
175
+ test('distributes the refund across multiple invoices when the first is exhausted', () => {
176
+ const splitPaymentInvoices = [
177
+ {
178
+ _id: 'first-payment',
179
+ amount: 14.7,
180
+ paymentOrderItems: [overpaidInvoiceItem],
181
+ },
182
+ {
183
+ _id: 'second-payment',
184
+ amount: 10.3,
185
+ paymentOrderItems: [partiallyPaidInvoiceItem],
186
+ },
187
+ ];
188
+ // Refund 5: overpaid item covers 4.2, the remaining 0.8 comes from the second invoice
189
+ const refundInvoices = pricing.invoice.applyRefundToInvoices(
190
+ splitPaymentInvoices,
191
+ ordersById,
192
+ 5
193
+ );
194
+ expect(refundInvoices).toHaveLength(2);
195
+ expect(refundInvoices[0].amount).toBe(-4.2);
196
+ expect(refundInvoices[1].amount).toBe(-0.8);
197
+ });
198
+ });
199
+
200
+ describe('buildItemRefund', () => {
201
+ const overpaidItemState = pricing.invoice.resolveItemState(
202
+ overpaidInvoiceItem,
203
+ ordersById
204
+ );
205
+ const underpaidItemState = pricing.invoice.resolveItemState(
206
+ partiallyPaidInvoiceItem,
207
+ ordersById
208
+ );
209
+
210
+ test('overpaid item: refunding its full excess results in a negative amount and paid = true', () => {
211
+ const itemRefund = pricing.invoice.buildItemRefund(overpaidItemState, 4.2);
212
+ expect(itemRefund.amount).toBe(-4.2);
213
+ expect(itemRefund.totalPaid).toBe(14.7); // pre-refund value preserved
214
+ expect(itemRefund.total).toBe(10.5);
215
+ expect(itemRefund.status.paid.value).toBe(true); // 14.7 - 4.2 = 10.5 >= 10.5
216
+ });
217
+
218
+ test('underpaid item: applying a credit results in a positive amount and paid = true', () => {
219
+ const itemRefund = pricing.invoice.buildItemRefund(
220
+ underpaidItemState,
221
+ -0.2
222
+ );
223
+ expect(itemRefund.amount).toBe(0.2); // positive = credit
224
+ expect(itemRefund.totalPaid).toBe(10.3);
225
+ expect(itemRefund.total).toBe(10.5);
226
+ expect(itemRefund.status.paid.value).toBe(true); // 10.3 - (-0.2) = 10.5 >= 10.5
227
+ });
228
+
229
+ test('paid stays false when the refund does not fully cover the remaining balance', () => {
230
+ const itemRefund = pricing.invoice.buildItemRefund(underpaidItemState, 2); // totalPaid drops to 8.3
231
+ expect(itemRefund.status.paid.value).toBe(false); // 8.3 < 10.5
232
+ });
233
+
234
+ test('preserves all other fields from the original invoice item', () => {
235
+ const itemRefund = pricing.invoice.buildItemRefund(overpaidItemState, 4.2);
236
+ expect(itemRefund.orderId).toBe(ORDER_ID);
237
+ expect(itemRefund.orderItemId).toBe(OVERPAID_ITEM_ID);
238
+ });
239
+ });
@@ -0,0 +1,52 @@
1
+ module.exports = ({ utils, actions }) => {
2
+ const { math } = utils;
3
+
4
+ /**
5
+ * Distributes a refund amount across a set of payment invoices.
6
+ *
7
+ * For each invoice, it resolves the live state of each item, computes
8
+ * the refundable amounts (order-independent), and builds the refund entries.
9
+ *
10
+ * Returns an array of refund invoices ready to be persisted — each with a
11
+ * negative amount (the refund) and updated paymentOrderItems.
12
+ */
13
+ return function applyRefundToInvoices(
14
+ paymentInvoices,
15
+ ordersById,
16
+ totalToRefund
17
+ ) {
18
+ let remaining = totalToRefund;
19
+
20
+ return paymentInvoices
21
+ .filter(({ amount }) => amount > 0)
22
+ .map(({ amount, paymentOrderItems, ...rest }) => {
23
+ if (remaining <= 0) return null;
24
+
25
+ const itemStates = (paymentOrderItems || []).map(item =>
26
+ actions.resolveItemState(item, ordersById)
27
+ );
28
+ const refundables = actions.computeRefundables(itemStates, remaining);
29
+
30
+ let invoiceRefundAmount = 0;
31
+ const itemRefunds = itemStates
32
+ .map((state, i) => {
33
+ const refundable = refundables[i];
34
+ if (refundable === 0) return null;
35
+ invoiceRefundAmount = math.add(invoiceRefundAmount, refundable);
36
+ return actions.buildItemRefund(state, refundable);
37
+ })
38
+ .filter(Boolean);
39
+
40
+ remaining = math.sub(remaining, invoiceRefundAmount);
41
+
42
+ if (invoiceRefundAmount <= 0) return null;
43
+
44
+ return {
45
+ ...rest,
46
+ amount: -invoiceRefundAmount,
47
+ paymentOrderItems: itemRefunds,
48
+ };
49
+ })
50
+ .filter(Boolean);
51
+ };
52
+ };
@@ -0,0 +1,23 @@
1
+ module.exports = ({ utils }) => {
2
+ const { math } = utils;
3
+
4
+ return function buildItemRefund(
5
+ { item, itemTotal, itemTotalPaid, currentStatus },
6
+ refundable
7
+ ) {
8
+ const nextTotalPaid = math.sub(itemTotalPaid, refundable);
9
+ return {
10
+ ...item,
11
+ total: itemTotal,
12
+ totalPaid: itemTotalPaid,
13
+ amount: -refundable,
14
+ status: {
15
+ ...currentStatus,
16
+ paid: {
17
+ ...((currentStatus && currentStatus.paid) || {}),
18
+ value: nextTotalPaid >= itemTotal,
19
+ },
20
+ },
21
+ };
22
+ };
23
+ };
@@ -0,0 +1,59 @@
1
+ module.exports = ({ utils }) => {
2
+ const { math } = utils;
3
+
4
+ return function computeRefundables(itemStates, totalToRefund) {
5
+ const refundables = new Array(itemStates.length).fill(0);
6
+
7
+ if (totalToRefund <= 0) return refundables;
8
+
9
+ // Only partially-paid underpaid items are eligible to absorb redistribution credits.
10
+ const eligibleDeficit = itemStates.reduce(
11
+ (sum, { excess, itemTotalPaid }) => {
12
+ const isPartiallyPaidAndUnderpaid = excess < 0 && itemTotalPaid > 0;
13
+ return isPartiallyPaidAndUnderpaid
14
+ ? math.add(sum, math.sub(0, excess))
15
+ : sum;
16
+ },
17
+ 0
18
+ );
19
+
20
+ // Cap total drawn from overpaid items so the net refund never exceeds totalToRefund.
21
+ let maxFromOverpaid = math.add(totalToRefund, eligibleDeficit);
22
+ let tempRemaining = totalToRefund;
23
+
24
+ // Pass 1: refund each overpaid item's excess, capped by maxFromOverpaid
25
+ itemStates.forEach(({ excess }, i) => {
26
+ if (excess > 0 && maxFromOverpaid > 0) {
27
+ const refund = excess > maxFromOverpaid ? maxFromOverpaid : excess;
28
+ refundables[i] = refund;
29
+ tempRemaining = math.sub(tempRemaining, refund);
30
+ maxFromOverpaid = math.sub(maxFromOverpaid, refund);
31
+ }
32
+ });
33
+
34
+ if (tempRemaining < 0) {
35
+ // Pass 2a: over-refunded — spread surplus as credits to partially-paid underpaid items
36
+ let surplus = math.sub(0, tempRemaining);
37
+ itemStates.forEach(({ excess, itemTotalPaid }, i) => {
38
+ if (excess < 0 && itemTotalPaid > 0 && surplus > 0) {
39
+ const deficit = math.sub(0, excess);
40
+ const credit = surplus > deficit ? deficit : surplus;
41
+ refundables[i] = -credit;
42
+ surplus = math.sub(surplus, credit);
43
+ }
44
+ });
45
+ } else if (tempRemaining > 0) {
46
+ // Pass 2b: under-refunded — draw remainder from non-overpaid items (totalPaid > 0)
47
+ itemStates.forEach(({ itemTotalPaid, excess }, i) => {
48
+ if (excess <= 0 && itemTotalPaid > 0 && tempRemaining > 0) {
49
+ const refund =
50
+ tempRemaining > itemTotalPaid ? itemTotalPaid : tempRemaining;
51
+ refundables[i] = refund;
52
+ tempRemaining = math.sub(tempRemaining, refund);
53
+ }
54
+ });
55
+ }
56
+
57
+ return refundables;
58
+ };
59
+ };
@@ -1,5 +1,9 @@
1
1
  const getStatusByItems = require('./getStatusByItems');
2
2
  const getTotalByItems = require('./getTotalByItems');
3
+ const resolveItemState = require('./resolveItemState');
4
+ const computeRefundables = require('./computeRefundables');
5
+ const buildItemRefund = require('./buildItemRefund');
6
+ const applyRefundToInvoices = require('./applyRefundToInvoices');
3
7
 
4
8
  const invoiceActions = (deps = {}) => {
5
9
  const actions = {};
@@ -12,6 +16,10 @@ const invoiceActions = (deps = {}) => {
12
16
  const freezedActions = Object.freeze({
13
17
  getStatusByItems: getStatusByItems(innerDeps),
14
18
  getTotalByItems: getTotalByItems(innerDeps),
19
+ resolveItemState: resolveItemState(innerDeps),
20
+ computeRefundables: computeRefundables(innerDeps),
21
+ buildItemRefund: buildItemRefund(innerDeps),
22
+ applyRefundToInvoices: applyRefundToInvoices(innerDeps),
15
23
  });
16
24
 
17
25
  Object.keys(freezedActions).forEach(actionName => {
@@ -0,0 +1,25 @@
1
+ module.exports = ({ utils }) => {
2
+ const { math } = utils;
3
+
4
+ return function resolveItemState(paymentOrderItem, ordersById) {
5
+ const order = (ordersById && ordersById[paymentOrderItem.orderId]) || {};
6
+ const orderItem = (order.items || []).find(
7
+ each => each._id === paymentOrderItem.orderItemId
8
+ );
9
+
10
+ const itemTotal =
11
+ (orderItem && orderItem.total) || paymentOrderItem.total || 0;
12
+ const itemTotalPaid =
13
+ (orderItem && orderItem.totalPaid) || paymentOrderItem.totalPaid || 0;
14
+ const currentStatus =
15
+ (orderItem && orderItem.status) || paymentOrderItem.status || {};
16
+
17
+ return {
18
+ item: paymentOrderItem,
19
+ itemTotal,
20
+ itemTotalPaid,
21
+ currentStatus,
22
+ excess: math.sub(itemTotalPaid, itemTotal),
23
+ };
24
+ };
25
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@darkpos/pricing",
3
- "version": "1.0.146",
3
+ "version": "1.0.147",
4
4
  "description": "Pricing calculator",
5
5
  "author": "Dark POS",
6
6
  "license": "ISC",
@@ -54,5 +54,5 @@
54
54
  "supertest": "^6.2.3",
55
55
  "supervisor": "^0.12.0"
56
56
  },
57
- "gitHead": "50cda463bcaad6e5aa03bded3329269a9ac99398"
57
+ "gitHead": "c983de0f3b8d4978e2fb0b68551ba347dbdc4d55"
58
58
  }