# Product Expiry by Batch (Production Guide)

## Your exact problem
You currently store stock and expiry on the product document itself:
- one `stock`
- one `expirydate`

So when a new shipment arrives and you update the same product, the new expiry can overwrite/mix with old stock that expires tomorrow. Then you cannot know which quantity belongs to which expiry.

## Real code from current system (why this happens)

Current product schema has single stock + single expiry:

```js
// src/modules/products/products.model.js
stock: { type: Number, required: [true, 'Please add the amount of stoks !'] },
expirydate: { type: Date },
```

Current sale flow only decrements total product stock, not a batch:

```js
// src/modules/bills/bills.controller.js (createBill)
await Product.bulkWrite(
  items.map(item => ({
    updateOne: {
      filter: { _id: item.item },
      update: { $inc: { stock: -item.quantity } }
    }
  }))
)
```

Same pattern in invoices:

```js
// src/modules/invoices/invoices.controller.js
update: { $inc: { stock: -item.quantity } },
```

This is the core reason expiry tracking is impossible per package/batch right now.

---

## Best and effective solution (not just "working")
Use **FEFO** inventory with a separate batch collection.

FEFO = First-Expire-First-Out.
Sell from the earliest expiry batch first.

### 1. New collection: product batches

Create `src/modules/product-batches/product-batches.model.js`:

```js
const mongoose = require('mongoose');

const productBatchSchema = new mongoose.Schema(
  {
    productId: {
      type: mongoose.Schema.Types.ObjectId,
      ref: 'Product',
      required: true,
      index: true,
    },
    batchNumber: {
      type: String,
      trim: true,
      required: true,
    },
    expiryDate: {
      type: Date,
      required: true,
      index: true,
    },
    receivedDate: {
      type: Date,
      required: true,
      default: Date.now,
    },
    costPrice: {
      type: Number,
      min: 0,
      default: 0,
    },
    qtyReceived: {
      type: Number,
      required: true,
      min: 0,
    },
    qtyAvailable: {
      type: Number,
      required: true,
      min: 0,
      index: true,
    },
    supplierRef: {
      type: String,
      trim: true,
    },
  },
  {
    timestamps: true,
    versionKey: false,
  }
);

productBatchSchema.index({ productId: 1, expiryDate: 1, qtyAvailable: 1 });
productBatchSchema.index({ productId: 1, batchNumber: 1 }, { unique: false });

module.exports = mongoose.model('ProductBatch', productBatchSchema);
```

### 2. Add stock by creating a batch (never overwrite expiry on product)

Create `src/modules/product-batches/product-batches.service.js`:

```js
const Product = require('../products/products.model');
const ProductBatch = require('./product-batches.model');
const AppError = require('../../shared/utils/AppError');

const toMoney = (value) => Number((value || 0).toFixed(2));

exports.receiveStockBatch = async ({
  productId,
  batchNumber,
  expiryDate,
  qty,
  costPrice = 0,
  supplierRef,
  session,
}) => {
  if (!qty || qty <= 0) throw new AppError('Quantity must be greater than 0', 400);

  const batch = await ProductBatch.create(
    [
      {
        productId,
        batchNumber,
        expiryDate: new Date(expiryDate),
        qtyReceived: qty,
        qtyAvailable: qty,
        costPrice: toMoney(costPrice),
        supplierRef,
      },
    ],
    { session }
  );

  await Product.updateOne(
    { _id: productId },
    {
      $inc: { stock: qty },
      // Keep this field for backward compatibility only.
      // It can store earliest expiry among available batches.
      $min: { expirydate: new Date(expiryDate) },
    },
    { session }
  );

  return batch[0];
};
```

### 3. Sell stock using FEFO and keep exact batch trace

Continue in `product-batches.service.js`:

```js
exports.consumeStockFEFO = async ({ productId, qtyNeeded, session }) => {
  let remaining = qtyNeeded;

  const batches = await ProductBatch.find({
    productId,
    qtyAvailable: { $gt: 0 },
    expiryDate: { $ne: null },
  })
    .sort({ expiryDate: 1, receivedDate: 1, createdAt: 1 })
    .session(session);

  const totalAvailable = batches.reduce((sum, b) => sum + b.qtyAvailable, 0);
  if (totalAvailable < qtyNeeded) {
    throw new AppError('Insufficient stock for product batch-wise sale', 400);
  }

  const allocations = [];

  for (const batch of batches) {
    if (remaining <= 0) break;

    const take = Math.min(batch.qtyAvailable, remaining);
    batch.qtyAvailable -= take;
    remaining -= take;

    await batch.save({ session });

    allocations.push({
      batchId: batch._id,
      batchNumber: batch.batchNumber,
      expiryDate: batch.expiryDate,
      quantity: take,
      costPrice: batch.costPrice,
    });
  }

  await Product.updateOne(
    { _id: productId },
    { $inc: { stock: -qtyNeeded } },
    { session }
  );

  return allocations;
};
```

### 4. Integrate this into your current bill/invoice flow

Replace direct `$inc stock` logic inside:
- `src/modules/bills/bills.controller.js` -> `createBill`
- `src/modules/invoices/invoices.controller.js` -> `prepareStockAndGrossProfitForSaleInvoice`

with FEFO consumption using transaction:

```js
const mongoose = require('mongoose');
const { consumeStockFEFO } = require('../product-batches/product-batches.service');

// inside createBill or createInvoice sale flow
const session = await mongoose.startSession();
session.startTransaction();

try {
  const allocationByItem = [];

  for (const item of items) {
    const allocations = await consumeStockFEFO({
      productId: item.item,
      qtyNeeded: item.quantity,
      session,
    });

    allocationByItem.push({
      productId: item.item,
      quantity: item.quantity,
      allocations,
    });
  }

  // save bill/invoice with allocationByItem in line items for full traceability
  // ... existing create bill/invoice code here, but pass { session }

  await session.commitTransaction();
} catch (err) {
  await session.abortTransaction();
  throw err;
} finally {
  session.endSession();
}
```

### 5. Save sold batch details in bill/invoice item

Add this in bill/invoice line item schema:

```js
batchAllocations: [
  {
    batchId: { type: mongoose.Schema.Types.ObjectId, ref: 'ProductBatch' },
    batchNumber: String,
    expiryDate: Date,
    quantity: Number,
    costPrice: Number,
  },
],
```

Why this matters:
- refund can return stock to the exact original batch
- expiry-loss reports are accurate
- audit trace is complete

### 6. Low stock and near-expiry analytics should read from batches

Near expiry should come from `ProductBatch` (`qtyAvailable > 0` and `expiryDate` in window), not from `Product.expirydate`.

---

## Migration from your current data (safe path)

1. Keep current product fields for compatibility.
2. Backfill one initial batch per product from current data:
   - `qtyAvailable = product.stock`
   - `expiryDate = product.expirydate` (if exists)
   - `batchNumber = 'legacy-' + product._id`
3. Switch sale logic to FEFO service.
4. Later, treat `Product.expirydate` as optional derived field (earliest active batch).

---

## Performance and correctness checklist

- Use indexes on `(productId, expiryDate, qtyAvailable)`.
- Always use Mongo transaction for sale + stock consume + bill/invoice write.
- Never update product stock manually from UI for received goods; always add a batch receive record.
- Prevent selling expired batches by adding `expiryDate: { $gte: todayStart }` in FEFO query if your business rule requires strict blocking.
- Keep `Product.stock` as a cached total for fast reads, but source of truth is batches.

---

## Direct answer to your scenario

If first package expires tomorrow and new package arrives today:
- do **not** edit only product `stock`/`expirydate`
- create a **new batch row** for the new package
- keep old batch unchanged (still expires tomorrow)
- during sale, system auto-consumes old batch first (FEFO)

This way you always know:
- how much is from old vs new package
- what expired
- what was sold from which batch
- what remains by expiry date

That is the reliable production approach for expiry tracking.
