matiru

Blockchain-Based Supply Chain Transparency for Agricultural Produce

fabric/chaincode/index.js (17966B)


  1"use strict";
  2const { Contract } = require("fabric-contract-api");
  3
  4class ProduceContract extends Contract {
  5  // Utilities
  6  async _getState(ctx, id) {
  7    const data = await ctx.stub.getState(id);
  8    if (!data || data.length === 0) {
  9      throw new Error(`Asset ${id} does not exist`);
 10    }
 11    return JSON.parse(data.toString());
 12  }
 13
 14  async _putState(ctx, id, obj) {
 15    await ctx.stub.putState(id, Buffer.from(JSON.stringify(obj)));
 16  }
 17
 18  // Deterministic timestamp from tx context
 19  _txTimestampISO(ctx) {
 20    const ts = ctx.stub.getTxTimestamp();
 21    // ts.seconds may be a Long object in some environments
 22    const seconds =
 23      typeof ts.seconds === "object" &&
 24      typeof ts.seconds.toNumber === "function"
 25        ? ts.seconds.toNumber()
 26        : Number(ts.seconds);
 27    const millis = seconds * 1000 + Math.floor((ts.nanos || 0) / 1e6);
 28    return new Date(millis).toISOString();
 29  }
 30
 31  // Deterministic action item
 32  _actionItem(ctx, action, location, owner, meta) {
 33    return {
 34      timestamp: this._txTimestampISO(ctx),
 35      action,
 36      currentLocation: location || "",
 37      currentOwner: owner || "",
 38      meta: meta || {},
 39    };
 40  }
 41
 42  // Find user state by ID
 43  async _findUserKeyById(ctx, id) {
 44    if (!id) return null;
 45    const roles = ["FARMER", "DISTRIBUTOR", "RETAILER", "INSPECTOR"];
 46    for (const r of roles) {
 47      const key = `${r}-${id}`;
 48      const data = await ctx.stub.getState(key);
 49      if (data && data.length > 0) return key;
 50    }
 51    return null;
 52  }
 53
 54  async _getUserById(ctx, id) {
 55    const key = await this._findUserKeyById(ctx, id);
 56    if (!key) return null;
 57    const data = await ctx.stub.getState(key);
 58    if (!data || data.length === 0) return null;
 59    return { key, user: JSON.parse(data.toString()) };
 60  }
 61
 62  async _addOwnedProduceToUser(ctx, userKey, userObj, produceId) {
 63    userObj.ownedProduce = userObj.ownedProduce || [];
 64    if (!userObj.ownedProduce.includes(produceId)) {
 65      userObj.ownedProduce.push(produceId);
 66      await this._putState(ctx, userKey, userObj);
 67    }
 68  }
 69
 70  async _removeOwnedProduceFromUser(ctx, userKey, userObj, produceId) {
 71    userObj.ownedProduce = userObj.ownedProduce || [];
 72    const idx = userObj.ownedProduce.indexOf(produceId);
 73    if (idx >= 0) {
 74      userObj.ownedProduce.splice(idx, 1);
 75      await this._putState(ctx, userKey, userObj);
 76    }
 77  }
 78
 79  _validateInput(data, requiredFields) {
 80    for (const field of requiredFields) {
 81      if (
 82        data[field] === undefined ||
 83        data[field] === null ||
 84        data[field] === ""
 85      ) {
 86        throw new Error(`Validation Error: Field '${field}' is required`);
 87      }
 88    }
 89    if (data.qty && (typeof data.qty !== "number" || data.qty <= 0))
 90      throw new Error("Validation Error: 'qty' must be a positive number");
 91
 92    if (
 93      data.pricePerUnit &&
 94      (typeof data.pricePerUnit !== "number" || data.pricePerUnit < 0)
 95    ) {
 96      throw new Error(
 97        "Validation Error: 'pricePerUnit' must be a non-negative number"
 98      );
 99    }
100  }
101
102  // Initialize ledger
103  async initLedger(ctx) {
104    console.info("Ledger initialized");
105  }
106
107  // Functions
108  // Produce Registration
109  async registerProduce(ctx, farmerId, detailsStr) {
110    const clientMspId = ctx.clientIdentity.getMSPID();
111    if (clientMspId !== "Org1MSP")
112      throw new Error(
113        `Client from ${clientMspId} is not authorized to register produce. Only Org1MSP is allowed.`
114      );
115
116    const farmerKey = `FARMER-${farmerId}`;
117    const farmerState = await ctx.stub.getState(farmerKey);
118    if (!farmerState || farmerState.length === 0)
119      throw new Error(`Farmer ${farmerId} is not registered`);
120
121    const farmer = JSON.parse(farmerState.toString());
122    const details = JSON.parse(detailsStr || "{}");
123
124    this._validateInput(details, [
125      "cropType",
126      "qty",
127      "qtyUnit",
128      "pricePerUnit",
129      "harvestDate",
130    ]);
131
132    const txId = ctx.stub.getTxID();
133    const now = this._txTimestampISO(ctx);
134    const id = `PRODUCE-${txId}`;
135    const produce = {
136      id,
137      parentId: null,
138      children: [],
139      qty: details.qty,
140      qtyUnit: details.qtyUnit,
141      pricePerUnit: details.pricePerUnit,
142      totalPrice: details.pricePerUnit * details.qty,
143      currentOwner: farmerId,
144      currentLocation: details.location || "",
145      actionHistory: [],
146      saleHistory: [],
147      cropType: details.cropType,
148      harvestDate: details.harvestDate || now,
149      quality: details.quality || null,
150      expiryDate: details.expiryDate || null,
151      storageConditions: details.storageConditions || [],
152      imageUrl: details.imageUrl || null,
153      certification: details.certification || farmer.certification || [],
154      status: "Harvested",
155      isAvailable: true,
156      notAvailableReason: null,
157    };
158
159    produce.actionHistory.push(
160      this._actionItem(ctx, "REGISTER", produce.currentLocation, farmerId, {
161        note: details.note || "",
162      })
163    );
164
165    await this._putState(ctx, id, produce);
166
167    const farmerObj = farmer;
168    farmerObj.registeredProduce = farmerObj.registeredProduce || [];
169    if (!farmerObj.registeredProduce.includes(id))
170      farmerObj.registeredProduce.push(id);
171
172    farmerObj.ownedProduce = farmerObj.ownedProduce || [];
173    if (!farmerObj.ownedProduce.includes(id)) farmerObj.ownedProduce.push(id);
174
175    await this._putState(ctx, farmerKey, farmerObj);
176    return produce;
177  }
178
179  // Update location
180  async updateLocation(ctx, produceId, actorId, newLocation) {
181    const produce = await this._getState(ctx, produceId);
182    if (newLocation === "In Transit") produce.status = "In Transit";
183    else {
184      produce.currentLocation = newLocation;
185      if (produce.status === "Harvested") produce.status = "In Transit";
186    }
187
188    produce.actionHistory.push(
189      this._actionItem(ctx, "MOVE", produce.currentLocation, actorId, {})
190    );
191    await this._putState(ctx, produceId, produce);
192    return produce;
193  }
194
195  // Mark as unavailable due to some reason
196  async markAsUnavailable(ctx, produceId, actorId, reason, newStatus) {
197    const produce = await this._getState(ctx, produceId);
198    produce.isAvailable = false;
199    produce.notAvailableReason = reason || "";
200    produce.status = newStatus || "Removed";
201
202    produce.actionHistory.push(
203      this._actionItem(ctx, "REMOVED", produce.currentLocation, actorId, {
204        reason,
205      })
206    );
207    await this._putState(ctx, produceId, produce);
208    return produce;
209  }
210
211  // Produce Inspection
212  async inspectProduce(ctx, produceId, inspectorId, qualityUpdateStr) {
213    const clientMspId = ctx.clientIdentity.getMSPID();
214    if (clientMspId !== "Org4MSP")
215      throw new Error(
216        `Client from ${clientMspId} is not authorized to inspect produce. Only Org4MSP is allowed.`
217      );
218
219    const qualityUpdate = JSON.parse(qualityUpdateStr || "{}");
220    const produce = await this._getState(ctx, produceId);
221
222    if (qualityUpdate.quality !== undefined)
223      produce.quality = qualityUpdate.quality;
224    if (qualityUpdate.expiryDate !== undefined)
225      produce.expiryDate = qualityUpdate.expiryDate;
226    if (qualityUpdate.storageConditions !== undefined)
227      produce.storageConditions = qualityUpdate.storageConditions;
228
229    produce.actionHistory.push(
230      this._actionItem(
231        ctx,
232        "INSPECT",
233        produce.currentLocation,
234        inspectorId,
235        qualityUpdate
236      )
237    );
238
239    if (qualityUpdate.failed === true) {
240      produce.isAvailable = false;
241      produce.notAvailableReason = qualityUpdate.reason || "Failed Inspection";
242      produce.status = "Failed Inspection";
243      produce.actionHistory.push(
244        this._actionItem(ctx, "REMOVED", produce.currentLocation, inspectorId, {
245          reason: produce.notAvailableReason,
246        })
247      );
248    }
249
250    await this._putState(ctx, produceId, produce);
251
252    const inspectorKey = `INSPECTOR-${inspectorId}`;
253    const inspectorState = await ctx.stub.getState(inspectorKey);
254    if (inspectorState && inspectorState.length > 0) {
255      const inspectorObj = JSON.parse(inspectorState.toString());
256      inspectorObj.inspectedProduce = inspectorObj.inspectedProduce || [];
257      if (!inspectorObj.inspectedProduce.includes(produceId)) {
258        inspectorObj.inspectedProduce.push(produceId);
259        await this._putState(ctx, inspectorKey, inspectorObj);
260      }
261    }
262    return produce;
263  }
264
265  // Update added details
266  async updateDetails(ctx, produceId, actorId, detailsStr) {
267    const details = JSON.parse(detailsStr || "{}");
268    const produce = await this._getState(ctx, produceId);
269
270    if (produce.currentOwner !== actorId)
271      throw new Error("Only current owner can update details");
272
273    if (details.pricePerUnit !== undefined)
274      produce.pricePerUnit = details.pricePerUnit;
275    if (details.storageConditions !== undefined)
276      produce.storageConditions = details.storageConditions;
277    if (details.imageUrl !== undefined) produce.imageUrl = details.imageUrl;
278    if (details.certification !== undefined)
279      produce.certification = details.certification;
280
281    produce.totalPrice = (produce.pricePerUnit || 0) * (produce.qty || 0);
282    produce.actionHistory.push(
283      this._actionItem(
284        ctx,
285        "UPDATED",
286        produce.currentLocation,
287        actorId,
288        details
289      )
290    );
291    await this._putState(ctx, produceId, produce);
292    return produce;
293  }
294
295  // Split into smaller batches
296  async splitProduce(ctx, produceId, qtyStr, ownerId) {
297    const qty = parseFloat(qtyStr);
298    const produce = await this._getState(ctx, produceId);
299
300    if (produce.currentOwner !== ownerId)
301      throw new Error("Only current owner can split produce");
302    if (qty <= 0 || qty > produce.qty)
303      throw new Error("Invalid quantity to split");
304
305    produce.qty -= qty;
306    produce.totalPrice = (produce.pricePerUnit || 0) * produce.qty;
307
308    const childId = `${produce.id}-${ctx.stub.getTxID()}-CHILD`;
309    const child = JSON.parse(JSON.stringify(produce));
310    child.id = childId;
311    child.parentId = produce.id;
312    child.children = [];
313    child.qty = qty;
314    child.totalPrice = (child.pricePerUnit || 0) * qty;
315    child.actionHistory = [];
316    child.saleHistory = [];
317    child.actionHistory.push(
318      this._actionItem(ctx, "SPLIT", produce.currentLocation, ownerId, { qty })
319    );
320
321    produce.children = produce.children || [];
322    produce.children.push(childId);
323    produce.actionHistory.push(
324      this._actionItem(ctx, "SPLIT", produce.currentLocation, ownerId, {
325        createdChild: childId,
326        qty,
327      })
328    );
329
330    await this._putState(ctx, produce.id, produce);
331    await this._putState(ctx, childId, child);
332
333    const ownerRes = await this._getUserById(ctx, ownerId);
334    if (ownerRes) {
335      await this._addOwnedProduceToUser(
336        ctx,
337        ownerRes.key,
338        ownerRes.user,
339        childId
340      );
341    }
342    return { parent: produce, child };
343  }
344
345  // Transfer ownership
346  async transferOwnership(ctx, produceId, newOwnerId, qtyStr, salePriceStr) {
347    const qty = parseFloat(qtyStr);
348    const salePrice = parseFloat(salePriceStr);
349    const produce = await this._getState(ctx, produceId);
350    const now = this._txTimestampISO(ctx);
351    const currentOwner = produce.currentOwner;
352
353    if (!produce.isAvailable) throw new Error("Asset not available");
354    if (qty <= 0 || qty > produce.qty) throw new Error("Invalid qty");
355
356    let resultAssetId = produceId;
357    if (qty < produce.qty) {
358      // Partial Transfer
359      const splitRes = await this.splitProduce(
360        ctx,
361        produceId,
362        "" + qty,
363        produce.currentOwner
364      );
365      const child = splitRes.child;
366      child.currentOwner = newOwnerId;
367      child.actionHistory.push(
368        this._actionItem(ctx, "SALE", child.currentLocation, newOwnerId, {
369          qty,
370          salePrice,
371        })
372      );
373      child.saleHistory = child.saleHistory || [];
374      child.saleHistory.push({
375        timestamp: now,
376        prevOwner: currentOwner,
377        newOwner: newOwnerId,
378        salePrice,
379        qtyBought: qty,
380        paymentStatus: "PENDING",
381      });
382      await this._putState(ctx, child.id, child);
383      resultAssetId = child.id;
384
385      const prevOwnerRes = await this._getUserById(ctx, currentOwner);
386      if (prevOwnerRes) {
387        await this._removeOwnedProduceFromUser(
388          ctx,
389          prevOwnerRes.key,
390          prevOwnerRes.user,
391          child.id
392        );
393      }
394      const newOwnerRes = await this._getUserById(ctx, newOwnerId);
395      if (newOwnerRes) {
396        await this._addOwnedProduceToUser(
397          ctx,
398          newOwnerRes.key,
399          newOwnerRes.user,
400          child.id
401        );
402      }
403    } else {
404      // Full Transfer
405      produce.currentOwner = newOwnerId;
406      produce.actionHistory.push(
407        this._actionItem(ctx, "SALE", produce.currentLocation, newOwnerId, {
408          qty,
409          salePrice,
410        })
411      );
412      produce.saleHistory = produce.saleHistory || [];
413      produce.saleHistory.push({
414        timestamp: now,
415        prevOwner: currentOwner,
416        newOwner: newOwnerId,
417        salePrice,
418        qtyBought: qty,
419        paymentStatus: "PENDING",
420      });
421      await this._putState(ctx, produceId, produce);
422      resultAssetId = produceId;
423
424      const prevOwnerRes = await this._getUserById(ctx, currentOwner);
425      if (prevOwnerRes) {
426        await this._removeOwnedProduceFromUser(
427          ctx,
428          prevOwnerRes.key,
429          prevOwnerRes.user,
430          produceId
431        );
432      }
433      const newOwnerRes = await this._getUserById(ctx, newOwnerId);
434      if (newOwnerRes) {
435        await this._addOwnedProduceToUser(
436          ctx,
437          newOwnerRes.key,
438          newOwnerRes.user,
439          produceId
440        );
441      }
442    }
443    return { newAssetId: resultAssetId };
444  }
445
446  // Record payment for transfer
447  async recordPayment(
448    ctx,
449    produceId,
450    transactionId,
451    paymentStatus,
452    paymentMethod,
453    paymentRef
454  ) {
455    const produce = await this._getState(ctx, produceId);
456    const now = this._txTimestampISO(ctx);
457    produce.saleHistory = produce.saleHistory || [];
458
459    if (produce.saleHistory.length === 0) {
460      produce.saleHistory.push({
461        timestamp: now,
462        prevOwner: null,
463        newOwner: produce.currentOwner,
464        salePrice: produce.totalPrice,
465        qtyBought: produce.qty,
466        paymentStatus,
467      });
468    } else {
469      const last = produce.saleHistory[produce.saleHistory.length - 1];
470      last.paymentStatus = paymentStatus;
471      last.paymentMethod = paymentMethod;
472      last.paymentRef = paymentRef || transactionId;
473    }
474
475    produce.paymentStatus = paymentStatus;
476    produce.paymentMethod = paymentMethod;
477    produce.paymentRef = paymentRef || transactionId;
478
479    produce.actionHistory.push(
480      this._actionItem(
481        ctx,
482        "PAYMENT",
483        produce.currentLocation,
484        produce.currentOwner,
485        {
486          transactionId,
487          paymentStatus,
488          paymentMethod,
489          paymentRef,
490        }
491      )
492    );
493    await this._putState(ctx, produceId, produce);
494    return produce;
495  }
496
497  // Queries
498  async getProduceById(ctx, produceId) {
499    return await this._getState(ctx, produceId);
500  }
501
502  async getProduceByOwner(ctx, ownerId) {
503    const iterator = await ctx.stub.getStateByRange("", "");
504    const results = [];
505    while (true) {
506      const res = await iterator.next();
507      if (res.value && res.value.key) {
508        if (res.value.key.startsWith("PRODUCE-")) {
509          const obj = JSON.parse(res.value.value.toString("utf8"));
510          if (obj.currentOwner === ownerId) results.push(obj);
511        }
512      }
513      if (res.done) {
514        await iterator.close();
515        break;
516      }
517    }
518    return results;
519  }
520
521  // Governance
522  async registerUser(ctx, role, detailsStr) {
523    const clientMspId = ctx.clientIdentity.getMSPID();
524    const details = JSON.parse(detailsStr || "{}");
525    const roleUpper = role.toUpperCase();
526
527    const roleToOrgMap = {
528      FARMER: "Org1MSP",
529      DISTRIBUTOR: "Org2MSP",
530      RETAILER: "Org3MSP",
531      INSPECTOR: "Org4MSP",
532    };
533
534    const expectedMspId = roleToOrgMap[roleUpper];
535    if (!expectedMspId || clientMspId !== expectedMspId)
536      throw new Error(
537        `Client from ${clientMspId} cannot register a ${role}. Expected admin from ${expectedMspId}.`
538      );
539
540    const id = details.id || `USER-${ctx.stub.getTxID()}`;
541    const key = `${roleUpper}-${id}`;
542
543    const base = {
544      role,
545      id,
546      name: details.name || "",
547      location: details.location || "",
548      walletId: details.walletId || "",
549    };
550
551    let user = null;
552    if (roleUpper === "FARMER") {
553      user = {
554        ...base,
555        registeredProduce: details.registeredProduce || [],
556        ownedProduce: details.ownedProduce || [],
557        certification: details.certification || [],
558      };
559    } else if (roleUpper === "DISTRIBUTOR" || roleUpper === "RETAILER") {
560      user = {
561        ...base,
562        ownedProduce: details.ownedProduce || [],
563      };
564    } else if (roleUpper === "INSPECTOR") {
565      user = {
566        ...base,
567        inspectedProduce: details.inspectedProduce || [],
568      };
569    }
570
571    await this._putState(ctx, key, user);
572    return user;
573  }
574
575  async getUserDetails(ctx, userKey) {
576    const data = await ctx.stub.getState(userKey);
577    if (!data || data.length === 0)
578      throw new Error(`User ${userKey} not found`);
579    return JSON.parse(data.toString());
580  }
581
582  async updateUser(ctx, userKey, detailsStr) {
583    const data = await ctx.stub.getState(userKey);
584    if (!data || data.length === 0)
585      throw new Error(`User ${userKey} not found`);
586
587    const user = JSON.parse(data.toString());
588    const details = JSON.parse(detailsStr || "{}");
589    Object.assign(user, details);
590
591    await this._putState(ctx, userKey, user);
592    return user;
593  }
594}
595
596module.exports.contracts = [ProduceContract];