import assert from 'node:assert/strict'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createHash } from 'node:crypto'; import LibDashStore from '../src/index.js'; import { BetterSqlite3Provider } from '../src/providers/sqlite3/index.js'; import { computePrevHash, INIT_HASH } from '../src/store/ledger/add.js'; async function run() { const tempDir = mkdtempSync(join(tmpdir(), 'dash-ledger-test-')); const dbFile = join(tempDir, 'test.db'); const storageDir = join(tempDir, 'storage'); const provider = new BetterSqlite3Provider({ filename: dbFile, storageDir, }); await provider.initialize(); const store = await new LibDashStore(provider); try { console.log('--- Testing store.ledger (acceptance, warehouse, shipment, guards) ---'); const googleAuthId = await store.google_auth.add('ledger-tester@example.com'); const company = await store.company.create({ name: 'Ledger Test GmbH', iso639: 'de', taxrate: 19, reducedtaxrate: 7, googleAuthId, }); await store.google_auth.setSelectedCompany(googleAuthId, company.id); const supplier = await store.supplier.create(googleAuthId, { name: 'Supplier X', }); // Create an article with track_stock = false initially const article = await store.article.createUnderParent({ googleAuthId, parentId: company.rootCategoryId, name: 'Ledger Linked Article', track_stock: false, }); assert.equal(article.track_stock, false, 'Newly created article must have track_stock disabled'); await store.articlesupplier.create(googleAuthId, article.sku, { entityId: supplier.id, sku: 'SKU-ABC', pu: 1, }); const customer1 = await store.entity.create(googleAuthId, { name: 'Customer Quote Test', isCustomer: true, customergroupId: company.rootCategoryId, }); // Create a quote and verify ledger bookings are rejected const quote = await store.quote.create(googleAuthId, { number: 'Q-1', entityId: customer1.id, date: new Date().toISOString(), lines: [{ sku: 'SKU-ABC', name: 'Quote item', pu: 1, amount: 2, net: 10, taxrate: 19 }], }); await assert.rejects( async () => store.ledger.add(googleAuthId, { manifestarticle_id: quote.lines[0].id, transaction_type: 'ACCEPTANCE', amount: 2, transaction_text: 'Should fail for quote', }), /Cannot book ledger entries for quote/, 'Should reject booking ledger entries for quote manifest', ); // Create an order and verify ledger bookings are rejected const order = await store.order.create(googleAuthId, { number: 'O-1', entityId: customer1.id, date: new Date().toISOString(), lines: [{ sku: 'SKU-ABC', name: 'Order item', pu: 1, amount: 2, net: 10, taxrate: 19 }], }); await assert.rejects( async () => store.ledger.add(googleAuthId, { manifestarticle_id: order.lines[0].id, transaction_type: 'ACCEPTANCE', amount: 2, transaction_text: 'Should fail for order', }), /Cannot book ledger entries for order/, 'Should reject booking ledger entries for order manifest', ); // 1. Create a delivery manifest with lines const delivery = await store.delivery.create(googleAuthId, { supplierId: supplier.id, externalNumber: 'DEL-1', date: new Date().toISOString(), lines: [ { sku: 'SKU-ABC', name: 'Ledger Item 1', pu: 1, amount: 5, net: 10, rebate: 0, taxrate: 19, }, { sku: 'SKU-ABC', name: 'Ledger Item 2', pu: 1, amount: 10, net: 20, rebate: 0, taxrate: 19, }, ], }); const manifestarticleId1 = delivery.lines[0].id; const manifestarticleId2 = delivery.lines[1].id; // 2. Book line 1 for untracked article (acceptance only, warehouse_id is null) console.log('1. Booking acceptance for untracked article line 1...'); const row1 = await store.ledger.add(googleAuthId, { manifestarticle_id: manifestarticleId1, transaction_type: 'ACCEPTANCE', amount: 5, transaction_text: `Lieferung ${delivery.number}`, }); assert.equal(row1.id, 1); assert.equal(row1.manifestarticle_id, manifestarticleId1); assert.equal(row1.warehouse_id, null); assert.equal(row1.transaction_type, 'ACCEPTANCE'); assert.equal(row1.amount, 5); assert.equal(row1.hash_version, 1); assert.equal(row1.prev_hash, INIT_HASH); assert(row1.created_at, 'created_at should exist'); // Attempting to book line 1 again must be rejected await assert.rejects( async () => store.ledger.add(googleAuthId, { manifestarticle_id: manifestarticleId1, transaction_type: 'ACCEPTANCE', amount: 5, transaction_text: 'Double booking attempt', }), /Delivery line already booked on ledger/, 'Should reject booking an already booked delivery line', ); // 3. Create warehouse and enable tracking on the article console.log('2. Creating warehouse and enabling stock tracking...'); const wh1 = await store.warehouse.create(googleAuthId, { name: 'Hauptlager' }); await store.article.setTrackStockBySku(googleAuthId, article.sku, true); // Even though tracking is now enabled, line 1 is already accepted so it cannot be booked again await assert.rejects( async () => store.ledger.add(googleAuthId, { manifestarticle_id: manifestarticleId1, warehouse_id: wh1.id, transaction_type: 'ACCEPTANCE', amount: 5, transaction_text: 'Late booking attempt after tracking enabled', }), /Delivery line already booked on ledger/, 'Cannot book a delivery line later once stock tracking is enabled if already accepted', ); // 4. Book line 2 (now tracked): books ACCEPTANCE (row 2) + WAREHOUSE (row 3) in one transaction console.log('3. Booking line 2 with tracking enabled (dual entries: ACCEPTANCE + WAREHOUSE)...'); const expectedHash2 = computePrevHash(row1); const row2 = await store.ledger.add(googleAuthId, { manifestarticle_id: manifestarticleId2, warehouse_id: wh1.id, transaction_type: 'ACCEPTANCE', amount: 10, transaction_text: `Lieferung ${delivery.number}`, }); assert.equal(row2.id, 2); assert.equal(row2.manifestarticle_id, manifestarticleId2); assert.equal(row2.warehouse_id, null); assert.equal(row2.transaction_type, 'ACCEPTANCE'); assert.equal(row2.amount, 10); assert.equal(row2.prev_hash, expectedHash2); // Query ledger lines for manifestarticle 2 to verify row 3 (WAREHOUSE entry) const line2Entries = await store.ledger.listByManifestarticleId(googleAuthId, manifestarticleId2); assert.equal(line2Entries.length, 2, 'Line 2 must have 2 ledger entries (ACCEPTANCE + WAREHOUSE)'); assert.equal(line2Entries[0].transaction_type, 'ACCEPTANCE'); assert.equal(line2Entries[0].warehouse_id, null); assert.equal(line2Entries[1].transaction_type, 'WAREHOUSE'); assert.equal(line2Entries[1].warehouse_id, wh1.id); assert.equal(line2Entries[1].amount, 10); assert.equal(line2Entries[1].id, 3); assert.equal(line2Entries[1].prev_hash, computePrevHash(line2Entries[0])); // 5. Check warehouse stock for article console.log('4. Checking warehouse stock calculation...'); const artDetail = await store.article.getBySku(googleAuthId, article.sku); const whStock = artDetail.article.warehouses.find((w) => w.id === wh1.id)?.stock; assert.equal(whStock, 10, 'Warehouse stock must be 10 (from line 2 WAREHOUSE ledger entry only)'); // 6. Check deliverysummary is_booked const updatedDelivery = await store.delivery.getById(googleAuthId, delivery.id); assert.equal(updatedDelivery.ledger.isBooked, true, 'Delivery should be fully booked'); assert.equal(updatedDelivery.ledger.completelyOnLedgerPositions, 2); // 7. Check article history console.log('5. Verifying article history...'); const history = await store.article.getHistoryBySku(googleAuthId, article.sku); const historyTexts = history.map((h) => h.text); console.log('History entries:', historyTexts); assert( historyTexts.some((t) => t.includes(`Lieferung ${delivery.number}`) && t.includes('Lieferant: "Supplier X"') && t.includes('EK brutto: 11.90') && t.includes('Menge: 5')), 'History must include acceptance of line 1 with Lieferung number, supplier, EK brutto, amount', ); assert( historyTexts.some((t) => t.includes(`Lieferung ${delivery.number}`) && t.includes('Lieferant: "Supplier X"') && t.includes('EK brutto: 23.80') && t.includes('Menge: 10')), 'History must include acceptance of line 2', ); assert( historyTexts.some((t) => t.includes(`Lieferung ${delivery.number}`) && t.includes('Lager: "Hauptlager"') && t.includes('Menge: 10')), 'History must include warehouse entry for tracked line 2', ); // 8. Test store.delivery.book on a fresh delivery console.log('6. Testing store.delivery.book batch booking...'); const delivery2 = await store.delivery.create(googleAuthId, { supplierId: supplier.id, externalNumber: 'DEL-2', date: new Date().toISOString(), lines: [ { sku: 'SKU-ABC', name: 'Batch Item 1', pu: 1, amount: 4, net: 15, taxrate: 19 }, ], }); const bookedDelivery2 = await store.delivery.book(googleAuthId, { id: delivery2.id, warehouseId: wh1.id }); assert.equal(bookedDelivery2.ledger.isBooked, true); assert.equal(bookedDelivery2.ledger.completelyOnLedgerPositions, 1); // Double booking delivery2 must reject await assert.rejects( async () => store.delivery.book(googleAuthId, { id: delivery2.id }), /Delivery line(s)? already (accepted|booked) on ledger/, 'Should reject booking already booked delivery', ); // 9. Test shipment booking console.log('7. Testing shipment booking (SHIPMENT + WAREHOUSE dual entries)...'); const customer = await store.entity.create(googleAuthId, { name: 'Customer Y', isCustomer: true, customergroupId: company.rootCategoryId, }); const order2 = await store.order.create(googleAuthId, { number: 'O-2', entityId: customer.id, date: new Date().toISOString(), lines: [ { sku: 'SKU-ABC', name: 'Ordered Item', pu: 1, amount: 3, net: 25, taxrate: 19 }, ], }); const shipment = await store.shipment.create(googleAuthId, { orderId: order2.id, lines: [{ sku: 'SKU-ABC', amount: 3 }], }); const shipmentLineId = shipment.lines[0].id; const shipRow = await store.ledger.add(googleAuthId, { manifestarticle_id: shipmentLineId, warehouse_id: wh1.id, transaction_type: 'SHIPMENT', amount: 3, transaction_text: `Lieferschein ${shipment.number}`, }); assert.equal(shipRow.transaction_type, 'SHIPMENT'); assert.equal(shipRow.warehouse_id, null); assert.equal(shipRow.amount, -3); const shipEntries = await store.ledger.listByManifestarticleId(googleAuthId, shipmentLineId); assert.equal(shipEntries.length, 2, 'Shipment line must have 2 entries (SHIPMENT + WAREHOUSE)'); assert.equal(shipEntries[0].transaction_type, 'SHIPMENT'); assert.equal(shipEntries[0].warehouse_id, null); assert.equal(shipEntries[0].amount, -3); assert.equal(shipEntries[1].transaction_type, 'WAREHOUSE'); assert.equal(shipEntries[1].warehouse_id, wh1.id); assert.equal(shipEntries[1].amount, -3); // Stock should now be: 10 (line 2) + 4 (delivery2) - 3 (shipment) = 11 const artDetailAfterShip = await store.article.getBySku(googleAuthId, article.sku); const whStockAfterShip = artDetailAfterShip.article.warehouses.find((w) => w.id === wh1.id)?.stock; assert.equal(whStockAfterShip, 11, 'Warehouse stock must be 11 (10 + 4 - 3)'); // Verify shipment history const historyAfterShip = await store.article.getHistoryBySku(googleAuthId, article.sku); const shipHistoryTexts = historyAfterShip.map((h) => h.text); assert( shipHistoryTexts.some((t) => t.includes(`Lieferschein ${shipment.number}`) && t.includes('Menge: 3')), 'Article history should record outgoing shipment', ); // 8. Test store.stock.accept with deliverymanifestitem console.log('8. Testing store.stock.accept (positive acceptance & negative return)...'); const delivery3 = await store.delivery.create(googleAuthId, { supplierId: supplier.id, externalNumber: 'DEL-3', date: new Date().toISOString(), lines: [ { sku: 'SKU-ABC', name: 'Positive Delivery Line', pu: 1, amount: 6, net: 10, taxrate: 19 }, { sku: 'SKU-ABC', name: 'Negative Delivery Line (Return)', pu: 1, amount: -2, net: 10, taxrate: 19 }, ], }); const posDeliveryLineId = delivery3.lines[0].id; const negDeliveryLineId = delivery3.lines[1].id; const acceptPosResult = await store.stock.accept(googleAuthId, { manifestarticleId: posDeliveryLineId, warehouseId: wh1.id, }); assert.equal(acceptPosResult.document_ledger.transaction_type, 'ACCEPTANCE'); assert.equal(acceptPosResult.document_ledger.amount, 6); assert.equal(acceptPosResult.document_ledger.warehouse_id, null); assert.equal(acceptPosResult.adjust_ledger.amount, 6); assert.equal(acceptPosResult.adjust_ledger.warehouse_id, wh1.id); // Negative delivery line: return of a delivery (outgoing) const acceptNegResult = await store.stock.accept(googleAuthId, { manifestarticleId: negDeliveryLineId, warehouseId: wh1.id, }); assert.equal(acceptNegResult.document_ledger.transaction_type, 'RETURN'); assert.equal(acceptNegResult.document_ledger.amount, -2); assert.equal(acceptNegResult.document_ledger.warehouse_id, null); assert.equal(acceptNegResult.adjust_ledger.amount, -2); assert.equal(acceptNegResult.adjust_ledger.warehouse_id, wh1.id); // 9. Test store.stock.ship with shipmentmanifestitem console.log('9. Testing store.stock.ship (positive shipment & negative retoure)...'); const order3 = await store.order.create(googleAuthId, { number: 'O-3', entityId: customer.id, date: new Date().toISOString(), lines: [ { sku: 'SKU-ABC', name: 'Shipment Item Normal', pu: 1, amount: 5, net: 10, taxrate: 19 }, ], }); const shipment3 = await store.shipment.create(googleAuthId, { orderId: order3.id, lines: [ { sku: 'SKU-ABC', amount: 4 }, { sku: 'SKU-ABC', amount: -1 }, ], }); const posShipLineId = shipment3.lines[0].id; const negShipLineId = shipment3.lines[1].id; const shipPosResult = await store.stock.ship(googleAuthId, { manifestarticleId: posShipLineId, warehouseId: wh1.id, }); assert.equal(shipPosResult.document_ledger.transaction_type, 'SHIPMENT'); assert.equal(shipPosResult.document_ledger.amount, -4); assert.equal(shipPosResult.adjust_ledger.amount, -4); // Negative shipment line: retoure of a shipment (incoming) const shipNegResult = await store.stock.ship(googleAuthId, { manifestarticleId: negShipLineId, warehouseId: wh1.id, }); assert.equal(shipNegResult.document_ledger.transaction_type, 'RETOURE'); assert.equal(shipNegResult.document_ledger.amount, 1); assert.equal(shipNegResult.adjust_ledger.amount, 1); // 10. Test store.stock.adjust console.log('10. Testing store.stock.adjust failure on untracked article...'); const untrackedArt = await store.article.createUnderParent({ googleAuthId, parentId: company.rootCategoryId, name: 'Pure Untracked Article', track_stock: false, }); await assert.rejects( async () => store.stock.adjust(googleAuthId, { sku: untrackedArt.sku, warehouseId: wh1.id, amount: 5, }), /Stock tracking is disabled for article/, 'Adjust must fail when article has no stock tracking', ); // 11. Test before stock tracking is disabled, adjust to 0 has to be ledgerized console.log('11. Testing zero-adjustment before stock tracking is disabled...'); const artBeforeDisable = await store.article.getBySku(googleAuthId, article.sku); const stockBeforeDisable = artBeforeDisable.article.warehouses.find((w) => w.id === wh1.id)?.stock; console.log('Current stock before disabling tracking:', stockBeforeDisable); assert(stockBeforeDisable > 0, 'Article must have non-zero stock before disabling'); // Disable stock tracking await store.article.setTrackStockBySku(googleAuthId, article.sku, false); const artAfterDisable = await store.article.getBySku(googleAuthId, article.sku); assert.equal(artAfterDisable.article.track_stock, false, 'Article track_stock must now be false'); const stockAfterDisable = artAfterDisable.article.warehouses.find((w) => w.id === wh1.id)?.stock; assert.equal(stockAfterDisable, 0, 'Warehouse stock must be adjusted to 0 on the ledger'); // Calling adjust now must fail await assert.rejects( async () => store.stock.adjust(googleAuthId, { sku: article.sku, warehouseId: wh1.id, amount: 1, }), /Stock tracking is disabled for article/, 'Subsequent adjust must fail once tracking is disabled', ); console.log('✓ All ledger tests passed successfully!'); } finally { try { rmSync(tempDir, { recursive: true, force: true }); } catch { // ignore } } } run().catch((err) => { console.error('Test failed:', err); process.exit(1); });