jpdcl 1.0.0

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 (103) hide show
  1. package/.env.example +15 -0
  2. package/LICENSE +21 -0
  3. package/README.md +219 -0
  4. package/dist/catalog.d.ts +141 -0
  5. package/dist/catalog.js +261 -0
  6. package/dist/catalog.js.map +1 -0
  7. package/dist/cli.d.ts +2 -0
  8. package/dist/cli.js +395 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/config.d.ts +6 -0
  11. package/dist/config.js +13 -0
  12. package/dist/config.js.map +1 -0
  13. package/dist/credentials.d.ts +13 -0
  14. package/dist/credentials.js +100 -0
  15. package/dist/credentials.js.map +1 -0
  16. package/dist/crypto.d.ts +3 -0
  17. package/dist/crypto.js +21 -0
  18. package/dist/crypto.js.map +1 -0
  19. package/dist/dates.d.ts +5 -0
  20. package/dist/dates.js +20 -0
  21. package/dist/dates.js.map +1 -0
  22. package/dist/errors.d.ts +11 -0
  23. package/dist/errors.js +23 -0
  24. package/dist/errors.js.map +1 -0
  25. package/dist/http-handler.d.ts +1 -0
  26. package/dist/http-handler.js +46 -0
  27. package/dist/http-handler.js.map +1 -0
  28. package/dist/http.d.ts +9 -0
  29. package/dist/http.js +43 -0
  30. package/dist/http.js.map +1 -0
  31. package/dist/index.d.ts +13 -0
  32. package/dist/index.js +14 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/launcher.d.ts +2 -0
  35. package/dist/launcher.js +11 -0
  36. package/dist/launcher.js.map +1 -0
  37. package/dist/ledger-client.d.ts +112 -0
  38. package/dist/ledger-client.js +221 -0
  39. package/dist/ledger-client.js.map +1 -0
  40. package/dist/main-client.d.ts +25 -0
  41. package/dist/main-client.js +221 -0
  42. package/dist/main-client.js.map +1 -0
  43. package/dist/mcp-stdio.d.ts +2 -0
  44. package/dist/mcp-stdio.js +6 -0
  45. package/dist/mcp-stdio.js.map +1 -0
  46. package/dist/mcp.d.ts +8 -0
  47. package/dist/mcp.js +517 -0
  48. package/dist/mcp.js.map +1 -0
  49. package/dist/runtime.d.ts +27 -0
  50. package/dist/runtime.js +316 -0
  51. package/dist/runtime.js.map +1 -0
  52. package/dist/server.d.ts +4 -0
  53. package/dist/server.js +221 -0
  54. package/dist/server.js.map +1 -0
  55. package/dist/session.d.ts +4 -0
  56. package/dist/session.js +30 -0
  57. package/dist/session.js.map +1 -0
  58. package/dist/smart-client.d.ts +50 -0
  59. package/dist/smart-client.js +238 -0
  60. package/dist/smart-client.js.map +1 -0
  61. package/dist/tariff.d.ts +121 -0
  62. package/dist/tariff.js +124 -0
  63. package/dist/tariff.js.map +1 -0
  64. package/dist/types.d.ts +51 -0
  65. package/dist/types.js +2 -0
  66. package/dist/types.js.map +1 -0
  67. package/dist/worker.d.ts +5 -0
  68. package/dist/worker.js +3 -0
  69. package/dist/worker.js.map +1 -0
  70. package/openapi.yaml +284 -0
  71. package/package.json +89 -0
  72. package/scripts/audit-http.ts +36 -0
  73. package/scripts/audit-launcher.mjs +28 -0
  74. package/scripts/audit-ledger-portal.ts +23 -0
  75. package/scripts/audit-mcp.mjs +140 -0
  76. package/scripts/audit-smart-portal.mjs +63 -0
  77. package/scripts/build.mjs +19 -0
  78. package/scripts/publish-smithery.mjs +47 -0
  79. package/src/catalog.ts +291 -0
  80. package/src/cli.ts +397 -0
  81. package/src/config.ts +15 -0
  82. package/src/credentials.ts +114 -0
  83. package/src/crypto.ts +21 -0
  84. package/src/dates.ts +19 -0
  85. package/src/errors.ts +26 -0
  86. package/src/http-handler.ts +48 -0
  87. package/src/http.ts +49 -0
  88. package/src/index.ts +13 -0
  89. package/src/launcher.ts +9 -0
  90. package/src/ledger-client.ts +258 -0
  91. package/src/main-client.ts +230 -0
  92. package/src/mcp-stdio.ts +6 -0
  93. package/src/mcp.ts +546 -0
  94. package/src/runtime.ts +329 -0
  95. package/src/server.ts +218 -0
  96. package/src/session.ts +29 -0
  97. package/src/smart-client.ts +249 -0
  98. package/src/tariff.ts +148 -0
  99. package/src/toolkit.test.ts +136 -0
  100. package/src/types.ts +51 -0
  101. package/src/worker.ts +3 -0
  102. package/tsconfig.build.json +11 -0
  103. package/tsconfig.json +16 -0
@@ -0,0 +1,249 @@
1
+ import { endpointCatalog, isEndpointName, type EndpointName } from "./catalog.js";
2
+ import { SMART_API_URL, mutationsEnabled } from "./config.js";
3
+ import { assertDateRange } from "./dates.js";
4
+ import { AuthenticationError, JpdclError, MutationDisabledError } from "./errors.js";
5
+ import { buildUrl } from "./http.js";
6
+ import type { PortalResponse, RequestOptions } from "./types.js";
7
+
8
+ export interface SmartTokenClaims {
9
+ sub?: string;
10
+ exp?: number;
11
+ tenantId?: string;
12
+ currentAccountTenantId?: string;
13
+ currentAccountKno?: string;
14
+ currentAccountMeterNo?: string;
15
+ currentAccountIsMeterPrepaid?: boolean | string;
16
+ currentAccountMeteringMode?: string;
17
+ currentAccountName?: string;
18
+ userAccounts?: string | unknown[];
19
+ [key: string]: unknown;
20
+ }
21
+
22
+ export class SmartMeterClient {
23
+ readonly claims: SmartTokenClaims;
24
+
25
+ constructor(
26
+ private token?: string,
27
+ readonly baseUrl = SMART_API_URL,
28
+ private readonly allowMutations = mutationsEnabled(),
29
+ readonly tenantId?: string,
30
+ private readonly adminToken = process.env.JPDCL_SMART_ADMIN_TOKEN,
31
+ ) {
32
+ this.claims = token ? decodeJwt(token) : {};
33
+ }
34
+
35
+ get bearerToken(): string | undefined { return this.token; }
36
+ get accountId(): string | undefined { return stringClaim(this.claims.currentAccountKno); }
37
+ get meterNumber(): string | undefined { return stringClaim(this.claims.currentAccountMeterNo); }
38
+ get effectiveTenantId(): string | undefined {
39
+ return this.tenantId ?? stringClaim(this.claims.currentAccountTenantId) ?? stringClaim(this.claims.tenantId);
40
+ }
41
+ get expiresAt(): string | undefined {
42
+ return typeof this.claims.exp === "number" ? new Date(this.claims.exp * 1000).toISOString() : undefined;
43
+ }
44
+
45
+ sessionInfo(): Record<string, unknown> {
46
+ return {
47
+ authenticated: Boolean(this.token),
48
+ expiresAt: this.expiresAt,
49
+ accountId: this.accountId,
50
+ meterNumber: this.meterNumber,
51
+ tenantId: this.effectiveTenantId,
52
+ userId: stringClaim(this.claims.sub),
53
+ isPrepaid: booleanClaim(this.claims.currentAccountIsMeterPrepaid),
54
+ meteringMode: stringClaim(this.claims.currentAccountMeteringMode),
55
+ accounts: parseAccounts(this.claims.userAccounts),
56
+ };
57
+ }
58
+
59
+ async request<T = unknown>(name: EndpointName | string, options: RequestOptions = {}): Promise<PortalResponse<T>> {
60
+ if (!isEndpointName(name)) throw new JpdclError(`Unknown endpoint: ${name}`, 400);
61
+ const endpoint = endpointCatalog[name];
62
+ if (endpoint.portal !== "smart") throw new JpdclError(`${name} belongs to the main JPDCL portal`, 400);
63
+ if (endpoint.mutation && !this.allowMutations) throw new MutationDisabledError();
64
+ if (endpoint.auth === "smart-bearer" && !this.token) throw new AuthenticationError("A smart-meter bearer token is required");
65
+ if (endpoint.auth === "smart-admin" && !this.adminToken) throw new AuthenticationError("JPDCL_SMART_ADMIN_TOKEN is required for this administrative endpoint");
66
+
67
+ const url = buildUrl(this.baseUrl, endpoint.path, options.params);
68
+ const headers: Record<string, string> = { Accept: endpoint.binary ? "*/*" : "application/json", ...options.headers };
69
+ if (endpoint.auth === "smart-bearer" && this.token) headers.Authorization = `Bearer ${this.token}`;
70
+ if (endpoint.auth === "smart-bearer" && this.effectiveTenantId) headers.TenantId = this.effectiveTenantId;
71
+ if (endpoint.auth === "smart-admin" && this.adminToken) headers.Authorization = `Bearer ${this.adminToken}`;
72
+
73
+ let body: string | undefined;
74
+ if (options.body !== undefined && endpoint.method !== "GET" && endpoint.method !== "DELETE") {
75
+ headers["Content-Type"] = "application/json";
76
+ body = JSON.stringify(options.body);
77
+ }
78
+ const response = await fetch(url, { method: endpoint.method, headers, body });
79
+ const contentType = response.headers.get("content-type") ?? "";
80
+ if (endpoint.binary || !contentType.toLowerCase().includes("json")) {
81
+ const bytes = Buffer.from(await response.arrayBuffer());
82
+ if (!response.ok) throw new JpdclError(`Smart-meter portal returned HTTP ${response.status}`, response.status, bytes.toString("utf8"));
83
+ return { status: true, data: { base64: bytes.toString("base64"), contentType } as T };
84
+ }
85
+
86
+ const raw = (await response.json()) as PortalResponse<T> & { success?: boolean; error?: unknown };
87
+ if (!response.ok || raw.success === false) {
88
+ throw new JpdclError(errorMessage(raw) ?? `Smart-meter portal returned HTTP ${response.status}`, response.status || 502, raw);
89
+ }
90
+ return normalizeEnvelope(raw);
91
+ }
92
+
93
+ async connections(): Promise<PortalResponse> {
94
+ const accounts = parseAccounts(this.claims.userAccounts);
95
+ const current = this.sessionInfo();
96
+ return { status: true, data: accounts.length ? accounts : [current] };
97
+ }
98
+
99
+ async dashboard(accountId = this.accountId, options: { includeDerived?: boolean } = {}): Promise<PortalResponse> {
100
+ if (!accountId) throw new JpdclError("Smart-meter account ID is required", 400);
101
+ const meterNumber = this.meterNumber;
102
+ if (!meterNumber) throw new JpdclError("Smart-meter number is required", 400);
103
+ const calls: Record<string, Promise<PortalResponse>> = {
104
+ usage: this.request("smart_today_monthly", { params: { meterNumber } }),
105
+ currentMonth: this.request("smart_current_month", { params: { accountId } }),
106
+ meterReadings: this.request("smart_meter_reading", { params: { meterNumber } }),
107
+ reading: this.request("smart_current_meter_reading", { params: { accountId } }),
108
+ lastBill: this.request("smart_postpaid_last_bill", { params: { accountId } }),
109
+ billHistory: this.request("smart_postpaid_bill_history", { params: { accountId } }),
110
+ paymentHistory: this.request("smart_postpaid_payment_history", { params: { accountId } }),
111
+ meterDetails: this.request("smart_meter_details", { params: { meterNumber } }),
112
+ preferences: this.request("smart_preferences", { params: { isPrepaid: booleanClaim(this.claims.currentAccountIsMeterPrepaid) ?? false } }),
113
+ alerts: this.request("smart_my_alerts", { params: { accountId, meterNumber } }),
114
+ onDemandRequests: this.request("smart_on_demand_logs", { params: { meterNumber } }),
115
+ };
116
+ const userId = stringClaim(this.claims.sub);
117
+ if (userId) {
118
+ calls.notifications = this.request("smart_notifications", { params: { userId } }).catch(() => ({ status: true, data: [] }));
119
+ calls.unreadNotifications = this.request("smart_notification_unread_count", { params: { userId } }).catch(() => ({ status: true, data: { unreadCount: 0 } }));
120
+ }
121
+ if (booleanClaim(this.claims.currentAccountIsMeterPrepaid)) {
122
+ delete calls.lastBill;
123
+ delete calls.billHistory;
124
+ delete calls.paymentHistory;
125
+ calls.prepaidBalance = this.request("smart_prepaid_balance", { params: { meterNumber } });
126
+ calls.rechargeBalance = this.request("smart_prepaid_recharge_balance", { params: { accountId } });
127
+ calls.rechargeHistory = this.request("smart_prepaid_recharge_history", { params: { meterNumber } });
128
+ calls.billHistory = this.request("smart_prepaid_bill_history", { params: { meterNumber } });
129
+ }
130
+ if (options.includeDerived) {
131
+ calls.derivedInsights = this.request("smart_insights", { params: { accountId } });
132
+ calls.derivedForecastToday = this.request("smart_forecast_today", { params: { meterNumber } });
133
+ calls.derivedForecastWeekly = this.request("smart_forecast_weekly", { params: { meterNumber } });
134
+ calls.derivedForecastMonthly = this.request("smart_forecast_monthly", { params: { meterNumber } });
135
+ }
136
+ const entries = await Promise.all(Object.entries(calls).map(async ([key, promise]) => {
137
+ try { return [key, (await promise).data] as const; }
138
+ catch (error) { return [key, { unavailable: true, message: error instanceof Error ? error.message : String(error) }] as const; }
139
+ }));
140
+ return {
141
+ status: true,
142
+ data: {
143
+ _meta: options.includeDerived
144
+ ? { dataPolicy: "observed-plus-derived", warning: "derivedInsights and derivedForecast* are predictions/advice, not meter evidence" }
145
+ : { dataPolicy: "observed-only", derivedAndAdvisoryExcluded: true },
146
+ session: this.sessionInfo(),
147
+ ...Object.fromEntries(entries),
148
+ },
149
+ };
150
+ }
151
+
152
+ consumption(accountId = this.accountId, type = "monthly", value = 12): Promise<PortalResponse> {
153
+ if (!accountId) throw new JpdclError("Smart-meter account ID is required", 400);
154
+ if (!this.meterNumber) throw new JpdclError("Smart-meter number is required", 400);
155
+ return this.request("smart_consumption_comparison", { params: { meterNumber: this.meterNumber, type, value } });
156
+ }
157
+
158
+ intervalConsumption(accountId: string | undefined, from: string, to: string, sortOrder = "date"): Promise<PortalResponse> {
159
+ if (!(accountId ?? this.accountId)) throw new JpdclError("Smart-meter account ID is required", 400);
160
+ if (!this.meterNumber) throw new JpdclError("Smart-meter number is required", 400);
161
+ assertDateRange(from, to, { requireBoth: true });
162
+ return this.request("smart_consumption_30min", { params: { meterNumber: this.meterNumber, fromDate: from, toDate: to, sortOrder } });
163
+ }
164
+
165
+ report(reportType: SmartReportType, accountId = this.accountId, options: SmartReportOptions = {}): Promise<PortalResponse> {
166
+ if (!accountId) throw new JpdclError("Smart-meter account ID is required", 400);
167
+ if (!this.meterNumber) throw new JpdclError("Smart-meter number is required", 400);
168
+ assertDateRange(options.from, options.to, { pairedWhenPresent: true });
169
+ const filter = options.filter ?? (options.from && options.to
170
+ ? encodeReportFilter(options.from, options.to, options.start ?? 1, options.end ?? defaultReportEnd(reportType))
171
+ : undefined);
172
+ return this.request("smart_report", { params: { meterNumber: this.meterNumber, reportType, filter, format: options.format } });
173
+ }
174
+ }
175
+
176
+ export type SmartReportType = "PowerOnOff" | "DayWiseTOD" | "MonthlyTOD" | "PeakSlotConsumption" |
177
+ "PeakSlotConsumptionMonthly" | "ConsumerVoltageDataProfile" | "SanctionLoadVSMaxDemand";
178
+
179
+ export interface SmartReportOptions {
180
+ from?: string;
181
+ to?: string;
182
+ start?: number;
183
+ end?: number;
184
+ filter?: string;
185
+ format?: "xlsx" | "pdf";
186
+ }
187
+
188
+ export function encodeReportFilter(from: string, to: string, start = 1, end = 100): string {
189
+ return Buffer.from(JSON.stringify({
190
+ start,
191
+ end,
192
+ filters: { createdDateFrom: from, createdDateTo: to },
193
+ }), "utf8").toString("base64");
194
+ }
195
+
196
+ export function smartApiUrlFromAppUrl(appUrl?: string): string {
197
+ if (!appUrl) return SMART_API_URL;
198
+ return new URL("/api", appUrl).toString().replace(/\/$/, "");
199
+ }
200
+
201
+ export function decodeJwt(token: string): SmartTokenClaims {
202
+ try {
203
+ const payload = token.split(".")[1];
204
+ if (!payload) throw new Error("JWT payload is missing");
205
+ return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as SmartTokenClaims;
206
+ } catch (error) {
207
+ throw new AuthenticationError("Invalid smart-meter SSO token", error);
208
+ }
209
+ }
210
+
211
+ function normalizeEnvelope<T>(raw: PortalResponse<T> & { success?: boolean }): PortalResponse<T> {
212
+ if (Object.prototype.hasOwnProperty.call(raw, "success")) {
213
+ return { status: raw.success, message: raw.message, data: raw.data, correlationId: raw.correlationId };
214
+ }
215
+ return raw;
216
+ }
217
+
218
+ function errorMessage(value: Record<string, unknown>): string | undefined {
219
+ const error = value.error;
220
+ if (typeof error === "string") return error;
221
+ if (error && typeof error === "object") {
222
+ const record = error as Record<string, unknown>;
223
+ return stringClaim(record.message) ?? stringClaim(record.title);
224
+ }
225
+ return stringClaim(value.message);
226
+ }
227
+
228
+ function stringClaim(value: unknown): string | undefined {
229
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
230
+ }
231
+
232
+ function booleanClaim(value: unknown): boolean | undefined {
233
+ if (typeof value === "boolean") return value;
234
+ if (typeof value === "string" && ["true", "false"].includes(value.toLowerCase())) return value.toLowerCase() === "true";
235
+ return undefined;
236
+ }
237
+
238
+ function parseAccounts(value: unknown): unknown[] {
239
+ if (Array.isArray(value)) return value;
240
+ if (typeof value === "string") {
241
+ try { const parsed = JSON.parse(value); return Array.isArray(parsed) ? parsed : []; }
242
+ catch { return []; }
243
+ }
244
+ return [];
245
+ }
246
+
247
+ function defaultReportEnd(reportType: SmartReportType): number {
248
+ return reportType === "ConsumerVoltageDataProfile" ? 100 : reportType === "SanctionLoadVSMaxDemand" ? 12 : 10;
249
+ }
package/src/tariff.ts ADDED
@@ -0,0 +1,148 @@
1
+ export const JPDCL_TARIFF_ORDER_2025_26 = {
2
+ id: "JPDCL-JERC-FY2025-26-SUBSIDIZED",
3
+ title: "JPDCL & KPDCL ARR and Tariff for FY 2025-26 - Subsidized Tariff",
4
+ fiscalYear: "2025-26",
5
+ sourceUrl: "https://jpdcl.jk.gov.in/downloads/tariff/JPDCL_Tariff_Order_2025_26.pdf",
6
+ domesticSchedule: {
7
+ pdfPages: [159, 160],
8
+ printedPages: [139, 140],
9
+ energySlabs: [
10
+ { upToKwh: 200, ratePerKwh: 2.30 },
11
+ { upToKwh: 400, ratePerKwh: 4.00 },
12
+ { upToKwh: null, ratePerKwh: 4.35 },
13
+ ],
14
+ fixedChargePerKwMonth: 8.00,
15
+ loadRoundingKw: 0.5,
16
+ },
17
+ generalTerms: {
18
+ pdfPages: [176, 177, 178, 179, 180],
19
+ printedPages: [156, 157, 158, 159, 160],
20
+ prepaidEnergyRebatePercent: 2,
21
+ latePaymentPercentPerMonth: 1.5,
22
+ solarWaterHeaterRebatePerMonth: 150,
23
+ },
24
+ } as const;
25
+
26
+ export interface DomesticTariffInput {
27
+ unitsKwh: number;
28
+ sanctionedLoadKw: number;
29
+ prepaid?: boolean;
30
+ solarWaterHeaterEligible?: boolean;
31
+ electricityDutyAmount?: number;
32
+ otherChargesAmount?: number;
33
+ unpaidPrincipalAmount?: number;
34
+ lateMonths?: number;
35
+ }
36
+
37
+ export interface TariffSlabCharge {
38
+ fromKwh: number;
39
+ toKwh: number | null;
40
+ unitsKwh: number;
41
+ ratePerKwh: number;
42
+ amount: number;
43
+ }
44
+
45
+ export function calculateDomesticMeteredCharges(input: DomesticTariffInput) {
46
+ requireNonNegative("unitsKwh", input.unitsKwh);
47
+ requireNonNegative("sanctionedLoadKw", input.sanctionedLoadKw);
48
+ const billedUnitsKwh = Math.round(input.unitsKwh);
49
+ const billedLoadKw = Math.ceil(input.sanctionedLoadKw / 0.5) * 0.5;
50
+ const slabCharges = calculateSlabs(billedUnitsKwh);
51
+ const energyCharge = roundMoney(slabCharges.reduce((sum, slab) => sum + slab.amount, 0));
52
+ const fixedCharge = roundMoney(billedLoadKw * JPDCL_TARIFF_ORDER_2025_26.domesticSchedule.fixedChargePerKwMonth);
53
+ const prepaidRebate = input.prepaid
54
+ ? roundMoney(energyCharge * JPDCL_TARIFF_ORDER_2025_26.generalTerms.prepaidEnergyRebatePercent / 100)
55
+ : 0;
56
+ const solarWaterHeaterRebate = input.solarWaterHeaterEligible
57
+ ? JPDCL_TARIFF_ORDER_2025_26.generalTerms.solarWaterHeaterRebatePerMonth
58
+ : 0;
59
+ const electricityDuty = optionalMoney("electricityDutyAmount", input.electricityDutyAmount);
60
+ const otherCharges = optionalMoney("otherChargesAmount", input.otherChargesAmount);
61
+ const lateMonths = input.lateMonths ?? 0;
62
+ requireNonNegative("lateMonths", lateMonths);
63
+ const unpaidPrincipal = optionalMoney("unpaidPrincipalAmount", input.unpaidPrincipalAmount);
64
+ const latePaymentSurcharge = roundMoney(
65
+ unpaidPrincipal * lateMonths * JPDCL_TARIFF_ORDER_2025_26.generalTerms.latePaymentPercentPerMonth / 100,
66
+ );
67
+ const tariffSubtotal = roundMoney(Math.max(0, energyCharge + fixedCharge - prepaidRebate - solarWaterHeaterRebate));
68
+ const totalEstimate = roundMoney(tariffSubtotal + electricityDuty + otherCharges + latePaymentSurcharge);
69
+
70
+ return {
71
+ _meta: {
72
+ dataClass: "deterministic-calculation",
73
+ purpose: "estimate-not-utility-bill",
74
+ tariffOrderId: JPDCL_TARIFF_ORDER_2025_26.id,
75
+ formulaVersion: "1.0",
76
+ },
77
+ input: {
78
+ measuredUnitsKwh: input.unitsKwh,
79
+ billedUnitsKwh,
80
+ sanctionedLoadKw: input.sanctionedLoadKw,
81
+ billedLoadKw,
82
+ prepaid: Boolean(input.prepaid),
83
+ solarWaterHeaterEligible: Boolean(input.solarWaterHeaterEligible),
84
+ },
85
+ rates: {
86
+ energySlabs: JPDCL_TARIFF_ORDER_2025_26.domesticSchedule.energySlabs,
87
+ fixedChargePerKwMonth: JPDCL_TARIFF_ORDER_2025_26.domesticSchedule.fixedChargePerKwMonth,
88
+ prepaidEnergyRebatePercent: JPDCL_TARIFF_ORDER_2025_26.generalTerms.prepaidEnergyRebatePercent,
89
+ latePaymentPercentPerMonth: JPDCL_TARIFF_ORDER_2025_26.generalTerms.latePaymentPercentPerMonth,
90
+ solarWaterHeaterRebatePerMonth: JPDCL_TARIFF_ORDER_2025_26.generalTerms.solarWaterHeaterRebatePerMonth,
91
+ },
92
+ slabCharges,
93
+ charges: {
94
+ energyCharge,
95
+ fixedCharge,
96
+ prepaidRebate: -prepaidRebate,
97
+ solarWaterHeaterRebate: -solarWaterHeaterRebate,
98
+ tariffSubtotal,
99
+ electricityDuty,
100
+ otherCharges,
101
+ latePaymentSurcharge,
102
+ totalEstimate,
103
+ currency: "INR",
104
+ },
105
+ exclusions: [
106
+ "arrears and bill adjustments unless supplied as otherChargesAmount",
107
+ "electricity duty or government levy unless supplied as electricityDutyAmount",
108
+ "FPPCA or later tariff revision not present in the cited order",
109
+ "excess-demand charges unless assessed by JPDCL",
110
+ "meter defects, penalties, and utility-specific rounding or adjustments",
111
+ ],
112
+ source: JPDCL_TARIFF_ORDER_2025_26,
113
+ };
114
+ }
115
+
116
+ function calculateSlabs(unitsKwh: number): TariffSlabCharge[] {
117
+ const definitions = [
118
+ { fromKwh: 1, toKwh: 200, width: 200, ratePerKwh: 2.30 },
119
+ { fromKwh: 201, toKwh: 400, width: 200, ratePerKwh: 4.00 },
120
+ { fromKwh: 401, toKwh: null, width: Number.POSITIVE_INFINITY, ratePerKwh: 4.35 },
121
+ ] as const;
122
+ let remaining = unitsKwh;
123
+ return definitions.map((definition) => {
124
+ const slabUnits = Math.max(0, Math.min(remaining, definition.width));
125
+ remaining -= slabUnits;
126
+ return {
127
+ fromKwh: definition.fromKwh,
128
+ toKwh: definition.toKwh,
129
+ unitsKwh: slabUnits,
130
+ ratePerKwh: definition.ratePerKwh,
131
+ amount: roundMoney(slabUnits * definition.ratePerKwh),
132
+ };
133
+ }).filter((slab) => slab.unitsKwh > 0);
134
+ }
135
+
136
+ function optionalMoney(name: string, value: number | undefined): number {
137
+ if (value === undefined) return 0;
138
+ requireNonNegative(name, value);
139
+ return roundMoney(value);
140
+ }
141
+
142
+ function requireNonNegative(name: string, value: number): void {
143
+ if (!Number.isFinite(value) || value < 0) throw new TypeError(`${name} must be a non-negative finite number`);
144
+ }
145
+
146
+ function roundMoney(value: number): number {
147
+ return Math.round((value + Number.EPSILON) * 100) / 100;
148
+ }
@@ -0,0 +1,136 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { endpointCatalog, listEndpoints } from "./catalog.js";
4
+ import { decryptPayload, encryptPayload, ENCRYPTED_FIELD } from "./crypto.js";
5
+ import { resolveCredentials } from "./credentials.js";
6
+ import { assertDateRange, isIsoDate } from "./dates.js";
7
+ import { buildUrl } from "./http.js";
8
+ import { ledgerPeriod, parseLedgerHtml } from "./ledger-client.js";
9
+ import { decodeJwt, encodeReportFilter, smartApiUrlFromAppUrl } from "./smart-client.js";
10
+ import { calculateDomesticMeteredCharges } from "./tariff.js";
11
+
12
+ describe("JPDCL protocol helpers", () => {
13
+ it("round-trips the portal AES envelope", () => {
14
+ const source = { accountid: "0000000000", nested: { enabled: true } };
15
+ const encrypted = encryptPayload(source)[ENCRYPTED_FIELD];
16
+ assert.equal(typeof encrypted, "string");
17
+ assert.deepEqual(decryptPayload(encrypted!), source);
18
+ });
19
+
20
+ it("fills path parameters and preserves query parameters", () => {
21
+ assert.equal(buildUrl("https://example.test/api", "/meter/{id}", { id: "A/B", from: "2026-01-01" }),
22
+ "https://example.test/api/meter/A%2FB?from=2026-01-01");
23
+ });
24
+
25
+ it("derives the API root and decodes SSO claims", () => {
26
+ const payload = Buffer.from(JSON.stringify({ currentAccountKno: "123", exp: 2_000_000_000 }), "utf8").toString("base64url");
27
+ assert.equal(decodeJwt(`x.${payload}.y`).currentAccountKno, "123");
28
+ assert.equal(smartApiUrlFromAppUrl("https://cp.example.test/dashboard"), "https://cp.example.test/api");
29
+ });
30
+
31
+ it("encodes the report filter exactly as the smart portal", () => {
32
+ const decoded = JSON.parse(Buffer.from(encodeReportFilter("2026-07-01", "2026-07-24", 1, 10), "base64").toString("utf8"));
33
+ assert.deepEqual(decoded, { start: 1, end: 10, filters: { createdDateFrom: "2026-07-01", createdDateTo: "2026-07-24" } });
34
+ });
35
+
36
+ it("rejects impossible or descending date ranges before a portal call", () => {
37
+ assert.equal(isIsoDate("2026-02-28"), true);
38
+ assert.equal(isIsoDate("2026-02-31"), false);
39
+ assert.throws(() => assertDateRange("2026-07-25", "2026-07-24"), /from must be on or before to/);
40
+ assert.throws(() => assertDateRange("2026-07-24", undefined, { pairedWhenPresent: true }), /both from and to/i);
41
+ });
42
+
43
+ it("resolves unattended credentials from the environment without persisting them", async () => {
44
+ const previousLogin = process.env.JPDCL_LOGIN_ID;
45
+ const previousPassword = process.env.JPDCL_PASSWORD;
46
+ try {
47
+ process.env.JPDCL_LOGIN_ID = "test-login";
48
+ process.env.JPDCL_PASSWORD = "test-password";
49
+ assert.deepEqual(await resolveCredentials(), {
50
+ loginId: "test-login",
51
+ password: "test-password",
52
+ source: "environment",
53
+ });
54
+ } finally {
55
+ if (previousLogin === undefined) delete process.env.JPDCL_LOGIN_ID;
56
+ else process.env.JPDCL_LOGIN_ID = previousLogin;
57
+ if (previousPassword === undefined) delete process.env.JPDCL_PASSWORD;
58
+ else process.env.JPDCL_PASSWORD = previousPassword;
59
+ }
60
+ });
61
+ });
62
+
63
+ describe("endpoint catalog", () => {
64
+ it("contains broad main and smart coverage", () => {
65
+ assert.ok(listEndpoints("main").length >= 40);
66
+ assert.ok(listEndpoints("smart").length >= 70);
67
+ assert.equal(listEndpoints("ledger").length, 2);
68
+ });
69
+
70
+ it("marks consequential actions as mutations", () => {
71
+ assert.equal(endpointCatalog.main_initiate_payment.mutation, true);
72
+ assert.equal(endpointCatalog.smart_on_demand_request.mutation, true);
73
+ assert.equal(endpointCatalog.smart_update_preferences.mutation, true);
74
+ assert.equal(endpointCatalog.smart_update_preferences.method, "PUT");
75
+ assert.equal(endpointCatalog.smart_update_alerts.method, "POST");
76
+ assert.notEqual(endpointCatalog.smart_today_monthly.mutation, true);
77
+ assert.equal(endpointCatalog.smart_today_monthly.dataClass, "observed");
78
+ assert.equal(endpointCatalog.smart_forecast_monthly.dataClass, "derived");
79
+ assert.equal(endpointCatalog.smart_insights.dataClass, "derived");
80
+ assert.equal(endpointCatalog.smart_page_content.dataClass, "advisory");
81
+ });
82
+ });
83
+
84
+ describe("daily smart-meter ledger", () => {
85
+ it("normalizes public cumulative registers and computes net-meter period deltas", () => {
86
+ const html = `<div class="row"><div class="col">Consumer ID</div><div class="col">123</div></div>
87
+ <div class="row"><div class="col">Smart Meter</div><input value="M1"></div>
88
+ <div class="row"><div class="col">Net Meter</div><div class="col">Yes</div></div>||||
89
+ <table id="readtable1"><tbody>
90
+ <tr><td>2026-07-02</td><td>120</td><td>45</td><td>75</td><td>125</td><td>46</td><td>79</td></tr>
91
+ <tr><td>2026-07-01</td><td>110</td><td>42</td><td>68</td><td>114</td><td>43</td><td>71</td></tr>
92
+ <tr><td>2026-06-30</td><td>100</td><td>40</td><td>60</td><td>104</td><td>41</td><td>63</td></tr>
93
+ </tbody></table>`;
94
+ const data = parseLedgerHtml(html, "123");
95
+ assert.equal(data.consumer.netMeter, true);
96
+ assert.equal(data.readings.length, 3);
97
+ const period = ledgerPeriod(data, { from: "2026-07-01", to: "2026-07-02", limit: 10 });
98
+ assert.equal(period.period.usage?.importKwh, 20);
99
+ assert.equal(period.period.usage?.exportKwh, 5);
100
+ assert.equal(period.period.provisionalBillableKwh, 15);
101
+ assert.equal(period.readings[0]?.usageSincePreviousObservation?.netImportKwh, 7);
102
+ });
103
+ });
104
+
105
+ describe("FY 2025-26 domestic tariff", () => {
106
+ it("matches the user's actual recent bill components", () => {
107
+ const estimate = calculateDomesticMeteredCharges({ unitsKwh: 98, sanctionedLoadKw: 0.5 });
108
+ assert.equal(estimate.charges.energyCharge, 225.4);
109
+ assert.equal(estimate.charges.fixedCharge, 4);
110
+ assert.equal(estimate.charges.totalEstimate, 229.4);
111
+ });
112
+
113
+ it("applies progressive slabs and load rounding", () => {
114
+ assert.equal(calculateDomesticMeteredCharges({ unitsKwh: 220, sanctionedLoadKw: 0.5 }).charges.totalEstimate, 544);
115
+ assert.equal(calculateDomesticMeteredCharges({ unitsKwh: 240, sanctionedLoadKw: 0.5 }).charges.totalEstimate, 624);
116
+ const estimate = calculateDomesticMeteredCharges({ unitsKwh: 500, sanctionedLoadKw: 3 });
117
+ assert.equal(estimate.charges.energyCharge, 1695);
118
+ assert.equal(estimate.charges.fixedCharge, 24);
119
+ assert.equal(estimate.charges.totalEstimate, 1719);
120
+ });
121
+
122
+ it("applies only explicitly eligible rebates and additional charges", () => {
123
+ const estimate = calculateDomesticMeteredCharges({
124
+ unitsKwh: 100,
125
+ sanctionedLoadKw: 0.6,
126
+ prepaid: true,
127
+ electricityDutyAmount: 5,
128
+ unpaidPrincipalAmount: 1000,
129
+ lateMonths: 2,
130
+ });
131
+ assert.equal(estimate.charges.prepaidRebate, -4.6);
132
+ assert.equal(estimate.charges.fixedCharge, 8);
133
+ assert.equal(estimate.charges.latePaymentSurcharge, 30);
134
+ assert.equal(estimate.charges.totalEstimate, 268.4);
135
+ });
136
+ });
package/src/types.ts ADDED
@@ -0,0 +1,51 @@
1
+ export type JsonPrimitive = string | number | boolean | null;
2
+ export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
3
+ export type JsonObject = { [key: string]: JsonValue };
4
+
5
+ export interface PortalResponse<T = unknown> {
6
+ status?: boolean | number | string;
7
+ message?: string;
8
+ data?: T;
9
+ [key: string]: unknown;
10
+ }
11
+
12
+ export interface MainSession {
13
+ version: 1;
14
+ createdAt: string;
15
+ updatedAt: string;
16
+ cookies: string;
17
+ loginId?: string;
18
+ primaryAccountId?: string;
19
+ consumerCode?: string;
20
+ user?: Record<string, unknown>;
21
+ smart?: {
22
+ token: string;
23
+ baseUrl: string;
24
+ appUrl?: string;
25
+ tenantId?: string;
26
+ accountId?: string;
27
+ meterNumber?: string;
28
+ userId?: string;
29
+ expiresAt?: string;
30
+ };
31
+ }
32
+
33
+ export interface EndpointDefinition {
34
+ portal: "main" | "smart" | "ledger";
35
+ method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
36
+ path: string;
37
+ encrypted?: boolean;
38
+ auth: "public" | "public-basic" | "main-session" | "smart-bearer" | "smart-admin" | "external-basic";
39
+ mutation?: boolean;
40
+ binary?: boolean;
41
+ dataClass?: "observed" | "derived" | "advisory" | "configuration" | "transactional";
42
+ description: string;
43
+ parameters?: string[];
44
+ bodyExample?: Record<string, unknown>;
45
+ }
46
+
47
+ export interface RequestOptions {
48
+ params?: Record<string, string | number | boolean | null | undefined>;
49
+ body?: unknown;
50
+ headers?: Record<string, string>;
51
+ }
package/src/worker.ts ADDED
@@ -0,0 +1,3 @@
1
+ import { handleMcpRequest } from "./http-handler.js";
2
+
3
+ export default { fetch: handleMcpRequest };
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "dist",
6
+ "sourceMap": true,
7
+ "declaration": true
8
+ },
9
+ "include": ["src/**/*.ts"],
10
+ "exclude": ["src/**/*.test.ts"]
11
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "lib": ["ES2022", "DOM"],
7
+ "strict": true,
8
+ "declaration": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "resolveJsonModule": true,
12
+ "noUncheckedIndexedAccess": true,
13
+ "outDir": "dist"
14
+ },
15
+ "include": ["src", "test"]
16
+ }