@robosystems/client 0.2.48 → 0.2.49

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.
@@ -976,9 +976,10 @@ describe('LedgerClient', () => {
976
976
  })
977
977
 
978
978
  describe('createClosingEntry', () => {
979
- it('should create and return closing entry', async () => {
979
+ it('should return a created outcome with entry details', async () => {
980
980
  mockFetch.mockResolvedValueOnce(
981
981
  createMockResponse({
982
+ outcome: 'created',
982
983
  entry_id: 'entry_1',
983
984
  status: 'draft',
984
985
  posting_date: '2025-03-31',
@@ -986,6 +987,7 @@ describe('LedgerClient', () => {
986
987
  debit_element_id: 'debit_elem',
987
988
  credit_element_id: 'credit_elem',
988
989
  amount: 15000,
990
+ reason: null,
989
991
  })
990
992
  )
991
993
 
@@ -999,6 +1001,7 @@ describe('LedgerClient', () => {
999
1001
  )
1000
1002
 
1001
1003
  expect(result).toEqual({
1004
+ outcome: 'created',
1002
1005
  entryId: 'entry_1',
1003
1006
  status: 'draft',
1004
1007
  postingDate: '2025-03-31',
@@ -1006,19 +1009,22 @@ describe('LedgerClient', () => {
1006
1009
  debitElementId: 'debit_elem',
1007
1010
  creditElementId: 'credit_elem',
1008
1011
  amount: 15000,
1012
+ reason: null,
1009
1013
  })
1010
1014
  })
1011
1015
 
1012
- it('should work without memo', async () => {
1016
+ it('should surface a skipped outcome with null entry fields', async () => {
1013
1017
  mockFetch.mockResolvedValueOnce(
1014
1018
  createMockResponse({
1015
- entry_id: 'entry_2',
1016
- status: 'draft',
1017
- posting_date: '2025-03-31',
1018
- memo: '',
1019
- debit_element_id: 'd',
1020
- credit_element_id: 'c',
1021
- amount: 1000,
1019
+ outcome: 'skipped',
1020
+ entry_id: null,
1021
+ status: null,
1022
+ posting_date: null,
1023
+ memo: null,
1024
+ debit_element_id: null,
1025
+ credit_element_id: null,
1026
+ amount: null,
1027
+ reason: 'No in-scope fact for this period.',
1022
1028
  })
1023
1029
  )
1024
1030
 
@@ -1030,8 +1036,10 @@ describe('LedgerClient', () => {
1030
1036
  '2025-03-31'
1031
1037
  )
1032
1038
 
1033
- expect(result.entryId).toBe('entry_2')
1034
- expect(result.amount).toBe(1000)
1039
+ expect(result.outcome).toBe('skipped')
1040
+ expect(result.entryId).toBeNull()
1041
+ expect(result.amount).toBeNull()
1042
+ expect(result.reason).toBe('No in-scope fact for this period.')
1035
1043
  })
1036
1044
 
1037
1045
  it('should throw on error', async () => {
@@ -1117,6 +1125,515 @@ describe('LedgerClient', () => {
1117
1125
  })
1118
1126
  })
1119
1127
 
1128
+ // ── Fiscal Calendar ───────────────────────────────────────────────────
1129
+
1130
+ const mockFiscalCalendar = {
1131
+ graph_id: 'graph_1',
1132
+ fiscal_year_start_month: 1,
1133
+ closed_through: '2026-02',
1134
+ close_target: '2026-03',
1135
+ gap_periods: 1,
1136
+ catch_up_sequence: ['2026-03'],
1137
+ closeable_now: true,
1138
+ blockers: [],
1139
+ last_close_at: '2026-03-01T00:00:00Z',
1140
+ initialized_at: '2025-01-01T00:00:00Z',
1141
+ last_sync_at: '2026-03-15T00:00:00Z',
1142
+ periods: [
1143
+ {
1144
+ name: '2026-03',
1145
+ start_date: '2026-03-01',
1146
+ end_date: '2026-03-31',
1147
+ status: 'open',
1148
+ closed_at: null,
1149
+ },
1150
+ ],
1151
+ }
1152
+
1153
+ describe('initializeLedger', () => {
1154
+ it('should initialize and return calendar state', async () => {
1155
+ mockFetch.mockResolvedValueOnce(
1156
+ createMockResponse({
1157
+ fiscal_calendar: mockFiscalCalendar,
1158
+ periods_created: 24,
1159
+ warnings: [],
1160
+ })
1161
+ )
1162
+
1163
+ const result = await client.initializeLedger('graph_1', {
1164
+ closedThrough: '2026-02',
1165
+ fiscalYearStartMonth: 1,
1166
+ })
1167
+
1168
+ expect(result.periodsCreated).toBe(24)
1169
+ expect(result.warnings).toEqual([])
1170
+ expect(result.fiscalCalendar.graphId).toBe('graph_1')
1171
+ expect(result.fiscalCalendar.closedThrough).toBe('2026-02')
1172
+ expect(result.fiscalCalendar.closeTarget).toBe('2026-03')
1173
+ expect(result.fiscalCalendar.catchUpSequence).toEqual(['2026-03'])
1174
+ expect(result.fiscalCalendar.closeableNow).toBe(true)
1175
+ expect(result.fiscalCalendar.periods).toHaveLength(1)
1176
+ })
1177
+
1178
+ it('should propagate warnings for unimplemented features', async () => {
1179
+ mockFetch.mockResolvedValueOnce(
1180
+ createMockResponse({
1181
+ fiscal_calendar: mockFiscalCalendar,
1182
+ periods_created: 0,
1183
+ warnings: ['auto_seed_schedules is not yet implemented.'],
1184
+ })
1185
+ )
1186
+
1187
+ const result = await client.initializeLedger('graph_1', {
1188
+ autoSeedSchedules: true,
1189
+ })
1190
+
1191
+ expect(result.warnings).toHaveLength(1)
1192
+ expect(result.warnings[0]).toContain('auto_seed_schedules')
1193
+ })
1194
+
1195
+ it('should map camelCase options to snake_case body fields', async () => {
1196
+ mockFetch.mockResolvedValueOnce(
1197
+ createMockResponse({
1198
+ fiscal_calendar: mockFiscalCalendar,
1199
+ periods_created: 12,
1200
+ warnings: [],
1201
+ })
1202
+ )
1203
+
1204
+ await client.initializeLedger('graph_1', {
1205
+ closedThrough: '2026-02',
1206
+ fiscalYearStartMonth: 7,
1207
+ earliestDataPeriod: '2024-01',
1208
+ autoSeedSchedules: true,
1209
+ note: 'kickoff',
1210
+ })
1211
+
1212
+ const request = mockFetch.mock.calls[0][0] as Request
1213
+ const body = JSON.parse(await request.text())
1214
+ expect(body).toEqual({
1215
+ closed_through: '2026-02',
1216
+ fiscal_year_start_month: 7,
1217
+ earliest_data_period: '2024-01',
1218
+ auto_seed_schedules: true,
1219
+ note: 'kickoff',
1220
+ })
1221
+ })
1222
+
1223
+ it('should throw on 409 already-initialized', async () => {
1224
+ mockFetch.mockResolvedValueOnce(
1225
+ createMockResponse({ detail: 'already initialized' }, { ok: false, status: 409 })
1226
+ )
1227
+
1228
+ await expect(client.initializeLedger('graph_1')).rejects.toThrow('Initialize ledger failed')
1229
+ })
1230
+ })
1231
+
1232
+ describe('getFiscalCalendar', () => {
1233
+ it('should return current calendar state', async () => {
1234
+ mockFetch.mockResolvedValueOnce(createMockResponse(mockFiscalCalendar))
1235
+
1236
+ const result = await client.getFiscalCalendar('graph_1')
1237
+
1238
+ expect(result.graphId).toBe('graph_1')
1239
+ expect(result.closedThrough).toBe('2026-02')
1240
+ expect(result.gapPeriods).toBe(1)
1241
+ expect(result.blockers).toEqual([])
1242
+ })
1243
+
1244
+ it('should throw on 404 calendar missing', async () => {
1245
+ mockFetch.mockResolvedValueOnce(
1246
+ createMockResponse({ detail: 'Not initialized' }, { ok: false, status: 404 })
1247
+ )
1248
+
1249
+ await expect(client.getFiscalCalendar('graph_1')).rejects.toThrow(
1250
+ 'Get fiscal calendar failed'
1251
+ )
1252
+ })
1253
+ })
1254
+
1255
+ describe('setCloseTarget', () => {
1256
+ it('should set target and return updated calendar', async () => {
1257
+ mockFetch.mockResolvedValueOnce(
1258
+ createMockResponse({
1259
+ ...mockFiscalCalendar,
1260
+ close_target: '2026-06',
1261
+ })
1262
+ )
1263
+
1264
+ const result = await client.setCloseTarget('graph_1', '2026-06', 'catch up Q2')
1265
+
1266
+ expect(result.closeTarget).toBe('2026-06')
1267
+ })
1268
+
1269
+ it('should throw on 422 invalid target', async () => {
1270
+ mockFetch.mockResolvedValueOnce(
1271
+ createMockResponse(
1272
+ { detail: 'target cannot be before closed_through' },
1273
+ { ok: false, status: 422 }
1274
+ )
1275
+ )
1276
+
1277
+ await expect(client.setCloseTarget('graph_1', '2025-01')).rejects.toThrow(
1278
+ 'Set close target failed'
1279
+ )
1280
+ })
1281
+ })
1282
+
1283
+ describe('closePeriod', () => {
1284
+ it('should close the period and return transition results', async () => {
1285
+ mockFetch.mockResolvedValueOnce(
1286
+ createMockResponse({
1287
+ fiscal_calendar: {
1288
+ ...mockFiscalCalendar,
1289
+ closed_through: '2026-03',
1290
+ close_target: '2026-04',
1291
+ },
1292
+ period: '2026-03',
1293
+ entries_posted: 3,
1294
+ target_auto_advanced: true,
1295
+ })
1296
+ )
1297
+
1298
+ const result = await client.closePeriod('graph_1', '2026-03')
1299
+
1300
+ expect(result.period).toBe('2026-03')
1301
+ expect(result.entriesPosted).toBe(3)
1302
+ expect(result.targetAutoAdvanced).toBe(true)
1303
+ expect(result.fiscalCalendar.closedThrough).toBe('2026-03')
1304
+ expect(result.fiscalCalendar.closeTarget).toBe('2026-04')
1305
+ })
1306
+
1307
+ it('should propagate allow_stale_sync flag', async () => {
1308
+ mockFetch.mockResolvedValueOnce(
1309
+ createMockResponse({
1310
+ fiscal_calendar: mockFiscalCalendar,
1311
+ period: '2026-03',
1312
+ entries_posted: 0,
1313
+ target_auto_advanced: false,
1314
+ })
1315
+ )
1316
+
1317
+ await client.closePeriod('graph_1', '2026-03', { allowStaleSync: true })
1318
+
1319
+ // hey-api's client-fetch passes a Request object to fetch. Read the
1320
+ // body back off it to verify the flag was serialized.
1321
+ const request = mockFetch.mock.calls[0][0] as Request
1322
+ const body = await request.text()
1323
+ expect(body).toContain('"allow_stale_sync":true')
1324
+ })
1325
+
1326
+ it('should throw on blocked close with structured detail', async () => {
1327
+ mockFetch.mockResolvedValueOnce(
1328
+ createMockResponse(
1329
+ {
1330
+ detail: {
1331
+ message: 'Cannot close period',
1332
+ blockers: ['sync_stale'],
1333
+ },
1334
+ },
1335
+ { ok: false, status: 422 }
1336
+ )
1337
+ )
1338
+
1339
+ await expect(client.closePeriod('graph_1', '2026-03')).rejects.toThrow('Close period failed')
1340
+ })
1341
+ })
1342
+
1343
+ describe('reopenPeriod', () => {
1344
+ it('should reopen the period and return updated calendar', async () => {
1345
+ mockFetch.mockResolvedValueOnce(
1346
+ createMockResponse({
1347
+ ...mockFiscalCalendar,
1348
+ closed_through: '2026-01',
1349
+ })
1350
+ )
1351
+
1352
+ const result = await client.reopenPeriod('graph_1', '2026-02', 'missed expense')
1353
+
1354
+ expect(result.closedThrough).toBe('2026-01')
1355
+ })
1356
+
1357
+ it('should require reason propagation', async () => {
1358
+ mockFetch.mockResolvedValueOnce(createMockResponse(mockFiscalCalendar))
1359
+
1360
+ await client.reopenPeriod('graph_1', '2026-02', 'audit correction', 'see ticket #123')
1361
+
1362
+ const request = mockFetch.mock.calls[0][0] as Request
1363
+ const body = await request.text()
1364
+ expect(body).toContain('"reason":"audit correction"')
1365
+ expect(body).toContain('"note":"see ticket #123"')
1366
+ })
1367
+
1368
+ it('should throw on 422 when period not closed', async () => {
1369
+ mockFetch.mockResolvedValueOnce(
1370
+ createMockResponse({ detail: 'not closed' }, { ok: false, status: 422 })
1371
+ )
1372
+
1373
+ await expect(client.reopenPeriod('graph_1', '2026-02', 'reason')).rejects.toThrow(
1374
+ 'Reopen period failed'
1375
+ )
1376
+ })
1377
+ })
1378
+
1379
+ describe('listPeriodDrafts', () => {
1380
+ it('should return grouped draft entries with line items', async () => {
1381
+ mockFetch.mockResolvedValueOnce(
1382
+ createMockResponse({
1383
+ period: '2026-03',
1384
+ period_start: '2026-03-01',
1385
+ period_end: '2026-03-31',
1386
+ draft_count: 1,
1387
+ total_debit: 15000,
1388
+ total_credit: 15000,
1389
+ all_balanced: true,
1390
+ drafts: [
1391
+ {
1392
+ entry_id: 'entry_1',
1393
+ posting_date: '2026-03-31',
1394
+ type: 'closing',
1395
+ memo: 'Monthly depreciation',
1396
+ provenance: 'schedule_derived',
1397
+ source_structure_id: 'str_sched',
1398
+ source_structure_name: 'Computer Depreciation',
1399
+ line_items: [
1400
+ {
1401
+ line_item_id: 'li_1',
1402
+ element_id: 'el_dep_exp',
1403
+ element_code: '6100',
1404
+ element_name: 'Depreciation Expense',
1405
+ debit_amount: 15000,
1406
+ credit_amount: 0,
1407
+ description: null,
1408
+ },
1409
+ {
1410
+ line_item_id: 'li_2',
1411
+ element_id: 'el_accum_dep',
1412
+ element_code: '1510',
1413
+ element_name: 'Accumulated Depreciation',
1414
+ debit_amount: 0,
1415
+ credit_amount: 15000,
1416
+ description: null,
1417
+ },
1418
+ ],
1419
+ total_debit: 15000,
1420
+ total_credit: 15000,
1421
+ balanced: true,
1422
+ },
1423
+ ],
1424
+ })
1425
+ )
1426
+
1427
+ const result = await client.listPeriodDrafts('graph_1', '2026-03')
1428
+
1429
+ expect(result.period).toBe('2026-03')
1430
+ expect(result.draftCount).toBe(1)
1431
+ expect(result.allBalanced).toBe(true)
1432
+ expect(result.drafts[0].entryId).toBe('entry_1')
1433
+ expect(result.drafts[0].sourceStructureName).toBe('Computer Depreciation')
1434
+ expect(result.drafts[0].lineItems).toHaveLength(2)
1435
+ expect(result.drafts[0].lineItems[0].elementCode).toBe('6100')
1436
+ expect(result.drafts[0].lineItems[0].debitAmount).toBe(15000)
1437
+ expect(result.drafts[0].balanced).toBe(true)
1438
+ })
1439
+
1440
+ it('should surface unbalanced drafts', async () => {
1441
+ mockFetch.mockResolvedValueOnce(
1442
+ createMockResponse({
1443
+ period: '2026-03',
1444
+ period_start: '2026-03-01',
1445
+ period_end: '2026-03-31',
1446
+ draft_count: 1,
1447
+ total_debit: 10000,
1448
+ total_credit: 9000,
1449
+ all_balanced: false,
1450
+ drafts: [
1451
+ {
1452
+ entry_id: 'entry_bad',
1453
+ posting_date: '2026-03-31',
1454
+ type: 'closing',
1455
+ memo: null,
1456
+ provenance: null,
1457
+ source_structure_id: null,
1458
+ source_structure_name: null,
1459
+ line_items: [
1460
+ {
1461
+ line_item_id: 'li_1',
1462
+ element_id: 'el_1',
1463
+ element_code: null,
1464
+ element_name: 'A',
1465
+ debit_amount: 10000,
1466
+ credit_amount: 0,
1467
+ description: null,
1468
+ },
1469
+ {
1470
+ line_item_id: 'li_2',
1471
+ element_id: 'el_2',
1472
+ element_code: null,
1473
+ element_name: 'B',
1474
+ debit_amount: 0,
1475
+ credit_amount: 9000,
1476
+ description: null,
1477
+ },
1478
+ ],
1479
+ total_debit: 10000,
1480
+ total_credit: 9000,
1481
+ balanced: false,
1482
+ },
1483
+ ],
1484
+ })
1485
+ )
1486
+
1487
+ const result = await client.listPeriodDrafts('graph_1', '2026-03')
1488
+
1489
+ expect(result.allBalanced).toBe(false)
1490
+ expect(result.drafts[0].balanced).toBe(false)
1491
+ })
1492
+ })
1493
+
1494
+ // ── Schedule mutations (truncate + manual entry) ────────────────────
1495
+
1496
+ describe('truncateSchedule', () => {
1497
+ it('should truncate and return counts', async () => {
1498
+ mockFetch.mockResolvedValueOnce(
1499
+ createMockResponse({
1500
+ structure_id: 'sched_1',
1501
+ new_end_date: '2026-06-30',
1502
+ facts_deleted: 18,
1503
+ reason: 'Asset sold 2026-06-15',
1504
+ })
1505
+ )
1506
+
1507
+ const result = await client.truncateSchedule('graph_1', 'sched_1', {
1508
+ newEndDate: '2026-06-30',
1509
+ reason: 'Asset sold 2026-06-15',
1510
+ })
1511
+
1512
+ expect(result.structureId).toBe('sched_1')
1513
+ expect(result.newEndDate).toBe('2026-06-30')
1514
+ expect(result.factsDeleted).toBe(18)
1515
+ expect(result.reason).toBe('Asset sold 2026-06-15')
1516
+ })
1517
+
1518
+ it('should throw on 422 mid-month date', async () => {
1519
+ mockFetch.mockResolvedValueOnce(
1520
+ createMockResponse(
1521
+ { detail: 'new_end_date must be a month-end' },
1522
+ { ok: false, status: 422 }
1523
+ )
1524
+ )
1525
+
1526
+ await expect(
1527
+ client.truncateSchedule('graph_1', 'sched_1', {
1528
+ newEndDate: '2026-06-15',
1529
+ reason: 'test',
1530
+ })
1531
+ ).rejects.toThrow('Truncate schedule failed')
1532
+ })
1533
+ })
1534
+
1535
+ describe('createManualClosingEntry', () => {
1536
+ it('should create entry with arbitrary balanced line items', async () => {
1537
+ mockFetch.mockResolvedValueOnce(
1538
+ createMockResponse({
1539
+ outcome: 'created',
1540
+ entry_id: 'entry_manual',
1541
+ status: 'draft',
1542
+ posting_date: '2026-03-15',
1543
+ memo: 'Sale of computer to Vendor X',
1544
+ debit_element_id: null,
1545
+ credit_element_id: null,
1546
+ amount: 500000,
1547
+ reason: null,
1548
+ })
1549
+ )
1550
+
1551
+ const result = await client.createManualClosingEntry('graph_1', {
1552
+ postingDate: '2026-03-15',
1553
+ memo: 'Sale of computer to Vendor X',
1554
+ entryType: 'closing',
1555
+ lineItems: [
1556
+ { elementId: 'el_cash', debitAmount: 500000 },
1557
+ { elementId: 'el_asset', creditAmount: 300000 },
1558
+ { elementId: 'el_gain', creditAmount: 200000 },
1559
+ ],
1560
+ })
1561
+
1562
+ expect(result.outcome).toBe('created')
1563
+ expect(result.entryId).toBe('entry_manual')
1564
+ expect(result.memo).toBe('Sale of computer to Vendor X')
1565
+ })
1566
+
1567
+ it('should serialize line items to snake_case and default missing amounts to 0', async () => {
1568
+ mockFetch.mockResolvedValueOnce(
1569
+ createMockResponse({
1570
+ outcome: 'created',
1571
+ entry_id: 'entry_manual',
1572
+ status: 'draft',
1573
+ posting_date: '2026-03-15',
1574
+ memo: 'Sale',
1575
+ debit_element_id: null,
1576
+ credit_element_id: null,
1577
+ amount: 500000,
1578
+ reason: null,
1579
+ })
1580
+ )
1581
+
1582
+ await client.createManualClosingEntry('graph_1', {
1583
+ postingDate: '2026-03-15',
1584
+ memo: 'Sale',
1585
+ entryType: 'closing',
1586
+ lineItems: [
1587
+ { elementId: 'el_cash', debitAmount: 500000, description: 'cash in' },
1588
+ { elementId: 'el_asset', creditAmount: 300000 },
1589
+ { elementId: 'el_gain', creditAmount: 200000 },
1590
+ ],
1591
+ })
1592
+
1593
+ const request = mockFetch.mock.calls[0][0] as Request
1594
+ const body = JSON.parse(await request.text())
1595
+ expect(body).toEqual({
1596
+ posting_date: '2026-03-15',
1597
+ memo: 'Sale',
1598
+ entry_type: 'closing',
1599
+ line_items: [
1600
+ {
1601
+ element_id: 'el_cash',
1602
+ debit_amount: 500000,
1603
+ credit_amount: 0,
1604
+ description: 'cash in',
1605
+ },
1606
+ {
1607
+ element_id: 'el_asset',
1608
+ debit_amount: 0,
1609
+ credit_amount: 300000,
1610
+ description: null,
1611
+ },
1612
+ {
1613
+ element_id: 'el_gain',
1614
+ debit_amount: 0,
1615
+ credit_amount: 200000,
1616
+ description: null,
1617
+ },
1618
+ ],
1619
+ })
1620
+ })
1621
+
1622
+ it('should throw on 422 unbalanced line items', async () => {
1623
+ mockFetch.mockResolvedValueOnce(
1624
+ createMockResponse({ detail: 'line items must balance' }, { ok: false, status: 422 })
1625
+ )
1626
+
1627
+ await expect(
1628
+ client.createManualClosingEntry('graph_1', {
1629
+ postingDate: '2026-03-15',
1630
+ memo: 'Unbalanced',
1631
+ lineItems: [{ elementId: 'el_a', debitAmount: 1000 }],
1632
+ })
1633
+ ).rejects.toThrow('Create manual closing entry failed')
1634
+ })
1635
+ })
1636
+
1120
1637
  // ── Constructor ───────────────────────────────────────────────────────
1121
1638
 
1122
1639
  describe('constructor', () => {