From 21981a10327d59b388a54d263631b022bc071d3d Mon Sep 17 00:00:00 2001 From: Ali <010109ali@gmail.com> Date: Wed, 22 Apr 2026 13:56:25 +0000 Subject: [PATCH] Add Asan Imza step that prefills company name from cert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New wizard step between language and company: 1. User enters phone + Asan user_id, hits "Send SMS" 2. ensure_asan_login (jey_wizard.api) creates/resets the single Asan Login record 3. invoice_az.auth.handle_authentication kicks off SMS, JS shows verification code 4. Polling loop calls poll_auth_status every 5s up to 20 attempts (~100s total) 5. On confirmation, get_auth_certificates fetches certs; UI filters to legal-only 6. User picks one; select_certificate + select_taxpayer run back-to-back 7. get_company_from_cert (jey_wizard.api) parses selected_certificate_json and pulls legalInfo.name + voen — no extra API call to the tax portal needed 8. Company step is then prefilled with that name (still editable) Test scope: legal entities only, single Asan Login reused/reset on phone change, no rollback machinery, no own renew_token scheduler (piggybacks on invoice_az's). required_apps now includes invoice_az since we call its auth methods directly. Bump to 0.0.5. Co-Authored-By: Claude Opus 4.7 (1M context) --- jey_wizard/__init__.py | 2 +- jey_wizard/api.py | 70 +++++- jey_wizard/hooks.py | 4 +- jey_wizard/public/js/jey_setup.js | 399 +++++++++++++++++++++++++++--- 4 files changed, 438 insertions(+), 37 deletions(-) diff --git a/jey_wizard/__init__.py b/jey_wizard/__init__.py index 81f0fde..b1a19e3 100644 --- a/jey_wizard/__init__.py +++ b/jey_wizard/__init__.py @@ -1 +1 @@ -__version__ = "0.0.4" +__version__ = "0.0.5" diff --git a/jey_wizard/api.py b/jey_wizard/api.py index 60b6dac..9a8f17f 100644 --- a/jey_wizard/api.py +++ b/jey_wizard/api.py @@ -1,4 +1,66 @@ -# The skeleton wizard now calls frappe.desk.page.setup_wizard.setup_wizard.setup_complete -# directly from the client, so no server-side finalize shim is needed here. -# Real integrations (tax API, session-backed steps, rollback) will live in this module -# once the skeleton is verified. +import json + +import frappe +from frappe import _ + + +@frappe.whitelist() +def ensure_asan_login(phone, user_id): + """Reuse the single Asan Login record (or create one) and reset its auth state. + + The wizard is one-shot, so we keep at most one record. If the user goes back to the + credentials step and changes phone/user_id, we wipe the previous tokens so a fresh + auth round can run cleanly. + """ + _only_admin() + + existing = frappe.get_all("Asan Login", limit=1, fields=["name"]) + if existing: + doc = frappe.get_doc("Asan Login", existing[0].name) + else: + doc = frappe.new_doc("Asan Login") + + doc.phone = phone + doc.user_id = user_id + doc.auth_status = "Not Authenticated" + doc.bearer_token = "" + doc.main_token = "" + doc.certificates_json = "" + doc.selected_certificate = "" + doc.selected_certificate_index = "" + doc.selected_certificate_json = "" + doc.choose_taxpayer_response = "" + doc.verification_code = "" + doc.is_default = 1 + doc.flags.ignore_permissions = True + doc.save() if existing else doc.insert(ignore_permissions=True) + + return {"name": doc.name} + + +@frappe.whitelist() +def get_company_from_cert(asan_login_name): + """After select_taxpayer ran, pull the company name + VÖEN from the chosen + certificate. The cert payload from the tax portal already carries everything we need + for the wizard's company step — no extra HTTP call required. + """ + _only_admin() + + doc = frappe.get_doc("Asan Login", asan_login_name) + cert = json.loads(doc.selected_certificate_json or "{}") + + legal = cert.get("legalInfo") or {} + # Wizard is legal-only by user requirement; we still defensively fall back to + # individualInfo so the JS never crashes if a stray individual cert sneaks in. + individual = cert.get("individualInfo") or {} + + return { + "taxpayer_type": cert.get("taxpayerType") or "", + "company_name": legal.get("name") or individual.get("name") or "", + "voen": legal.get("voen") or legal.get("tin") or individual.get("fin") or "", + } + + +def _only_admin(): + if frappe.session.user != "Administrator": + frappe.throw(_("Only Administrator may run setup"), frappe.PermissionError) diff --git a/jey_wizard/hooks.py b/jey_wizard/hooks.py index 3ea1677..37b9d26 100644 --- a/jey_wizard/hooks.py +++ b/jey_wizard/hooks.py @@ -8,7 +8,9 @@ app_license = "unlicense" # Apps # ------------------ -# required_apps = [] +# Asan Imza authentication in the wizard reuses invoice_az's Asan Login DocType and +# its auth.* whitelisted methods directly. The wizard cannot run without invoice_az. +required_apps = ["invoice_az"] # Each item in the list will be shown as an app in the apps page # add_to_apps_screen = [ diff --git a/jey_wizard/public/js/jey_setup.js b/jey_wizard/public/js/jey_setup.js index 6e1343a..b0cd1a8 100644 --- a/jey_wizard/public/js/jey_setup.js +++ b/jey_wizard/public/js/jey_setup.js @@ -7,10 +7,10 @@ frappe.provide("jey_wizard"); -// Bump this string in every commit that changes wizard code. Displayed in the footer so +// Bump this string in every commit that changes wizard code. Displayed in the badge so // we can tell at a glance which version is actually running on a given machine. Kept in // sync with __version__ in jey_wizard/__init__.py. -const JEY_WIZARD_VERSION = "0.0.4"; +const JEY_WIZARD_VERSION = "0.0.5"; // Wipe Frappe + ERPNext default slides so their `before_load`/`after_load` listeners // don't try to mutate a wizard that isn't slide-based anymore. @@ -18,16 +18,32 @@ frappe.setup.slides = []; frappe.setup.slides_settings = []; frappe.setup.events = {}; +// Asan polling: 20 attempts × 5s = ~100s window for the user to confirm on phone. +// Mirrors invoice_az/asan_login.js timing. +const ASAN_POLL_INTERVAL_MS = 5000; +const ASAN_POLL_MAX_ATTEMPTS = 20; + frappe.setup.SetupWizard = class JeySetupWizard { constructor(settings = {}) { this.parent = settings.parent; this.values = {}; this.data = { language: "az", - company_name: "Test Company AZ", + asan_phone: "", + asan_user_id: "", + asan_login_name: "", + asan_voen: "", + company_name: "", }; + // Sub-state for the asan step: input -> polling -> certs -> done + this.asan_state = "input"; + this.asan_certificates = []; + this.asan_bearer_token = ""; + this.asan_poll_attempts = 0; + this.asan_poll_timer = null; + this.current_step = 0; - this.steps = ["language", "company", "confirm"]; + this.steps = ["language", "asan", "company", "confirm"]; this.render(); } @@ -37,7 +53,7 @@ frappe.setup.SetupWizard = class JeySetupWizard { render() { const $parent = $(this.parent).empty(); this.$wrap = $(` -
+
v${JEY_WIZARD_VERSION}

Jey Setup

@@ -50,6 +66,7 @@ frappe.setup.SetupWizard = class JeySetupWizard { } show_current_step() { + this.stop_polling(); const step = this.steps[this.current_step]; const $body = this.$wrap.find(".jey-body").empty(); const $nav = this.$wrap.find(".jey-nav").empty(); @@ -59,54 +76,171 @@ frappe.setup.SetupWizard = class JeySetupWizard { this.$wrap.find(".jey-status").empty(); if (step === "language") { - $body.html(` - - - `); - $body.find(".jey-language").val(this.data.language); + this.render_language(($body)); + } else if (step === "asan") { + this.render_asan($body); } else if (step === "company") { - $body.html(` - - - `); - $body.find(".jey-company-name").val(this.data.company_name); + this.render_company($body); } else if (step === "confirm") { + this.render_confirm($body); + } + + this.render_nav($nav, step); + } + + // ---------- step renderers ---------- + + render_language($body) { + $body.html(` + + + `); + $body.find(".jey-language").val(this.data.language); + } + + render_asan($body) { + // Sub-state machine: input -> polling -> certs -> done. + // All four sub-states render into the same $body slot. + if (this.asan_state === "input") { $body.html(` -

Confirm

+

Asan Imza authentication

+
+ + +
+
+ + +
+ + `); + $body.find(".jey-asan-phone").val(this.data.asan_phone); + $body.find(".jey-asan-userid").val(this.data.asan_user_id); + $body.find(".jey-asan-auth").on("click", () => this.start_asan_auth()); + } else if (this.asan_state === "polling") { + const code = this.asan_verification_code || ""; + $body.html(` +

Confirm on your phone

+

Open the Asan Imza app on your phone and confirm the request.

+ ${code + ? `
+
Verification code
+
${frappe.utils.escape_html(String(code))}
+
` + : ""} +
+
+
Waiting for confirmation...
+
+ + `); + } else if (this.asan_state === "certs") { + const rows = (this.asan_certificates || []).map((c, i) => { + const legal = c.legalInfo || {}; + const name = legal.name || ""; + const voen = legal.voen || legal.tin || ""; + const liquidated = c.liquidated ? " (liquidated)" : ""; + return ` + + ${frappe.utils.escape_html(name)}${liquidated} + ${frappe.utils.escape_html(voen)} + + + + + `; + }).join(""); + + $body.html(` +

Select your company

+ ${rows + ? ` + + + + + + ${rows} +
CompanyVÖEN
` + : `

No legal-entity certificates available on this Asan ID. Use a phone/ID linked to a company.

`} + `); + $body.find(".jey-cert-pick").on("click", (e) => { + const idx = parseInt($(e.currentTarget).attr("data-idx"), 10); + this.pick_certificate(idx); + }); + } else if (this.asan_state === "done") { + $body.html(` +

✓ Authenticated

+

Pulled from the tax portal:

    -
  • Language: ${this.data.language}
  • Company: ${frappe.utils.escape_html(this.data.company_name)}
  • -
  • Country: Azerbaijan (hardcoded)
  • -
  • Currency: AZN (hardcoded)
  • -
  • Timezone: Asia/Baku (hardcoded)
  • -
  • Chart of Accounts: az_chart_of_accounts (hardcoded)
  • -
  • Fiscal Year: current year (hardcoded)
  • -
  • Telemetry: off (hardcoded)
  • -
  • Demo data: off (hardcoded)
  • +
  • VÖEN: ${frappe.utils.escape_html(this.data.asan_voen)}
+

Click Next to continue — you'll be able to edit the company name on the next step.

`); } + } + + render_company($body) { + $body.html(` + + + ${this.data.asan_voen + ? `
VÖEN ${frappe.utils.escape_html(this.data.asan_voen)} (from Asan)
` + : ""} + `); + $body.find(".jey-company-name").val(this.data.company_name); + } + + render_confirm($body) { + $body.html(` +

Confirm

+
    +
  • Language: ${this.data.language}
  • +
  • Company: ${frappe.utils.escape_html(this.data.company_name)}
  • + ${this.data.asan_voen + ? `
  • VÖEN: ${frappe.utils.escape_html(this.data.asan_voen)}
  • ` + : ""} +
  • Country: Azerbaijan (hardcoded)
  • +
  • Currency: AZN (hardcoded)
  • +
  • Timezone: Asia/Baku (hardcoded)
  • +
  • Chart of Accounts: Azerbaijan Republic (hardcoded)
  • +
  • Fiscal Year: current year (hardcoded)
  • +
+ `); + } + + render_nav($nav, step) { + // On the asan step we don't show standard Next — auth flow drives navigation + // internally and only enables Next when the user reaches the "done" sub-state. + const showStdNext = + this.current_step < this.steps.length - 1 && + !(step === "asan" && this.asan_state !== "done"); + const showFinish = this.current_step === this.steps.length - 1; if (this.current_step > 0) { $(``) .appendTo($nav) .on("click", () => this.prev()); } - if (this.current_step < this.steps.length - 1) { + if (showStdNext) { $(``) .appendTo($nav) .on("click", () => this.next()); - } else { + } + if (showFinish) { $(``) .appendTo($nav) .on("click", () => this.finalize()); } } + // ---------- step navigation ---------- + capture_current() { const step = this.steps[this.current_step]; if (step === "language") { @@ -129,10 +263,214 @@ frappe.setup.SetupWizard = class JeySetupWizard { } prev() { + // Going back to/from asan resets its sub-state so the user can re-enter cleanly. + const step = this.steps[this.current_step]; + if (step === "asan") { + this.asan_state = "input"; + } this.current_step--; + // Same when stepping back ONTO asan from the company step — keep it simple, + // don't try to resume polling/cert state mid-flow. + if (this.steps[this.current_step] === "asan") { + this.asan_state = "input"; + } this.show_current_step(); } + // ---------- asan flow ---------- + + start_asan_auth() { + const phone = (this.$wrap.find(".jey-asan-phone").val() || "").trim(); + const userId = (this.$wrap.find(".jey-asan-userid").val() || "").trim(); + if (!phone || !userId) { + this.$wrap.find(".jey-status").text("Phone and User ID are required"); + return; + } + this.data.asan_phone = phone; + this.data.asan_user_id = userId; + this.$wrap.find(".jey-status").empty(); + + // Step 1: ensure Asan Login record exists & is reset, then trigger SMS. + frappe.call({ + method: "jey_wizard.api.ensure_asan_login", + args: { phone, user_id: userId }, + freeze: true, + freeze_message: "Preparing...", + callback: (r) => { + const m = r.message || {}; + if (!m.name) { + this.$wrap.find(".jey-status").text("Failed to create Asan Login record"); + return; + } + this.data.asan_login_name = m.name; + this.trigger_asan_authentication(); + }, + }); + } + + trigger_asan_authentication() { + frappe.call({ + method: "invoice_az.auth.handle_authentication", + args: { asan_login_name: this.data.asan_login_name }, + freeze: true, + freeze_message: "Sending request to Asan Imza...", + callback: (r) => { + const m = r.message || {}; + if (!m.success) { + this.$wrap.find(".jey-status").text( + m.message || "Failed to start authentication" + ); + return; + } + this.asan_bearer_token = m.bearer_token || ""; + this.asan_verification_code = m.verification_code || ""; + this.asan_state = "polling"; + this.asan_poll_attempts = 0; + this.show_current_step(); + this.poll_asan(); + }, + }); + } + + poll_asan() { + if (this.asan_poll_attempts >= ASAN_POLL_MAX_ATTEMPTS) { + this.$wrap.find(".jey-asan-poll-status").html( + `Timeout — confirmation not received within ~100 seconds.` + ); + this.$wrap.find(".jey-spinner").remove(); + this.$wrap.find(".jey-status").empty(); + this.$wrap.find(".jey-nav") + .empty() + .html( + `` + + `` + ) + .find(".jey-back").on("click", () => this.prev()).end() + .find(".jey-asan-restart").on("click", () => { + this.asan_state = "input"; + this.show_current_step(); + }); + return; + } + this.asan_poll_attempts += 1; + + frappe.call({ + method: "invoice_az.auth.poll_auth_status", + args: { + asan_login_name: this.data.asan_login_name, + bearer_token: this.asan_bearer_token, + }, + callback: (r) => { + if (this.asan_state !== "polling") return; // user navigated away + const m = r.message || {}; + if (m.authenticated) { + this.fetch_certificates(); + } else { + this.asan_poll_timer = setTimeout( + () => this.poll_asan(), + ASAN_POLL_INTERVAL_MS + ); + } + }, + }); + } + + stop_polling() { + if (this.asan_poll_timer) { + clearTimeout(this.asan_poll_timer); + this.asan_poll_timer = null; + } + } + + fetch_certificates() { + this.stop_polling(); + this.$wrap.find(".jey-asan-poll-status").text("Loading certificates..."); + frappe.call({ + method: "invoice_az.auth.get_auth_certificates", + args: { asan_login_name: this.data.asan_login_name }, + callback: (r) => { + const m = r.message || {}; + if (!m.success) { + this.$wrap.find(".jey-status").text( + m.message || "Failed to load certificates" + ); + return; + } + const all = m.certificates || []; + // Wizard scope: legal entities only (per requirement). Filter and warn + // if nothing remains so the user knows why the list is empty. + this.asan_certificates = all.filter( + (c) => c.taxpayerType === "legal" + ); + this.asan_state = "certs"; + this.show_current_step(); + }, + }); + } + + pick_certificate(idx) { + const cert = this.asan_certificates[idx]; + if (!cert) return; + const legal = cert.legalInfo || {}; + const certName = `${legal.name || ""} (${legal.voen || legal.tin || ""})`; + + frappe.call({ + method: "invoice_az.auth.select_certificate", + args: { + asan_login_name: this.data.asan_login_name, + certificate_data: cert, + certificate_name: certName, + }, + freeze: true, + freeze_message: "Selecting certificate...", + callback: (r) => { + const m = r.message || {}; + if (!m.success) { + this.$wrap.find(".jey-status").text( + m.message || "Failed to select certificate" + ); + return; + } + this.run_select_taxpayer(); + }, + }); + } + + run_select_taxpayer() { + frappe.call({ + method: "invoice_az.auth.select_taxpayer", + args: { asan_login_name: this.data.asan_login_name }, + freeze: true, + freeze_message: "Choosing taxpayer & getting main token...", + callback: (r) => { + const m = r.message || {}; + if (!m.success) { + this.$wrap.find(".jey-status").text( + m.message || "Failed to choose taxpayer" + ); + return; + } + this.fetch_company_from_cert(); + }, + }); + } + + fetch_company_from_cert() { + frappe.call({ + method: "jey_wizard.api.get_company_from_cert", + args: { asan_login_name: this.data.asan_login_name }, + callback: (r) => { + const m = r.message || {}; + this.data.company_name = m.company_name || this.data.company_name; + this.data.asan_voen = m.voen || ""; + this.asan_state = "done"; + this.show_current_step(); + }, + }); + } + + // ---------- finalization ---------- + build_setup_args() { const LANG_CODE_TO_NAME = { en: "English", @@ -198,7 +536,6 @@ frappe.setup.SetupWizard = class JeySetupWizard { window.location.href = "/app"; }, 1500); } else if (m.status === "registered") { - // Background-task mode: wait for socket events (we don't support that yet). this.show_error( "Setup is running in background mode, not supported by skeleton", JSON.stringify(m, null, 2)