scripter-x 1.0.22 → 1.0.24

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.22",
3
+ "version": "1.0.24",
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
@@ -3,7 +3,7 @@
3
3
  // stdin/stdout). Each command: async (io, api, args) => void.
4
4
  import * as config from './config.js';
5
5
  import { ApiClient, AuthError } from './api.js';
6
- import { saveSessions, CANCEL } from './util.js';
6
+ import { saveSessions, saveSessionsLocal, CANCEL } from './util.js';
7
7
  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';
@@ -205,18 +205,27 @@ export async function run(io, api, args = {}) {
205
205
 
206
206
  io.print(` ✓ done — ${stats.succeeded} succeeded · ${stats.failed} failed · ${stats.cancelled} cancelled · ₹${stats.charges} spent`);
207
207
  if (stats.succeeded > 0) {
208
- let saved = null, lastErr = null;
209
- for (let a = 0; a < 5 && !saved; a++) {
210
- if (a > 0) await new Promise((r) => setTimeout(r, 1000));
211
- try { saved = await saveSessions(api, cid, { campaignName: name }); } catch (e) { lastErr = e; }
208
+ // PRIMARY: save LOCALLY from the worker's in-memory buffer — never depends on the
209
+ // server, so an expired dashboard JWT can't lose the JSONs the user paid for.
210
+ let saved = null;
211
+ try {
212
+ saved = saveSessionsLocal(worker.results, cid, { campaignName: name, out: args.out, checkMinutes });
213
+ } catch (e) {
214
+ io.print(` ! local save error: ${e.message}`, 'danger');
215
+ }
216
+ // FALLBACK: only if the local buffer was somehow empty, try the server export.
217
+ if (!saved && worker.results.length === 0) {
218
+ for (let a = 0; a < 3 && !saved; a++) {
219
+ if (a > 0) await new Promise((r) => setTimeout(r, 1000));
220
+ try { saved = await saveSessions(api, cid, { campaignName: name, out: args.out }); } catch { /* */ }
221
+ }
212
222
  }
213
223
  if (saved) {
214
224
  io.print(' ✓ saved session(s) to:');
215
- for (const res of saved) {
216
- io.print(` ${res.path} (${res.count} sessions)`, 'accent');
217
- }
225
+ for (const res of saved) io.print(` ${res.label} ${res.path} (${res.count} sessions)`, 'accent');
226
+ } else {
227
+ io.print(' ! could not write the session file — check disk permissions', 'danger');
218
228
  }
219
- else io.print(` ! couldn't auto-save${lastErr ? ` (${lastErr.message})` : ''} — run \`export ${name}\` to retry`);
220
229
  } else io.print(' ◉ no sessions extracted — nothing to save.');
221
230
  if (worker.logPath) io.print(` ◉ log saved to: ${worker.logPath}`);
222
231
  }
@@ -624,10 +633,23 @@ export function help(io) {
624
633
 
625
634
  export async function update(io) {
626
635
  io.print(' ◉ updating ScripterX to the latest version…');
627
- const { runUpdate } = await import('./update.js');
636
+ const { runUpdate, restartCli } = await import('./update.js');
628
637
  const r = await runUpdate();
629
- if (r.ok) io.print(' ✓ updated! restart `scripterx` to use the new version.');
630
- else io.print(` ✗ update failed: ${r.output.split('\n')[0]} — try: npm install -g scripter-x@latest`);
638
+ if (!r.ok) {
639
+ io.print(` ✗ update failed: ${r.output.split('\n')[0]} — try: npm install -g scripter-x@latest`);
640
+ return;
641
+ }
642
+ io.print(' ✓ updated! restarting to load the new version…');
643
+ // Tear down the interactive ink UI first (restores the terminal) so the relaunched
644
+ // process paints onto a clean screen. exitApp exists only in the interactive shell;
645
+ // in one-shot mode there's nothing to tear down.
646
+ if (typeof io.exitApp === 'function') {
647
+ try { io.exitApp(); } catch { /* */ }
648
+ }
649
+ // Give ink a beat to unmount + the alt-screen restore to flush, then re-exec.
650
+ await new Promise((res) => setTimeout(res, 250));
651
+ const ok = restartCli();
652
+ if (!ok) io.print(' ◉ could not auto-restart — please run `scripter-x` again.');
631
653
  }
632
654
 
633
655
  // dispatch table used by the interactive shell
@@ -155,12 +155,15 @@ export class OTPCartProvider {
155
155
  }
156
156
  }
157
157
 
158
- // Grace period after the WS connects with no OTP push before we suspect the token
159
- // is dead and reconnect with a freshly re-logged-in token. The real SMS usually
160
- // lands well within this; a *dead-token* socket NEVER pushes, so this is what
161
- // rescues an otherwise-silent run.
162
- const WS_SILENT_RELOGIN_MS = 25000;
163
- const WS_PING_MS = 20000; // keep-alive so an idle socket isn't dropped before the SMS lands
158
+ // Concurrency note: OTPCart happily serves MANY concurrent WS connections on ONE valid
159
+ // session each rented number gets its own socket and its own OTP push. The hazard is
160
+ // NOT the parallel sockets; it's rotating the shared JWT. So the watchdog below must
161
+ // NEVER relogin just because a socket is *quiet* (a slow SMS would then rotate the token
162
+ // and knock out every other concurrent slot's open socket — a relogin storm). We only
163
+ // relogin on hard evidence the token is dead: the HANDSHAKE itself fails (close/error
164
+ // before we ever bound the socket). A connected-but-silent socket just re-subscribes.
165
+ const WS_RESUBSCRIBE_MS = 20000; // periodically re-send the bind frame on a quiet socket
166
+ const WS_PING_MS = 20000; // keep-alive so an idle socket isn't dropped before the SMS lands
164
167
  const WS_DEBUG = process.env.OTPCART_WS_DEBUG === '1';
165
168
 
166
169
  class OTPCartStream {
@@ -176,10 +179,11 @@ class OTPCartStream {
176
179
  this._openP = null; // resolves when the WS is OPEN
177
180
  this._reconnectTimer = null;
178
181
  this._connectedAt = 0;
179
- this._silentTimer = null; // fires if no OTP arrives → relogin + reconnect
182
+ this._everOpened = false; // did this socket ever reach OPEN?
183
+ this._resubTimer = null; // re-subscribe on a quiet socket (no token rotation)
180
184
  this._pingTimer = null; // keep-alive
181
185
  this._reloggingIn = false;
182
- this._silentReloginsLeft = 1; // recover a taken-over session once; don't churn a slow SMS
186
+ this._handshakeReloginsLeft = 1; // recover a taken-over session once on a bad handshake
183
187
  this._gotServerAck = false; // server sent the {serialNumber,mobileId} connect frame
184
188
  this._connect();
185
189
  }
@@ -187,38 +191,34 @@ class OTPCartStream {
187
191
  _currentToken() { return this.provider.token; }
188
192
  _dbg(...a) { if (WS_DEBUG) { try { console.error('[otpcart-ws]', ...a); } catch { /* */ } } }
189
193
 
190
- _clearSilentTimer() {
191
- if (this._silentTimer) { clearTimeout(this._silentTimer); this._silentTimer = null; }
194
+ _clearResubTimer() {
195
+ if (this._resubTimer) { clearInterval(this._resubTimer); this._resubTimer = null; }
192
196
  }
193
197
  _clearPingTimer() {
194
198
  if (this._pingTimer) { clearInterval(this._pingTimer); this._pingTimer = null; }
195
199
  }
196
200
 
197
- // Schedule the "this socket has gone quiet maybe the token is dead" check.
198
- // Only while we still have a relogin budget (a genuinely slow SMS shouldn't keep
199
- // rotating the shared token, which would force every other slot to reconnect).
200
- _armSilentTimer() {
201
- this._clearSilentTimer();
202
- if (this.closed || this.otp || !this.provider.canRelogin || this._silentReloginsLeft <= 0) return;
203
- this._silentTimer = setTimeout(() => this._handleSilent(), WS_SILENT_RELOGIN_MS);
204
- }
205
-
206
- async _handleSilent() {
207
- if (this.closed || this.otp || this._reloggingIn || this._silentReloginsLeft <= 0) return;
208
- this._silentReloginsLeft--;
201
+ // A socket that errored/closed WITHOUT ever opening is the dead-token signal: the
202
+ // server rejected the handshake. Relogin (de-duped + epoch-guarded so concurrent
203
+ // slots don't storm) and reconnect with the fresh token. A socket that DID open is
204
+ // a valid session — we never relogin it, so other concurrent sockets stay intact.
205
+ async _handleHandshakeFailure() {
206
+ if (this.closed || this.otp || this._reloggingIn) return;
207
+ if (!this.provider.canRelogin || this._handshakeReloginsLeft <= 0) {
208
+ // No creds (or budget spent) — just retry the socket with the current token.
209
+ if (!this.closed && !this.otp) this._reconnectTimer = setTimeout(() => this._connect(), 1000);
210
+ return;
211
+ }
212
+ this._handshakeReloginsLeft--;
209
213
  this._reloggingIn = true;
210
- this._dbg('silent watchdog fired — relogin + reconnect');
214
+ this._dbg('handshake failed before open — relogin + reconnect');
211
215
  try {
212
- // Re-login (de-duped at the provider) to get a token that the WS will accept,
213
- // then reconnect this socket with it. If the token was actually fine, this is
214
- // a cheap no-op reconnect that costs nothing but a fresh socket.
215
216
  const epoch = this.provider.tokenEpoch;
216
217
  await this.provider.relogin(epoch);
217
- } catch { /* keep the old socket; nothing better to do */ }
218
+ } catch { /* nothing better to do */ }
218
219
  finally { this._reloggingIn = false; }
219
220
  if (this.closed || this.otp) return;
220
- try { if (this.ws) this.ws.terminate(); } catch { /* */ }
221
- this._connect(); // reconnect with the (possibly refreshed) current token
221
+ this._connect();
222
222
  }
223
223
 
224
224
  // The browser sends an Origin (and a UA) on the WS handshake. The `ws` library
@@ -253,15 +253,21 @@ class OTPCartStream {
253
253
  this._dbg('connecting', WS_URL, 'token', String(token).slice(0, 10) + '…');
254
254
  this.ws = new WebSocket(`${WS_URL}?token=${token}`, this._wsOptions());
255
255
  this._gotServerAck = false;
256
+ let openedThisSocket = false; // did THIS socket reach OPEN (vs. fail the handshake)?
256
257
  this._openP = new Promise((resolve) => {
257
258
  this.ws.once('open', resolve);
258
259
  this.ws.once('error', resolve); // resolve anyway so ready() never hangs
259
260
  });
260
261
  this.ws.on('open', () => {
261
262
  this._connectedAt = Date.now();
263
+ this._everOpened = true;
264
+ openedThisSocket = true;
262
265
  this._dbg('open');
263
266
  this._subscribe(); // bind this socket to our number (browser parity)
264
- this._armSilentTimer(); // start the dead-token watchdog
267
+ // Quiet-socket handling = RE-SUBSCRIBE only (NEVER relogin). This keeps concurrency
268
+ // safe: a slow SMS on one slot won't rotate the shared token and break other slots.
269
+ this._clearResubTimer();
270
+ this._resubTimer = setInterval(() => { if (!this.otp && !this.closed) this._subscribe(); }, WS_RESUBSCRIBE_MS);
265
271
  this._clearPingTimer();
266
272
  this._pingTimer = setInterval(() => { try { this.ws.ping(); } catch { /* */ } }, WS_PING_MS);
267
273
  });
@@ -272,7 +278,7 @@ class OTPCartStream {
272
278
  // OTP frame: {message, otpMessage}.
273
279
  if (msg.otpMessage) {
274
280
  const m = String(msg.otpMessage).match(OTP_RE);
275
- if (m) { this.otp = m[1]; this.arrived = true; this._clearSilentTimer(); this._dbg('OTP', m[1]); }
281
+ if (m) { this.otp = m[1]; this.arrived = true; this._clearResubTimer(); this._dbg('OTP', m[1]); }
276
282
  return;
277
283
  }
278
284
  // Ack frame: {serialNumber, mobileId, resend}. Confirms the socket is bound.
@@ -285,14 +291,20 @@ class OTPCartStream {
285
291
  }
286
292
  });
287
293
  this.ws.on('pong', () => this._dbg('pong'));
288
- this.ws.on('error', (e) => { this._dbg('error', e?.message); /* swallow — poll() returns nothing; we auto-reconnect */ });
289
- // Auto-reconnect if the socket drops before the OTP arrives (so a dropped WS doesn't
290
- // silently lose the push — this is the "purchased but never got OTP" symptom).
294
+ this.ws.on('error', (e) => { this._dbg('error', e?.message); /* swallow — handled in close */ });
291
295
  this.ws.on('close', (code) => {
292
- this._dbg('close', code);
293
- this._clearSilentTimer();
296
+ this._dbg('close', code, 'opened=' + openedThisSocket);
297
+ this._clearResubTimer();
294
298
  this._clearPingTimer();
295
299
  if (this.closed || this.otp || this._reloggingIn) return;
300
+ if (!openedThisSocket) {
301
+ // Never reached OPEN → the handshake was rejected → likely a dead token.
302
+ // Relogin (epoch-guarded, capped) then reconnect — this is the ONLY relogin path.
303
+ this._handleHandshakeFailure();
304
+ return;
305
+ }
306
+ // Was a healthy socket that dropped before the OTP — just reconnect with the SAME
307
+ // token (no relogin, so we don't disturb other concurrent slots).
296
308
  this._reconnectTimer = setTimeout(() => { if (!this.closed && !this.otp) this._connect(); }, 1000);
297
309
  });
298
310
  }
@@ -324,7 +336,7 @@ class OTPCartStream {
324
336
 
325
337
  close() {
326
338
  this.closed = true;
327
- this._clearSilentTimer();
339
+ this._clearResubTimer();
328
340
  this._clearPingTimer();
329
341
  if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; }
330
342
  try { this.ws.close(); } catch { /* */ }
package/src/update.js CHANGED
@@ -2,8 +2,10 @@
2
2
  // • checkForUpdate() — quietly asks the npm registry for the latest version (cached 6h),
3
3
  // returns the newer version string or null. Used to show a notice on launch.
4
4
  // • runUpdate() — runs `npm install -g scripter-x@latest`.
5
- import { execFile } from 'node:child_process';
5
+ // • restartCli() re-exec the freshly-installed CLI so the new version takes over.
6
+ import { execFile, spawn } from 'node:child_process';
6
7
  import { promisify } from 'node:util';
8
+ import process from 'node:process';
7
9
  import * as config from './config.js';
8
10
 
9
11
  const execFileP = promisify(execFile);
@@ -45,3 +47,48 @@ export async function runUpdate() {
45
47
  return { ok: false, output: e.message };
46
48
  }
47
49
  }
50
+
51
+ // Re-exec the CLI so the just-installed version takes over. Spawns a fresh, DETACHED
52
+ // process that inherits this terminal, then exits the current one. The caller MUST have
53
+ // already torn down the ink/alt-screen UI (so the terminal is restored) before calling
54
+ // this — otherwise the child renders over a dirty screen.
55
+ //
56
+ // We re-run the CURRENT entry file with node (`process.argv[1]`): after `npm install -g`,
57
+ // the global bin symlink points at the new version, so this loads the updated code. If
58
+ // that entry path is somehow missing, we fall back to the `scripterx` bin on PATH.
59
+ //
60
+ // NOTE: on success it exits the process and never returns. Returns false only if BOTH
61
+ // spawn attempts throw synchronously, so the caller can show "restart manually".
62
+ export function restartCli({ binName = 'scripterx', delayMs = 150 } = {}) {
63
+ // Re-run with the SAME args the user launched with (drop node + the script path), so
64
+ // `scripterx` → interactive shell, `scripterx zepto` → zepto, etc.
65
+ const argv = process.argv.slice(2);
66
+ const entry = process.argv[1];
67
+
68
+ const trySpawn = (cmd, args) => {
69
+ const child = spawn(cmd, args, { stdio: 'inherit', detached: true, shell: false });
70
+ child.unref();
71
+ return child;
72
+ };
73
+
74
+ try {
75
+ const primary = trySpawn(process.execPath, [entry, ...argv]);
76
+ // If the entry re-exec fails to even start, fall back to the named bin on PATH.
77
+ primary.on('error', () => {
78
+ try { trySpawn(binName, argv); } catch { /* nothing more we can do */ }
79
+ });
80
+ // Exit the old process; its `process.on('exit', restore)` cleans the terminal and the
81
+ // detached child takes over the same TTY running the new version.
82
+ setTimeout(() => process.exit(0), delayMs);
83
+ return true;
84
+ } catch {
85
+ // process.execPath spawn threw synchronously — try the named bin directly.
86
+ try {
87
+ trySpawn(binName, argv);
88
+ setTimeout(() => process.exit(0), delayMs);
89
+ return true;
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+ }
package/src/util.js CHANGED
@@ -31,34 +31,19 @@ export const log = {
31
31
  err: (m) => console.log(` ${paint.err('✗')} ${m}`),
32
32
  };
33
33
 
34
- // Resolve + write a campaign's sessions to a file. Returns {path, count} or null
35
- // (null when there are no extracted sessions). Mirrors the Python CLI's behavior:
36
- // out=<file> → that file; out=<dir>/ default name inside; no out → ~/Downloads/scripterx/
37
- export async function saveSessions(api, cid, { campaignName, out } = {}) {
38
- let name = (campaignName || cid.slice(0, 8)).replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || cid.slice(0, 8);
39
- const accounts = await api.exportCampaign(cid);
34
+ // Core: partition an accounts array by Minutes status and write the JSON file(s).
35
+ // accounts = [{ session, coupon_eligible? }]. Pure local I/O no server.
36
+ // Returns [{label, path, count}] or null.
37
+ function writeAccounts(accounts, { name, out, checkMinutes } = {}) {
40
38
  if (!accounts || !accounts.length) return null;
41
39
 
42
- let checkMinutes = false;
43
- try {
44
- const { campaign } = await api.getCampaign(cid);
45
- checkMinutes = !!campaign.check_minutes;
46
- } catch (e) {
47
- // ignore
48
- }
49
-
50
40
  // Resolve base directory and optional forced filename.
51
41
  let baseDir, forcedName = null;
52
- const defaultName = `${name}-${cid.slice(0, 8)}.json`;
53
42
  if (out) {
54
43
  const expanded = out.startsWith('~') ? join(homedir(), out.slice(1)) : out;
55
44
  const isDir = (existsSync(expanded) && statSync(expanded).isDirectory()) || /[/\\]$/.test(out);
56
- if (isDir) {
57
- baseDir = expanded;
58
- } else {
59
- baseDir = dirname(expanded);
60
- forcedName = expanded.split(/[/\\]/).pop();
61
- }
45
+ if (isDir) baseDir = expanded;
46
+ else { baseDir = dirname(expanded); forcedName = expanded.split(/[/\\]/).pop(); }
62
47
  } else {
63
48
  const downloads = join(homedir(), 'Downloads');
64
49
  const base = existsSync(downloads) ? downloads : homedir();
@@ -67,22 +52,19 @@ export async function saveSessions(api, cid, { campaignName, out } = {}) {
67
52
 
68
53
  function write(suffix, sessionsToWrite) {
69
54
  if (!sessionsToWrite || !sessionsToWrite.length) return null;
70
- let fname;
71
- if (forcedName) {
72
- fname = suffix === '' ? forcedName : forcedName.replace(/\.json$/i, `${suffix}.json`);
73
- } else {
74
- fname = `${name}-${cid.slice(0, 8)}${suffix}.json`;
75
- }
55
+ const fname = forcedName
56
+ ? (suffix === '' ? forcedName : forcedName.replace(/\.json$/i, `${suffix}.json`))
57
+ : `${name}${suffix}.json`;
76
58
  const dest = join(baseDir, fname);
77
59
  mkdirSync(baseDir, { recursive: true });
78
60
  writeFileSync(dest, JSON.stringify(sessionsToWrite, null, 2));
79
61
  return dest;
80
62
  }
81
63
 
82
- // Partition by coupon_eligible when minutes check was requested.
64
+ // Partition by coupon_eligible when the Minutes check was requested.
83
65
  const minutesFree = []; // coupon_eligible=true → no Minutes order, ₹100 coupon available
84
66
  const minutesUsed = []; // coupon_eligible=false → already placed a Minutes order
85
- const unchecked = []; // coupon_eligible absent → check wasn't done for this account
67
+ const unchecked = []; // coupon_eligible absent → check wasn't done
86
68
 
87
69
  let hasChecked = false;
88
70
  for (const a of accounts) {
@@ -101,10 +83,8 @@ export async function saveSessions(api, cid, { campaignName, out } = {}) {
101
83
  if (hasChecked || (checkMinutes && (minutesFree.length || minutesUsed.length))) {
102
84
  const destFree = write('-minutes-free', minutesFree);
103
85
  if (destFree) results.push({ label: '🟢 minutes-free (₹100 coupon)', path: destFree, count: minutesFree.length });
104
-
105
86
  const destUsed = write('-minutes-used', minutesUsed);
106
87
  if (destUsed) results.push({ label: '🔴 minutes-used (coupon gone)', path: destUsed, count: minutesUsed.length });
107
-
108
88
  if (unchecked.length) {
109
89
  const destUnk = write('-unchecked', unchecked);
110
90
  if (destUnk) results.push({ label: '⚪ unchecked', path: destUnk, count: unchecked.length });
@@ -114,6 +94,27 @@ export async function saveSessions(api, cid, { campaignName, out } = {}) {
114
94
  const dest = write('', allSessions);
115
95
  if (dest) results.push({ label: 'combined', path: dest, count: allSessions.length });
116
96
  }
117
-
118
97
  return results.length ? results : null;
119
98
  }
99
+
100
+ function campaignFileStem(campaignName, cid) {
101
+ const name = (campaignName || cid.slice(0, 8)).replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || cid.slice(0, 8);
102
+ return `${name}-${cid.slice(0, 8)}`;
103
+ }
104
+
105
+ // LOCAL autosave — the primary save path. Writes directly from the worker's
106
+ // in-memory results, so a campaign's JSONs are ALWAYS saved even if the dashboard
107
+ // JWT expired mid-run (no server round-trip). Returns [{label,path,count}] or null.
108
+ export function saveSessionsLocal(accounts, cid, { campaignName, out, checkMinutes } = {}) {
109
+ return writeAccounts(accounts, { name: campaignFileStem(campaignName, cid), out, checkMinutes });
110
+ }
111
+
112
+ // Resolve + write a campaign's sessions to a file, fetching from the SERVER.
113
+ // Used by the standalone `export` command (no local buffer available there).
114
+ export async function saveSessions(api, cid, { campaignName, out } = {}) {
115
+ const accounts = await api.exportCampaign(cid);
116
+ if (!accounts || !accounts.length) return null;
117
+ let checkMinutes = false;
118
+ try { const { campaign } = await api.getCampaign(cid); checkMinutes = !!campaign.check_minutes; } catch { /* ignore */ }
119
+ return writeAccounts(accounts, { name: campaignFileStem(campaignName, cid), out, checkMinutes });
120
+ }
package/src/worker.js CHANGED
@@ -39,6 +39,10 @@ export class Worker {
39
39
  this.slots = {};
40
40
  this.seq = 0;
41
41
  this.stopped = false;
42
+ // Local buffer of every successful session — the source of truth for autosave.
43
+ // We NEVER depend on a server round-trip to save: even if the dashboard JWT
44
+ // expires mid-run, the file still gets written from here.
45
+ this.results = [];
42
46
  this.logger = new CampaignLogger({ campaignId, name: name || campaignId, provider: provider.name });
43
47
  }
44
48
 
@@ -69,6 +73,21 @@ export class Worker {
69
73
  else { this.stats.failed++; this.stats.charges += res.cost || 0; } // failed w/ SMS = charged by provider
70
74
  this.onEvent('progress', this.stats);
71
75
  this.onEvent('row', res);
76
+ // Buffer every successful session LOCALLY first — autosave reads from here, so a
77
+ // failed/expired server ingest can never lose the JSON the user paid for.
78
+ if (res.status === 'success' && res.session) {
79
+ this.results.push({
80
+ id_no: res.id_no, mobile: res.mobile || '', session: res.session,
81
+ minutes_checked: !!res.minutes_checked,
82
+ coupon_eligible: res.coupon_eligible ?? undefined,
83
+ has_minutes_order: res.has_minutes_order ?? undefined,
84
+ minutes_order_count: res.minutes_order_count ?? undefined,
85
+ minutes_orders: res.minutes_orders ?? undefined,
86
+ linked_email: res.linked_email ?? undefined,
87
+ email_linked: res.email_linked ?? undefined,
88
+ email_reason: res.email_reason ?? undefined,
89
+ });
90
+ }
72
91
  try {
73
92
  await this.api.ingestAccount(this.cid, {
74
93
  id_no: res.id_no, mobile: res.mobile || '', status: res.status, cost: res.cost || 0,