Implement sales CRM workflow foundation
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
import json
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
import services.sales_service.app as sales_module
|
||||
from services.shared.db import get_session
|
||||
from services.shared.sql_models import EventOutboxRow
|
||||
from services.shared.sales_sql_models import SalesStageHistoryRow
|
||||
|
||||
|
||||
def _headers(tenant_id: str = "tenant_state", role: str = "admin") -> dict[str, str]:
|
||||
return {"X-User": "admin", "X-Role": role, "X-Tenant-ID": tenant_id}
|
||||
|
||||
|
||||
def _deal_payload(seed: str = "state", **overrides: object) -> dict:
|
||||
payload = {
|
||||
"stage_id": "new_qualified_lead",
|
||||
"scenario_type": "quick_sale",
|
||||
"priority": 3,
|
||||
"title": f"State machine deal {seed}",
|
||||
"need_summary": "Need a managed sales flow.",
|
||||
"document_required": False,
|
||||
"preferred_channel": "telegram",
|
||||
"current_channel": "telegram",
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def _create_deal(client: TestClient, tenant_id: str = "tenant_state", **overrides: object) -> dict:
|
||||
response = client.post("/api/v1/deals", json=_deal_payload(tenant_id, **overrides), headers=_headers(tenant_id))
|
||||
assert response.status_code == 200
|
||||
return response.json()
|
||||
|
||||
|
||||
def _change_stage(
|
||||
client: TestClient,
|
||||
deal_id: str,
|
||||
target_stage_code: str,
|
||||
tenant_id: str = "tenant_state",
|
||||
*,
|
||||
reason: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
force: bool = False,
|
||||
role: str = "admin",
|
||||
) -> dict:
|
||||
payload = {"target_stage_code": target_stage_code, "metadata": metadata or {}, "force": force}
|
||||
if reason is not None:
|
||||
payload["reason"] = reason
|
||||
response = client.post(f"/api/v1/deals/{deal_id}/change-stage", json=payload, headers=_headers(tenant_id, role))
|
||||
assert response.status_code == 200
|
||||
return response.json()
|
||||
|
||||
|
||||
def _prepare_need_confirmed(client: TestClient, deal_id: str, tenant_id: str = "tenant_state") -> None:
|
||||
_change_stage(client, deal_id, "active_text_communication", tenant_id, reason="text started")
|
||||
_change_stage(client, deal_id, "need_confirmed", tenant_id, reason="need confirmed")
|
||||
|
||||
|
||||
def _prepare_invoice(client: TestClient, deal_id: str, tenant_id: str = "tenant_state") -> dict:
|
||||
_prepare_need_confirmed(client, deal_id, tenant_id)
|
||||
created = client.post(
|
||||
f"/api/v1/deals/{deal_id}/invoices",
|
||||
json={"amount": 1000, "currency": "KZT", "due_date": "2026-05-15"},
|
||||
headers=_headers(tenant_id),
|
||||
)
|
||||
assert created.status_code == 200
|
||||
sent = client.post(f"/api/v1/invoices/{created.json()['invoice_id']}/send", headers=_headers(tenant_id))
|
||||
assert sent.status_code == 200
|
||||
return sent.json()
|
||||
|
||||
|
||||
def _stage_id(client: TestClient, tenant_id: str, code: str) -> str:
|
||||
pipelines = client.get("/api/v1/pipelines", headers=_headers(tenant_id))
|
||||
assert pipelines.status_code == 200
|
||||
default_pipeline = next(item for item in pipelines.json() if item["is_default"])
|
||||
stages = client.get(f"/api/v1/pipelines/{default_pipeline['pipeline_id']}/stages", headers=_headers(tenant_id))
|
||||
assert stages.status_code == 200
|
||||
return next(item["stage_id"] for item in stages.json() if item["code"] == code)
|
||||
|
||||
|
||||
def _event_payloads(event_type: str, tenant_id: str) -> list[dict]:
|
||||
session = get_session()
|
||||
try:
|
||||
rows = session.execute(
|
||||
select(EventOutboxRow)
|
||||
.where(EventOutboxRow.producer_service == "sales-service", EventOutboxRow.event_type == event_type)
|
||||
.order_by(EventOutboxRow.id.asc())
|
||||
).scalars().all()
|
||||
payloads = []
|
||||
for row in rows:
|
||||
envelope = json.loads(row.payload_json or "{}")
|
||||
payload = envelope.get("payload") if isinstance(envelope.get("payload"), dict) else {}
|
||||
if payload.get("tenant_id") == tenant_id:
|
||||
payloads.append(payload)
|
||||
return payloads
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_valid_stage_transition_succeeds():
|
||||
client = TestClient(sales_module.app)
|
||||
deal = _create_deal(client)
|
||||
|
||||
changed = _change_stage(client, deal["deal_id"], "warm_lead", reason="lead warmed")
|
||||
|
||||
assert changed["stage"]["code"] == "warm_lead"
|
||||
|
||||
|
||||
def test_invalid_stage_transition_fails():
|
||||
client = TestClient(sales_module.app)
|
||||
deal = _create_deal(client)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/deals/{deal['deal_id']}/change-stage",
|
||||
json={"target_stage_code": "offer_sent", "reason": "skip pipeline"},
|
||||
headers=_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["error"] == "invalid_stage_transition"
|
||||
|
||||
|
||||
def test_change_stage_missing_deal_returns_state_machine_error():
|
||||
client = TestClient(sales_module.app)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/deals/sde_missing/change-stage",
|
||||
json={"target_stage_code": "warm_lead", "reason": "missing deal"},
|
||||
headers=_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json()["error"] == "deal_not_found"
|
||||
|
||||
|
||||
def test_cannot_jump_from_new_lead_to_paid():
|
||||
client = TestClient(sales_module.app)
|
||||
deal = _create_deal(client)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/deals/{deal['deal_id']}/change-stage",
|
||||
json={"target_stage_code": "paid", "reason": "jump"},
|
||||
headers=_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["error"] == "invalid_stage_transition"
|
||||
|
||||
|
||||
def test_cannot_set_stage_from_another_tenant():
|
||||
client = TestClient(sales_module.app)
|
||||
foreign_stage_id = _stage_id(client, "tenant_state_foreign", "warm_lead")
|
||||
deal = _create_deal(client, "tenant_state_owner")
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/deals/{deal['deal_id']}/change-stage",
|
||||
json={"target_stage_id": foreign_stage_id, "reason": "foreign stage"},
|
||||
headers=_headers("tenant_state_owner"),
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["error"] == "cross_tenant_stage_forbidden"
|
||||
|
||||
|
||||
def test_cannot_set_stage_from_another_pipeline():
|
||||
client = TestClient(sales_module.app)
|
||||
tenant_id = "tenant_state_pipeline"
|
||||
deal = _create_deal(client, tenant_id)
|
||||
pipeline = client.post(
|
||||
"/api/v1/pipelines",
|
||||
json={"code": "enterprise_sales", "name": "Enterprise Sales"},
|
||||
headers=_headers(tenant_id),
|
||||
)
|
||||
assert pipeline.status_code == 200
|
||||
stage = client.post(
|
||||
f"/api/v1/pipelines/{pipeline.json()['pipeline_id']}/stages",
|
||||
json={"code": "enterprise_warm", "name": "Enterprise Warm", "category": "entry", "sort_order": 10},
|
||||
headers=_headers(tenant_id),
|
||||
)
|
||||
assert stage.status_code == 200
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/deals/{deal['deal_id']}/change-stage",
|
||||
json={"target_stage_id": stage.json()["stage_id"], "reason": "wrong pipeline"},
|
||||
headers=_headers(tenant_id),
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["error"] == "invalid_stage_pipeline"
|
||||
|
||||
|
||||
def test_offer_sent_requires_offer():
|
||||
client = TestClient(sales_module.app)
|
||||
deal = _create_deal(client)
|
||||
_prepare_need_confirmed(client, deal["deal_id"])
|
||||
_change_stage(client, deal["deal_id"], "offer_preparing", reason="prepare offer")
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/deals/{deal['deal_id']}/change-stage",
|
||||
json={"target_stage_code": "offer_sent", "reason": "send missing offer"},
|
||||
headers=_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["error"] == "offer_required_before_offer_sent"
|
||||
|
||||
|
||||
def test_invoice_sent_requires_invoice():
|
||||
client = TestClient(sales_module.app)
|
||||
deal = _create_deal(client)
|
||||
_prepare_need_confirmed(client, deal["deal_id"])
|
||||
_change_stage(client, deal["deal_id"], "invoice_preparing", reason="prepare invoice")
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/deals/{deal['deal_id']}/change-stage",
|
||||
json={"target_stage_code": "invoice_sent", "reason": "send missing invoice"},
|
||||
headers=_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["error"] == "invoice_required_before_invoice_sent"
|
||||
|
||||
|
||||
def test_won_requires_payment_when_payment_required():
|
||||
client = TestClient(sales_module.app)
|
||||
deal = _create_deal(client, payment_required=True)
|
||||
escalated = client.post(
|
||||
f"/api/v1/deals/{deal['deal_id']}/escalate",
|
||||
json={"escalation_type": "manual_review", "reason": "Need human review", "severity": "medium"},
|
||||
headers=_headers(),
|
||||
)
|
||||
assert escalated.status_code == 200
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/deals/{deal['deal_id']}/change-stage",
|
||||
json={"target_stage_code": "won", "reason": "manual win"},
|
||||
headers=_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["error"] == "payment_required_before_won"
|
||||
|
||||
|
||||
def test_lost_requires_reason():
|
||||
client = TestClient(sales_module.app)
|
||||
deal = _create_deal(client)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/deals/{deal['deal_id']}/change-stage",
|
||||
json={"target_stage_code": "lost"},
|
||||
headers=_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["error"] == "lost_reason_required"
|
||||
|
||||
|
||||
def test_successful_transition_writes_stage_history():
|
||||
client = TestClient(sales_module.app)
|
||||
deal = _create_deal(client, "tenant_state_history")
|
||||
|
||||
_change_stage(client, deal["deal_id"], "warm_lead", "tenant_state_history", reason="lead warmed")
|
||||
|
||||
session = get_session()
|
||||
try:
|
||||
rows = session.execute(
|
||||
select(SalesStageHistoryRow).where(
|
||||
SalesStageHistoryRow.tenant_id == "tenant_state_history",
|
||||
SalesStageHistoryRow.deal_id == deal["deal_id"],
|
||||
)
|
||||
).scalars().all()
|
||||
assert rows
|
||||
assert rows[-1].reason == "lead warmed"
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_successful_transition_publishes_deal_stage_changed():
|
||||
client = TestClient(sales_module.app)
|
||||
deal = _create_deal(client, "tenant_state_event")
|
||||
|
||||
_change_stage(client, deal["deal_id"], "warm_lead", "tenant_state_event", reason="lead warmed")
|
||||
|
||||
payload = _event_payloads("deal.stage_changed", "tenant_state_event")[-1]
|
||||
assert payload["deal_id"] == deal["deal_id"]
|
||||
assert payload["from_stage_code"] == "new_qualified_lead"
|
||||
assert payload["to_stage_code"] == "warm_lead"
|
||||
|
||||
|
||||
def test_won_transition_sets_status_and_closed_at():
|
||||
client = TestClient(sales_module.app)
|
||||
tenant_id = "tenant_state_won"
|
||||
deal = _create_deal(client, tenant_id)
|
||||
invoice = _prepare_invoice(client, deal["deal_id"], tenant_id)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/payments/webhook",
|
||||
json={
|
||||
"deal_id": deal["deal_id"],
|
||||
"invoice_id": invoice["invoice_id"],
|
||||
"payment_provider": "manual",
|
||||
"external_payment_id": "state-paid-1",
|
||||
"amount": 1000,
|
||||
"currency": "KZT",
|
||||
"status": "success",
|
||||
},
|
||||
headers=_headers(tenant_id),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
refreshed = client.get(f"/api/v1/deals/{deal['deal_id']}", headers=_headers(tenant_id))
|
||||
assert refreshed.status_code == 200
|
||||
assert refreshed.json()["status"] == "won"
|
||||
assert refreshed.json()["closed_at"]
|
||||
assert refreshed.json()["stage"]["code"] == "won"
|
||||
|
||||
|
||||
def test_lost_transition_sets_status_and_closed_at():
|
||||
client = TestClient(sales_module.app)
|
||||
deal = _create_deal(client, "tenant_state_lost")
|
||||
|
||||
changed = _change_stage(client, deal["deal_id"], "lost", "tenant_state_lost", reason="not interested")
|
||||
|
||||
assert changed["status"] == "lost"
|
||||
assert changed["closed_at"]
|
||||
assert changed["lost_reason"] == "not interested"
|
||||
|
||||
|
||||
def test_payment_webhook_transitions_deal_to_paid_and_won():
|
||||
client = TestClient(sales_module.app)
|
||||
tenant_id = "tenant_state_payment"
|
||||
deal = _create_deal(client, tenant_id)
|
||||
invoice = _prepare_invoice(client, deal["deal_id"], tenant_id)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/payments/webhook",
|
||||
json={
|
||||
"deal_id": deal["deal_id"],
|
||||
"invoice_id": invoice["invoice_id"],
|
||||
"payment_provider": "manual",
|
||||
"external_payment_id": "state-paid-2",
|
||||
"amount": 1000,
|
||||
"currency": "KZT",
|
||||
"status": "success",
|
||||
},
|
||||
headers=_headers(tenant_id),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
workspace = client.get(f"/api/v1/deals/{deal['deal_id']}/workspace", headers=_headers(tenant_id))
|
||||
assert workspace.status_code == 200
|
||||
stage_codes = [item["to_stage"]["code"] for item in workspace.json()["stage_history"]]
|
||||
assert "paid" in stage_codes
|
||||
assert "won" in stage_codes
|
||||
|
||||
|
||||
def test_force_transition_requires_system_or_admin_actor():
|
||||
client = TestClient(sales_module.app)
|
||||
deal = _create_deal(client, "tenant_state_force")
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/deals/{deal['deal_id']}/change-stage",
|
||||
json={"target_stage_code": "warm_lead", "reason": "operator force", "force": True},
|
||||
headers=_headers("tenant_state_force", role="operator"),
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["error"] == "force_transition_forbidden"
|
||||
@@ -0,0 +1,437 @@
|
||||
import json
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
import services.sales_service.app as sales_module
|
||||
from services.sales_service.automation_worker import SalesAutomationWorker
|
||||
from services.shared.core import new_id, utc_now_iso
|
||||
from services.shared.db import get_session
|
||||
from services.shared.sales_sql_models import (
|
||||
SalesAutomationTaskRow,
|
||||
SalesDealRow,
|
||||
SalesEscalationRow,
|
||||
SalesInvoiceRow,
|
||||
SalesPipelineStageRow,
|
||||
)
|
||||
from services.shared.sql_models import EventOutboxRow
|
||||
|
||||
|
||||
def _headers(tenant_id: str = "tenant_worker") -> dict[str, str]:
|
||||
return {"X-User": "admin", "X-Role": "admin", "X-Tenant-ID": tenant_id}
|
||||
|
||||
|
||||
def _deal_payload(seed: str = "worker", **overrides: object) -> dict:
|
||||
payload = {
|
||||
"stage_id": "new_qualified_lead",
|
||||
"scenario_type": "quick_sale",
|
||||
"priority": 3,
|
||||
"title": f"Automation deal {seed}",
|
||||
"need_summary": "Need automation coverage.",
|
||||
"document_required": False,
|
||||
"preferred_channel": "telegram",
|
||||
"current_channel": "telegram",
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def _create_deal(client: TestClient, tenant_id: str = "tenant_worker", **overrides: object) -> dict:
|
||||
response = client.post("/api/v1/deals", json=_deal_payload(tenant_id, **overrides), headers=_headers(tenant_id))
|
||||
assert response.status_code == 200
|
||||
return response.json()
|
||||
|
||||
|
||||
def _change_stage(client: TestClient, deal_id: str, stage_code: str, tenant_id: str = "tenant_worker") -> dict:
|
||||
response = client.post(
|
||||
f"/api/v1/deals/{deal_id}/change-stage",
|
||||
json={"target_stage_code": stage_code, "reason": f"test.{stage_code}"},
|
||||
headers=_headers(tenant_id),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return response.json()
|
||||
|
||||
|
||||
def _prepare_invoice(client: TestClient, deal_id: str, tenant_id: str = "tenant_worker") -> dict:
|
||||
_change_stage(client, deal_id, "active_text_communication", tenant_id)
|
||||
_change_stage(client, deal_id, "need_confirmed", tenant_id)
|
||||
created = client.post(
|
||||
f"/api/v1/deals/{deal_id}/invoices",
|
||||
json={"amount": 1000, "currency": "KZT", "due_date": "2026-05-15"},
|
||||
headers=_headers(tenant_id),
|
||||
)
|
||||
assert created.status_code == 200
|
||||
sent = client.post(f"/api/v1/invoices/{created.json()['invoice_id']}/send", headers=_headers(tenant_id))
|
||||
assert sent.status_code == 200
|
||||
return sent.json()
|
||||
|
||||
|
||||
def _create_task(
|
||||
*,
|
||||
tenant_id: str,
|
||||
deal_id: str,
|
||||
task_type: str,
|
||||
payload: dict | None = None,
|
||||
run_at: str | None = None,
|
||||
status: str = "pending",
|
||||
retry_count: int = 0,
|
||||
max_retries: int = 3,
|
||||
) -> str:
|
||||
session = get_session()
|
||||
try:
|
||||
now = utc_now_iso()
|
||||
row = SalesAutomationTaskRow(
|
||||
task_id=new_id("tsk"),
|
||||
tenant_id=tenant_id,
|
||||
deal_id=deal_id,
|
||||
task_type=task_type,
|
||||
payload_json=json.dumps(payload or {}, ensure_ascii=False),
|
||||
run_at=run_at or now,
|
||||
status=status,
|
||||
retry_count=retry_count,
|
||||
max_retries=max_retries,
|
||||
locked_at=None,
|
||||
locked_by=None,
|
||||
completed_at=None,
|
||||
failed_at=None,
|
||||
last_error=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
session.add(row)
|
||||
session.commit()
|
||||
return row.task_id
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def _task(task_id: str) -> SalesAutomationTaskRow:
|
||||
session = get_session()
|
||||
try:
|
||||
return session.execute(select(SalesAutomationTaskRow).where(SalesAutomationTaskRow.task_id == task_id)).scalar_one()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def _deal(deal_id: str, tenant_id: str) -> SalesDealRow:
|
||||
session = get_session()
|
||||
try:
|
||||
return session.execute(
|
||||
select(SalesDealRow).where(SalesDealRow.deal_id == deal_id, SalesDealRow.tenant_id == tenant_id)
|
||||
).scalar_one()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def _invoice(invoice_id: str, tenant_id: str) -> SalesInvoiceRow:
|
||||
session = get_session()
|
||||
try:
|
||||
return session.execute(
|
||||
select(SalesInvoiceRow).where(SalesInvoiceRow.invoice_id == invoice_id, SalesInvoiceRow.tenant_id == tenant_id)
|
||||
).scalar_one()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def _stage_code(deal_id: str, tenant_id: str) -> str:
|
||||
session = get_session()
|
||||
try:
|
||||
deal = session.execute(
|
||||
select(SalesDealRow).where(SalesDealRow.deal_id == deal_id, SalesDealRow.tenant_id == tenant_id)
|
||||
).scalar_one()
|
||||
stage = session.execute(
|
||||
select(SalesPipelineStageRow).where(
|
||||
SalesPipelineStageRow.stage_id == deal.stage_id,
|
||||
SalesPipelineStageRow.tenant_id == tenant_id,
|
||||
)
|
||||
).scalar_one()
|
||||
return stage.code
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def _events(event_type: str, tenant_id: str) -> list[dict]:
|
||||
session = get_session()
|
||||
try:
|
||||
rows = session.execute(
|
||||
select(EventOutboxRow)
|
||||
.where(EventOutboxRow.producer_service == "sales-service", EventOutboxRow.event_type == event_type)
|
||||
.order_by(EventOutboxRow.id.asc())
|
||||
).scalars().all()
|
||||
payloads = []
|
||||
for row in rows:
|
||||
envelope = json.loads(row.payload_json or "{}")
|
||||
payload = envelope.get("payload") if isinstance(envelope.get("payload"), dict) else {}
|
||||
if payload.get("tenant_id") == tenant_id:
|
||||
payloads.append(payload)
|
||||
return payloads
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_worker_claims_due_pending_task():
|
||||
client = TestClient(sales_module.app)
|
||||
deal = _create_deal(client)
|
||||
task_id = _create_task(tenant_id="tenant_worker", deal_id=deal["deal_id"], task_type="follow_up_customer")
|
||||
worker = SalesAutomationWorker(worker_id="worker-claim")
|
||||
session = get_session()
|
||||
try:
|
||||
claimed = worker.claim_pending_tasks(session)
|
||||
assert [task.task_id for task in claimed] == [task_id]
|
||||
assert claimed[0].status == "running"
|
||||
assert claimed[0].locked_by == "worker-claim"
|
||||
finally:
|
||||
session.rollback()
|
||||
session.close()
|
||||
|
||||
|
||||
def test_worker_does_not_claim_future_task():
|
||||
client = TestClient(sales_module.app)
|
||||
deal = _create_deal(client)
|
||||
_create_task(
|
||||
tenant_id="tenant_worker",
|
||||
deal_id=deal["deal_id"],
|
||||
task_type="follow_up_customer",
|
||||
run_at="2999-01-01T00:00:00+00:00",
|
||||
)
|
||||
worker = SalesAutomationWorker(worker_id="worker-future")
|
||||
session = get_session()
|
||||
try:
|
||||
assert worker.claim_pending_tasks(session) == []
|
||||
finally:
|
||||
session.rollback()
|
||||
session.close()
|
||||
|
||||
|
||||
def test_worker_marks_task_completed_on_success():
|
||||
client = TestClient(sales_module.app)
|
||||
deal = _create_deal(client)
|
||||
task_id = _create_task(tenant_id="tenant_worker", deal_id=deal["deal_id"], task_type="follow_up_customer")
|
||||
|
||||
assert SalesAutomationWorker(worker_id="worker-complete").run_once() == 1
|
||||
|
||||
task = _task(task_id)
|
||||
assert task.status == "completed"
|
||||
assert task.completed_at
|
||||
|
||||
|
||||
def test_worker_retries_failed_task():
|
||||
client = TestClient(sales_module.app)
|
||||
deal = _create_deal(client)
|
||||
task_id = _create_task(tenant_id="tenant_worker", deal_id=deal["deal_id"], task_type="unknown_task")
|
||||
|
||||
SalesAutomationWorker(worker_id="worker-retry").run_once()
|
||||
|
||||
task = _task(task_id)
|
||||
assert task.status == "pending"
|
||||
assert task.retry_count == 1
|
||||
assert task.last_error
|
||||
|
||||
|
||||
def test_worker_marks_task_failed_after_max_retries():
|
||||
client = TestClient(sales_module.app)
|
||||
deal = _create_deal(client)
|
||||
task_id = _create_task(
|
||||
tenant_id="tenant_worker",
|
||||
deal_id=deal["deal_id"],
|
||||
task_type="unknown_task",
|
||||
max_retries=1,
|
||||
)
|
||||
|
||||
SalesAutomationWorker(worker_id="worker-failed").run_once()
|
||||
|
||||
task = _task(task_id)
|
||||
assert task.status == "failed"
|
||||
assert task.retry_count == 1
|
||||
assert task.failed_at
|
||||
|
||||
|
||||
def test_mark_invoice_overdue_updates_invoice_status():
|
||||
client = TestClient(sales_module.app)
|
||||
deal = _create_deal(client)
|
||||
invoice = _prepare_invoice(client, deal["deal_id"])
|
||||
task_id = _create_task(
|
||||
tenant_id="tenant_worker",
|
||||
deal_id=deal["deal_id"],
|
||||
task_type="mark_invoice_overdue",
|
||||
payload={"invoice_id": invoice["invoice_id"]},
|
||||
)
|
||||
|
||||
SalesAutomationWorker(worker_id="worker-overdue").run_once()
|
||||
|
||||
assert _task(task_id).status == "completed"
|
||||
assert _invoice(invoice["invoice_id"], "tenant_worker").status == "overdue"
|
||||
|
||||
|
||||
def test_mark_invoice_overdue_transitions_deal_via_state_machine():
|
||||
client = TestClient(sales_module.app)
|
||||
deal = _create_deal(client, "tenant_worker_stage")
|
||||
invoice = _prepare_invoice(client, deal["deal_id"], "tenant_worker_stage")
|
||||
_create_task(
|
||||
tenant_id="tenant_worker_stage",
|
||||
deal_id=deal["deal_id"],
|
||||
task_type="mark_invoice_overdue",
|
||||
payload={"invoice_id": invoice["invoice_id"]},
|
||||
)
|
||||
|
||||
SalesAutomationWorker(worker_id="worker-overdue-stage").run_once()
|
||||
|
||||
assert _stage_code(deal["deal_id"], "tenant_worker_stage") == "payment_overdue"
|
||||
assert _events("deal.stage_changed", "tenant_worker_stage")[-1]["to_stage_code"] == "payment_overdue"
|
||||
|
||||
|
||||
def test_send_invoice_reminder_creates_follow_up():
|
||||
client = TestClient(sales_module.app)
|
||||
deal = _create_deal(client, "tenant_worker_reminder")
|
||||
invoice = _prepare_invoice(client, deal["deal_id"], "tenant_worker_reminder")
|
||||
_create_task(
|
||||
tenant_id="tenant_worker_reminder",
|
||||
deal_id=deal["deal_id"],
|
||||
task_type="send_invoice_reminder",
|
||||
payload={"invoice_id": invoice["invoice_id"]},
|
||||
)
|
||||
|
||||
SalesAutomationWorker(worker_id="worker-reminder").run_once()
|
||||
|
||||
session = get_session()
|
||||
try:
|
||||
follow_up = session.execute(
|
||||
select(SalesAutomationTaskRow).where(
|
||||
SalesAutomationTaskRow.tenant_id == "tenant_worker_reminder",
|
||||
SalesAutomationTaskRow.deal_id == deal["deal_id"],
|
||||
SalesAutomationTaskRow.task_type == "follow_up_customer",
|
||||
)
|
||||
).scalars().all()
|
||||
assert len(follow_up) == 1
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_follow_up_customer_updates_next_action():
|
||||
client = TestClient(sales_module.app)
|
||||
deal = _create_deal(client, "tenant_worker_follow")
|
||||
_create_task(tenant_id="tenant_worker_follow", deal_id=deal["deal_id"], task_type="follow_up_customer")
|
||||
|
||||
SalesAutomationWorker(worker_id="worker-follow").run_once()
|
||||
|
||||
assert _deal(deal["deal_id"], "tenant_worker_follow").next_action_type == "follow_up_customer"
|
||||
|
||||
|
||||
def test_recommend_channel_switch_records_recommendation():
|
||||
client = TestClient(sales_module.app)
|
||||
deal = _create_deal(client, "tenant_worker_switch")
|
||||
task_id = _create_task(
|
||||
tenant_id="tenant_worker_switch",
|
||||
deal_id=deal["deal_id"],
|
||||
task_type="recommend_channel_switch",
|
||||
payload={"recommended_from_channel": "text", "recommended_to_channel": "voice", "reason": "no_reply_in_text"},
|
||||
)
|
||||
|
||||
SalesAutomationWorker(worker_id="worker-switch").run_once()
|
||||
|
||||
payload = json.loads(_task(task_id).payload_json)
|
||||
assert payload["automation_result"]["recommended_to_channel"] == "voice"
|
||||
assert _events("deal.channel_switch_recommended", "tenant_worker_switch")
|
||||
|
||||
|
||||
def test_escalate_to_human_creates_escalation():
|
||||
client = TestClient(sales_module.app)
|
||||
deal = _create_deal(client, "tenant_worker_escalation")
|
||||
_create_task(
|
||||
tenant_id="tenant_worker_escalation",
|
||||
deal_id=deal["deal_id"],
|
||||
task_type="escalate_to_human",
|
||||
payload={"reason": "low_ai_confidence", "assigned_to_user_id": "operator-1"},
|
||||
)
|
||||
|
||||
SalesAutomationWorker(worker_id="worker-escalation").run_once()
|
||||
|
||||
session = get_session()
|
||||
try:
|
||||
rows = session.execute(
|
||||
select(SalesEscalationRow).where(
|
||||
SalesEscalationRow.tenant_id == "tenant_worker_escalation",
|
||||
SalesEscalationRow.deal_id == deal["deal_id"],
|
||||
)
|
||||
).scalars().all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].assigned_to_user_id == "operator-1"
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_escalate_to_human_transitions_deal_to_support():
|
||||
client = TestClient(sales_module.app)
|
||||
deal = _create_deal(client, "tenant_worker_support")
|
||||
_create_task(
|
||||
tenant_id="tenant_worker_support",
|
||||
deal_id=deal["deal_id"],
|
||||
task_type="escalate_to_human",
|
||||
payload={"reason": "customer_requested_human"},
|
||||
)
|
||||
|
||||
SalesAutomationWorker(worker_id="worker-support").run_once()
|
||||
|
||||
assert _stage_code(deal["deal_id"], "tenant_worker_support") == "transferred_to_support"
|
||||
|
||||
|
||||
def test_post_sale_transfer_publishes_event():
|
||||
client = TestClient(sales_module.app)
|
||||
tenant_id = "tenant_worker_post_sale"
|
||||
deal = _create_deal(client, tenant_id)
|
||||
invoice = _prepare_invoice(client, deal["deal_id"], tenant_id)
|
||||
payment = client.post(
|
||||
"/api/v1/payments/webhook",
|
||||
json={
|
||||
"deal_id": deal["deal_id"],
|
||||
"invoice_id": invoice["invoice_id"],
|
||||
"payment_provider": "manual",
|
||||
"external_payment_id": "worker-paid-1",
|
||||
"amount": 1000,
|
||||
"currency": "KZT",
|
||||
"status": "success",
|
||||
},
|
||||
headers=_headers(tenant_id),
|
||||
)
|
||||
assert payment.status_code == 200
|
||||
_create_task(tenant_id=tenant_id, deal_id=deal["deal_id"], task_type="post_sale_transfer")
|
||||
|
||||
SalesAutomationWorker(worker_id="worker-post-sale").run_once()
|
||||
|
||||
assert _events("deal.transferred_post_sale", tenant_id)
|
||||
assert _stage_code(deal["deal_id"], tenant_id) == "transferred_to_execution"
|
||||
|
||||
|
||||
def test_worker_is_tenant_scoped():
|
||||
client = TestClient(sales_module.app)
|
||||
deal_a = _create_deal(client, "tenant_worker_a")
|
||||
deal_b = _create_deal(client, "tenant_worker_b")
|
||||
invoice_b = _prepare_invoice(client, deal_b["deal_id"], "tenant_worker_b")
|
||||
task_id = _create_task(
|
||||
tenant_id="tenant_worker_a",
|
||||
deal_id=deal_a["deal_id"],
|
||||
task_type="mark_invoice_overdue",
|
||||
payload={"invoice_id": invoice_b["invoice_id"]},
|
||||
)
|
||||
|
||||
SalesAutomationWorker(worker_id="worker-tenant").run_once()
|
||||
|
||||
assert _task(task_id).status == "pending"
|
||||
assert _invoice(invoice_b["invoice_id"], "tenant_worker_b").status == "sent"
|
||||
|
||||
|
||||
def test_worker_is_idempotent_for_completed_task():
|
||||
client = TestClient(sales_module.app)
|
||||
deal = _create_deal(client, "tenant_worker_done")
|
||||
task_id = _create_task(
|
||||
tenant_id="tenant_worker_done",
|
||||
deal_id=deal["deal_id"],
|
||||
task_type="follow_up_customer",
|
||||
status="completed",
|
||||
)
|
||||
|
||||
assert SalesAutomationWorker(worker_id="worker-done").run_once() == 0
|
||||
|
||||
task = _task(task_id)
|
||||
assert task.status == "completed"
|
||||
assert _events("deal.follow_up_ready", "tenant_worker_done") == []
|
||||
@@ -68,7 +68,34 @@ def _create_lead_and_deal(client: TestClient, tenant_id: str = "tenant_events")
|
||||
return lead, deal
|
||||
|
||||
|
||||
def _change_stage(client: TestClient, deal_id: str, stage_code: str, tenant_id: str = "tenant_events") -> dict:
|
||||
response = client.post(
|
||||
f"/api/v1/deals/{deal_id}/change-stage",
|
||||
json={"target_stage_code": stage_code, "reason": f"test.{stage_code}"},
|
||||
headers=_headers(tenant_id),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return response.json()
|
||||
|
||||
|
||||
def _prepare_offer_selection(client: TestClient, deal_id: str, tenant_id: str = "tenant_events") -> None:
|
||||
_change_stage(client, deal_id, "hot_lead", tenant_id)
|
||||
_change_stage(client, deal_id, "offer_selection", tenant_id)
|
||||
|
||||
|
||||
def _prepare_invoice_deal(client: TestClient, deal_id: str, tenant_id: str = "tenant_events") -> None:
|
||||
updated = client.patch(
|
||||
f"/api/v1/deals/{deal_id}",
|
||||
json={"document_required": False},
|
||||
headers=_headers(tenant_id),
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
_change_stage(client, deal_id, "active_text_communication", tenant_id)
|
||||
_change_stage(client, deal_id, "need_confirmed", tenant_id)
|
||||
|
||||
|
||||
def _create_invoice(client: TestClient, deal_id: str, tenant_id: str = "tenant_events") -> dict:
|
||||
_prepare_invoice_deal(client, deal_id, tenant_id)
|
||||
response = client.post(
|
||||
f"/api/v1/deals/{deal_id}/invoices",
|
||||
json={"amount": 150000, "currency": "KZT", "due_date": "2026-05-15"},
|
||||
@@ -96,7 +123,7 @@ def test_stage_change_publishes_deal_stage_changed():
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/deals/{deal['deal_id']}/change-stage",
|
||||
json={"stage_code": "offer_sent", "reason": "offer sent"},
|
||||
json={"target_stage_code": "warm_lead", "reason": "lead warmed"},
|
||||
headers=_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -104,7 +131,7 @@ def test_stage_change_publishes_deal_stage_changed():
|
||||
|
||||
assert payload["deal_id"] == deal["deal_id"]
|
||||
assert payload["from_stage_code"] == "new_qualified_lead"
|
||||
assert payload["to_stage_code"] == "offer_sent"
|
||||
assert payload["to_stage_code"] == "warm_lead"
|
||||
assert payload["from_stage_id"].startswith("pst_")
|
||||
assert payload["to_stage_id"].startswith("pst_")
|
||||
|
||||
@@ -170,6 +197,7 @@ def test_complete_call_publishes_call_completed():
|
||||
def test_create_offer_publishes_offer_created():
|
||||
client = TestClient(sales_module.app)
|
||||
_, deal = _create_lead_and_deal(client)
|
||||
_prepare_offer_selection(client, deal["deal_id"])
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/deals/{deal['deal_id']}/offers",
|
||||
@@ -186,6 +214,7 @@ def test_create_offer_publishes_offer_created():
|
||||
def test_send_offer_publishes_offer_sent():
|
||||
client = TestClient(sales_module.app)
|
||||
_, deal = _create_lead_and_deal(client)
|
||||
_prepare_offer_selection(client, deal["deal_id"])
|
||||
offer = client.post(
|
||||
f"/api/v1/deals/{deal['deal_id']}/offers",
|
||||
json={"offer_type": "quotation", "title": "Quotation", "total_amount": 150000, "currency": "KZT"},
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
import json
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
import services.sales_service.app as sales_module
|
||||
from services.shared.core import new_id
|
||||
from services.shared.db import get_session
|
||||
from services.shared.sql_models import EventOutboxRow
|
||||
from services.shared.sales_sql_models import SalesChannelSwitchRow, SalesDealRow, SalesEscalationRow
|
||||
|
||||
|
||||
def _headers(tenant_id: str = "tenant_omni") -> dict[str, str]:
|
||||
return {"X-User": "admin", "X-Role": "admin", "X-Tenant-ID": tenant_id}
|
||||
|
||||
|
||||
def _lead_payload(seed: str) -> dict:
|
||||
return {
|
||||
"source_type": "website",
|
||||
"source_channel": "webchat",
|
||||
"full_name": f"Omni Buyer {seed}",
|
||||
"company_name": "Omni QA",
|
||||
"phone": f"+7700{seed[-7:]}",
|
||||
"email": f"{seed}@omni.test",
|
||||
"lead_temperature": "warm",
|
||||
"lead_score": 70,
|
||||
"initial_need_summary": "Need omnichannel CRM context.",
|
||||
"preferred_channel": "telegram",
|
||||
"assigned_agent_type": "text_ai",
|
||||
"status": "new_qualified_lead",
|
||||
"priority": 3,
|
||||
"title": f"Omni Lead {seed}",
|
||||
}
|
||||
|
||||
|
||||
def _create_lead_and_deal(client: TestClient, tenant_id: str = "tenant_omni") -> tuple[dict, dict]:
|
||||
seed = new_id("omn").replace("_", "")
|
||||
response = client.post("/api/v1/leads", json=_lead_payload(seed), headers=_headers(tenant_id))
|
||||
assert response.status_code == 200
|
||||
lead = response.json()
|
||||
deals = client.get("/api/v1/deals", headers=_headers(tenant_id))
|
||||
assert deals.status_code == 200
|
||||
deal = next(item for item in deals.json() if item["lead_id"] == lead["lead_id"])
|
||||
return lead, deal
|
||||
|
||||
|
||||
def _start_communication(client: TestClient, deal_id: str, channel_type: str, tenant_id: str = "tenant_omni") -> dict:
|
||||
response = client.post(
|
||||
f"/api/v1/deals/{deal_id}/communications/{channel_type}",
|
||||
json={
|
||||
"channel_type": channel_type,
|
||||
"direction": "outbound",
|
||||
"agent_type": "human",
|
||||
"subject": f"{channel_type} contact",
|
||||
},
|
||||
headers=_headers(tenant_id),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return response.json()
|
||||
|
||||
|
||||
def _change_stage(client: TestClient, deal_id: str, stage_code: str, tenant_id: str = "tenant_omni") -> dict:
|
||||
response = client.post(
|
||||
f"/api/v1/deals/{deal_id}/change-stage",
|
||||
json={"target_stage_code": stage_code, "reason": f"test.{stage_code}"},
|
||||
headers=_headers(tenant_id),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return response.json()
|
||||
|
||||
|
||||
def _prepare_invoice(client: TestClient, deal_id: str, tenant_id: str = "tenant_omni") -> dict:
|
||||
updated = client.patch(f"/api/v1/deals/{deal_id}", json={"document_required": False}, headers=_headers(tenant_id))
|
||||
assert updated.status_code == 200
|
||||
_change_stage(client, deal_id, "active_text_communication", tenant_id)
|
||||
_change_stage(client, deal_id, "need_confirmed", tenant_id)
|
||||
invoice = client.post(
|
||||
f"/api/v1/deals/{deal_id}/invoices",
|
||||
json={"amount": 1000, "currency": "KZT", "due_date": "2026-05-15"},
|
||||
headers=_headers(tenant_id),
|
||||
)
|
||||
assert invoice.status_code == 200
|
||||
sent = client.post(f"/api/v1/invoices/{invoice.json()['invoice_id']}/send", headers=_headers(tenant_id))
|
||||
assert sent.status_code == 200
|
||||
return sent.json()
|
||||
|
||||
|
||||
def _event_payload(event_type: str, tenant_id: str) -> dict:
|
||||
session = get_session()
|
||||
try:
|
||||
rows = session.execute(select(EventOutboxRow).where(EventOutboxRow.event_type == event_type).order_by(EventOutboxRow.id.desc())).scalars().all()
|
||||
for row in rows:
|
||||
payload = json.loads(row.payload_json or "{}").get("payload", {})
|
||||
if payload.get("tenant_id") == tenant_id:
|
||||
return payload
|
||||
raise AssertionError(f"expected {event_type}")
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_switch_text_to_voice_keeps_same_deal():
|
||||
client = TestClient(sales_module.app)
|
||||
_, deal = _create_lead_and_deal(client, "tenant_omni_text_voice")
|
||||
communication = _start_communication(client, deal["deal_id"], "text", "tenant_omni_text_voice")
|
||||
|
||||
switched = client.post(
|
||||
f"/api/v1/communications/{communication['communication_id']}/switch-channel",
|
||||
json={"to_channel": "voice", "reason_code": "customer_requested_call", "reason_text": "Customer asked for a call"},
|
||||
headers=_headers("tenant_omni_text_voice"),
|
||||
)
|
||||
|
||||
assert switched.status_code == 200
|
||||
assert switched.json()["deal_id"] == deal["deal_id"]
|
||||
workspace = client.get(f"/api/v1/deals/{deal['deal_id']}/workspace", headers=_headers("tenant_omni_text_voice")).json()
|
||||
assert workspace["deal"]["deal_id"] == deal["deal_id"]
|
||||
assert workspace["deal"]["current_channel"] == "voice"
|
||||
assert workspace["stage"]["code"] == "active_voice_communication"
|
||||
|
||||
|
||||
def test_switch_voice_to_text_keeps_same_deal():
|
||||
client = TestClient(sales_module.app)
|
||||
_, deal = _create_lead_and_deal(client, "tenant_omni_voice_text")
|
||||
communication = _start_communication(client, deal["deal_id"], "voice", "tenant_omni_voice_text")
|
||||
|
||||
switched = client.post(
|
||||
f"/api/v1/communications/{communication['communication_id']}/switch-channel",
|
||||
json={"to_channel": "text", "reason_code": "after_call_send_offer", "reason_text": "Send written follow-up", "create_session": True},
|
||||
headers=_headers("tenant_omni_voice_text"),
|
||||
)
|
||||
|
||||
assert switched.status_code == 200
|
||||
assert switched.json()["deal_id"] == deal["deal_id"]
|
||||
assert switched.json()["new_communication"]["deal_id"] == deal["deal_id"]
|
||||
workspace = client.get(f"/api/v1/deals/{deal['deal_id']}/workspace", headers=_headers("tenant_omni_voice_text")).json()
|
||||
assert workspace["deal"]["current_channel"] == "text"
|
||||
assert workspace["stage"]["code"] == "active_text_communication"
|
||||
|
||||
|
||||
def test_switch_channel_creates_channel_switch_history():
|
||||
client = TestClient(sales_module.app)
|
||||
_, deal = _create_lead_and_deal(client, "tenant_omni_switch_history")
|
||||
communication = _start_communication(client, deal["deal_id"], "text", "tenant_omni_switch_history")
|
||||
response = client.post(
|
||||
f"/api/v1/communications/{communication['communication_id']}/switch-channel",
|
||||
json={"to_channel": "voice", "reason_code": "no_reply_in_text", "reason_text": "No reply"},
|
||||
headers=_headers("tenant_omni_switch_history"),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
history = client.get(f"/api/v1/deals/{deal['deal_id']}/channel-switches", headers=_headers("tenant_omni_switch_history"))
|
||||
|
||||
assert history.status_code == 200
|
||||
assert history.json()[0]["switch_id"] == response.json()["switch_id"]
|
||||
assert history.json()[0]["reason_code"] == "no_reply_in_text"
|
||||
|
||||
|
||||
def test_switch_channel_publishes_event():
|
||||
client = TestClient(sales_module.app)
|
||||
_, deal = _create_lead_and_deal(client, "tenant_omni_switch_event")
|
||||
communication = _start_communication(client, deal["deal_id"], "text", "tenant_omni_switch_event")
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/communications/{communication['communication_id']}/switch-channel",
|
||||
json={"to_channel": "voice", "reason_code": "complex_question", "reason_text": "Need voice"},
|
||||
headers=_headers("tenant_omni_switch_event"),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = _event_payload("communication.channel_switched", "tenant_omni_switch_event")
|
||||
assert payload["switch_id"] == response.json()["switch_id"]
|
||||
assert payload["to_channel"] == "voice"
|
||||
|
||||
|
||||
def test_switch_channel_does_not_change_finance_stage_unnecessarily():
|
||||
client = TestClient(sales_module.app)
|
||||
_, deal = _create_lead_and_deal(client, "tenant_omni_finance_stage")
|
||||
_prepare_invoice(client, deal["deal_id"], "tenant_omni_finance_stage")
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/deals/{deal['deal_id']}/switch-channel",
|
||||
json={"to_channel": "voice", "reason_code": "payment_follow_up", "reason_text": "Call about payment"},
|
||||
headers=_headers("tenant_omni_finance_stage"),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
workspace = client.get(f"/api/v1/deals/{deal['deal_id']}/workspace", headers=_headers("tenant_omni_finance_stage")).json()
|
||||
assert workspace["deal"]["current_channel"] == "voice"
|
||||
assert workspace["stage"]["code"] == "invoice_sent"
|
||||
|
||||
|
||||
def test_channel_switch_is_tenant_scoped():
|
||||
client = TestClient(sales_module.app)
|
||||
_, foreign_deal = _create_lead_and_deal(client, "tenant_omni_switch_foreign")
|
||||
communication = _start_communication(client, foreign_deal["deal_id"], "text", "tenant_omni_switch_foreign")
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/communications/{communication['communication_id']}/switch-channel",
|
||||
json={"to_channel": "voice", "reason_code": "human_decision"},
|
||||
headers=_headers("tenant_omni_switch_other"),
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert client.get(f"/api/v1/deals/{foreign_deal['deal_id']}/channel-switches", headers=_headers("tenant_omni_switch_other")).status_code == 404
|
||||
|
||||
|
||||
def test_create_list_update_deal_note_and_workspace_contains_notes():
|
||||
tenant_id = "tenant_omni_notes"
|
||||
client = TestClient(sales_module.app)
|
||||
_, deal = _create_lead_and_deal(client, tenant_id)
|
||||
|
||||
created = client.post(
|
||||
f"/api/v1/deals/{deal['deal_id']}/notes",
|
||||
json={"note_type": "objection", "content": "Customer asked about payment delay", "source_type": "manual"},
|
||||
headers=_headers(tenant_id),
|
||||
)
|
||||
listed = client.get(f"/api/v1/deals/{deal['deal_id']}/notes", headers=_headers(tenant_id))
|
||||
updated = client.patch(
|
||||
f"/api/v1/deals/{deal['deal_id']}/notes/{created.json()['note_id']}",
|
||||
json={"content": "Customer objection resolved", "note_type": "summary"},
|
||||
headers=_headers(tenant_id),
|
||||
)
|
||||
workspace = client.get(f"/api/v1/deals/{deal['deal_id']}/workspace", headers=_headers(tenant_id))
|
||||
|
||||
assert created.status_code == 200
|
||||
assert listed.status_code == 200
|
||||
assert listed.json()[0]["note_id"] == created.json()["note_id"]
|
||||
assert updated.status_code == 200
|
||||
assert updated.json()["content"] == "Customer objection resolved"
|
||||
assert workspace.status_code == 200
|
||||
assert workspace.json()["notes"][0]["note_id"] == created.json()["note_id"]
|
||||
|
||||
|
||||
def test_notes_are_tenant_scoped():
|
||||
client = TestClient(sales_module.app)
|
||||
_, deal = _create_lead_and_deal(client, "tenant_omni_note_owner")
|
||||
created = client.post(
|
||||
f"/api/v1/deals/{deal['deal_id']}/notes",
|
||||
json={"note_type": "general", "content": "Private note"},
|
||||
headers=_headers("tenant_omni_note_owner"),
|
||||
)
|
||||
assert created.status_code == 200
|
||||
|
||||
assert client.get(f"/api/v1/deals/{deal['deal_id']}/notes", headers=_headers("tenant_omni_note_other")).status_code == 404
|
||||
assert (
|
||||
client.patch(
|
||||
f"/api/v1/deals/{deal['deal_id']}/notes/{created.json()['note_id']}",
|
||||
json={"content": "Nope"},
|
||||
headers=_headers("tenant_omni_note_other"),
|
||||
).status_code
|
||||
== 404
|
||||
)
|
||||
|
||||
|
||||
def test_workspace_contains_transcripts_and_communication_channel_provider():
|
||||
tenant_id = "tenant_omni_workspace_contract"
|
||||
client = TestClient(sales_module.app)
|
||||
response = client.post(
|
||||
"/internal/sales-sync/voice",
|
||||
json={
|
||||
"call_id": "call-omni-workspace-1",
|
||||
"voice_session_id": "voice-omni-workspace-1",
|
||||
"caller_number": "+77009991122",
|
||||
"caller_name": "Voice Prospect",
|
||||
"summary": "Needs invoice",
|
||||
"transcript_text": "customer: hello\nagent: hello",
|
||||
},
|
||||
headers=_headers(tenant_id),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
workspace = response.json()
|
||||
assert workspace["transcripts"][0]["transcript_text"].startswith("customer:")
|
||||
assert workspace["communications"][0]["channel_provider"] == "voice"
|
||||
|
||||
|
||||
def test_create_escalation_and_lifecycle():
|
||||
tenant_id = "tenant_omni_escalation_lifecycle"
|
||||
client = TestClient(sales_module.app)
|
||||
_, deal = _create_lead_and_deal(client, tenant_id)
|
||||
|
||||
created = client.post(
|
||||
f"/api/v1/deals/{deal['deal_id']}/escalate",
|
||||
json={"escalation_type": "customer_requested_human", "reason": "Customer asked for a person", "severity": "high"},
|
||||
headers=_headers(tenant_id),
|
||||
)
|
||||
assigned = client.post(
|
||||
f"/api/v1/escalations/{created.json()['escalation_id']}/assign",
|
||||
json={"assigned_to_user_id": "human-1"},
|
||||
headers=_headers(tenant_id),
|
||||
)
|
||||
started = client.post(f"/api/v1/escalations/{created.json()['escalation_id']}/start", headers=_headers(tenant_id))
|
||||
resolved = client.post(
|
||||
f"/api/v1/escalations/{created.json()['escalation_id']}/resolve",
|
||||
json={"resolution_code": "handled", "resolution_summary": "Human handled the case"},
|
||||
headers=_headers(tenant_id),
|
||||
)
|
||||
|
||||
assert created.status_code == 200
|
||||
assert assigned.status_code == 200
|
||||
assert assigned.json()["status"] == "assigned"
|
||||
assert started.status_code == 200
|
||||
assert started.json()["status"] == "in_progress"
|
||||
assert resolved.status_code == 200
|
||||
assert resolved.json()["status"] == "resolved"
|
||||
workspace = client.get(f"/api/v1/deals/{deal['deal_id']}/workspace", headers=_headers(tenant_id)).json()
|
||||
assert workspace["escalations"][0]["status"] == "resolved"
|
||||
|
||||
|
||||
def test_cancel_escalation():
|
||||
tenant_id = "tenant_omni_escalation_cancel"
|
||||
client = TestClient(sales_module.app)
|
||||
_, deal = _create_lead_and_deal(client, tenant_id)
|
||||
created = client.post(
|
||||
f"/api/v1/deals/{deal['deal_id']}/escalate",
|
||||
json={"escalation_type": "low_confidence", "reason": "AI confidence low"},
|
||||
headers=_headers(tenant_id),
|
||||
)
|
||||
|
||||
canceled = client.post(
|
||||
f"/api/v1/escalations/{created.json()['escalation_id']}/cancel",
|
||||
json={"resolution_code": "not_needed", "resolution_summary": "Handled automatically"},
|
||||
headers=_headers(tenant_id),
|
||||
)
|
||||
|
||||
assert canceled.status_code == 200
|
||||
assert canceled.json()["status"] == "canceled"
|
||||
|
||||
|
||||
def test_escalation_transitions_deal_to_support_and_does_not_duplicate_open_escalation():
|
||||
tenant_id = "tenant_omni_escalation_support"
|
||||
client = TestClient(sales_module.app)
|
||||
_, deal = _create_lead_and_deal(client, tenant_id)
|
||||
payload = {"escalation_type": "complex_b2b_case", "reason": "Complex B2B contract"}
|
||||
|
||||
first = client.post(f"/api/v1/deals/{deal['deal_id']}/escalate", json=payload, headers=_headers(tenant_id))
|
||||
second = client.post(f"/api/v1/deals/{deal['deal_id']}/escalate", json=payload, headers=_headers(tenant_id))
|
||||
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 200
|
||||
assert second.json()["escalation_id"] == first.json()["escalation_id"]
|
||||
workspace = client.get(f"/api/v1/deals/{deal['deal_id']}/workspace", headers=_headers(tenant_id)).json()
|
||||
assert workspace["stage"]["code"] == "transferred_to_support"
|
||||
assert workspace["active_escalation"]["escalation_id"] == first.json()["escalation_id"]
|
||||
|
||||
|
||||
def test_escalation_is_tenant_scoped():
|
||||
client = TestClient(sales_module.app)
|
||||
_, deal = _create_lead_and_deal(client, "tenant_omni_escalation_owner")
|
||||
created = client.post(
|
||||
f"/api/v1/deals/{deal['deal_id']}/escalate",
|
||||
json={"escalation_type": "payment_dispute", "reason": "Payment dispute"},
|
||||
headers=_headers("tenant_omni_escalation_owner"),
|
||||
)
|
||||
assert created.status_code == 200
|
||||
|
||||
assert client.get(f"/api/v1/deals/{deal['deal_id']}/escalations", headers=_headers("tenant_omni_escalation_other")).status_code == 404
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/v1/escalations/{created.json()['escalation_id']}/assign",
|
||||
json={"assigned_to_user_id": "other-human"},
|
||||
headers=_headers("tenant_omni_escalation_other"),
|
||||
).status_code
|
||||
== 404
|
||||
)
|
||||
|
||||
|
||||
def test_switch_channel_does_not_create_new_deal():
|
||||
tenant_id = "tenant_omni_no_new_deal"
|
||||
client = TestClient(sales_module.app)
|
||||
_, deal = _create_lead_and_deal(client, tenant_id)
|
||||
communication = _start_communication(client, deal["deal_id"], "text", tenant_id)
|
||||
before = client.get("/api/v1/deals", headers=_headers(tenant_id)).json()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/communications/{communication['communication_id']}/switch-channel",
|
||||
json={"to_channel": "voice", "reason_code": "human_decision"},
|
||||
headers=_headers(tenant_id),
|
||||
)
|
||||
after = client.get("/api/v1/deals", headers=_headers(tenant_id)).json()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert len(after) == len(before)
|
||||
assert {item["deal_id"] for item in after} == {item["deal_id"] for item in before}
|
||||
@@ -0,0 +1,523 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
import services.sales_service.app as sales_module
|
||||
from services.sales_service.payment_webhooks import register_payment_provider_adapter
|
||||
from services.shared.core import new_id, utc_now_iso
|
||||
from services.shared.db import get_session
|
||||
from services.shared.sql_models import EventOutboxRow
|
||||
from services.shared.sales_sql_models import (
|
||||
SalesAutomationTaskRow,
|
||||
SalesInvoiceRow,
|
||||
SalesPaymentRow,
|
||||
SalesPaymentWebhookEventRow,
|
||||
TenantIntegrationRow,
|
||||
)
|
||||
|
||||
|
||||
def _headers(tenant_id: str = "tenant_payment_webhooks") -> dict[str, str]:
|
||||
return {"X-User": "admin", "X-Role": "admin", "X-Tenant-ID": tenant_id}
|
||||
|
||||
|
||||
def _lead_payload(seed: str) -> dict:
|
||||
return {
|
||||
"source_type": "website",
|
||||
"source_channel": "webchat",
|
||||
"full_name": f"Payment Buyer {seed}",
|
||||
"company_name": "Payments QA",
|
||||
"phone": f"+7700{seed[-7:]}",
|
||||
"email": f"{seed}@payments.test",
|
||||
"lead_temperature": "warm",
|
||||
"lead_score": 70,
|
||||
"initial_need_summary": "Need invoice payment coverage.",
|
||||
"preferred_channel": "telegram",
|
||||
"assigned_agent_type": "text_ai",
|
||||
"status": "new_qualified_lead",
|
||||
"priority": 3,
|
||||
"title": f"Payment Lead {seed}",
|
||||
}
|
||||
|
||||
|
||||
def _create_lead_and_deal(client: TestClient, tenant_id: str = "tenant_payment_webhooks") -> tuple[dict, dict]:
|
||||
seed = new_id("paywh").replace("_", "")
|
||||
response = client.post("/api/v1/leads", json=_lead_payload(seed), headers=_headers(tenant_id))
|
||||
assert response.status_code == 200
|
||||
lead = response.json()
|
||||
deals = client.get("/api/v1/deals", headers=_headers(tenant_id))
|
||||
assert deals.status_code == 200
|
||||
deal = next(item for item in deals.json() if item["lead_id"] == lead["lead_id"])
|
||||
return lead, deal
|
||||
|
||||
|
||||
def _change_stage(client: TestClient, deal_id: str, stage_code: str, tenant_id: str = "tenant_payment_webhooks") -> None:
|
||||
response = client.post(
|
||||
f"/api/v1/deals/{deal_id}/change-stage",
|
||||
json={"target_stage_code": stage_code, "reason": f"test.{stage_code}"},
|
||||
headers=_headers(tenant_id),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def _prepare_invoice_deal(client: TestClient, deal_id: str, tenant_id: str = "tenant_payment_webhooks") -> None:
|
||||
updated = client.patch(
|
||||
f"/api/v1/deals/{deal_id}",
|
||||
json={"document_required": False},
|
||||
headers=_headers(tenant_id),
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
_change_stage(client, deal_id, "active_text_communication", tenant_id)
|
||||
_change_stage(client, deal_id, "need_confirmed", tenant_id)
|
||||
|
||||
|
||||
def _create_invoice(
|
||||
client: TestClient,
|
||||
deal_id: str,
|
||||
tenant_id: str = "tenant_payment_webhooks",
|
||||
*,
|
||||
amount: float = 150000,
|
||||
currency: str = "KZT",
|
||||
) -> dict:
|
||||
_prepare_invoice_deal(client, deal_id, tenant_id)
|
||||
response = client.post(
|
||||
f"/api/v1/deals/{deal_id}/invoices",
|
||||
json={"amount": amount, "currency": currency, "due_date": "2026-05-15"},
|
||||
headers=_headers(tenant_id),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return response.json()
|
||||
|
||||
|
||||
def _create_payment_integration(
|
||||
*,
|
||||
tenant_id: str,
|
||||
provider: str = "kaspi",
|
||||
provider_account_id: str = "merchant-1",
|
||||
secret: str = "whsec_test",
|
||||
) -> None:
|
||||
session = get_session()
|
||||
try:
|
||||
now = utc_now_iso()
|
||||
session.add(
|
||||
TenantIntegrationRow(
|
||||
integration_id=new_id("pin"),
|
||||
tenant_id=tenant_id,
|
||||
provider_type="payment",
|
||||
provider_name=provider,
|
||||
provider_account_id=provider_account_id,
|
||||
external_identifier=None,
|
||||
settings_json=json.dumps({"webhook_secret": secret, "webhook_replay_window_seconds": 600}),
|
||||
is_active=True,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def _signed_headers(raw_body: bytes, *, provider: str = "kaspi", provider_account_id: str = "merchant-1", secret: str = "whsec_test", timestamp: int | None = None) -> dict[str, str]:
|
||||
ts = str(timestamp if timestamp is not None else int(datetime.now(timezone.utc).timestamp()))
|
||||
digest = hmac.new(secret.encode("utf-8"), f"{ts}.".encode("utf-8") + raw_body, hashlib.sha256).hexdigest()
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"X-Payment-Provider": provider,
|
||||
"X-Provider-Account-Id": provider_account_id,
|
||||
"X-Webhook-Timestamp": ts,
|
||||
"X-Webhook-Signature": digest,
|
||||
}
|
||||
|
||||
|
||||
def _post_signed_webhook(client: TestClient, payload: dict, *, secret: str = "whsec_test", timestamp: int | None = None):
|
||||
raw_body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||
return client.post(
|
||||
"/api/v1/payments/webhook",
|
||||
content=raw_body,
|
||||
headers=_signed_headers(raw_body, secret=secret, timestamp=timestamp),
|
||||
)
|
||||
|
||||
|
||||
def _event(event_type: str, tenant_id: str) -> EventOutboxRow | None:
|
||||
session = get_session()
|
||||
try:
|
||||
rows = session.execute(
|
||||
select(EventOutboxRow).where(EventOutboxRow.event_type == event_type).order_by(EventOutboxRow.id.desc())
|
||||
).scalars().all()
|
||||
for row in rows:
|
||||
payload = json.loads(row.payload_json or "{}").get("payload", {})
|
||||
if payload.get("tenant_id") == tenant_id:
|
||||
return row
|
||||
return None
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def _webhook_event(external_event_id: str) -> SalesPaymentWebhookEventRow:
|
||||
session = get_session()
|
||||
try:
|
||||
row = session.execute(
|
||||
select(SalesPaymentWebhookEventRow).where(SalesPaymentWebhookEventRow.external_event_id == external_event_id)
|
||||
).scalar_one()
|
||||
session.expunge(row)
|
||||
return row
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_payment_webhook_logs_processed_event_hash_and_normalized_payload():
|
||||
tenant_id = "tenant_payment_webhook_log"
|
||||
client = TestClient(sales_module.app)
|
||||
_create_payment_integration(tenant_id=tenant_id)
|
||||
_, deal = _create_lead_and_deal(client, tenant_id)
|
||||
invoice = _create_invoice(client, deal["deal_id"], tenant_id)
|
||||
payload = {
|
||||
"external_event_id": "evt-log-1",
|
||||
"external_payment_id": "ext-log-1",
|
||||
"event_type": "payment.succeeded",
|
||||
"deal_id": deal["deal_id"],
|
||||
"invoice_id": invoice["invoice_id"],
|
||||
"amount": 150000,
|
||||
"currency": "KZT",
|
||||
"status": "succeeded",
|
||||
}
|
||||
raw_body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
response = client.post("/api/v1/payments/webhook", content=raw_body, headers=_signed_headers(raw_body))
|
||||
|
||||
assert response.status_code == 200
|
||||
event = _webhook_event("evt-log-1")
|
||||
assert event.event_status == "processed"
|
||||
assert event.signature_status == "valid"
|
||||
assert event.raw_payload_hash == hashlib.sha256(raw_body).hexdigest()
|
||||
normalized = json.loads(event.normalized_payload_json)
|
||||
assert normalized["external_payment_id"] == "ext-log-1"
|
||||
assert normalized["result"]["payment_id"] == response.json()["payment_id"]
|
||||
|
||||
|
||||
def test_payment_webhook_rejects_invalid_signature_before_business_logic():
|
||||
tenant_id = "tenant_payment_bad_signature"
|
||||
client = TestClient(sales_module.app)
|
||||
_create_payment_integration(tenant_id=tenant_id)
|
||||
_, deal = _create_lead_and_deal(client, tenant_id)
|
||||
invoice = _create_invoice(client, deal["deal_id"], tenant_id)
|
||||
payload = {
|
||||
"external_event_id": "evt-bad-signature-1",
|
||||
"external_payment_id": "ext-bad-signature-1",
|
||||
"deal_id": deal["deal_id"],
|
||||
"invoice_id": invoice["invoice_id"],
|
||||
"amount": 150000,
|
||||
"currency": "KZT",
|
||||
"status": "succeeded",
|
||||
}
|
||||
raw_body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||
headers = _signed_headers(raw_body)
|
||||
headers["X-Webhook-Signature"] = "sha256=invalid"
|
||||
|
||||
response = client.post("/api/v1/payments/webhook", content=raw_body, headers=headers)
|
||||
|
||||
assert response.status_code == 403
|
||||
event = _webhook_event("evt-bad-signature-1")
|
||||
assert event.event_status == "rejected"
|
||||
assert event.signature_status == "invalid"
|
||||
session = get_session()
|
||||
try:
|
||||
assert session.execute(select(SalesPaymentRow).where(SalesPaymentRow.external_payment_id == "ext-bad-signature-1")).scalar_one_or_none() is None
|
||||
stored_invoice = session.execute(select(SalesInvoiceRow).where(SalesInvoiceRow.invoice_id == invoice["invoice_id"])).scalar_one()
|
||||
assert stored_invoice.status == "draft"
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_payment_webhook_rejects_expired_replay_timestamp():
|
||||
tenant_id = "tenant_payment_expired"
|
||||
client = TestClient(sales_module.app)
|
||||
_create_payment_integration(tenant_id=tenant_id)
|
||||
_, deal = _create_lead_and_deal(client, tenant_id)
|
||||
invoice = _create_invoice(client, deal["deal_id"], tenant_id)
|
||||
payload = {
|
||||
"external_event_id": "evt-expired-1",
|
||||
"external_payment_id": "ext-expired-1",
|
||||
"deal_id": deal["deal_id"],
|
||||
"invoice_id": invoice["invoice_id"],
|
||||
"amount": 150000,
|
||||
"currency": "KZT",
|
||||
"status": "succeeded",
|
||||
}
|
||||
expired_ts = int((datetime.now(timezone.utc) - timedelta(minutes=20)).timestamp())
|
||||
|
||||
response = _post_signed_webhook(client, payload, timestamp=expired_ts)
|
||||
|
||||
assert response.status_code == 400
|
||||
event = _webhook_event("evt-expired-1")
|
||||
assert event.event_status == "rejected"
|
||||
assert event.signature_status == "expired"
|
||||
|
||||
|
||||
def test_payment_webhook_duplicate_external_event_is_idempotent():
|
||||
tenant_id = "tenant_payment_duplicate_event"
|
||||
client = TestClient(sales_module.app)
|
||||
_create_payment_integration(tenant_id=tenant_id)
|
||||
_, deal = _create_lead_and_deal(client, tenant_id)
|
||||
invoice = _create_invoice(client, deal["deal_id"], tenant_id)
|
||||
payload = {
|
||||
"external_event_id": "evt-dupe-1",
|
||||
"external_payment_id": "ext-dupe-1",
|
||||
"deal_id": deal["deal_id"],
|
||||
"invoice_id": invoice["invoice_id"],
|
||||
"amount": 150000,
|
||||
"currency": "KZT",
|
||||
"status": "succeeded",
|
||||
}
|
||||
|
||||
first = _post_signed_webhook(client, payload)
|
||||
second = _post_signed_webhook(client, payload)
|
||||
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 200
|
||||
assert second.json()["status"] == "duplicate"
|
||||
session = get_session()
|
||||
try:
|
||||
payments = session.execute(select(SalesPaymentRow).where(SalesPaymentRow.external_payment_id == "ext-dupe-1")).scalars().all()
|
||||
events = session.execute(select(SalesPaymentWebhookEventRow).where(SalesPaymentWebhookEventRow.external_event_id == "evt-dupe-1")).scalars().all()
|
||||
assert len(payments) == 1
|
||||
assert len(events) == 1
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_payment_webhook_same_external_payment_does_not_double_paid_sum():
|
||||
tenant_id = "tenant_payment_duplicate_payment"
|
||||
client = TestClient(sales_module.app)
|
||||
_create_payment_integration(tenant_id=tenant_id)
|
||||
_, deal = _create_lead_and_deal(client, tenant_id)
|
||||
invoice = _create_invoice(client, deal["deal_id"], tenant_id)
|
||||
|
||||
first = _post_signed_webhook(
|
||||
client,
|
||||
{
|
||||
"external_event_id": "evt-payment-1",
|
||||
"external_payment_id": "ext-payment-1",
|
||||
"deal_id": deal["deal_id"],
|
||||
"invoice_id": invoice["invoice_id"],
|
||||
"amount": 150000,
|
||||
"currency": "KZT",
|
||||
"status": "succeeded",
|
||||
},
|
||||
)
|
||||
second = _post_signed_webhook(
|
||||
client,
|
||||
{
|
||||
"external_event_id": "evt-payment-2",
|
||||
"external_payment_id": "ext-payment-1",
|
||||
"deal_id": deal["deal_id"],
|
||||
"invoice_id": invoice["invoice_id"],
|
||||
"amount": 150000,
|
||||
"currency": "KZT",
|
||||
"status": "succeeded",
|
||||
},
|
||||
)
|
||||
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 200
|
||||
session = get_session()
|
||||
try:
|
||||
payments = session.execute(select(SalesPaymentRow).where(SalesPaymentRow.external_payment_id == "ext-payment-1")).scalars().all()
|
||||
stored_invoice = session.execute(select(SalesInvoiceRow).where(SalesInvoiceRow.invoice_id == invoice["invoice_id"])).scalar_one()
|
||||
assert len(payments) == 1
|
||||
assert payments[0].amount == 150000
|
||||
assert stored_invoice.status == "paid"
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_payment_webhook_rejects_currency_mismatch_without_invoice_update():
|
||||
tenant_id = "tenant_payment_currency_mismatch"
|
||||
client = TestClient(sales_module.app)
|
||||
_create_payment_integration(tenant_id=tenant_id)
|
||||
_, deal = _create_lead_and_deal(client, tenant_id)
|
||||
invoice = _create_invoice(client, deal["deal_id"], tenant_id, currency="KZT")
|
||||
|
||||
response = _post_signed_webhook(
|
||||
client,
|
||||
{
|
||||
"external_event_id": "evt-currency-1",
|
||||
"external_payment_id": "ext-currency-1",
|
||||
"deal_id": deal["deal_id"],
|
||||
"invoice_id": invoice["invoice_id"],
|
||||
"amount": 150000,
|
||||
"currency": "USD",
|
||||
"status": "succeeded",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
event = _webhook_event("evt-currency-1")
|
||||
assert event.event_status == "failed"
|
||||
session = get_session()
|
||||
try:
|
||||
stored_invoice = session.execute(select(SalesInvoiceRow).where(SalesInvoiceRow.invoice_id == invoice["invoice_id"])).scalar_one()
|
||||
assert stored_invoice.status == "draft"
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_partial_payment_sets_invoice_partially_paid():
|
||||
tenant_id = "tenant_payment_partial"
|
||||
client = TestClient(sales_module.app)
|
||||
_create_payment_integration(tenant_id=tenant_id)
|
||||
_, deal = _create_lead_and_deal(client, tenant_id)
|
||||
invoice = _create_invoice(client, deal["deal_id"], tenant_id)
|
||||
|
||||
response = _post_signed_webhook(
|
||||
client,
|
||||
{
|
||||
"external_event_id": "evt-partial-1",
|
||||
"external_payment_id": "ext-partial-1",
|
||||
"deal_id": deal["deal_id"],
|
||||
"invoice_id": invoice["invoice_id"],
|
||||
"amount": 50000,
|
||||
"currency": "KZT",
|
||||
"status": "partial",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
session = get_session()
|
||||
try:
|
||||
stored_invoice = session.execute(select(SalesInvoiceRow).where(SalesInvoiceRow.invoice_id == invoice["invoice_id"])).scalar_one()
|
||||
assert stored_invoice.status == "partially_paid"
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_paid_invoice_cancels_payment_related_automation_tasks():
|
||||
tenant_id = "tenant_payment_cancels_tasks"
|
||||
client = TestClient(sales_module.app)
|
||||
_create_payment_integration(tenant_id=tenant_id)
|
||||
_, deal = _create_lead_and_deal(client, tenant_id)
|
||||
invoice = _create_invoice(client, deal["deal_id"], tenant_id)
|
||||
sent = client.post(f"/api/v1/invoices/{invoice['invoice_id']}/send", headers=_headers(tenant_id))
|
||||
assert sent.status_code == 200
|
||||
|
||||
response = _post_signed_webhook(
|
||||
client,
|
||||
{
|
||||
"external_event_id": "evt-cancel-tasks-1",
|
||||
"external_payment_id": "ext-cancel-tasks-1",
|
||||
"deal_id": deal["deal_id"],
|
||||
"invoice_id": invoice["invoice_id"],
|
||||
"amount": 150000,
|
||||
"currency": "KZT",
|
||||
"status": "succeeded",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
session = get_session()
|
||||
try:
|
||||
tasks = session.execute(
|
||||
select(SalesAutomationTaskRow).where(
|
||||
SalesAutomationTaskRow.deal_id == deal["deal_id"],
|
||||
SalesAutomationTaskRow.task_type.in_(["send_invoice_reminder", "mark_invoice_overdue"]),
|
||||
)
|
||||
).scalars().all()
|
||||
assert tasks
|
||||
assert {task.status for task in tasks} == {"canceled"}
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
class _FakePaidAdapter:
|
||||
def get_status(self, external_payment_id: str, *, context: dict | None = None):
|
||||
return {"status": "captured", "metadata": {"reconciled": True}}
|
||||
|
||||
|
||||
def test_reconcile_uses_provider_adapter_and_safe_payment_flow():
|
||||
tenant_id = "tenant_payment_reconcile"
|
||||
client = TestClient(sales_module.app)
|
||||
_create_payment_integration(tenant_id=tenant_id)
|
||||
register_payment_provider_adapter("kaspi", _FakePaidAdapter())
|
||||
_, deal = _create_lead_and_deal(client, tenant_id)
|
||||
invoice = _create_invoice(client, deal["deal_id"], tenant_id)
|
||||
created = client.post(
|
||||
"/api/v1/payments/webhook",
|
||||
json={
|
||||
"deal_id": deal["deal_id"],
|
||||
"invoice_id": invoice["invoice_id"],
|
||||
"payment_provider": "manual",
|
||||
"external_payment_id": "ext-reconcile-1",
|
||||
"amount": 150000,
|
||||
"currency": "KZT",
|
||||
"status": "pending",
|
||||
},
|
||||
headers=_headers(tenant_id),
|
||||
)
|
||||
assert created.status_code == 200
|
||||
payment_id = created.json()["payment_id"]
|
||||
session = get_session()
|
||||
try:
|
||||
payment = session.execute(select(SalesPaymentRow).where(SalesPaymentRow.payment_id == payment_id)).scalar_one()
|
||||
payment.payment_provider = "kaspi"
|
||||
payment.updated_at = utc_now_iso()
|
||||
session.commit()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
response = client.post(f"/api/v1/payments/{payment_id}/reconcile", headers=_headers(tenant_id))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "success"
|
||||
session = get_session()
|
||||
try:
|
||||
stored_invoice = session.execute(select(SalesInvoiceRow).where(SalesInvoiceRow.invoice_id == invoice["invoice_id"])).scalar_one()
|
||||
assert stored_invoice.status == "paid"
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_payment_webhook_publishes_payment_and_invoice_events_once_for_duplicate_payment():
|
||||
tenant_id = "tenant_payment_events_once"
|
||||
client = TestClient(sales_module.app)
|
||||
_create_payment_integration(tenant_id=tenant_id)
|
||||
_, deal = _create_lead_and_deal(client, tenant_id)
|
||||
invoice = _create_invoice(client, deal["deal_id"], tenant_id)
|
||||
|
||||
for index in range(2):
|
||||
response = _post_signed_webhook(
|
||||
client,
|
||||
{
|
||||
"external_event_id": f"evt-events-once-{index}",
|
||||
"external_payment_id": "ext-events-once",
|
||||
"deal_id": deal["deal_id"],
|
||||
"invoice_id": invoice["invoice_id"],
|
||||
"amount": 150000,
|
||||
"currency": "KZT",
|
||||
"status": "succeeded",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
assert _event("payment.received", tenant_id) is not None
|
||||
assert _event("invoice.paid", tenant_id) is not None
|
||||
session = get_session()
|
||||
try:
|
||||
payment_events = []
|
||||
invoice_events = []
|
||||
for row in session.execute(select(EventOutboxRow)).scalars().all():
|
||||
envelope = json.loads(row.payload_json or "{}")
|
||||
payload = envelope.get("payload", {})
|
||||
if payload.get("tenant_id") != tenant_id:
|
||||
continue
|
||||
if row.event_type == "payment.received":
|
||||
payment_events.append(row)
|
||||
if row.event_type == "invoice.paid":
|
||||
invoice_events.append(row)
|
||||
assert len(payment_events) == 1
|
||||
assert len(invoice_events) == 1
|
||||
finally:
|
||||
session.close()
|
||||
@@ -142,7 +142,7 @@ def test_stage_history_uses_real_stage_ids():
|
||||
deal = _create_deal(client, "tenant_stage_history")
|
||||
changed = client.post(
|
||||
f"/api/v1/deals/{deal['deal_id']}/change-stage",
|
||||
json={"stage_id": "offer_sent", "reason": "offer sent"},
|
||||
json={"stage_id": "warm_lead", "reason": "lead warmed"},
|
||||
headers=_headers("tenant_stage_history"),
|
||||
)
|
||||
assert changed.status_code == 200
|
||||
@@ -151,8 +151,8 @@ def test_stage_history_uses_real_stage_ids():
|
||||
|
||||
history = workspace.json()["stage_history"][0]
|
||||
assert history["to_stage_id"].startswith("pst_")
|
||||
assert history["to_stage"]["code"] == "offer_sent"
|
||||
assert history["to_stage_id"] != "offer_sent"
|
||||
assert history["to_stage"]["code"] == "warm_lead"
|
||||
assert history["to_stage_id"] != "warm_lead"
|
||||
|
||||
|
||||
def test_pipeline_list_is_tenant_scoped():
|
||||
|
||||
@@ -189,7 +189,6 @@ def test_sales_inbound_call_bridges_voice_runtime(monkeypatch):
|
||||
assert communication["metadata"]["voice_session_id"] == "avs_sales_case_01"
|
||||
|
||||
|
||||
@pytest.mark.xfail(reason="Known pre-step2 sales workspace contract gap: communications[].channel_provider", strict=False)
|
||||
def test_sales_internal_telegram_sync_auto_creates_workspace():
|
||||
client = TestClient(sales_module.app)
|
||||
|
||||
@@ -224,7 +223,6 @@ def test_sales_internal_telegram_sync_auto_creates_workspace():
|
||||
assert workspace["messages"][0]["external_message_id"] == "ext_tg_auto_01"
|
||||
|
||||
|
||||
@pytest.mark.xfail(reason="Known pre-step2 sales workspace contract gap: workspace.transcripts", strict=False)
|
||||
def test_sales_internal_voice_sync_creates_call_and_transcript():
|
||||
client = TestClient(sales_module.app)
|
||||
|
||||
|
||||
@@ -55,6 +55,22 @@ def _create_lead_and_deal(client: TestClient, tenant_id: str) -> tuple[dict, str
|
||||
return lead, deal_id
|
||||
|
||||
|
||||
def _prepare_invoice_deal(client: TestClient, deal_id: str, tenant_id: str) -> None:
|
||||
updated = client.patch(
|
||||
f"/api/v1/deals/{deal_id}",
|
||||
json={"document_required": False},
|
||||
headers=_headers(tenant_id),
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
for stage_code in ["active_text_communication", "need_confirmed"]:
|
||||
response = client.post(
|
||||
f"/api/v1/deals/{deal_id}/change-stage",
|
||||
json={"target_stage_code": stage_code, "reason": f"test.{stage_code}"},
|
||||
headers=_headers(tenant_id),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def _create_integration(*, tenant_id: str, provider_type: str, provider_name: str, provider_account_id: str) -> None:
|
||||
session = get_session()
|
||||
try:
|
||||
@@ -90,6 +106,7 @@ def test_tenant_cannot_read_foreign_lead_or_deal():
|
||||
def test_tenant_cannot_access_foreign_invoice_or_payment():
|
||||
client = TestClient(sales_module.app)
|
||||
_, deal_b = _create_lead_and_deal(client, "tenant_b")
|
||||
_prepare_invoice_deal(client, deal_b, "tenant_b")
|
||||
|
||||
invoice = client.post(
|
||||
f"/api/v1/deals/{deal_b}/invoices",
|
||||
|
||||
Reference in New Issue
Block a user