@devx-retailos/cms 0.0.1 → 0.1.0

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.
Files changed (30) hide show
  1. package/.medusa/server/src/api/admin/retailos/cms/accumulation/[storeId]/route.js +3 -5
  2. package/.medusa/server/src/api/admin/retailos/cms/export/route.js +2 -2
  3. package/.medusa/server/src/api/admin/retailos/cms/handovers/[id]/route.js +2 -2
  4. package/.medusa/server/src/api/admin/retailos/cms/handovers/route.js +72 -7
  5. package/.medusa/server/src/api/admin/retailos/cms/petty-cash/route.js +35 -4
  6. package/.medusa/server/src/api/admin/retailos/cms/reconciliation/route.js +42 -0
  7. package/.medusa/server/src/modules/cms/migrations/Migration20260626000000.js +16 -0
  8. package/.medusa/server/src/modules/cms/models/cms-handover.js +3 -1
  9. package/.medusa/server/src/modules/cms/permissions.js +6 -1
  10. package/.medusa/server/src/modules/cms/services/cms-module-service.js +137 -42
  11. package/README.md +116 -0
  12. package/package.json +3 -3
  13. package/src/api/admin/retailos/cms/accumulation/[storeId]/__tests__/route.test.ts +27 -9
  14. package/src/api/admin/retailos/cms/accumulation/[storeId]/route.ts +2 -4
  15. package/src/api/admin/retailos/cms/export/__tests__/route.test.ts +9 -9
  16. package/src/api/admin/retailos/cms/export/route.ts +1 -1
  17. package/src/api/admin/retailos/cms/handovers/[id]/__tests__/route.test.ts +5 -5
  18. package/src/api/admin/retailos/cms/handovers/[id]/route.ts +1 -1
  19. package/src/api/admin/retailos/cms/handovers/__tests__/route.test.ts +22 -11
  20. package/src/api/admin/retailos/cms/handovers/route.ts +78 -6
  21. package/src/api/admin/retailos/cms/petty-cash/__tests__/route.test.ts +18 -12
  22. package/src/api/admin/retailos/cms/petty-cash/route.ts +46 -4
  23. package/src/api/admin/retailos/cms/reconciliation/__tests__/route.test.ts +124 -0
  24. package/src/api/admin/retailos/cms/reconciliation/route.ts +47 -0
  25. package/src/modules/cms/__tests__/cms-module-service.test.ts +219 -78
  26. package/src/modules/cms/__tests__/permissions.test.ts +3 -2
  27. package/src/modules/cms/migrations/Migration20260626000000.ts +17 -0
  28. package/src/modules/cms/models/cms-handover.ts +2 -0
  29. package/src/modules/cms/permissions.ts +5 -0
  30. package/src/modules/cms/services/cms-module-service.ts +217 -41
@@ -4,18 +4,18 @@ import CmsModuleService from "../services/cms-module-service"
4
4
 
5
5
  // Test subclass that overrides all MedusaService base CRUD methods
6
6
  class TestCmsService extends (CmsModuleService as any) {
7
- createCmsHandovers = vi.fn()
8
- listCmsHandovers = vi.fn()
9
- listAndCountCmsHandovers = vi.fn()
10
- retrieveCmsHandover = vi.fn()
11
- createCmsAccumulations = vi.fn()
12
- listCmsAccumulations = vi.fn()
13
- updateCmsAccumulations = vi.fn()
14
- createPettyCashes = vi.fn()
15
- listPettyCashes = vi.fn()
16
- updatePettyCashes = vi.fn()
17
- createPettyCashTransactions = vi.fn()
18
- listPettyCashTransactions = vi.fn()
7
+ createRetailosCmsHandovers = vi.fn()
8
+ listRetailosCmsHandovers = vi.fn().mockResolvedValue([])
9
+ listAndCountRetailosCmsHandovers = vi.fn()
10
+ retrieveRetailosCmsHandover = vi.fn()
11
+ createRetailosCmsAccumulations = vi.fn()
12
+ listRetailosCmsAccumulations = vi.fn()
13
+ updateRetailosCmsAccumulations = vi.fn()
14
+ createRetailosPettyCashes = vi.fn()
15
+ listRetailosPettyCashes = vi.fn().mockResolvedValue([])
16
+ updateRetailosPettyCashes = vi.fn()
17
+ createRetailosPettyCashTransactions = vi.fn()
18
+ listRetailosPettyCashTransactions = vi.fn()
19
19
 
20
20
  constructor() {
21
21
  // Skip super() to avoid MedusaService container requirement
@@ -37,30 +37,30 @@ function makeService(): TestCmsService {
37
37
  describe("CmsModuleService.dayStart", () => {
38
38
  it("creates accumulation when none exists", async () => {
39
39
  const service = makeService()
40
- service.listCmsAccumulations.mockResolvedValue([])
41
- service.createCmsAccumulations.mockResolvedValue([{ id: "cmsa_1", store_id: "s1", cash_in_store: 500 }])
42
- service.createPettyCashTransactions.mockResolvedValue([{ id: "pct_1" }])
40
+ service.listRetailosCmsAccumulations.mockResolvedValue([])
41
+ service.createRetailosCmsAccumulations.mockResolvedValue([{ id: "cmsa_1", store_id: "s1", cash_in_store: 500 }])
42
+ service.createRetailosPettyCashTransactions.mockResolvedValue([{ id: "pct_1" }])
43
43
 
44
44
  await service.dayStart({ store_id: "s1", employee_id: "emp_1", opening_amount: 500 })
45
45
 
46
- expect(service.createCmsAccumulations).toHaveBeenCalledWith([
46
+ expect(service.createRetailosCmsAccumulations).toHaveBeenCalledWith([
47
47
  expect.objectContaining({ store_id: "s1", cash_in_store: 500 }),
48
48
  ])
49
- expect(service.createPettyCashTransactions).toHaveBeenCalledWith([
49
+ expect(service.createRetailosPettyCashTransactions).toHaveBeenCalledWith([
50
50
  expect.objectContaining({ transaction_type: "open", entry_type: "CR", amount: 500 }),
51
51
  ])
52
52
  })
53
53
 
54
54
  it("updates accumulation when already exists", async () => {
55
55
  const service = makeService()
56
- service.listCmsAccumulations.mockResolvedValue([{ id: "cmsa_1", store_id: "s1", cash_in_store: 0 }])
57
- service.updateCmsAccumulations.mockResolvedValue([])
58
- service.createPettyCashTransactions.mockResolvedValue([{ id: "pct_1" }])
56
+ service.listRetailosCmsAccumulations.mockResolvedValue([{ id: "cmsa_1", store_id: "s1", cash_in_store: 0 }])
57
+ service.updateRetailosCmsAccumulations.mockResolvedValue([])
58
+ service.createRetailosPettyCashTransactions.mockResolvedValue([{ id: "pct_1" }])
59
59
 
60
60
  await service.dayStart({ store_id: "s1", employee_id: "emp_1", opening_amount: 1000 })
61
61
 
62
- expect(service.createCmsAccumulations).not.toHaveBeenCalled()
63
- expect(service.updateCmsAccumulations).toHaveBeenCalled()
62
+ expect(service.createRetailosCmsAccumulations).not.toHaveBeenCalled()
63
+ expect(service.updateRetailosCmsAccumulations).toHaveBeenCalled()
64
64
  })
65
65
 
66
66
  it("throws when opening_amount is negative", async () => {
@@ -74,12 +74,12 @@ describe("CmsModuleService.dayStart", () => {
74
74
  describe("CmsModuleService.dayEnd", () => {
75
75
  it("records closing transaction with difference", async () => {
76
76
  const service = makeService()
77
- service.listCmsAccumulations.mockResolvedValue([{ id: "cmsa_1", store_id: "s1", cash_in_store: 1000 }])
78
- service.createPettyCashTransactions.mockResolvedValue([{ id: "pct_2" }])
77
+ service.listRetailosCmsAccumulations.mockResolvedValue([{ id: "cmsa_1", store_id: "s1", cash_in_store: 1000 }])
78
+ service.createRetailosPettyCashTransactions.mockResolvedValue([{ id: "pct_2" }])
79
79
 
80
80
  await service.dayEnd({ store_id: "s1", employee_id: "emp_1", closing_amount: 800 })
81
81
 
82
- expect(service.createPettyCashTransactions).toHaveBeenCalledWith([
82
+ expect(service.createRetailosPettyCashTransactions).toHaveBeenCalledWith([
83
83
  expect.objectContaining({
84
84
  transaction_type: "close",
85
85
  entry_type: "DB",
@@ -101,9 +101,9 @@ describe("CmsModuleService.dayEnd", () => {
101
101
  describe("CmsModuleService.handover", () => {
102
102
  it("creates handover and increases accumulation for CR type", async () => {
103
103
  const service = makeService()
104
- service.createCmsHandovers.mockResolvedValue([{ id: "cmsh_1", store_id: "s1", handover_amount: 200, type: "CR" }])
105
- service.listCmsAccumulations.mockResolvedValue([{ id: "cmsa_1", store_id: "s1", cash_in_store: 1000 }])
106
- service.updateCmsAccumulations.mockResolvedValue([])
104
+ service.createRetailosCmsHandovers.mockResolvedValue([{ id: "cmsh_1", store_id: "s1", handover_amount: 200, type: "CR" }])
105
+ service.listRetailosCmsAccumulations.mockResolvedValue([{ id: "cmsa_1", store_id: "s1", cash_in_store: 1000 }])
106
+ service.updateRetailosCmsAccumulations.mockResolvedValue([])
107
107
 
108
108
  const result = await service.handover({
109
109
  store_id: "s1",
@@ -113,20 +113,20 @@ describe("CmsModuleService.handover", () => {
113
113
  })
114
114
 
115
115
  expect(result).toMatchObject({ id: "cmsh_1" })
116
- expect(service.updateCmsAccumulations).toHaveBeenCalledWith([
116
+ expect(service.updateRetailosCmsAccumulations).toHaveBeenCalledWith([
117
117
  expect.objectContaining({ id: "cmsa_1", cash_in_store: 1200 }),
118
118
  ])
119
119
  })
120
120
 
121
121
  it("decreases accumulation for DB type", async () => {
122
122
  const service = makeService()
123
- service.createCmsHandovers.mockResolvedValue([{ id: "cmsh_2", store_id: "s1", handover_amount: 300, type: "DB" }])
124
- service.listCmsAccumulations.mockResolvedValue([{ id: "cmsa_1", store_id: "s1", cash_in_store: 1000 }])
125
- service.updateCmsAccumulations.mockResolvedValue([])
123
+ service.createRetailosCmsHandovers.mockResolvedValue([{ id: "cmsh_2", store_id: "s1", handover_amount: 300, type: "DB" }])
124
+ service.listRetailosCmsAccumulations.mockResolvedValue([{ id: "cmsa_1", store_id: "s1", cash_in_store: 1000 }])
125
+ service.updateRetailosCmsAccumulations.mockResolvedValue([])
126
126
 
127
127
  await service.handover({ store_id: "s1", employee_id: "emp_1", handover_amount: 300, type: "DB" })
128
128
 
129
- expect(service.updateCmsAccumulations).toHaveBeenCalledWith([
129
+ expect(service.updateRetailosCmsAccumulations).toHaveBeenCalledWith([
130
130
  expect.objectContaining({ id: "cmsa_1", cash_in_store: 700 }),
131
131
  ])
132
132
  })
@@ -142,16 +142,16 @@ describe("CmsModuleService.handover", () => {
142
142
  describe("CmsModuleService.addPettyCash", () => {
143
143
  it("creates petty cash record and transaction when none exists", async () => {
144
144
  const service = makeService()
145
- service.listPettyCashes.mockResolvedValue([])
146
- service.createPettyCashes.mockResolvedValue([{ id: "pc_1", store_id: "s1", balance: 500 }])
147
- service.createPettyCashTransactions.mockResolvedValue([{ id: "pct_3" }])
145
+ service.listRetailosPettyCashes.mockResolvedValue([])
146
+ service.createRetailosPettyCashes.mockResolvedValue([{ id: "pc_1", store_id: "s1", balance: 500 }])
147
+ service.createRetailosPettyCashTransactions.mockResolvedValue([{ id: "pct_3" }])
148
148
 
149
149
  await service.addPettyCash({ store_id: "s1", employee_id: "emp_1", amount: 500 })
150
150
 
151
- expect(service.createPettyCashes).toHaveBeenCalledWith([
151
+ expect(service.createRetailosPettyCashes).toHaveBeenCalledWith([
152
152
  expect.objectContaining({ store_id: "s1", balance: 500 }),
153
153
  ])
154
- expect(service.createPettyCashTransactions).toHaveBeenCalledWith([
154
+ expect(service.createRetailosPettyCashTransactions).toHaveBeenCalledWith([
155
155
  expect.objectContaining({ entry_type: "CR", amount: 500 }),
156
156
  ])
157
157
  })
@@ -167,16 +167,16 @@ describe("CmsModuleService.addPettyCash", () => {
167
167
  describe("CmsModuleService.addExpense", () => {
168
168
  it("debits petty cash and records transaction", async () => {
169
169
  const service = makeService()
170
- service.listPettyCashes.mockResolvedValue([{ id: "pc_1", store_id: "s1", balance: 500 }])
171
- service.updatePettyCashes.mockResolvedValue([])
172
- service.createPettyCashTransactions.mockResolvedValue([{ id: "pct_4" }])
170
+ service.listRetailosPettyCashes.mockResolvedValue([{ id: "pc_1", store_id: "s1", balance: 500 }])
171
+ service.updateRetailosPettyCashes.mockResolvedValue([])
172
+ service.createRetailosPettyCashTransactions.mockResolvedValue([{ id: "pct_4" }])
173
173
 
174
174
  await service.addExpense({ store_id: "s1", employee_id: "emp_1", amount: 100, category: "office" })
175
175
 
176
- expect(service.updatePettyCashes).toHaveBeenCalledWith([
176
+ expect(service.updateRetailosPettyCashes).toHaveBeenCalledWith([
177
177
  expect.objectContaining({ id: "pc_1", balance: 400 }),
178
178
  ])
179
- expect(service.createPettyCashTransactions).toHaveBeenCalledWith([
179
+ expect(service.createRetailosPettyCashTransactions).toHaveBeenCalledWith([
180
180
  expect.objectContaining({ entry_type: "DB", expense_amount: 100, category: "office" }),
181
181
  ])
182
182
  })
@@ -192,7 +192,7 @@ describe("CmsModuleService.addExpense", () => {
192
192
  describe("CmsModuleService.getAccumulation", () => {
193
193
  it("returns accumulation for store", async () => {
194
194
  const service = makeService()
195
- service.listCmsAccumulations.mockResolvedValue([{ id: "cmsa_1", store_id: "s1", cash_in_store: 750 }])
195
+ service.listRetailosCmsAccumulations.mockResolvedValue([{ id: "cmsa_1", store_id: "s1", cash_in_store: 750 }])
196
196
 
197
197
  const result = await service.getAccumulation("s1")
198
198
 
@@ -201,7 +201,7 @@ describe("CmsModuleService.getAccumulation", () => {
201
201
 
202
202
  it("returns null when no accumulation exists", async () => {
203
203
  const service = makeService()
204
- service.listCmsAccumulations.mockResolvedValue([])
204
+ service.listRetailosCmsAccumulations.mockResolvedValue([])
205
205
 
206
206
  const result = await service.getAccumulation("s1")
207
207
 
@@ -216,11 +216,11 @@ describe("CmsModuleService.getShiftLogs", () => {
216
216
  { id: "pct_1", transaction_type: "open", store_id: "s1" },
217
217
  { id: "pct_2", transaction_type: "close", store_id: "s1" },
218
218
  ]
219
- service.listPettyCashTransactions.mockResolvedValue(mockLogs)
219
+ service.listRetailosPettyCashTransactions.mockResolvedValue(mockLogs)
220
220
 
221
221
  const result = await service.getShiftLogs({ store_id: "s1" })
222
222
 
223
- expect(service.listPettyCashTransactions).toHaveBeenCalledWith(
223
+ expect(service.listRetailosPettyCashTransactions).toHaveBeenCalledWith(
224
224
  expect.objectContaining({ transaction_type: ["open", "close"], store_id: ["s1"] }),
225
225
  expect.any(Object)
226
226
  )
@@ -229,23 +229,23 @@ describe("CmsModuleService.getShiftLogs", () => {
229
229
 
230
230
  it("omits store_id filter when not provided", async () => {
231
231
  const service = makeService()
232
- service.listPettyCashTransactions.mockResolvedValue([])
232
+ service.listRetailosPettyCashTransactions.mockResolvedValue([])
233
233
 
234
234
  await service.getShiftLogs({})
235
235
 
236
- expect(service.listPettyCashTransactions).toHaveBeenCalledWith(
236
+ expect(service.listRetailosPettyCashTransactions).toHaveBeenCalledWith(
237
237
  expect.not.objectContaining({ store_id: expect.anything() }),
238
238
  expect.any(Object)
239
239
  )
240
240
  })
241
241
 
242
- it("passes custom limit to listPettyCashTransactions", async () => {
242
+ it("passes custom limit to listRetailosPettyCashTransactions", async () => {
243
243
  const service = makeService()
244
- service.listPettyCashTransactions.mockResolvedValue([])
244
+ service.listRetailosPettyCashTransactions.mockResolvedValue([])
245
245
 
246
246
  await service.getShiftLogs({ store_id: "s1", limit: 10 })
247
247
 
248
- expect(service.listPettyCashTransactions).toHaveBeenCalledWith(
248
+ expect(service.listRetailosPettyCashTransactions).toHaveBeenCalledWith(
249
249
  expect.any(Object),
250
250
  expect.objectContaining({ take: 10 })
251
251
  )
@@ -255,12 +255,12 @@ describe("CmsModuleService.getShiftLogs", () => {
255
255
  describe("CmsModuleService.dayEnd - no prior accumulation", () => {
256
256
  it("uses zero as current_cash when no accumulation exists", async () => {
257
257
  const service = makeService()
258
- service.listCmsAccumulations.mockResolvedValue([])
259
- service.createPettyCashTransactions.mockResolvedValue([{ id: "pct_x" }])
258
+ service.listRetailosCmsAccumulations.mockResolvedValue([])
259
+ service.createRetailosPettyCashTransactions.mockResolvedValue([{ id: "pct_x" }])
260
260
 
261
261
  await service.dayEnd({ store_id: "s1", employee_id: "emp_1", closing_amount: 300 })
262
262
 
263
- expect(service.createPettyCashTransactions).toHaveBeenCalledWith([
263
+ expect(service.createRetailosPettyCashTransactions).toHaveBeenCalledWith([
264
264
  expect.objectContaining({
265
265
  opening_balance: 0,
266
266
  closing_balance: 300,
@@ -271,28 +271,24 @@ describe("CmsModuleService.dayEnd - no prior accumulation", () => {
271
271
  })
272
272
 
273
273
  describe("CmsModuleService.handover - edge cases", () => {
274
- it("creates new accumulation when none exists", async () => {
274
+ it("throws when no Day Start has been run for the store", async () => {
275
275
  const service = makeService()
276
- service.createCmsHandovers.mockResolvedValue([{ id: "cmsh_3", store_id: "s1", handover_amount: 100, type: "CR" }])
277
- service.listCmsAccumulations.mockResolvedValue([])
278
- service.createCmsAccumulations.mockResolvedValue([])
276
+ service.listRetailosCmsAccumulations.mockResolvedValue([])
279
277
 
280
- await service.handover({ store_id: "s1", employee_id: "emp_1", handover_amount: 100, type: "CR" })
281
-
282
- expect(service.createCmsAccumulations).toHaveBeenCalledWith([
283
- expect.objectContaining({ store_id: "s1", cash_in_store: 100 }),
284
- ])
278
+ await expect(
279
+ service.handover({ store_id: "s1", employee_id: "emp_1", handover_amount: 100, type: "CR" })
280
+ ).rejects.toThrow("Day Start must be completed before creating a handover")
285
281
  })
286
282
 
287
283
  it("does not change cash when type is null", async () => {
288
284
  const service = makeService()
289
- service.createCmsHandovers.mockResolvedValue([{ id: "cmsh_4", store_id: "s1", handover_amount: 50, type: null }])
290
- service.listCmsAccumulations.mockResolvedValue([{ id: "cmsa_1", store_id: "s1", cash_in_store: 1000 }])
291
- service.updateCmsAccumulations.mockResolvedValue([])
285
+ service.createRetailosCmsHandovers.mockResolvedValue([{ id: "cmsh_4", store_id: "s1", handover_amount: 50, type: null }])
286
+ service.listRetailosCmsAccumulations.mockResolvedValue([{ id: "cmsa_1", store_id: "s1", cash_in_store: 1000 }])
287
+ service.updateRetailosCmsAccumulations.mockResolvedValue([])
292
288
 
293
289
  await service.handover({ store_id: "s1", employee_id: "emp_1", handover_amount: 50, type: null })
294
290
 
295
- expect(service.updateCmsAccumulations).toHaveBeenCalledWith([
291
+ expect(service.updateRetailosCmsAccumulations).toHaveBeenCalledWith([
296
292
  expect.objectContaining({ cash_in_store: 1000 }),
297
293
  ])
298
294
  })
@@ -301,17 +297,17 @@ describe("CmsModuleService.handover - edge cases", () => {
301
297
  describe("CmsModuleService.addPettyCash - update path", () => {
302
298
  it("updates existing petty cash balance", async () => {
303
299
  const service = makeService()
304
- service.listPettyCashes.mockResolvedValue([{ id: "pc_1", store_id: "s1", balance: 200 }])
305
- service.updatePettyCashes.mockResolvedValue([])
306
- service.createPettyCashTransactions.mockResolvedValue([{ id: "pct_5" }])
300
+ service.listRetailosPettyCashes.mockResolvedValue([{ id: "pc_1", store_id: "s1", balance: 200 }])
301
+ service.updateRetailosPettyCashes.mockResolvedValue([])
302
+ service.createRetailosPettyCashTransactions.mockResolvedValue([{ id: "pct_5" }])
307
303
 
308
304
  await service.addPettyCash({ store_id: "s1", employee_id: "emp_1", amount: 300 })
309
305
 
310
- expect(service.createPettyCashes).not.toHaveBeenCalled()
311
- expect(service.updatePettyCashes).toHaveBeenCalledWith([
306
+ expect(service.createRetailosPettyCashes).not.toHaveBeenCalled()
307
+ expect(service.updateRetailosPettyCashes).toHaveBeenCalledWith([
312
308
  expect.objectContaining({ id: "pc_1", balance: 500 }),
313
309
  ])
314
- expect(service.createPettyCashTransactions).toHaveBeenCalledWith([
310
+ expect(service.createRetailosPettyCashTransactions).toHaveBeenCalledWith([
315
311
  expect.objectContaining({ opening_balance: 200, closing_balance: 500 }),
316
312
  ])
317
313
  })
@@ -320,14 +316,159 @@ describe("CmsModuleService.addPettyCash - update path", () => {
320
316
  describe("CmsModuleService.addExpense - no existing petty cash", () => {
321
317
  it("creates petty cash record when none exists", async () => {
322
318
  const service = makeService()
323
- service.listPettyCashes.mockResolvedValue([])
324
- service.createPettyCashes.mockResolvedValue([{ id: "pc_new", store_id: "s1", balance: -100 }])
325
- service.createPettyCashTransactions.mockResolvedValue([{ id: "pct_6" }])
319
+ service.listRetailosPettyCashes.mockResolvedValue([])
320
+ service.createRetailosPettyCashes.mockResolvedValue([{ id: "pc_new", store_id: "s1", balance: -100 }])
321
+ service.createRetailosPettyCashTransactions.mockResolvedValue([{ id: "pct_6" }])
326
322
 
327
323
  await service.addExpense({ store_id: "s1", employee_id: "emp_1", amount: 100 })
328
324
 
329
- expect(service.createPettyCashes).toHaveBeenCalledWith([
325
+ expect(service.createRetailosPettyCashes).toHaveBeenCalledWith([
330
326
  expect.objectContaining({ store_id: "s1", balance: -100 }),
331
327
  ])
332
328
  })
333
329
  })
330
+
331
+ describe("CmsModuleService.getReconciliationReport", () => {
332
+ it("returns zeros when no transactions exist for the day", async () => {
333
+ const service = makeService()
334
+ service.listRetailosPettyCashTransactions.mockResolvedValue([])
335
+ service.listRetailosCmsHandovers.mockResolvedValue([])
336
+ service.listRetailosCmsAccumulations.mockResolvedValue([{ id: "cmsa_1", cash_in_store: 5000 }])
337
+
338
+ const result = await service.getReconciliationReport({ store_id: "s1", date: "2026-06-23" })
339
+
340
+ expect(result).toMatchObject({
341
+ date: "2026-06-23",
342
+ store_id: "s1",
343
+ opening_balance: 0,
344
+ net_cash_sales: 0,
345
+ total_handovers_in: 0,
346
+ total_handovers_out: 0,
347
+ petty_cash_in: 0,
348
+ petty_cash_out: 0,
349
+ expected_closing_balance: 0,
350
+ closing_balance: null,
351
+ cms_balance: 5000,
352
+ variance: null,
353
+ is_balanced: null,
354
+ })
355
+ })
356
+
357
+ it("computes expected_closing_balance correctly", async () => {
358
+ const service = makeService()
359
+ service.listRetailosPettyCashTransactions.mockResolvedValue([
360
+ { transaction_type: "open", entry_type: "CR", amount: 8200 },
361
+ { transaction_type: "petty_cash", entry_type: "CR", amount: 500 },
362
+ { transaction_type: "petty_cash", entry_type: "DB", amount: 1000 },
363
+ ])
364
+ service.listRetailosCmsHandovers.mockResolvedValue([
365
+ { type: "CR", handover_amount: 2000 },
366
+ { type: "DB", handover_amount: 5000 },
367
+ ])
368
+ service.listRetailosCmsAccumulations.mockResolvedValue([{ id: "cmsa_1", cash_in_store: 12700 }])
369
+
370
+ const result = await service.getReconciliationReport({
371
+ store_id: "s1",
372
+ date: "2026-06-23",
373
+ net_cash_sales: 28450,
374
+ })
375
+
376
+ // 8200 + 28450 + 500 - 1000 + 2000 - 5000 = 33150
377
+ expect(result.expected_closing_balance).toBe(33150)
378
+ expect(result.opening_balance).toBe(8200)
379
+ expect(result.petty_cash_in).toBe(500)
380
+ expect(result.petty_cash_out).toBe(1000)
381
+ expect(result.total_handovers_in).toBe(2000)
382
+ expect(result.total_handovers_out).toBe(5000)
383
+ })
384
+
385
+ it("computes variance and is_balanced when day is closed", async () => {
386
+ const service = makeService()
387
+ service.listRetailosPettyCashTransactions.mockResolvedValue([
388
+ { transaction_type: "open", entry_type: "CR", amount: 8200 },
389
+ { transaction_type: "close", entry_type: "DB", amount: 31650 },
390
+ ])
391
+ service.listRetailosCmsHandovers.mockResolvedValue([
392
+ { type: "DB", handover_amount: 5000 },
393
+ ])
394
+ service.listRetailosCmsAccumulations.mockResolvedValue([])
395
+
396
+ const result = await service.getReconciliationReport({
397
+ store_id: "s1",
398
+ date: "2026-06-23",
399
+ net_cash_sales: 28450,
400
+ })
401
+
402
+ // expected = 8200 + 28450 - 5000 = 31650; actual = 31650
403
+ expect(result.expected_closing_balance).toBe(31650)
404
+ expect(result.closing_balance).toBe(31650)
405
+ expect(result.variance).toBe(0)
406
+ expect(result.is_balanced).toBe(true)
407
+ expect(result.cms_balance).toBeNull()
408
+ })
409
+
410
+ it("detects variance when actual closing differs from expected", async () => {
411
+ const service = makeService()
412
+ service.listRetailosPettyCashTransactions.mockResolvedValue([
413
+ { transaction_type: "open", entry_type: "CR", amount: 8200 },
414
+ { transaction_type: "close", entry_type: "DB", amount: 16350 },
415
+ ])
416
+ service.listRetailosCmsHandovers.mockResolvedValue([
417
+ { type: "DB", handover_amount: 5000 },
418
+ ])
419
+ service.listRetailosCmsAccumulations.mockResolvedValue([{ id: "cmsa_1", cash_in_store: 50500 }])
420
+
421
+ const result = await service.getReconciliationReport({
422
+ store_id: "s1",
423
+ date: "2026-06-23",
424
+ net_cash_sales: 28450,
425
+ })
426
+
427
+ // expected = 8200 + 28450 - 5000 = 31650; actual = 16350
428
+ expect(result.variance).toBe(-15300)
429
+ expect(result.is_balanced).toBe(false)
430
+ })
431
+
432
+ it("filters transactions by date range using created_at", async () => {
433
+ const service = makeService()
434
+ service.listRetailosPettyCashTransactions.mockResolvedValue([])
435
+ service.listRetailosCmsHandovers.mockResolvedValue([])
436
+ service.listRetailosCmsAccumulations.mockResolvedValue([])
437
+
438
+ await service.getReconciliationReport({ store_id: "s1", date: "2026-06-23" })
439
+
440
+ expect(service.listRetailosPettyCashTransactions).toHaveBeenCalledWith(
441
+ expect.objectContaining({
442
+ store_id: ["s1"],
443
+ created_at: expect.objectContaining({ $gte: expect.any(Date), $lte: expect.any(Date) }),
444
+ }),
445
+ expect.any(Object)
446
+ )
447
+ expect(service.listRetailosCmsHandovers).toHaveBeenCalledWith(
448
+ expect.objectContaining({
449
+ store_id: ["s1"],
450
+ created_at: expect.objectContaining({ $gte: expect.any(Date), $lte: expect.any(Date) }),
451
+ }),
452
+ expect.any(Object)
453
+ )
454
+ })
455
+
456
+ it("separates shift_logs from handovers in the report", async () => {
457
+ const service = makeService()
458
+ const openTx = { id: "pct_open", transaction_type: "open", entry_type: "CR", amount: 8200 }
459
+ const closeTx = { id: "pct_close", transaction_type: "close", entry_type: "DB", amount: 8200 }
460
+ const pettyCashTx = { id: "pct_pc", transaction_type: "petty_cash", entry_type: "CR", amount: 500 }
461
+ const handover = { id: "cmsh_1", type: "DB", handover_amount: 1000 }
462
+
463
+ service.listRetailosPettyCashTransactions.mockResolvedValue([openTx, pettyCashTx, closeTx])
464
+ service.listRetailosCmsHandovers.mockResolvedValue([handover])
465
+ service.listRetailosCmsAccumulations.mockResolvedValue([])
466
+
467
+ const result = await service.getReconciliationReport({ store_id: "s1", date: "2026-06-23" })
468
+
469
+ expect(result.shift_logs).toHaveLength(2)
470
+ expect(result.shift_logs).toEqual(expect.arrayContaining([openTx, closeTx]))
471
+ expect(result.handovers).toHaveLength(1)
472
+ expect(result.handovers[0]).toMatchObject({ id: "cmsh_1" })
473
+ })
474
+ })
@@ -9,10 +9,11 @@ describe("CMS_PERMISSIONS", () => {
9
9
  "cms.petty_cash.read",
10
10
  "cms.petty_cash.operate",
11
11
  "cms.accumulation.read",
12
+ "cms.reconciliation.read",
12
13
  ]
13
14
 
14
- it("exports exactly 6 permission keys", () => {
15
- expect(CMS_PERMISSIONS).toHaveLength(6)
15
+ it("exports exactly 7 permission keys", () => {
16
+ expect(CMS_PERMISSIONS).toHaveLength(7)
16
17
  })
17
18
 
18
19
  it("contains all required permission keys", () => {
@@ -0,0 +1,17 @@
1
+ import { Migration } from "@medusajs/framework/mikro-orm/migrations"
2
+
3
+ export class Migration20260626000000 extends Migration {
4
+ override async up(): Promise<void> {
5
+ this.addSql(
6
+ `alter table if exists "retailos_cms_handover" add column if not exists "opening_balance" numeric null;`
7
+ )
8
+ this.addSql(
9
+ `alter table if exists "retailos_cms_handover" add column if not exists "closing_balance" numeric null;`
10
+ )
11
+ }
12
+
13
+ override async down(): Promise<void> {
14
+ this.addSql(`alter table if exists "retailos_cms_handover" drop column if exists "opening_balance";`)
15
+ this.addSql(`alter table if exists "retailos_cms_handover" drop column if exists "closing_balance";`)
16
+ }
17
+ }
@@ -7,6 +7,8 @@ const CmsHandover = model.define("retailos_cms_handover", {
7
7
  handover_id: model.text().nullable(), // free-text correlation field: the brand can pass their own ID (e.g. a bank slip number, a vault reference, a POS transaction ID) so they can cross-reference this handover record with their own external system.
8
8
  handover_amount: model.number().nullable(),
9
9
  total_cash: model.number().nullable(),
10
+ opening_balance: model.number().nullable(),
11
+ closing_balance: model.number().nullable(),
10
12
  type: model.text().nullable(),
11
13
  image_url: model.text().nullable(),
12
14
  remark: model.text().nullable(),
@@ -31,4 +31,9 @@ export const CMS_PERMISSIONS: PermissionRegistration[] = [
31
31
  description: "View current cash accumulation for a store",
32
32
  registered_by: "cms",
33
33
  },
34
+ {
35
+ key: "cms.reconciliation.read",
36
+ description: "View daily cash reconciliation report with variance analysis",
37
+ registered_by: "cms",
38
+ },
34
39
  ]