feat: payment gating — view always, print/download/export ТЗ unlock after debt closed (free clients always unlocked)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wasrusgen 2026-05-30 16:13:19 +03:00
parent ebff789229
commit 7b5162937d
3 changed files with 94 additions and 3 deletions

View File

@ -163,6 +163,14 @@ body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);displ
.pay-opt-t{font-size:15px;font-weight:700}.pay-opt-d{font-size:12px;color:var(--muted)}
.modal-close{margin-top:8px;width:100%;background:transparent;border:none;color:var(--muted);font-size:13px;cursor:pointer;padding:8px}
::-webkit-scrollbar{width:6px}::-webkit-scrollbar-thumb{background:rgba(0,0,0,.12);border-radius:4px}
.btn-s{background:var(--white);color:var(--primary);border:1.5px solid var(--primary);font-size:13px;padding:9px 16px}
.btn-s:hover{background:var(--light)}
.exp-bar{margin-top:22px;border:1.5px solid var(--border);border-radius:16px;padding:22px;background:var(--white)}
.exp-bar.locked{background:linear-gradient(135deg,#FFFBEB,#FEF3C7);border-color:#FDE68A}
.exp-h{font-family:'Montserrat';font-weight:800;font-size:16px;color:var(--text);margin-bottom:6px}
.exp-d{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:14px}
.exp-btns{display:flex;flex-wrap:wrap;gap:10px}
@media print{.sb,.hdr,.pb,.hero,.nav,.exp-bar,.modal,.mic,.inp-bar{display:none!important}.main,.sv,.scroll,.pad{display:block!important;overflow:visible!important;height:auto!important;margin:0!important;padding:0!important}body{background:#fff}}
</style>
</head>
<body>
@ -496,8 +504,38 @@ function renderSpecPane(){
if(!state.spec){
if(!state.model){pad.innerHTML=runCard(null,"⚠️","Сначала Анализ","ТЗ собирается из функциональной модели. Перейдите на Этап 4 и постройте модель.","← К анализу",()=>go(4));return}
pad.innerHTML=runCard("spec","📋","Техническое задание","Из модели бизнеса Елена спроектирует программу: роли, модули, экраны, данные.","Собрать ТЗ →");return}
pad.innerHTML=renderSpec(state.spec);
pad.innerHTML=renderSpec(state.spec)+renderExportBar();
}
function renderExportBar(){
if(state.unlocked){
return `<div class="exp-bar"><div class="exp-h">📦 Готовые документы</div>
<div class="exp-d">Долг закрыт — документы и ТЗ доступны для печати и выгрузки.</div>
<div class="exp-btns">
<button class="btn btn-p" onclick="printDoc()">🖨 Печать / PDF</button>
<button class="btn btn-s" onclick="downloadTZ()">⬇ Скачать ТЗ</button>
<button class="btn btn-s" onclick="exportDev()">{ } Выгрузить для разработчика</button>
</div></div>`;
}
const debt=state.debt||0, deal=state.deal_amount||0;
const money=deal>0?`Сумма: ${deal.toLocaleString('ru')} ₽${debt>0?` · к оплате: <b>${debt.toLocaleString('ru')} ₽</b>`:''}`:'';
return `<div class="exp-bar locked">
<div class="exp-h">🔒 Документы готовы — доступны после оплаты</div>
<div class="exp-d">Вы видите полный результат на экране. Печатный документ и выгрузка ТЗ для разработчика открываются после закрытия оплаты.<br>${money}</div>
<div class="exp-btns"><button class="btn btn-p" onclick="(window.showPayModal||function(){alert('Оплата скоро будет доступна')})()">Оплатить и забрать документы →</button></div>
</div>`;
}
function buildTZmd(s){
let m=`# ТЗ · ${state.client_name||''}\n# @wasrusgen1 | КОНСАЛТИНГ\n\n## A. Обзор\n${s.overview||''}\n\n## A. Роли\n`;
(s.roles||[]).forEach(r=>m+=`- **${r.name}** — ${r.does} (доступ: ${r.access})\n`);
m+=`\n## B. Модули\n`;(s.modules||[]).forEach(x=>{m+=`### ${x.name} [${x.source_node}]\n${x.purpose}\nЭкраны: ${(x.screens||[]).join(', ')}\nДанные: вход — ${x.inputs_data}; выход — ${x.outputs_data}\n`;(x.rules||[]).forEach(r=>m+=`- правило: ${r}\n`);m+=`\n`});
m+=`## C. Модель данных\n`;(s.entities||[]).forEach(e=>{m+=`### ${e.name}\n`;(e.fields||[]).forEach(f=>m+=`- ${f.field}: ${f.type}\n`);if((e.relations||[]).length)m+=`Связи: ${e.relations.join(' · ')}\n`;m+=`Пример: ${e.example}\n\n`});
if((s.open_questions||[]).length){m+=`## Уточнить перед разработкой\n`;s.open_questions.forEach(q=>m+=`- ${q}\n`)}
return m;
}
function dl(name,text,type){const b=new Blob([text],{type:type||'text/plain;charset=utf-8'});const a=document.createElement('a');a.href=URL.createObjectURL(b);a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(a.href),2000)}
function printDoc(){if(!state.unlocked)return alert('Доступно после оплаты');window.print()}
function downloadTZ(){if(!state.unlocked)return alert('Доступно после оплаты');dl(`ТЗ_${(state.client_name||'проект').replace(/\s+/g,'_')}.md`,buildTZmd(state.spec),'text/markdown;charset=utf-8')}
function exportDev(){if(!state.unlocked)return alert('Доступно после оплаты');dl(`ТЗ_${(state.client_name||'проект').replace(/\s+/g,'_')}.json`,JSON.stringify({client:state.client_name,niche:state.niche,model:state.model,spec:state.spec},null,2),'application/json')}
function runCard(stage,ic,t,d,btn,custom){
const id=stage?`id="rb-${stage}"`:'';
const onclick=custom?'':`onclick="runBuild('${stage}')"`;

View File

@ -1058,6 +1058,16 @@ def get_project_state(token):
model_row = db().execute(
"SELECT blocks_json FROM models WHERE project_id=? ORDER BY id DESC LIMIT 1", (proj["id"],)
).fetchone()
crm = latest_artifact(proj["id"], "crm") or {"pipeline":"lead","deal_amount":0,"paid_amount":0,"contact":"","source":"","note":"","billing_type":"paid"}
payments = crm.get("payments", []) if isinstance(crm.get("payments"), list) else []
paid_total = sum(p.get("amount", 0) for p in payments) if payments else (crm.get("paid_amount", 0) or 0)
deal_amount = crm.get("deal_amount", 0) or 0
debt = max(0, deal_amount - paid_total)
billing = crm.get("billing_type", "paid")
# Разблокировка выгрузки документов/ТЗ: бесплатный клиент ИЛИ долг закрыт (платный с deal>0)
unlocked = (billing == "free") or (deal_amount > 0 and debt <= 0)
crm["paid_amount"] = paid_total
crm["debt"] = debt
return jsonify({
"token": token,
"client_name": proj["client_name"],
@ -1070,7 +1080,12 @@ def get_project_state(token):
"canvas": latest_artifact(proj["id"], "canvas"),
"spec": latest_artifact(proj["id"], "spec"),
"approvals": latest_artifact(proj["id"], "approvals") or {},
"crm": latest_artifact(proj["id"], "crm") or {"pipeline":"lead","deal_amount":0,"paid_amount":0,"contact":"","source":"","note":"","billing_type":"paid"},
"crm": crm,
"unlocked": unlocked,
"debt": debt,
"paid_total": paid_total,
"deal_amount": deal_amount,
"billing_type": billing,
"tasks": (latest_artifact(proj["id"], "tasks") or {}).get("items", []),
"pricing": latest_artifact(proj["id"], "pricing"),
"signed": db().execute("SELECT 1 FROM acceptances WHERE project_id=? AND doc='offer' LIMIT 1", (proj["id"],)).fetchone() is not None,

View File

@ -163,6 +163,14 @@ body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);displ
.pay-opt-t{font-size:15px;font-weight:700}.pay-opt-d{font-size:12px;color:var(--muted)}
.modal-close{margin-top:8px;width:100%;background:transparent;border:none;color:var(--muted);font-size:13px;cursor:pointer;padding:8px}
::-webkit-scrollbar{width:6px}::-webkit-scrollbar-thumb{background:rgba(0,0,0,.12);border-radius:4px}
.btn-s{background:var(--white);color:var(--primary);border:1.5px solid var(--primary);font-size:13px;padding:9px 16px}
.btn-s:hover{background:var(--light)}
.exp-bar{margin-top:22px;border:1.5px solid var(--border);border-radius:16px;padding:22px;background:var(--white)}
.exp-bar.locked{background:linear-gradient(135deg,#FFFBEB,#FEF3C7);border-color:#FDE68A}
.exp-h{font-family:'Montserrat';font-weight:800;font-size:16px;color:var(--text);margin-bottom:6px}
.exp-d{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:14px}
.exp-btns{display:flex;flex-wrap:wrap;gap:10px}
@media print{.sb,.hdr,.pb,.hero,.nav,.exp-bar,.modal,.mic,.inp-bar{display:none!important}.main,.sv,.scroll,.pad{display:block!important;overflow:visible!important;height:auto!important;margin:0!important;padding:0!important}body{background:#fff}}
</style>
</head>
<body>
@ -496,8 +504,38 @@ function renderSpecPane(){
if(!state.spec){
if(!state.model){pad.innerHTML=runCard(null,"⚠️","Сначала Анализ","ТЗ собирается из функциональной модели. Перейдите на Этап 4 и постройте модель.","← К анализу",()=>go(4));return}
pad.innerHTML=runCard("spec","📋","Техническое задание","Из модели бизнеса Елена спроектирует программу: роли, модули, экраны, данные.","Собрать ТЗ →");return}
pad.innerHTML=renderSpec(state.spec);
pad.innerHTML=renderSpec(state.spec)+renderExportBar();
}
function renderExportBar(){
if(state.unlocked){
return `<div class="exp-bar"><div class="exp-h">📦 Готовые документы</div>
<div class="exp-d">Долг закрыт — документы и ТЗ доступны для печати и выгрузки.</div>
<div class="exp-btns">
<button class="btn btn-p" onclick="printDoc()">🖨 Печать / PDF</button>
<button class="btn btn-s" onclick="downloadTZ()">⬇ Скачать ТЗ</button>
<button class="btn btn-s" onclick="exportDev()">{ } Выгрузить для разработчика</button>
</div></div>`;
}
const debt=state.debt||0, deal=state.deal_amount||0;
const money=deal>0?`Сумма: ${deal.toLocaleString('ru')} ₽${debt>0?` · к оплате: <b>${debt.toLocaleString('ru')} ₽</b>`:''}`:'';
return `<div class="exp-bar locked">
<div class="exp-h">🔒 Документы готовы — доступны после оплаты</div>
<div class="exp-d">Вы видите полный результат на экране. Печатный документ и выгрузка ТЗ для разработчика открываются после закрытия оплаты.<br>${money}</div>
<div class="exp-btns"><button class="btn btn-p" onclick="(window.showPayModal||function(){alert('Оплата скоро будет доступна')})()">Оплатить и забрать документы →</button></div>
</div>`;
}
function buildTZmd(s){
let m=`# ТЗ · ${state.client_name||''}\n# @wasrusgen1 | КОНСАЛТИНГ\n\n## A. Обзор\n${s.overview||''}\n\n## A. Роли\n`;
(s.roles||[]).forEach(r=>m+=`- **${r.name}** — ${r.does} (доступ: ${r.access})\n`);
m+=`\n## B. Модули\n`;(s.modules||[]).forEach(x=>{m+=`### ${x.name} [${x.source_node}]\n${x.purpose}\nЭкраны: ${(x.screens||[]).join(', ')}\nДанные: вход — ${x.inputs_data}; выход — ${x.outputs_data}\n`;(x.rules||[]).forEach(r=>m+=`- правило: ${r}\n`);m+=`\n`});
m+=`## C. Модель данных\n`;(s.entities||[]).forEach(e=>{m+=`### ${e.name}\n`;(e.fields||[]).forEach(f=>m+=`- ${f.field}: ${f.type}\n`);if((e.relations||[]).length)m+=`Связи: ${e.relations.join(' · ')}\n`;m+=`Пример: ${e.example}\n\n`});
if((s.open_questions||[]).length){m+=`## Уточнить перед разработкой\n`;s.open_questions.forEach(q=>m+=`- ${q}\n`)}
return m;
}
function dl(name,text,type){const b=new Blob([text],{type:type||'text/plain;charset=utf-8'});const a=document.createElement('a');a.href=URL.createObjectURL(b);a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(a.href),2000)}
function printDoc(){if(!state.unlocked)return alert('Доступно после оплаты');window.print()}
function downloadTZ(){if(!state.unlocked)return alert('Доступно после оплаты');dl(`ТЗ_${(state.client_name||'проект').replace(/\s+/g,'_')}.md`,buildTZmd(state.spec),'text/markdown;charset=utf-8')}
function exportDev(){if(!state.unlocked)return alert('Доступно после оплаты');dl(`ТЗ_${(state.client_name||'проект').replace(/\s+/g,'_')}.json`,JSON.stringify({client:state.client_name,niche:state.niche,model:state.model,spec:state.spec},null,2),'application/json')}
function runCard(stage,ic,t,d,btn,custom){
const id=stage?`id="rb-${stage}"`:'';
const onclick=custom?'':`onclick="runBuild('${stage}')"`;