scripter-x 1.0.28 → 1.0.30

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scripter-x",
3
- "version": "1.0.28",
3
+ "version": "1.0.30",
4
4
  "description": "ScripterX — local Flipkart session extractor (runs on your residential IP, syncs to marthunt)",
5
5
  "type": "module",
6
6
  "bin": {
package/src/commands.js CHANGED
@@ -8,9 +8,10 @@ import * as tp from './providers/tempotp.js';
8
8
  import * as oc from './providers/otpcart.js';
9
9
  import * as kuku from './providers/kuku.js';
10
10
  import * as zepto from './providers/zepto.js';
11
+ import { checkCouponTier } from './providers/zeptoCoupon.js';
11
12
  import { homedir } from 'node:os';
12
13
  import { join, dirname } from 'node:path';
13
- import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
14
+ import { mkdirSync, writeFileSync, appendFileSync, readFileSync, existsSync } from 'node:fs';
14
15
  import { Worker } from './worker.js';
15
16
  import { RunController } from './controller.js';
16
17
  import { STATUS } from './theme.js';
@@ -288,12 +289,47 @@ export async function recheck(io, _api, args = {}) {
288
289
  // code → we verify, build the ZAUTH1 envelope, and write {phone}-{timestamp}.txt.
289
290
  // LOOPS: after each one it asks for the next number — keep going until the user
290
291
  // presses Esc (or double Ctrl+C). Runs entirely on your IP.
292
+ // Resolve the base dir for Zepto envelope output.
293
+ function zeptoBaseDir(args) {
294
+ if (args.out) {
295
+ const expanded = args.out.startsWith('~') ? join(homedir(), args.out.slice(1)) : args.out;
296
+ return /\.(txt|json)$/i.test(expanded) ? dirname(expanded) : expanded;
297
+ }
298
+ const downloads = join(homedir(), 'Downloads');
299
+ return join(existsSync(downloads) ? downloads : homedir(), 'scripterx', 'zepto');
300
+ }
301
+
302
+ // Save one envelope. With a tier → APPEND to {tier}.txt (one envelope per block,
303
+ // blank-line separated, so the file is readable + paste-friendly). Without a tier →
304
+ // a single per-number timestamped file (legacy behaviour). Returns the path written.
305
+ function saveZeptoEnvelope(args, number, envelope, tier) {
306
+ const baseDir = zeptoBaseDir(args);
307
+ if (tier) {
308
+ mkdirSync(baseDir, { recursive: true });
309
+ const dest = join(baseDir, `${tier}.txt`);
310
+ // append with a blank-line separator if the file already has content
311
+ const block = (existsSync(dest) ? '\n' : '') + envelope + '\n';
312
+ appendFileSync(dest, block);
313
+ return dest;
314
+ }
315
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
316
+ const dest = join(baseDir, `${number}-${stamp}.txt`);
317
+ mkdirSync(baseDir, { recursive: true });
318
+ writeFileSync(dest, envelope + '\n');
319
+ return dest;
320
+ }
321
+
291
322
  export async function zeptoCmd(io, api, args = {}) {
292
323
  const mode = args.manual ? 'manual' : (args.auto ? 'auto' : (await io.select('Choose Zepto login flow', [
293
324
  { label: 'Auto', value: 'auto', description: 'Headless extraction via OTPCart (Recommended)' },
294
325
  { label: 'Manual', value: 'manual', description: 'Enter phone + type OTP manually' }
295
326
  ])).value || 'auto');
296
327
 
328
+ // Optional: check the coupon tier (₹50/₹75/₹100/normal) per account + split into
329
+ // 4 files. Runs headlessly right after verify while the token is fresh.
330
+ const checkCoupon = args.checkCoupon != null ? args.checkCoupon
331
+ : await io.confirm('check coupon tier (₹50/₹75/₹100) + split into files?', false);
332
+
297
333
  if (mode === 'manual') {
298
334
  io.print(' ◉ Zepto — local OTP → ZAUTH1 envelope (Manual)');
299
335
  io.print(' ◉ press esc to cancel', 'accent');
@@ -335,21 +371,18 @@ export async function zeptoCmd(io, api, args = {}) {
335
371
  // Build the ZAUTH1 envelope — the format the ZeptoAuthManager tool imports.
336
372
  const envelope = zepto.encodeEnvelope(zepto.toSessionKeys(session), args.cert);
337
373
 
338
- // {phone}-{timestamp}.txt in the chosen dir (default ~/Downloads/scripterx/zepto)
339
- const stamp = new Date().toISOString().replace(/[:.]/g, '-');
340
- const fname = `${number}-${stamp}.txt`;
341
- let dest;
342
- if (args.out) {
343
- const expanded = args.out.startsWith('~') ? join(homedir(), args.out.slice(1)) : args.out;
344
- dest = /\.(txt|json)$/i.test(expanded) ? expanded : join(expanded, fname);
345
- } else {
346
- const downloads = join(homedir(), 'Downloads');
347
- dest = join(existsSync(downloads) ? downloads : homedir(), 'scripterx', 'zepto', fname);
374
+ // Optional coupon-tier check (token is fresh right now) → split into 4 files.
375
+ let tier = '';
376
+ if (checkCoupon) {
377
+ io.print(' ◉ checking coupon tier …');
378
+ const c = await checkCouponTier(session);
379
+ tier = c.tier; // rs50 | rs75 | rs100 | normal | unchecked
380
+ io.print(` ✓ coupon: ${c.label}`, c.tier === 'normal' || c.tier === 'unchecked' ? undefined : 'accent');
348
381
  }
349
- mkdirSync(dirname(dest), { recursive: true });
350
- writeFileSync(dest, envelope + '\n');
382
+
383
+ const dest = saveZeptoEnvelope(args, number, envelope, tier);
351
384
  done++;
352
- io.print(' ✓ envelope saved to:');
385
+ io.print(` ✓ envelope saved${tier ? ` (${tier})` : ''} to:`);
353
386
  io.print(` ${dest}`, 'accent');
354
387
  io.print(' ◉ paste into ZeptoAuthManager → Import:');
355
388
  io.print(` ${envelope}`, 'accent');
@@ -412,6 +445,7 @@ export async function zeptoCmd(io, api, args = {}) {
412
445
  requested: count,
413
446
  concurrency,
414
447
  certSha: cert,
448
+ checkCoupon,
415
449
  onEvent: controller.handleEvent,
416
450
  });
417
451
  controller.stop = () => worker.stop();
@@ -450,11 +484,29 @@ export async function zeptoCmd(io, api, args = {}) {
450
484
  io.print(` ✓ saved ${saved.count} session(s) to 2 files:`);
451
485
  io.print(` full → ${saved.fullPath}`, 'accent');
452
486
  io.print(` zauth → ${saved.zauthPath}`, 'accent');
487
+ }
488
+ // If coupon-tier was checked, ALSO split the envelopes into per-tier files.
489
+ if (checkCoupon) {
490
+ const tiers = {};
491
+ for (const r of results) {
492
+ if (r.status !== 'success') continue;
493
+ const tier = r.coupon_tier || 'unchecked';
494
+ (tiers[tier] = tiers[tier] || []).push(r.envelope);
495
+ }
496
+ io.print(' ◉ coupon tiers:');
497
+ for (const tier of ['rs100', 'rs75', 'rs50', 'normal', 'unchecked']) {
498
+ if (!tiers[tier]?.length) continue;
499
+ const dir = zeptoTierDir(args, name);
500
+ const dest = join(dir, `${tier}.txt`);
501
+ mkdirSync(dir, { recursive: true });
502
+ // one envelope per block, separated by a blank line — readable + paste-friendly
503
+ writeFileSync(dest, tiers[tier].join('\n\n') + '\n');
504
+ io.print(` ${TIER_LABEL[tier]} (${tiers[tier].length}) → ${dest}`, 'accent');
505
+ }
506
+ } else {
453
507
  io.print(' ◉ ZAUTH1 envelopes:');
454
508
  for (const r of results) {
455
- if (r.status === 'success') {
456
- io.print(` +91${r.mobile} → ${r.envelope}`, 'accent');
457
- }
509
+ if (r.status === 'success') io.print(` +91${r.mobile} → ${r.envelope}`, 'accent');
458
510
  }
459
511
  }
460
512
  } else {
@@ -462,6 +514,21 @@ export async function zeptoCmd(io, api, args = {}) {
462
514
  }
463
515
  }
464
516
 
517
+ const TIER_LABEL = { rs100: '🟢 ₹100', rs75: '🟢 ₹75', rs50: '🟢 ₹50', normal: '⚪ normal', unchecked: '❔ unchecked' };
518
+
519
+ // The directory the per-tier files go in for an auto run.
520
+ function zeptoTierDir(args, name) {
521
+ let baseDir;
522
+ if (args.out) {
523
+ const expanded = args.out.startsWith('~') ? join(homedir(), args.out.slice(1)) : args.out;
524
+ baseDir = /\.(txt|json)$/i.test(expanded) ? dirname(expanded) : expanded;
525
+ } else {
526
+ const downloads = join(homedir(), 'Downloads');
527
+ baseDir = join(existsSync(downloads) ? downloads : homedir(), 'scripterx', 'zepto');
528
+ }
529
+ return join(baseDir, name);
530
+ }
531
+
465
532
  async function saveZeptoSessions(results, name, out) {
466
533
  const { statSync } = await import('node:fs');
467
534
  const ok = results.filter((r) => r.status === 'success');
package/src/index.js CHANGED
@@ -119,6 +119,7 @@ async function main() {
119
119
  target: flags.target, // run: 'flipkart' | 'zepto'
120
120
  number: flags.number, otp: flags.otp, cert: flags.cert, // zepto: local OTP login → ZAUTH1 envelope
121
121
  auto: flags.auto, manual: flags.manual,
122
+ checkCoupon: flags['check-coupon'] === true ? true : (flags['check-coupon'] === false ? false : null),
122
123
  campaign: flags._[0], out: flags.out, action: flags._[0], value: flags._[1],
123
124
  };
124
125
  try { await REGISTRY[fnName](cliIo, null, args); }
@@ -0,0 +1,241 @@
1
+ // Zepto coupon-tier checker — runs HEADLESSLY right after OTP verify, while the
2
+ // access token is fresh. Reads which new-user coupon (₹50 / ₹75 / ₹100 / none) an
3
+ // account is in, by building a cart over the MOV and reading the coupons fetch-list.
4
+ //
5
+ // Ported from zepto_coupon_checker/zeptoClient.js (CommonJS → ESM). Classification
6
+ // mirrors zepto_coupon_checker/idcheck.js: an explicit Z-CARTBOOST<N> code or a
7
+ // single {50,75,100} banner = that tier; a spread of 3+ amounts = the generic
8
+ // catalog (normal account, no unlocked coupon).
9
+ import https from 'node:https';
10
+ import crypto from 'node:crypto';
11
+
12
+ const API = 'api.zepto.co.in';
13
+ const GW = 'api-gateway.zepto.co.in';
14
+ const SEARCH = 'user-search.zepto.co.in';
15
+
16
+ const uuid = () => crypto.randomUUID();
17
+ const randHex = (n) => crypto.randomBytes(n).toString('hex').slice(0, n);
18
+
19
+ const COMPATIBLE_COMPONENTS = 'EXTERNAL_COUPONS,BUNDLE,MULTI_SELLER_ENABLED,ROLLUPS,RE_PROMISE_ETA_ORDER_SCREEN_ENABLED,RECOMMENDED_COUPON_WIDGET,PHARMA_ENABLED,GAMIFICATION_ENABLED,DYNAMIC_FILTERS,HOMEPAGE_V2,COUPON_WIDGET_CART_REVAMP,AUTOSUGGESTION_PIP,NEW_ETA_BANNER,IS_DYNAMIC_NZS_SUPPORTED,ZEPTO_THREE,RECOMMENDED_COUPON_WIDGET,COUPON_UPSELLING_WIDGET,CART_BOX_MODEL_WIDGETS,NEW_BILL_INFO,COUPON_BOTTOM_STRIP,SUPERSTORE_V1,SUPER_SAVER:1,SUPER_SAVER_DYNAMIC_MOV_ENABLED,PROMO_CASH:2,GIFT_CARD,WISHLIST,CART_PERSISTENCE,CART_REDESIGN_ENABLED,CART_LOCKING:1,NEW_WALLET_INFO,COLLAPSIBLE_BILL_INFO,ITEMISATION_ENABLED,NO_BAG,NO_BAG_DELIVERY_V3';
20
+
21
+ function baseHeaders(session, extra = {}) {
22
+ return {
23
+ 'content-type': 'application/json; charset=utf-8',
24
+ 'x-requested-with': 'XMLHttpRequest',
25
+ 'user-agent': 'okhttp/4.12.0',
26
+ appversion: '26.5.5', app_version: '26.5.5',
27
+ platform: 'android', tenant: 'ZEPTO',
28
+ sessionid: session.sessionId, session_id: session.sessionId,
29
+ deviceuid: session.deviceUid, device_uid: session.deviceUid,
30
+ device_brand: 'samsung', device_model: 'SM-G991B',
31
+ system_version: '13', systemversion: '13',
32
+ source: 'PLAY_STORE', is_internal_user: 'false', isinternaluser: 'false',
33
+ ...(session.token ? { authorization: `Bearer ${session.token}` } : {}),
34
+ ...extra,
35
+ };
36
+ }
37
+
38
+ function cartHeaders(session, storeId, extra = {}) {
39
+ return baseHeaders(session, {
40
+ compatible_components: COMPATIBLE_COMPONENTS,
41
+ marketplace_type: 'SUPER_SAVER', experience_variant: 'SS_UNIFIED',
42
+ performance_mode: 'NORMAL', auth_revamp_flow: 'v2',
43
+ is_new_font: 'true', accessibility_layout: 'false',
44
+ ...(storeId ? { store_id: storeId, storeid: storeId, store_ids: storeId } : {}),
45
+ ...extra,
46
+ });
47
+ }
48
+
49
+ function req(host, method, path, hdrs, bodyObj) {
50
+ return new Promise((resolve) => {
51
+ const body = bodyObj != null ? JSON.stringify(bodyObj) : null;
52
+ const h = { ...hdrs, host };
53
+ if (body) h['content-length'] = Buffer.byteLength(body);
54
+ const r = https.request({ hostname: host, port: 443, method, path, headers: h, timeout: 20000 }, (res) => {
55
+ const ch = [];
56
+ res.on('data', (c) => ch.push(c));
57
+ res.on('end', () => {
58
+ const text = Buffer.concat(ch).toString('utf8');
59
+ let json = null;
60
+ try { json = JSON.parse(text); } catch { /* */ }
61
+ resolve({ status: res.statusCode, text, json });
62
+ });
63
+ });
64
+ r.on('error', (e) => resolve({ status: 0, text: 'err:' + e.message, json: null }));
65
+ r.on('timeout', () => { r.destroy(); resolve({ status: 0, text: 'timeout', json: null }); });
66
+ if (body) r.write(body);
67
+ r.end();
68
+ });
69
+ }
70
+
71
+ // Build a coupon-check session from a logged-in zepto provider session.
72
+ function couponSession(session) {
73
+ return {
74
+ sessionId: session.sessionId || uuid(),
75
+ deviceUid: session.deviceUid || session.deviceId || randHex(16),
76
+ token: session.token || session.accessToken,
77
+ user: session.user || (session.userId ? { id: session.userId } : null),
78
+ };
79
+ }
80
+
81
+ // Parse coupon banners/codes out of a response. [{code, amount}].
82
+ function parseCoupons(text) {
83
+ const found = new Map();
84
+ if (!text) return [];
85
+ for (const m of text.matchAll(/Z-CARTBOOST(\d+)/g)) {
86
+ found.set(`Z-CARTBOOST${m[1]}`, { code: `Z-CARTBOOST${m[1]}`, amount: Number(m[1]) });
87
+ }
88
+ for (const m of text.matchAll(/(?:Extra|FLAT|Get|Unlock(?:ed)?)\s*₹\s*(\d{2,3})\s*OFF/gi)) {
89
+ const amt = Number(m[1]);
90
+ if (!found.has(`Z-CARTBOOST${amt}`)) found.set(`banner${amt}`, { code: null, amount: amt, banner: true });
91
+ }
92
+ return [...found.values()];
93
+ }
94
+
95
+ async function resolveStore(session, lat, lng) {
96
+ const r = await req(GW, 'GET',
97
+ `/serviceability-service/api/v1/serviceability?lat=${lat}&long=${lng}&includeAddressDetails=true`,
98
+ cartHeaders(session));
99
+ let primary = null, anyServ = null;
100
+ const re = /\{"serviceable":(true|false),"storeId":"([0-9a-f-]{36})","storeConstruct":"([^"]+)"[^}]*?"isNightlyStore":(true|false)/g;
101
+ let m;
102
+ while ((m = re.exec(r.text))) {
103
+ const s = { serviceable: m[1] === 'true', storeId: m[2], construct: m[3] };
104
+ if (s.serviceable && !anyServ) anyServ = s;
105
+ if (s.serviceable && s.construct === 'PRIMARY_STORE' && !primary) primary = s;
106
+ }
107
+ return primary || anyServ;
108
+ }
109
+
110
+ async function findProduct(session, storeId, lat, lng, query) {
111
+ const r = await req(SEARCH, 'POST', '/api/v3/search',
112
+ cartHeaders(session, storeId, {
113
+ storeid: storeId, store_id: storeId, store_ids: storeId,
114
+ store_etas: JSON.stringify({ [storeId]: -1 }),
115
+ store_serviceability: JSON.stringify({ data: { [storeId]: { type: 'PRIMARY_STORE' } } }),
116
+ gps_latitude: String(lat), gps_longitude: String(lng),
117
+ }),
118
+ { query, userSessionId: '', mode: 'TYPED', intentId: uuid(), cartLastUpdatedAt: 0,
119
+ user_action_meta: [], requestMeta: {}, pageNumber: 0, retryCount: 0 });
120
+ const idx = r.text.indexOf('"storeProductId"');
121
+ if (idx < 0) return null;
122
+ const block = r.text.slice(Math.max(0, idx - 500), idx + 500);
123
+ const sp = block.match(/"storeProductId":"([0-9a-f-]{36})"/);
124
+ const pv = block.match(/"productVariantId":"([0-9a-f-]{36})"/);
125
+ const pr = block.match(/"(?:discountedSellingPrice|sellingPrice)":(\d+)/);
126
+ if (!sp || !pv) return null;
127
+ return { storeProductId: sp[1], productVariantId: pv[1], price: pr ? Number(pr[1]) : 10000 };
128
+ }
129
+
130
+ // Run the coupon check. Returns { coupons:[{code,amount}], status?, reason? }.
131
+ async function fetchCoupons(session, opts = {}) {
132
+ const lat = opts.lat ?? 12.9263597;
133
+ const lng = opts.lng ?? 77.5838125;
134
+
135
+ let STORE_ID = opts.storeId;
136
+ if (!STORE_ID) STORE_ID = (await resolveStore(session, lat, lng))?.storeId;
137
+ if (!STORE_ID) STORE_ID = '5ec071fd-78df-41f6-b3ae-7298d9f96a3d';
138
+
139
+ // build a cart >= MOV (first 2 in-stock common items)
140
+ const PRODUCTS = [];
141
+ for (const q of ['water', 'milk', 'bread', 'redbull', 'chips']) {
142
+ if (PRODUCTS.length >= 2) break;
143
+ const p = await findProduct(session, STORE_ID, lat, lng, q);
144
+ if (p && !PRODUCTS.find((x) => x.productVariantId === p.productVariantId)) PRODUCTS.push(p);
145
+ }
146
+ if (PRODUCTS.length === 0) {
147
+ PRODUCTS.push({ storeProductId: 'b382dc23-0d70-4a50-82f3-5219a0bb8b12', productVariantId: '3e01e4c1-b31d-4305-810f-5c3a03a7401f', price: 21900 });
148
+ }
149
+
150
+ let cartId = '';
151
+ const cart = await req(GW, 'POST', '/cart-service/api/v1/cart/widget-action', cartHeaders(session, STORE_ID), {
152
+ actions: [{ widgetId: 'GET_CART_ID_WIDGET', actionType: 'GET_CART_ID', data: {} }],
153
+ });
154
+ const cm = cart.text.match(/"cartId"\s*:\s*"([0-9a-f-]{36})"/i) || cart.text.match(/"id"\s*:\s*"([0-9a-f-]{36})"/i);
155
+ if (cm) cartId = cm[1];
156
+
157
+ const cartProducts = PRODUCTS.map((p) => ({
158
+ quantity: 1, productVariantId: p.productVariantId, isAddedToGiftBag: false,
159
+ discountedSellingPrice: p.price, uclId: '', storeProductId: p.storeProductId,
160
+ }));
161
+ const putCart = await req(GW, 'PUT', '/cfs/api/v1/cart', cartHeaders(session, STORE_ID), {
162
+ storeId: STORE_ID, cartId, cartProducts,
163
+ cartProductsV2: cartProducts.map((p) => ({ cartProductId: p.productVariantId, ...p, addOns: [] })),
164
+ latitude: lat, longitude: lng,
165
+ deliveryInstructions: { leaveAtGate: false, additionalNote: '', doNotRingBell: false, bewareOfPets: false, returnPaperBag: false, leaveWithSecurity: false, returnCokePetBottle: false },
166
+ riderTip: 0, use_zepto_cash: true, zeptoPass: { isPassSelected: false },
167
+ isStoreChanged: false, isNightlyStore: false, manuallyAppliedFees: [], addAndSavePvIdsMap: {}, noBagDelivery: null,
168
+ userMeta: { whatsappOpted: 'PENDING', refresh: false, currentToPay: 0, currentGrandTotal: 0 },
169
+ userPreferences: { MANUAL_BXGY_BENEFIT: { opted: false }, Z_COINS: { opted: false } },
170
+ sessionMeta: { hasMovCrossed: true, lastMarketplaceType: 'SUPER_SAVER', toastNudgeViewed: {} },
171
+ openElements: { isPtpBottomSheetOpen: false, ptpBottomSheetLayoutId: '' },
172
+ user_action_meta: cartProducts.map((p) => ['atc', p.productVariantId]),
173
+ widgetLevelActions: {}, locationDistanceFromAddress: -1, installedUpiApps: [], isCredPayEligible: false,
174
+ isPtpLayoutMigrationEnabled: true, seenElementsInSession: { ptpBottomSheet: false, superSaverCart: true, seenWidgets: {} },
175
+ });
176
+ const pm = putCart.text.match(/"cartId"\s*:\s*"([0-9a-f-]{36})"/i);
177
+ if (pm) cartId = pm[1];
178
+
179
+ // store closed / paused → can't compute coupon
180
+ const ndr = putCart.text.match(/"nonDeliverableReason"\s*:\s*"([^"]+)"/);
181
+ const deliverable = /"isDeliverable"\s*:\s*true/.test(putCart.text);
182
+ if (!deliverable && ndr) {
183
+ return { coupons: [], status: 'store_unavailable', reason: ndr[1] };
184
+ }
185
+
186
+ // authoritative coupon source
187
+ const fl = await req(GW, 'POST', '/cfs/api/v1/cart/coupons/fetch-list', cartHeaders(session, STORE_ID), {
188
+ page_type: 'COUPON_REVAMP', useZCoins: false, activeTab: 'COUPONS_TAB', enableTabbedView: true,
189
+ props: {}, renderHeader: true, storeId: STORE_ID, cartId, latitude: lat, longitude: lng,
190
+ userAddressId: '', useZeptoCash: false, removedCampaignProducts: [], installedUpiApps: [], isCredPayEligible: false,
191
+ });
192
+ let coupons = parseCoupons(fl.text);
193
+
194
+ if (coupons.length === 0) {
195
+ const bwd = await req(GW, 'POST', '/cfs/api/v1/bulk-widget-data', cartHeaders(session, STORE_ID), {
196
+ widgets: [{ id: 100007034, pageIdentifier: 'cart_page', subscriptions: ['cart_change'] }],
197
+ cartProducts: PRODUCTS.map((p) => ({ storeProductId: p.storeProductId, productVariantId: p.productVariantId, mrp: p.price, markedPrice: p.price, quantity: 1, isDiscountApplicable: true, meta: {}, uclId: '' })),
198
+ enrichCall: true, campaignIds: [], couponIds: [], alreadyShownNudgeThresholds: [], alreadyShownNudgeThresholdsV2: [],
199
+ availedCampaignProducts: [], removedCampaigns: [], showAllOffers: true,
200
+ cartId, upsellPolicy: '', zeptoPassInfo: { isAddedToCart: false }, userActionMeta: [], latitude: lat, longitude: lng,
201
+ });
202
+ coupons = parseCoupons(bwd.text);
203
+ }
204
+ return { coupons, status: 'ok' };
205
+ }
206
+
207
+ // Classify the result into a tier. Returns { tier: 'rs50'|'rs75'|'rs100'|'normal'|'unchecked', amount, label }.
208
+ export function classifyCoupon(result) {
209
+ if (!result || result.status === 'store_unavailable') {
210
+ return { tier: 'unchecked', amount: null, label: 'store closed — unchecked' };
211
+ }
212
+ const coupons = result.coupons || [];
213
+ const coded = coupons.filter((c) => c.code && /Z-CARTBOOST/.test(c.code));
214
+ if (coded.length) {
215
+ const amt = Math.max(...coded.map((c) => c.amount || 0));
216
+ return tierFor(amt, 'code');
217
+ }
218
+ const tierAmts = [...new Set(coupons.map((c) => c.amount).filter((a) => [50, 75, 100].includes(a)))];
219
+ const allAmts = [...new Set(coupons.map((c) => c.amount).filter(Boolean))];
220
+ if (allAmts.length >= 3) return { tier: 'normal', amount: 0, label: 'normal (no coupon)' };
221
+ if (tierAmts.length === 1) return tierFor(tierAmts[0], 'banner');
222
+ if (tierAmts.length > 1) return tierFor(Math.max(...tierAmts), 'banner');
223
+ return { tier: 'normal', amount: 0, label: 'normal (no coupon)' };
224
+ }
225
+
226
+ function tierFor(amt) {
227
+ if (amt >= 100) return { tier: 'rs100', amount: 100, label: '₹100 off' };
228
+ if (amt >= 75) return { tier: 'rs75', amount: 75, label: '₹75 off' };
229
+ if (amt >= 50) return { tier: 'rs50', amount: 50, label: '₹50 off' };
230
+ return { tier: 'normal', amount: 0, label: 'normal (no coupon)' };
231
+ }
232
+
233
+ // Top-level: given a logged-in zepto session, return the classified coupon tier.
234
+ export async function checkCouponTier(session, opts = {}) {
235
+ const cs = couponSession(session);
236
+ if (!cs.token) return { tier: 'unchecked', amount: null, label: 'no token' };
237
+ let result;
238
+ try { result = await fetchCoupons(cs, opts); }
239
+ catch (e) { return { tier: 'unchecked', amount: null, label: `check failed: ${e.message}` }; }
240
+ return { ...classifyCoupon(result), coupons: result.coupons };
241
+ }
package/src/worker.js CHANGED
@@ -555,11 +555,12 @@ async function releaseNumber(provider, number, rentedAt, { log = () => {}, warn
555
555
  export const __releaseNumberForTest = releaseNumber;
556
556
 
557
557
  export class ZeptoWorker {
558
- constructor(provider, { requested, concurrency, certSha, onEvent }) {
558
+ constructor(provider, { requested, concurrency, certSha, onEvent, checkCoupon }) {
559
559
  this.provider = provider;
560
560
  this.requested = requested;
561
561
  this.concurrency = Math.max(1, Math.min(concurrency, requested));
562
562
  this.certSha = certSha;
563
+ this.checkCoupon = !!checkCoupon; // also classify the ₹50/₹75/₹100 coupon tier
563
564
  this.onEvent = onEvent || (() => {});
564
565
  // Surface OTPCart re-logins (session taken over by another login) in the UI.
565
566
  if (provider && 'onNotice' in provider) provider.onNotice = (msg) => this.onEvent('warn', `⚠ ${msg}`);
@@ -727,9 +728,20 @@ export class ZeptoWorker {
727
728
  if (!v.ok) return fail(`verify: ${v.error}`);
728
729
 
729
730
  const envelope = zepto.encodeEnvelope(zepto.toSessionKeys(session), this.certSha);
730
- this._emit(slot, { phase: 'saving', detail: 'encoding envelope' });
731
731
  res.status = 'success'; res.cost = number.cost; res.session = zepto.toSessionKeys(session); res.envelope = envelope;
732
- releaseOnce(true, 'extracted', true); // success: OTP consumed → no cancel
732
+
733
+ // Optional coupon-tier check — token is fresh right now (just verified).
734
+ if (this.checkCoupon) {
735
+ this._emit(slot, { phase: 'coupon', detail: 'checking coupon tier' });
736
+ try {
737
+ const { checkCouponTier } = await import('./providers/zeptoCoupon.js');
738
+ const c = await checkCouponTier(session);
739
+ res.coupon_tier = c.tier; res.coupon_label = c.label; res.coupon_amount = c.amount;
740
+ } catch { res.coupon_tier = 'unchecked'; }
741
+ }
742
+
743
+ this._emit(slot, { phase: 'saving', detail: 'encoding envelope' });
744
+ releaseOnce(true, res.coupon_label || 'extracted', true); // success: OTP consumed → no cancel
733
745
  return res;
734
746
  } catch (e) {
735
747
  return fail(`unexpected: ${e.message}`);