@devx-retailos/cms 0.0.1 → 0.0.2

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 (27) hide show
  1. package/.medusa/server/src/api/admin/retailos/cms/export/route.js +2 -2
  2. package/.medusa/server/src/api/admin/retailos/cms/handovers/[id]/route.js +2 -2
  3. package/.medusa/server/src/api/admin/retailos/cms/handovers/route.js +72 -7
  4. package/.medusa/server/src/api/admin/retailos/cms/petty-cash/route.js +35 -4
  5. package/.medusa/server/src/api/admin/retailos/cms/reconciliation/route.js +42 -0
  6. package/.medusa/server/src/modules/cms/migrations/Migration20260626000000.js +16 -0
  7. package/.medusa/server/src/modules/cms/models/cms-handover.js +3 -1
  8. package/.medusa/server/src/modules/cms/permissions.js +6 -1
  9. package/.medusa/server/src/modules/cms/services/cms-module-service.js +108 -38
  10. package/README.md +116 -0
  11. package/package.json +1 -1
  12. package/src/api/admin/retailos/cms/export/__tests__/route.test.ts +9 -9
  13. package/src/api/admin/retailos/cms/export/route.ts +1 -1
  14. package/src/api/admin/retailos/cms/handovers/[id]/__tests__/route.test.ts +5 -5
  15. package/src/api/admin/retailos/cms/handovers/[id]/route.ts +1 -1
  16. package/src/api/admin/retailos/cms/handovers/__tests__/route.test.ts +22 -11
  17. package/src/api/admin/retailos/cms/handovers/route.ts +78 -6
  18. package/src/api/admin/retailos/cms/petty-cash/__tests__/route.test.ts +18 -12
  19. package/src/api/admin/retailos/cms/petty-cash/route.ts +46 -4
  20. package/src/api/admin/retailos/cms/reconciliation/__tests__/route.test.ts +124 -0
  21. package/src/api/admin/retailos/cms/reconciliation/route.ts +47 -0
  22. package/src/modules/cms/__tests__/cms-module-service.test.ts +219 -78
  23. package/src/modules/cms/__tests__/permissions.test.ts +3 -2
  24. package/src/modules/cms/migrations/Migration20260626000000.ts +17 -0
  25. package/src/modules/cms/models/cms-handover.ts +2 -0
  26. package/src/modules/cms/permissions.ts +5 -0
  27. package/src/modules/cms/services/cms-module-service.ts +163 -37
@@ -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
  ]
@@ -59,11 +59,37 @@ export interface ShiftLogFilters {
59
59
  limit?: number
60
60
  }
61
61
 
62
+ export interface ReconciliationInput {
63
+ store_id: string
64
+ /** Calendar date in YYYY-MM-DD format (treated as UTC). */
65
+ date: string
66
+ /** Net cash collected from orders for the day. Defaults to 0 when not provided. */
67
+ net_cash_sales?: number
68
+ }
69
+
70
+ export interface ReconciliationReport {
71
+ date: string
72
+ store_id: string
73
+ opening_balance: number
74
+ net_cash_sales: number
75
+ total_handovers_in: number
76
+ total_handovers_out: number
77
+ petty_cash_in: number
78
+ petty_cash_out: number
79
+ expected_closing_balance: number
80
+ closing_balance: number | null
81
+ cms_balance: number | null
82
+ variance: number | null
83
+ is_balanced: boolean | null
84
+ handovers: unknown[]
85
+ shift_logs: unknown[]
86
+ }
87
+
62
88
  class CmsModuleService extends MedusaService({
63
- CmsHandover,
64
- CmsAccumulation,
65
- PettyCash,
66
- PettyCashTransaction,
89
+ RetailosCmsHandover: CmsHandover,
90
+ RetailosCmsAccumulation: CmsAccumulation,
91
+ RetailosPettyCash: PettyCash,
92
+ RetailosPettyCashTransaction: PettyCashTransaction,
67
93
  }) {
68
94
  protected logger_: Logger
69
95
 
@@ -80,14 +106,14 @@ class CmsModuleService extends MedusaService({
80
106
  throw new Error("[retailos/cms] opening_amount must be >= 0")
81
107
  }
82
108
 
83
- const existing = await this.listCmsAccumulations({ store_id: [store_id] })
109
+ const existing = await this.listRetailosCmsAccumulations({ store_id: [store_id] })
84
110
  if (existing.length === 0) {
85
- await this.createCmsAccumulations([{ store_id, cash_in_store: opening_amount }])
111
+ await this.createRetailosCmsAccumulations([{ store_id, cash_in_store: opening_amount }])
86
112
  } else {
87
- await this.updateCmsAccumulations([{ id: (existing[0] as any).id, cash_in_store: opening_amount }])
113
+ await this.updateRetailosCmsAccumulations([{ id: (existing[0] as any).id, cash_in_store: opening_amount }])
88
114
  }
89
115
 
90
- await this.createPettyCashTransactions([{
116
+ await this.createRetailosPettyCashTransactions([{
91
117
  store_id,
92
118
  employee_id,
93
119
  transaction_type: "open" as TransactionType,
@@ -115,11 +141,11 @@ class CmsModuleService extends MedusaService({
115
141
  throw new Error("[retailos/cms] closing_amount must be >= 0")
116
142
  }
117
143
 
118
- const accumulations = await this.listCmsAccumulations({ store_id: [store_id] })
144
+ const accumulations = await this.listRetailosCmsAccumulations({ store_id: [store_id] })
119
145
  const current_cash = accumulations[0] ? (accumulations[0] as any).cash_in_store : 0
120
146
  const difference = closing_amount - current_cash
121
147
 
122
- await this.createPettyCashTransactions([{
148
+ await this.createRetailosPettyCashTransactions([{
123
149
  store_id,
124
150
  employee_id,
125
151
  transaction_type: "close" as TransactionType,
@@ -147,39 +173,48 @@ class CmsModuleService extends MedusaService({
147
173
  throw new Error("[retailos/cms] handover_amount must be >= 0")
148
174
  }
149
175
 
150
- const [created] = await this.createCmsHandovers([{
176
+ const accumulations = await this.listRetailosCmsAccumulations({ store_id: [store_id] })
177
+ if (accumulations.length === 0) {
178
+ throw new Error("[retailos/cms] Day Start must be completed before creating a handover")
179
+ }
180
+
181
+ const opening_balance = (accumulations[0] as any).cash_in_store as number
182
+
183
+ let closing_balance = opening_balance
184
+ if (type === "CR") {
185
+ closing_balance = opening_balance + handover_amount
186
+ } else if (type === "DB") {
187
+ if (handover_amount > opening_balance) {
188
+ throw new Error(
189
+ `[retailos/cms] Insufficient cash in store: cannot debit ${handover_amount}, current balance is ${opening_balance}`
190
+ )
191
+ }
192
+ closing_balance = opening_balance - handover_amount
193
+ }
194
+
195
+ const [created] = await this.createRetailosCmsHandovers([{
151
196
  store_id,
152
197
  employee_id: input.employee_id,
153
198
  handover_id: input.handover_id ?? null,
154
199
  handover_amount,
155
- total_cash: input.total_cash ?? null,
200
+ total_cash: closing_balance,
201
+ opening_balance,
202
+ closing_balance,
156
203
  type: type ?? null,
157
204
  image_url: input.image_url ?? null,
158
205
  remark: input.remark ?? null,
159
206
  metadata: input.metadata ?? null,
160
207
  }])
161
208
 
162
- const accumulations = await this.listCmsAccumulations({ store_id: [store_id] })
163
- const current = accumulations[0] ? (accumulations[0] as any).cash_in_store : 0
164
-
165
- let updated_cash = current
166
- if (type === "CR") {
167
- updated_cash = current + handover_amount
168
- } else if (type === "DB") {
169
- updated_cash = current - handover_amount
170
- }
171
-
172
- if (accumulations.length === 0) {
173
- await this.createCmsAccumulations([{ store_id, cash_in_store: updated_cash }])
174
- } else {
175
- await this.updateCmsAccumulations([{ id: (accumulations[0] as any).id, cash_in_store: updated_cash }])
176
- }
209
+ await this.updateRetailosCmsAccumulations([{ id: (accumulations[0] as any).id, cash_in_store: closing_balance }])
177
210
 
178
211
  this.logger_.info("[retailos/cms] handover created", {
179
212
  id: (created as any)?.id,
180
213
  store_id,
181
214
  handover_amount,
182
215
  type,
216
+ opening_balance,
217
+ closing_balance,
183
218
  })
184
219
 
185
220
  return created
@@ -191,17 +226,34 @@ class CmsModuleService extends MedusaService({
191
226
  throw new Error("[retailos/cms] amount must be > 0")
192
227
  }
193
228
 
194
- const existing = await this.listPettyCashes({ store_id: [store_id] })
229
+ const existing = await this.listRetailosPettyCashes({ store_id: [store_id] })
195
230
  const current_balance = existing[0] ? (existing[0] as any).balance : 0
196
231
  const new_balance = current_balance + amount
197
232
 
198
233
  if (existing.length === 0) {
199
- await this.createPettyCashes([{ store_id, balance: new_balance }])
234
+ await this.createRetailosPettyCashes([{ store_id, balance: new_balance }])
200
235
  } else {
201
- await this.updatePettyCashes([{ id: (existing[0] as any).id, balance: new_balance }])
236
+ await this.updateRetailosPettyCashes([{ id: (existing[0] as any).id, balance: new_balance }])
237
+ }
238
+
239
+ // When funded from store cash, deduct the amount from cms_accumulation
240
+ if (source === "from_petty_cash") {
241
+ const accumulations = await this.listRetailosCmsAccumulations({ store_id: [store_id] })
242
+ const current_store_cash = accumulations[0] ? (accumulations[0] as any).cash_in_store : 0
243
+ const new_store_cash = current_store_cash - amount
244
+
245
+ if (accumulations.length === 0) {
246
+ await this.createRetailosCmsAccumulations([{ store_id, cash_in_store: new_store_cash }])
247
+ } else {
248
+ await this.updateRetailosCmsAccumulations([{ id: (accumulations[0] as any).id, cash_in_store: new_store_cash }])
249
+ }
250
+
251
+ this.logger_.info("[retailos/cms] store cash deducted for petty cash transfer", {
252
+ store_id, amount, new_store_cash,
253
+ })
202
254
  }
203
255
 
204
- const [tx] = await this.createPettyCashTransactions([{
256
+ const [tx] = await this.createRetailosPettyCashTransactions([{
205
257
  store_id,
206
258
  employee_id,
207
259
  transaction_type: "petty_cash" as TransactionType,
@@ -230,17 +282,17 @@ class CmsModuleService extends MedusaService({
230
282
  throw new Error("[retailos/cms] expense amount must be > 0")
231
283
  }
232
284
 
233
- const existing = await this.listPettyCashes({ store_id: [store_id] })
285
+ const existing = await this.listRetailosPettyCashes({ store_id: [store_id] })
234
286
  const current_balance = existing[0] ? (existing[0] as any).balance : 0
235
287
  const new_balance = current_balance - amount
236
288
 
237
289
  if (existing.length === 0) {
238
- await this.createPettyCashes([{ store_id, balance: new_balance }])
290
+ await this.createRetailosPettyCashes([{ store_id, balance: new_balance }])
239
291
  } else {
240
- await this.updatePettyCashes([{ id: (existing[0] as any).id, balance: new_balance }])
292
+ await this.updateRetailosPettyCashes([{ id: (existing[0] as any).id, balance: new_balance }])
241
293
  }
242
294
 
243
- const [tx] = await this.createPettyCashTransactions([{
295
+ const [tx] = await this.createRetailosPettyCashTransactions([{
244
296
  store_id,
245
297
  employee_id,
246
298
  transaction_type: "petty_cash" as TransactionType,
@@ -264,16 +316,90 @@ class CmsModuleService extends MedusaService({
264
316
  }
265
317
 
266
318
  async getAccumulation(store_id: string) {
267
- const rows = await this.listCmsAccumulations({ store_id: [store_id] })
319
+ const rows = await this.listRetailosCmsAccumulations({ store_id: [store_id] })
268
320
  return rows[0] ?? null
269
321
  }
270
322
 
323
+ async getReconciliationReport(input: ReconciliationInput): Promise<ReconciliationReport> {
324
+ const { store_id, date, net_cash_sales = 0 } = input
325
+
326
+ const start = new Date(`${date}T00:00:00.000Z`)
327
+ const end = new Date(`${date}T23:59:59.999Z`)
328
+ const dateFilter = { $gte: start, $lte: end }
329
+
330
+ const txns = await this.listRetailosPettyCashTransactions(
331
+ { store_id: [store_id], created_at: dateFilter },
332
+ { take: 1000, order: { created_at: "ASC" } as any }
333
+ )
334
+
335
+ const handovers = await this.listRetailosCmsHandovers(
336
+ { store_id: [store_id], created_at: dateFilter },
337
+ { take: 1000, order: { created_at: "ASC" } as any }
338
+ )
339
+
340
+ const accumulation = await this.getAccumulation(store_id)
341
+
342
+ const txnList = txns as any[]
343
+ const handoverList = handovers as any[]
344
+
345
+ const openTx = txnList.find((t) => t.transaction_type === "open")
346
+ const closeTx = txnList.find((t) => t.transaction_type === "close")
347
+
348
+ const opening_balance: number = openTx ? openTx.amount : 0
349
+ const closing_balance: number | null = closeTx ? closeTx.amount : null
350
+
351
+ const pettyCashTxns = txnList.filter((t) => t.transaction_type === "petty_cash")
352
+ const petty_cash_in = pettyCashTxns
353
+ .filter((t) => t.entry_type === "CR")
354
+ .reduce((sum: number, t: any) => sum + t.amount, 0)
355
+ const petty_cash_out = pettyCashTxns
356
+ .filter((t) => t.entry_type === "DB")
357
+ .reduce((sum: number, t: any) => sum + t.amount, 0)
358
+
359
+ const total_handovers_in = handoverList
360
+ .filter((h) => h.type === "CR")
361
+ .reduce((sum: number, h: any) => sum + h.handover_amount, 0)
362
+ const total_handovers_out = handoverList
363
+ .filter((h) => h.type === "DB")
364
+ .reduce((sum: number, h: any) => sum + h.handover_amount, 0)
365
+
366
+ const expected_closing_balance =
367
+ opening_balance + net_cash_sales + petty_cash_in - petty_cash_out + total_handovers_in - total_handovers_out
368
+
369
+ const variance = closing_balance !== null ? closing_balance - expected_closing_balance : null
370
+ const is_balanced = variance !== null ? variance === 0 : null
371
+
372
+ const shift_logs = txnList.filter(
373
+ (t) => t.transaction_type === "open" || t.transaction_type === "close"
374
+ )
375
+
376
+ this.logger_.info("[retailos/cms] reconciliation report generated", { store_id, date })
377
+
378
+ return {
379
+ date,
380
+ store_id,
381
+ opening_balance,
382
+ net_cash_sales,
383
+ total_handovers_in,
384
+ total_handovers_out,
385
+ petty_cash_in,
386
+ petty_cash_out,
387
+ expected_closing_balance,
388
+ closing_balance,
389
+ cms_balance: accumulation ? (accumulation as any).cash_in_store : null,
390
+ variance,
391
+ is_balanced,
392
+ handovers: handoverList,
393
+ shift_logs,
394
+ }
395
+ }
396
+
271
397
  async getShiftLogs(filters: ShiftLogFilters) {
272
398
  const dbFilters: Record<string, unknown> = {}
273
399
  if (filters.store_id) dbFilters.store_id = [filters.store_id]
274
400
  dbFilters.transaction_type = ["open", "close"]
275
401
 
276
- const rows = await this.listPettyCashTransactions(dbFilters, {
402
+ const rows = await this.listRetailosPettyCashTransactions(dbFilters, {
277
403
  take: filters.limit ?? 100,
278
404
  order: { created_at: "DESC" } as any,
279
405
  })