Add Asan Imza step that prefills company name from cert

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) <noreply@anthropic.com>
This commit is contained in:
Ali 2026-04-22 13:56:25 +00:00
parent f4e7d0a3b6
commit 21981a1032
4 changed files with 438 additions and 37 deletions

View File

@ -1 +1 @@
__version__ = "0.0.4" __version__ = "0.0.5"

View File

@ -1,4 +1,66 @@
# The skeleton wizard now calls frappe.desk.page.setup_wizard.setup_wizard.setup_complete import json
# 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 import frappe
# once the skeleton is verified. 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)

View File

@ -8,7 +8,9 @@ app_license = "unlicense"
# Apps # 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 # Each item in the list will be shown as an app in the apps page
# add_to_apps_screen = [ # add_to_apps_screen = [

View File

@ -7,10 +7,10 @@
frappe.provide("jey_wizard"); 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 // 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. // 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 // 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. // 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.slides_settings = [];
frappe.setup.events = {}; 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 { frappe.setup.SetupWizard = class JeySetupWizard {
constructor(settings = {}) { constructor(settings = {}) {
this.parent = settings.parent; this.parent = settings.parent;
this.values = {}; this.values = {};
this.data = { this.data = {
language: "az", 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.current_step = 0;
this.steps = ["language", "company", "confirm"]; this.steps = ["language", "asan", "company", "confirm"];
this.render(); this.render();
} }
@ -37,7 +53,7 @@ frappe.setup.SetupWizard = class JeySetupWizard {
render() { render() {
const $parent = $(this.parent).empty(); const $parent = $(this.parent).empty();
this.$wrap = $(` this.$wrap = $(`
<div class="jey-wizard" style="max-width:560px;margin:80px auto;padding:32px;font-family:system-ui,sans-serif;position:relative"> <div class="jey-wizard" style="max-width:640px;margin:80px auto;padding:32px;font-family:system-ui,sans-serif;position:relative">
<div class="jey-version" style="position:absolute;top:8px;right:8px;background:#222;color:#fff;padding:4px 10px;border-radius:4px;font-size:12px;font-weight:600">v${JEY_WIZARD_VERSION}</div> <div class="jey-version" style="position:absolute;top:8px;right:8px;background:#222;color:#fff;padding:4px 10px;border-radius:4px;font-size:12px;font-weight:600">v${JEY_WIZARD_VERSION}</div>
<h1 style="margin-bottom:8px">Jey Setup</h1> <h1 style="margin-bottom:8px">Jey Setup</h1>
<div class="jey-progress" style="color:#888;margin-bottom:24px"></div> <div class="jey-progress" style="color:#888;margin-bottom:24px"></div>
@ -50,6 +66,7 @@ frappe.setup.SetupWizard = class JeySetupWizard {
} }
show_current_step() { show_current_step() {
this.stop_polling();
const step = this.steps[this.current_step]; const step = this.steps[this.current_step];
const $body = this.$wrap.find(".jey-body").empty(); const $body = this.$wrap.find(".jey-body").empty();
const $nav = this.$wrap.find(".jey-nav").empty(); const $nav = this.$wrap.find(".jey-nav").empty();
@ -59,54 +76,171 @@ frappe.setup.SetupWizard = class JeySetupWizard {
this.$wrap.find(".jey-status").empty(); this.$wrap.find(".jey-status").empty();
if (step === "language") { if (step === "language") {
$body.html(` this.render_language(($body));
<label style="display:block;margin-bottom:8px">Wizard & site language</label> } else if (step === "asan") {
<select class="jey-language form-control" style="max-width:280px"> this.render_asan($body);
<option value="az">Azərbaycan</option>
<option value="ru">Русский</option>
<option value="en">English</option>
</select>
`);
$body.find(".jey-language").val(this.data.language);
} else if (step === "company") { } else if (step === "company") {
$body.html(` this.render_company($body);
<label style="display:block;margin-bottom:8px">Company name</label>
<input type="text" class="jey-company-name form-control" style="max-width:380px" />
`);
$body.find(".jey-company-name").val(this.data.company_name);
} else if (step === "confirm") { } else if (step === "confirm") {
this.render_confirm($body);
}
this.render_nav($nav, step);
}
// ---------- step renderers ----------
render_language($body) {
$body.html(`
<label style="display:block;margin-bottom:8px">Wizard & site language</label>
<select class="jey-language form-control" style="max-width:280px">
<option value="az">Azərbaycan</option>
<option value="ru">Русский</option>
<option value="en">English</option>
</select>
`);
$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(` $body.html(`
<h3>Confirm</h3> <h3 style="margin-bottom:16px">Asan Imza authentication</h3>
<div style="margin-bottom:12px">
<label style="display:block;margin-bottom:4px">Phone number</label>
<input type="text" class="jey-asan-phone form-control" style="max-width:280px" placeholder="e.g. 0501234567" />
</div>
<div style="margin-bottom:12px">
<label style="display:block;margin-bottom:4px">User ID</label>
<input type="text" class="jey-asan-userid form-control" style="max-width:280px" placeholder="ASAN ID" />
</div>
<button class="btn btn-primary jey-asan-auth" style="margin-top:8px">Send SMS to Asan Imza</button>
`);
$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(`
<h3 style="margin-bottom:16px">Confirm on your phone</h3>
<p>Open the Asan Imza app on your phone and confirm the request.</p>
${code
? `<div style="margin:16px 0;padding:14px;background:#f7f7f7;border-radius:6px;text-align:center">
<div style="color:#666;font-size:12px;margin-bottom:6px">Verification code</div>
<div style="font-size:28px;letter-spacing:4px;font-weight:600">${frappe.utils.escape_html(String(code))}</div>
</div>`
: ""}
<div style="display:flex;align-items:center;gap:12px;margin-top:16px">
<div class="jey-spinner" style="width:18px;height:18px;border:3px solid #ddd;border-top-color:#444;border-radius:50%;animation:jey-spin 0.8s linear infinite"></div>
<div class="jey-asan-poll-status" style="color:#666">Waiting for confirmation...</div>
</div>
<style>@keyframes jey-spin { to { transform: rotate(360deg) } }</style>
`);
} 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 ? " <i style='color:#c00'>(liquidated)</i>" : "";
return `
<tr>
<td style="padding:8px;border-bottom:1px solid #eee">${frappe.utils.escape_html(name)}${liquidated}</td>
<td style="padding:8px;border-bottom:1px solid #eee;font-family:monospace">${frappe.utils.escape_html(voen)}</td>
<td style="padding:8px;border-bottom:1px solid #eee;text-align:right">
<button class="btn btn-sm btn-primary jey-cert-pick" data-idx="${i}">Select</button>
</td>
</tr>
`;
}).join("");
$body.html(`
<h3 style="margin-bottom:16px">Select your company</h3>
${rows
? `<table style="width:100%;border-collapse:collapse">
<thead><tr>
<th style="text-align:left;padding:8px;border-bottom:2px solid #ccc">Company</th>
<th style="text-align:left;padding:8px;border-bottom:2px solid #ccc">VÖEN</th>
<th style="padding:8px;border-bottom:2px solid #ccc"></th>
</tr></thead>
<tbody>${rows}</tbody>
</table>`
: `<p style="color:#c00">No legal-entity certificates available on this Asan ID. Use a phone/ID linked to a company.</p>`}
`);
$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(`
<h3 style="margin-bottom:16px;color:#080"> Authenticated</h3>
<p>Pulled from the tax portal:</p>
<ul style="line-height:1.8"> <ul style="line-height:1.8">
<li><b>Language:</b> ${this.data.language}</li>
<li><b>Company:</b> ${frappe.utils.escape_html(this.data.company_name)}</li> <li><b>Company:</b> ${frappe.utils.escape_html(this.data.company_name)}</li>
<li><b>Country:</b> Azerbaijan <i>(hardcoded)</i></li> <li><b>VÖEN:</b> ${frappe.utils.escape_html(this.data.asan_voen)}</li>
<li><b>Currency:</b> AZN <i>(hardcoded)</i></li>
<li><b>Timezone:</b> Asia/Baku <i>(hardcoded)</i></li>
<li><b>Chart of Accounts:</b> az_chart_of_accounts <i>(hardcoded)</i></li>
<li><b>Fiscal Year:</b> current year <i>(hardcoded)</i></li>
<li><b>Telemetry:</b> off <i>(hardcoded)</i></li>
<li><b>Demo data:</b> off <i>(hardcoded)</i></li>
</ul> </ul>
<p style="color:#666;margin-top:12px">Click Next to continue you'll be able to edit the company name on the next step.</p>
`); `);
} }
}
render_company($body) {
$body.html(`
<label style="display:block;margin-bottom:8px">Company name</label>
<input type="text" class="jey-company-name form-control" style="max-width:380px" />
${this.data.asan_voen
? `<div style="color:#888;font-size:12px;margin-top:6px">VÖEN ${frappe.utils.escape_html(this.data.asan_voen)} (from Asan)</div>`
: ""}
`);
$body.find(".jey-company-name").val(this.data.company_name);
}
render_confirm($body) {
$body.html(`
<h3>Confirm</h3>
<ul style="line-height:1.8">
<li><b>Language:</b> ${this.data.language}</li>
<li><b>Company:</b> ${frappe.utils.escape_html(this.data.company_name)}</li>
${this.data.asan_voen
? `<li><b>VÖEN:</b> ${frappe.utils.escape_html(this.data.asan_voen)}</li>`
: ""}
<li><b>Country:</b> Azerbaijan <i>(hardcoded)</i></li>
<li><b>Currency:</b> AZN <i>(hardcoded)</i></li>
<li><b>Timezone:</b> Asia/Baku <i>(hardcoded)</i></li>
<li><b>Chart of Accounts:</b> Azerbaijan Republic <i>(hardcoded)</i></li>
<li><b>Fiscal Year:</b> current year <i>(hardcoded)</i></li>
</ul>
`);
}
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) { if (this.current_step > 0) {
$(`<button class="btn btn-default jey-back">Back</button>`) $(`<button class="btn btn-default jey-back">Back</button>`)
.appendTo($nav) .appendTo($nav)
.on("click", () => this.prev()); .on("click", () => this.prev());
} }
if (this.current_step < this.steps.length - 1) { if (showStdNext) {
$(`<button class="btn btn-primary jey-next">Next</button>`) $(`<button class="btn btn-primary jey-next">Next</button>`)
.appendTo($nav) .appendTo($nav)
.on("click", () => this.next()); .on("click", () => this.next());
} else { }
if (showFinish) {
$(`<button class="btn btn-primary jey-finish">Finish</button>`) $(`<button class="btn btn-primary jey-finish">Finish</button>`)
.appendTo($nav) .appendTo($nav)
.on("click", () => this.finalize()); .on("click", () => this.finalize());
} }
} }
// ---------- step navigation ----------
capture_current() { capture_current() {
const step = this.steps[this.current_step]; const step = this.steps[this.current_step];
if (step === "language") { if (step === "language") {
@ -129,10 +263,214 @@ frappe.setup.SetupWizard = class JeySetupWizard {
} }
prev() { 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--; 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(); 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(
`<span style="color:#c00">Timeout — confirmation not received within ~100 seconds.</span>`
);
this.$wrap.find(".jey-spinner").remove();
this.$wrap.find(".jey-status").empty();
this.$wrap.find(".jey-nav")
.empty()
.html(
`<button class="btn btn-default jey-back">Back</button>` +
`<button class="btn btn-primary jey-asan-restart">Try again</button>`
)
.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() { build_setup_args() {
const LANG_CODE_TO_NAME = { const LANG_CODE_TO_NAME = {
en: "English", en: "English",
@ -198,7 +536,6 @@ frappe.setup.SetupWizard = class JeySetupWizard {
window.location.href = "/app"; window.location.href = "/app";
}, 1500); }, 1500);
} else if (m.status === "registered") { } else if (m.status === "registered") {
// Background-task mode: wait for socket events (we don't support that yet).
this.show_error( this.show_error(
"Setup is running in background mode, not supported by skeleton", "Setup is running in background mode, not supported by skeleton",
JSON.stringify(m, null, 2) JSON.stringify(m, null, 2)