From 8577d97356873a6def2dbb4d6b6db32890b16dc9 Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 10 May 2026 20:54:01 +0500 Subject: [PATCH] Implement sales CRM workflow foundation --- ...7_sales_automation_task_locks_postgres.sql | 7 + ...027_sales_automation_task_locks_sqlite.sql | 7 + ..._sales_payment_webhook_events_postgres.sql | 31 + ...28_sales_payment_webhook_events_sqlite.sql | 31 + ...9_sales_omnichannel_lifecycle_postgres.sql | 44 + ...029_sales_omnichannel_lifecycle_sqlite.sql | 44 + services/sales_service/app.py | 2017 +++++++++++++++-- services/sales_service/automation_worker.py | 695 ++++++ services/sales_service/deal_state_machine.py | 648 ++++++ services/sales_service/payment_webhooks.py | 226 ++ services/sales_service/sales_events.py | 27 + services/shared/sales_models.py | 104 +- services/shared/sales_sql_models.py | 76 + services/shared/sql_init.py | 139 ++ tests/test_deal_state_machine.py | 370 +++ tests/test_sales_automation_worker.py | 437 ++++ tests/test_sales_events.py | 33 +- tests/test_sales_omnichannel.py | 383 ++++ tests/test_sales_payment_webhooks.py | 523 +++++ tests/test_sales_pipeline_stages.py | 6 +- tests/test_sales_service.py | 2 - tests/test_sales_tenant_isolation.py | 17 + 22 files changed, 5625 insertions(+), 242 deletions(-) create mode 100644 migrations/sql/0027_sales_automation_task_locks_postgres.sql create mode 100644 migrations/sql/0027_sales_automation_task_locks_sqlite.sql create mode 100644 migrations/sql/0028_sales_payment_webhook_events_postgres.sql create mode 100644 migrations/sql/0028_sales_payment_webhook_events_sqlite.sql create mode 100644 migrations/sql/0029_sales_omnichannel_lifecycle_postgres.sql create mode 100644 migrations/sql/0029_sales_omnichannel_lifecycle_sqlite.sql create mode 100644 services/sales_service/automation_worker.py create mode 100644 services/sales_service/deal_state_machine.py create mode 100644 services/sales_service/payment_webhooks.py create mode 100644 tests/test_deal_state_machine.py create mode 100644 tests/test_sales_automation_worker.py create mode 100644 tests/test_sales_omnichannel.py create mode 100644 tests/test_sales_payment_webhooks.py diff --git a/migrations/sql/0027_sales_automation_task_locks_postgres.sql b/migrations/sql/0027_sales_automation_task_locks_postgres.sql new file mode 100644 index 0000000..70a63a8 --- /dev/null +++ b/migrations/sql/0027_sales_automation_task_locks_postgres.sql @@ -0,0 +1,7 @@ +ALTER TABLE sales_automation_tasks ADD COLUMN IF NOT EXISTS locked_at TEXT; +ALTER TABLE sales_automation_tasks ADD COLUMN IF NOT EXISTS locked_by TEXT; +ALTER TABLE sales_automation_tasks ADD COLUMN IF NOT EXISTS completed_at TEXT; +ALTER TABLE sales_automation_tasks ADD COLUMN IF NOT EXISTS failed_at TEXT; +ALTER TABLE sales_automation_tasks ADD COLUMN IF NOT EXISTS max_retries INTEGER NOT NULL DEFAULT 3; + +CREATE INDEX IF NOT EXISTS idx_sales_tasks_lock ON sales_automation_tasks(status, locked_at, locked_by); diff --git a/migrations/sql/0027_sales_automation_task_locks_sqlite.sql b/migrations/sql/0027_sales_automation_task_locks_sqlite.sql new file mode 100644 index 0000000..f9f9d02 --- /dev/null +++ b/migrations/sql/0027_sales_automation_task_locks_sqlite.sql @@ -0,0 +1,7 @@ +ALTER TABLE sales_automation_tasks ADD COLUMN locked_at TEXT; +ALTER TABLE sales_automation_tasks ADD COLUMN locked_by TEXT; +ALTER TABLE sales_automation_tasks ADD COLUMN completed_at TEXT; +ALTER TABLE sales_automation_tasks ADD COLUMN failed_at TEXT; +ALTER TABLE sales_automation_tasks ADD COLUMN max_retries INTEGER NOT NULL DEFAULT 3; + +CREATE INDEX IF NOT EXISTS idx_sales_tasks_lock ON sales_automation_tasks(status, locked_at, locked_by); diff --git a/migrations/sql/0028_sales_payment_webhook_events_postgres.sql b/migrations/sql/0028_sales_payment_webhook_events_postgres.sql new file mode 100644 index 0000000..b955f9f --- /dev/null +++ b/migrations/sql/0028_sales_payment_webhook_events_postgres.sql @@ -0,0 +1,31 @@ +CREATE TABLE IF NOT EXISTS sales_payment_webhook_events ( + id SERIAL PRIMARY KEY, + tenant_id TEXT NOT NULL, + payment_provider TEXT NOT NULL, + provider_account_id TEXT, + external_event_id TEXT, + external_payment_id TEXT, + event_type TEXT NOT NULL, + event_status TEXT NOT NULL DEFAULT 'received', + signature_status TEXT NOT NULL DEFAULT 'unchecked', + raw_payload_hash TEXT NOT NULL, + normalized_payload_json TEXT NOT NULL DEFAULT '{}', + headers_json TEXT NOT NULL DEFAULT '{}', + received_at TEXT NOT NULL, + processed_at TEXT, + error_message TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_sales_payment_webhook_events_received + ON sales_payment_webhook_events(tenant_id, received_at); +CREATE INDEX IF NOT EXISTS idx_sales_payment_webhook_events_payment + ON sales_payment_webhook_events(tenant_id, payment_provider, external_payment_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_sales_payment_webhook_events_external_event_unique + ON sales_payment_webhook_events(tenant_id, payment_provider, external_event_id) + WHERE external_event_id IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_sales_payments_tenant_external_payment_unique + ON sales_payments(tenant_id, payment_provider, external_payment_id) + WHERE external_payment_id IS NOT NULL; diff --git a/migrations/sql/0028_sales_payment_webhook_events_sqlite.sql b/migrations/sql/0028_sales_payment_webhook_events_sqlite.sql new file mode 100644 index 0000000..68b13a6 --- /dev/null +++ b/migrations/sql/0028_sales_payment_webhook_events_sqlite.sql @@ -0,0 +1,31 @@ +CREATE TABLE IF NOT EXISTS sales_payment_webhook_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id TEXT NOT NULL, + payment_provider TEXT NOT NULL, + provider_account_id TEXT, + external_event_id TEXT, + external_payment_id TEXT, + event_type TEXT NOT NULL, + event_status TEXT NOT NULL DEFAULT 'received', + signature_status TEXT NOT NULL DEFAULT 'unchecked', + raw_payload_hash TEXT NOT NULL, + normalized_payload_json TEXT NOT NULL DEFAULT '{}', + headers_json TEXT NOT NULL DEFAULT '{}', + received_at TEXT NOT NULL, + processed_at TEXT, + error_message TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_sales_payment_webhook_events_received + ON sales_payment_webhook_events(tenant_id, received_at); +CREATE INDEX IF NOT EXISTS idx_sales_payment_webhook_events_payment + ON sales_payment_webhook_events(tenant_id, payment_provider, external_payment_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_sales_payment_webhook_events_external_event_unique + ON sales_payment_webhook_events(tenant_id, payment_provider, external_event_id) + WHERE external_event_id IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_sales_payments_tenant_external_payment_unique + ON sales_payments(tenant_id, payment_provider, external_payment_id) + WHERE external_payment_id IS NOT NULL; diff --git a/migrations/sql/0029_sales_omnichannel_lifecycle_postgres.sql b/migrations/sql/0029_sales_omnichannel_lifecycle_postgres.sql new file mode 100644 index 0000000..9f1538f --- /dev/null +++ b/migrations/sql/0029_sales_omnichannel_lifecycle_postgres.sql @@ -0,0 +1,44 @@ +ALTER TABLE sales_communication_sessions ADD COLUMN IF NOT EXISTS channel_provider TEXT; +CREATE INDEX IF NOT EXISTS idx_sales_comm_channel_provider ON sales_communication_sessions(channel_provider); + +ALTER TABLE sales_notes ADD COLUMN IF NOT EXISTS source_type TEXT; +ALTER TABLE sales_notes ADD COLUMN IF NOT EXISTS source_id TEXT; +ALTER TABLE sales_notes ADD COLUMN IF NOT EXISTS updated_at TEXT; +ALTER TABLE sales_notes ADD COLUMN IF NOT EXISTS archived_at TEXT; +UPDATE sales_notes +SET updated_at = created_at +WHERE updated_at IS NULL OR TRIM(updated_at) = ''; + +ALTER TABLE sales_escalations ADD COLUMN IF NOT EXISTS assigned_at TEXT; +ALTER TABLE sales_escalations ADD COLUMN IF NOT EXISTS started_at TEXT; +ALTER TABLE sales_escalations ADD COLUMN IF NOT EXISTS canceled_at TEXT; +ALTER TABLE sales_escalations ADD COLUMN IF NOT EXISTS resolution_code TEXT; +ALTER TABLE sales_escalations ADD COLUMN IF NOT EXISTS resolution_summary TEXT; +ALTER TABLE sales_escalations ADD COLUMN IF NOT EXISTS sla_due_at TEXT; +ALTER TABLE sales_escalations ADD COLUMN IF NOT EXISTS source_channel TEXT; +ALTER TABLE sales_escalations ADD COLUMN IF NOT EXISTS source_communication_session_id TEXT; +ALTER TABLE sales_escalations ADD COLUMN IF NOT EXISTS source_automation_task_id TEXT; +ALTER TABLE sales_escalations ADD COLUMN IF NOT EXISTS updated_at TEXT; +UPDATE sales_escalations +SET updated_at = COALESCE(updated_at, resolved_at, created_at) +WHERE updated_at IS NULL OR TRIM(updated_at) = ''; + +ALTER TABLE sales_channel_switches ADD COLUMN IF NOT EXISTS communication_session_id TEXT; +ALTER TABLE sales_channel_switches ADD COLUMN IF NOT EXISTS previous_communication_session_id TEXT; +ALTER TABLE sales_channel_switches ADD COLUMN IF NOT EXISTS new_communication_session_id TEXT; +ALTER TABLE sales_channel_switches ADD COLUMN IF NOT EXISTS reason_code TEXT DEFAULT 'human_decision'; +ALTER TABLE sales_channel_switches ADD COLUMN IF NOT EXISTS reason_text TEXT; +ALTER TABLE sales_channel_switches ADD COLUMN IF NOT EXISTS initiated_by_type TEXT DEFAULT 'human'; +ALTER TABLE sales_channel_switches ADD COLUMN IF NOT EXISTS initiated_by_id TEXT; +ALTER TABLE sales_channel_switches ADD COLUMN IF NOT EXISTS source_type TEXT; +ALTER TABLE sales_channel_switches ADD COLUMN IF NOT EXISTS source_id TEXT; +ALTER TABLE sales_channel_switches ADD COLUMN IF NOT EXISTS recommended_by_task_id TEXT; +ALTER TABLE sales_channel_switches ADD COLUMN IF NOT EXISTS created_at TEXT; +UPDATE sales_channel_switches +SET communication_session_id = COALESCE(communication_session_id, communication_id), + previous_communication_session_id = COALESCE(previous_communication_session_id, communication_id), + reason_code = COALESCE(reason_code, 'human_decision'), + reason_text = COALESCE(reason_text, reason_for_channel_switch), + initiated_by_type = COALESCE(initiated_by_type, 'human'), + created_at = COALESCE(created_at, switched_at) +WHERE created_at IS NULL OR TRIM(created_at) = ''; diff --git a/migrations/sql/0029_sales_omnichannel_lifecycle_sqlite.sql b/migrations/sql/0029_sales_omnichannel_lifecycle_sqlite.sql new file mode 100644 index 0000000..c241c28 --- /dev/null +++ b/migrations/sql/0029_sales_omnichannel_lifecycle_sqlite.sql @@ -0,0 +1,44 @@ +ALTER TABLE sales_communication_sessions ADD COLUMN channel_provider TEXT; +CREATE INDEX IF NOT EXISTS idx_sales_comm_channel_provider ON sales_communication_sessions(channel_provider); + +ALTER TABLE sales_notes ADD COLUMN source_type TEXT; +ALTER TABLE sales_notes ADD COLUMN source_id TEXT; +ALTER TABLE sales_notes ADD COLUMN updated_at TEXT; +ALTER TABLE sales_notes ADD COLUMN archived_at TEXT; +UPDATE sales_notes +SET updated_at = created_at +WHERE updated_at IS NULL OR TRIM(updated_at) = ''; + +ALTER TABLE sales_escalations ADD COLUMN assigned_at TEXT; +ALTER TABLE sales_escalations ADD COLUMN started_at TEXT; +ALTER TABLE sales_escalations ADD COLUMN canceled_at TEXT; +ALTER TABLE sales_escalations ADD COLUMN resolution_code TEXT; +ALTER TABLE sales_escalations ADD COLUMN resolution_summary TEXT; +ALTER TABLE sales_escalations ADD COLUMN sla_due_at TEXT; +ALTER TABLE sales_escalations ADD COLUMN source_channel TEXT; +ALTER TABLE sales_escalations ADD COLUMN source_communication_session_id TEXT; +ALTER TABLE sales_escalations ADD COLUMN source_automation_task_id TEXT; +ALTER TABLE sales_escalations ADD COLUMN updated_at TEXT; +UPDATE sales_escalations +SET updated_at = COALESCE(updated_at, resolved_at, created_at) +WHERE updated_at IS NULL OR TRIM(updated_at) = ''; + +ALTER TABLE sales_channel_switches ADD COLUMN communication_session_id TEXT; +ALTER TABLE sales_channel_switches ADD COLUMN previous_communication_session_id TEXT; +ALTER TABLE sales_channel_switches ADD COLUMN new_communication_session_id TEXT; +ALTER TABLE sales_channel_switches ADD COLUMN reason_code TEXT DEFAULT 'human_decision'; +ALTER TABLE sales_channel_switches ADD COLUMN reason_text TEXT; +ALTER TABLE sales_channel_switches ADD COLUMN initiated_by_type TEXT DEFAULT 'human'; +ALTER TABLE sales_channel_switches ADD COLUMN initiated_by_id TEXT; +ALTER TABLE sales_channel_switches ADD COLUMN source_type TEXT; +ALTER TABLE sales_channel_switches ADD COLUMN source_id TEXT; +ALTER TABLE sales_channel_switches ADD COLUMN recommended_by_task_id TEXT; +ALTER TABLE sales_channel_switches ADD COLUMN created_at TEXT; +UPDATE sales_channel_switches +SET communication_session_id = COALESCE(communication_session_id, communication_id), + previous_communication_session_id = COALESCE(previous_communication_session_id, communication_id), + reason_code = COALESCE(reason_code, 'human_decision'), + reason_text = COALESCE(reason_text, reason_for_channel_switch), + initiated_by_type = COALESCE(initiated_by_type, 'human'), + created_at = COALESCE(created_at, switched_at) +WHERE created_at IS NULL OR TRIM(created_at) = ''; diff --git a/services/sales_service/app.py b/services/sales_service/app.py index 085b341..a84f4df 100644 --- a/services/sales_service/app.py +++ b/services/sales_service/app.py @@ -2,11 +2,12 @@ from __future__ import annotations import json import os -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from typing import Any import httpx -from fastapi import Depends, FastAPI, Header, HTTPException, Query +from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request +from fastapi.responses import JSONResponse from sqlalchemy import func, or_, select from sqlalchemy.exc import IntegrityError @@ -40,7 +41,10 @@ from services.shared.sales_models import ( SalesDocumentCreate, SalesDocumentOut, SalesEscalationCreateIn, + SalesEscalationAssignIn, + SalesEscalationCancelIn, SalesEscalationOut, + SalesEscalationResolveIn, SalesInvoiceCreate, SalesInvoiceOut, SalesLeadCreate, @@ -50,6 +54,9 @@ from services.shared.sales_models import ( SalesMessageOut, SalesMessageSendIn, SalesMessageWebhookIn, + SalesNoteCreateIn, + SalesNoteOut, + SalesNoteUpdateIn, SalesOfferCreate, SalesOfferOut, SalesPaymentOut, @@ -87,8 +94,10 @@ from services.shared.sales_sql_models import ( SalesInvoiceRow, SalesLeadRow, SalesMessageRow, + SalesNoteRow, SalesOfferRow, SalesPaymentRow, + SalesPaymentWebhookEventRow, SalesPipelineRow, SalesPipelineStageRow, SalesStageHistoryRow, @@ -97,13 +106,35 @@ from services.shared.sales_sql_models import ( TenantSalesSettingsRow, ) from services.sales_service import sales_events as sales_event_types +from services.sales_service.deal_state_machine import DealStateMachineError, transition_deal_stage from services.sales_service.event_publisher import SalesEventPublisher +from services.sales_service.payment_webhooks import ( + PaymentWebhookVerificationError, + extract_event_type, + extract_external_event_id, + extract_external_payment_id, + extract_payment_provider, + extract_provider_account_id, + get_payment_provider_adapter, + header_value, + normalize_headers, + normalize_payment_event_type, + normalize_payment_status, + raw_payload_hash, + safe_json_loads, + verify_payment_webhook_signature, +) # Import the models module so sales tables are registered on the shared Base metadata. from services.shared import sales_sql_models as _sales_sql_models # noqa: F401 app = FastAPI(title="sales-service", version="1.0.0") + +@app.exception_handler(DealStateMachineError) +async def _deal_state_machine_error_handler(_request, exc: DealStateMachineError) -> JSONResponse: + return JSONResponse(status_code=exc.status_code, content=exc.response()) + init_sql_schema() DEFAULT_SALES_CURRENCY = os.getenv("DEFAULT_SALES_CURRENCY", "KZT").strip() or "KZT" @@ -148,7 +179,7 @@ DEFAULT_PIPELINE_STAGES = [ {"code": "postponed", "name": "Отложено", "category": "closing", "sort_order": 280, "is_terminal": True}, {"code": "follow_up_scheduled", "name": "Запланирован follow-up", "category": "closing", "sort_order": 290}, {"code": "transferred_to_execution", "name": "Передано в исполнение", "category": "closing", "sort_order": 300, "is_terminal": True}, - {"code": "transferred_to_support", "name": "Передано человеку", "category": "closing", "sort_order": 310, "is_terminal": True}, + {"code": "transferred_to_support", "name": "Передано человеку", "category": "closing", "sort_order": 310}, ] @@ -702,6 +733,10 @@ def _deal_to_out( def _communication_to_out(row: SalesCommunicationSessionRow) -> SalesCommunicationOut: + metadata = _json_dict(row.metadata_json) + channel_provider = row.channel_provider or metadata.get("channel_provider") + if not channel_provider: + channel_provider = "voice" if row.channel_type == "voice" else None return SalesCommunicationOut( communication_id=row.communication_id, tenant_id=row.tenant_id, @@ -709,6 +744,7 @@ def _communication_to_out(row: SalesCommunicationSessionRow) -> SalesCommunicati lead_id=row.lead_id, customer_id=row.customer_id, channel_type=row.channel_type, # type: ignore[arg-type] + channel_provider=channel_provider, direction=row.direction, # type: ignore[arg-type] agent_type=row.agent_type, # type: ignore[arg-type] started_at=row.started_at, @@ -722,7 +758,7 @@ def _communication_to_out(row: SalesCommunicationSessionRow) -> SalesCommunicati next_action_at=row.next_action_at, sentiment=row.sentiment, result_code=row.result_code, - metadata=_json_dict(row.metadata_json), + metadata=metadata, created_at=row.created_at, updated_at=row.updated_at, ) @@ -912,8 +948,18 @@ def _escalation_to_out(row: SalesEscalationRow) -> SalesEscalationOut: severity=row.severity, # type: ignore[arg-type] status=row.status, # type: ignore[arg-type] assigned_to_user_id=row.assigned_to_user_id, + assigned_at=row.assigned_at, + started_at=row.started_at, created_at=row.created_at, resolved_at=row.resolved_at, + canceled_at=row.canceled_at, + resolution_code=row.resolution_code, + resolution_summary=row.resolution_summary, + sla_due_at=row.sla_due_at, + source_channel=row.source_channel, + source_communication_session_id=row.source_communication_session_id, + source_automation_task_id=row.source_automation_task_id, + updated_at=row.updated_at, ) @@ -926,6 +972,11 @@ def _task_to_out(row: SalesAutomationTaskRow) -> SalesAutomationTaskOut: run_at=row.run_at, status=row.status, # type: ignore[arg-type] retry_count=row.retry_count, + max_retries=row.max_retries, + locked_at=row.locked_at, + locked_by=row.locked_by, + completed_at=row.completed_at, + failed_at=row.failed_at, last_error=row.last_error, created_at=row.created_at, updated_at=row.updated_at, @@ -953,10 +1004,47 @@ def _channel_switch_to_out(row: SalesChannelSwitchRow) -> SalesChannelSwitchOut: switch_id=row.switch_id, deal_id=row.deal_id, communication_id=row.communication_id, + communication_session_id=row.communication_session_id, + previous_communication_session_id=row.previous_communication_session_id, + new_communication_session_id=row.new_communication_session_id, from_channel=row.from_channel, to_channel=row.to_channel, + reason_code=row.reason_code, + reason_text=row.reason_text, reason_for_channel_switch=row.reason_for_channel_switch, + initiated_by_type=row.initiated_by_type, + initiated_by_id=row.initiated_by_id, + source_type=row.source_type, + source_id=row.source_id, + recommended_by_task_id=row.recommended_by_task_id, switched_at=row.switched_at, + created_at=row.created_at, + ) + + +def _channel_switch_to_out_with_session( + row: SalesChannelSwitchRow, + new_communication: SalesCommunicationSessionRow | None = None, +) -> SalesChannelSwitchOut: + out = _channel_switch_to_out(row) + if new_communication is not None: + out.new_communication = _communication_to_out(new_communication) + return out + + +def _note_to_out(row: SalesNoteRow) -> SalesNoteOut: + return SalesNoteOut( + note_id=row.note_id, + deal_id=row.deal_id, + author_type=row.author_type, + author_id=row.author_id, + note_type=row.note_type, + content=row.content, + source_type=row.source_type, + source_id=row.source_id, + created_at=row.created_at, + updated_at=row.updated_at, + archived_at=row.archived_at, ) @@ -1338,6 +1426,11 @@ def _schedule_task(session, *, deal: SalesDealRow, task_type: str, run_at: str | run_at=run_at or now, status="pending", retry_count=0, + max_retries=3, + locked_at=None, + locked_by=None, + completed_at=None, + failed_at=None, last_error=None, created_at=now, updated_at=now, @@ -1346,58 +1439,665 @@ def _schedule_task(session, *, deal: SalesDealRow, task_type: str, run_at: str | return task -def apply_stage_by_id(session, deal: SalesDealRow, stage_id: str, *, actor_type: str, actor_id: str | None, reason: str | None = None) -> SalesPipelineStageRow: +def _automation_run_at_after(*, minutes: int = 0, days: int = 0) -> str: + return (_now() + timedelta(days=days, minutes=minutes)).isoformat() + + +def _parse_due_date(value: str | None) -> datetime | None: + raw = str(value or "").strip() + if not raw: + return None + try: + parsed = datetime.fromisoformat(raw.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc).replace(microsecond=0) + + +def _invoice_reminder_run_at(due_date: str | None) -> str: + parsed = _parse_due_date(due_date) + if parsed is None: + return _automation_run_at_after(days=1) + return _iso_at_or_now(parsed - timedelta(days=1)) + + +def _invoice_overdue_run_at(due_date: str | None) -> str: + parsed = _parse_due_date(due_date) + if parsed is None: + return _automation_run_at_after(days=1) + return _iso_at_or_now(parsed + timedelta(days=1)) + + +def _iso_at_or_now(value: datetime) -> str: + now = _now() + return (value if value > now else now).isoformat() + + +def _schedule_unique_task( + session, + *, + deal: SalesDealRow, + task_type: str, + run_at: str | None, + payload: dict | None = None, + match_payload: dict | None = None, +) -> SalesAutomationTaskRow: + payload_data = payload or {} + match = match_payload or payload_data + rows = session.execute( + select(SalesAutomationTaskRow) + .where( + SalesAutomationTaskRow.tenant_id == deal.tenant_id, + SalesAutomationTaskRow.deal_id == deal.deal_id, + SalesAutomationTaskRow.task_type == task_type, + SalesAutomationTaskRow.status.in_(["pending", "running"]), + ) + .order_by(SalesAutomationTaskRow.id.desc()) + ).scalars().all() + for row in rows: + existing_payload = _json_dict(row.payload_json) + if all(existing_payload.get(key) == value for key, value in match.items()): + return row + return _schedule_task(session, deal=deal, task_type=task_type, run_at=run_at, payload=payload_data) + + +def _cancel_pending_automation_tasks( + session, + *, + deal: SalesDealRow, + task_types: set[str], + reason: str, +) -> None: + now = utc_now_iso() + rows = session.execute( + select(SalesAutomationTaskRow).where( + SalesAutomationTaskRow.tenant_id == deal.tenant_id, + SalesAutomationTaskRow.deal_id == deal.deal_id, + SalesAutomationTaskRow.task_type.in_(task_types), + SalesAutomationTaskRow.status == "pending", + ) + ).scalars().all() + for row in rows: + payload = _json_dict(row.payload_json) + payload["canceled_reason"] = reason + row.payload_json = json.dumps(payload, ensure_ascii=False) + row.status = "canceled" + row.updated_at = now + + +def apply_stage_by_id( + session, + deal: SalesDealRow, + stage_id: str, + *, + actor_type: str, + actor_id: str | None, + reason: str | None = None, + metadata: dict | None = None, + force: bool = False, +) -> SalesPipelineStageRow: stage = _get_stage(session, stage_id, deal.tenant_id, deal.pipeline_id) - if not stage.is_active: - raise HTTPException(status_code=400, detail="Stage is inactive") - if deal.stage_id != stage.stage_id: - previous = deal.stage_id - previous_stage = _stage_by_id_or_none(session, deal.tenant_id, previous) - deal.stage_id = stage.stage_id - _record_stage_change( - session, - deal=deal, - from_stage_id=previous, - to_stage_id=stage.stage_id, - changed_by_type=actor_type, - changed_by_id=actor_id, - reason=reason, - ) - _publish_sales_event( - session, - tenant_id=deal.tenant_id, - event_type=sales_event_types.DEAL_STAGE_CHANGED, - aggregate_type="deal", - aggregate_id=deal.deal_id, - actor_type=actor_type, - actor_id=actor_id, - payload={ - "deal_id": deal.deal_id, - "pipeline_id": deal.pipeline_id, - "from_stage_id": previous, - "from_stage_code": previous_stage.code if previous_stage else None, - "to_stage_id": stage.stage_id, - "to_stage_code": stage.code, - "reason": reason, - }, - ) - deal.updated_at = utc_now_iso() - return stage + return transition_deal_stage( + session, + tenant_id=deal.tenant_id, + deal_id=deal.deal_id, + target_stage_code=stage.code, + actor_type=actor_type, + actor_id=actor_id, + reason=reason, + metadata=metadata, + force=force, + ) -def apply_stage_by_code(session, deal: SalesDealRow, stage_code: str, *, actor_type: str, actor_id: str | None, reason: str | None = None) -> SalesPipelineStageRow: +def apply_stage_by_code( + session, + deal: SalesDealRow, + stage_code: str, + *, + actor_type: str, + actor_id: str | None, + reason: str | None = None, + metadata: dict | None = None, + force: bool = False, +) -> SalesPipelineStageRow: ensure_default_pipeline(session, deal.tenant_id) - stage = resolve_stage_by_code_or_id( + resolve_stage_by_code_or_id( session, tenant_id=deal.tenant_id, pipeline_id=deal.pipeline_id, stage_id=stage_code, ) - return apply_stage_by_id(session, deal, stage.stage_id, actor_type=actor_type, actor_id=actor_id, reason=reason) + return transition_deal_stage( + session, + tenant_id=deal.tenant_id, + deal_id=deal.deal_id, + target_stage_code=stage_code, + actor_type=actor_type, + actor_id=actor_id, + reason=reason, + metadata=metadata, + force=force, + ) -def _apply_stage(session, deal: SalesDealRow, stage_code: str, *, actor_type: str, actor_id: str | None, reason: str | None = None) -> None: - apply_stage_by_code(session, deal, stage_code, actor_type=actor_type, actor_id=actor_id, reason=reason) +def _apply_stage( + session, + deal: SalesDealRow, + stage_code: str, + *, + actor_type: str, + actor_id: str | None, + reason: str | None = None, + metadata: dict | None = None, + force: bool = False, +) -> None: + apply_stage_by_code( + session, + deal, + stage_code, + actor_type=actor_type, + actor_id=actor_id, + reason=reason, + metadata=metadata, + force=force, + ) + + +def _resolve_stage_change_target_code(session, deal: SalesDealRow, payload: SalesDealStageChangeIn) -> str: + target_stage_code = str(payload.target_stage_code or payload.stage_code or "").strip() or None + target_stage_id = str(payload.target_stage_id or payload.stage_id or "").strip() or None + if not target_stage_code and not target_stage_id: + raise DealStateMachineError( + status_code=400, + error="stage_required", + message="target_stage_code or target_stage_id is required", + details={"deal_id": deal.deal_id}, + ) + if not target_stage_id: + return target_stage_code or "" + + stage = session.execute( + select(SalesPipelineStageRow).where(SalesPipelineStageRow.stage_id == target_stage_id) + ).scalar_one_or_none() + if stage is None: + # Backward compatibility: old clients used stage_id for stable stage codes. + return target_stage_code or target_stage_id + if stage.tenant_id != deal.tenant_id: + raise DealStateMachineError( + status_code=403, + error="cross_tenant_stage_forbidden", + message="Stage belongs to another tenant", + details={"stage_id": stage.stage_id, "deal_id": deal.deal_id}, + ) + if stage.pipeline_id != deal.pipeline_id: + raise DealStateMachineError( + status_code=400, + error="invalid_stage_pipeline", + message="Stage does not belong to deal pipeline", + details={ + "stage_id": stage.stage_id, + "stage_pipeline_id": stage.pipeline_id, + "deal_pipeline_id": deal.pipeline_id, + }, + ) + if target_stage_code and _normalize_stage_code(target_stage_code) != stage.code: + raise DealStateMachineError( + status_code=400, + error="stage_target_mismatch", + message="target_stage_id and target_stage_code point to different stages", + details={"stage_id": stage.stage_id, "stage_code": stage.code, "target_stage_code": target_stage_code}, + ) + return stage.code + + +def _get_deal_for_state_change(session, deal_id: str, tenant_id: str) -> SalesDealRow: + try: + return _get_deal(session, deal_id, tenant_id) + except HTTPException as exc: + if exc.status_code == 404: + raise DealStateMachineError( + status_code=404, + error="deal_not_found", + message="Deal not found", + details={"deal_id": deal_id}, + ) from exc + raise + + +def _apply_payment_stage(session, deal: SalesDealRow, stage_code: str, *, reason: str, metadata: dict) -> None: + _, current_stage = _deal_pipeline_stage(session, deal) + if current_stage is not None and current_stage.code == stage_code: + return + try: + _apply_stage( + session, + deal, + stage_code, + actor_type="system", + actor_id=None, + reason=reason, + metadata=metadata, + ) + except DealStateMachineError as exc: + if exc.error != "invalid_stage_transition": + raise + _apply_stage( + session, + deal, + stage_code, + actor_type="system", + actor_id=None, + reason=reason, + metadata={**metadata, "force_reason": "verified_payment_webhook"}, + force=True, + ) + + +def _payment_to_dict(row: SalesPaymentRow) -> dict[str, Any]: + payload = _payment_to_out(row) + if hasattr(payload, "model_dump"): + return payload.model_dump() + return payload.dict() + + +def _integration_settings(integration: TenantIntegrationRow | None) -> dict: + return _json_dict(integration.settings_json if integration is not None else "{}") + + +def _payment_webhook_secret(integration: TenantIntegrationRow | None) -> str | None: + settings = _integration_settings(integration) + for key in ("webhook_secret", "payment_webhook_secret", "secret"): + value = str(settings.get(key) or "").strip() + if value: + return value + return None + + +def _payment_replay_window_seconds(integration: TenantIntegrationRow | None) -> int: + settings = _integration_settings(integration) + try: + value = int(settings.get("webhook_replay_window_seconds") or settings.get("replay_window_seconds") or 600) + except (TypeError, ValueError): + value = 600 + return max(value, 60) + + +def _payment_allows_overpayment(integration: TenantIntegrationRow | None, metadata: dict | None) -> bool: + settings = _integration_settings(integration) + raw = (metadata or {}).get("allow_overpayment", settings.get("allow_overpayment", False)) + if isinstance(raw, bool): + return raw + return str(raw or "").strip().lower() in {"1", "true", "yes", "on"} + + +def _resolve_payment_integration( + session, + actor: dict, + *, + provider: str, + provider_account_id: str | None, + external_identifier: str | None = None, +) -> tuple[str, TenantIntegrationRow | None]: + normalized_provider = str(provider or "").strip().lower() or "manual" + normalized_account = str(provider_account_id or "").strip() + normalized_external = str(external_identifier or "").strip() + + base_stmt = select(TenantIntegrationRow).where( + func.lower(TenantIntegrationRow.provider_type) == "payment", + func.lower(TenantIntegrationRow.provider_name) == normalized_provider, + TenantIntegrationRow.is_active == True, # noqa: E712 + ) + if normalized_account: + row = session.execute( + base_stmt.where(TenantIntegrationRow.provider_account_id == normalized_account).order_by(TenantIntegrationRow.id.desc()) + ).scalars().first() + if row is not None: + return row.tenant_id, row + if normalized_external: + row = session.execute( + base_stmt.where(TenantIntegrationRow.external_identifier == normalized_external).order_by(TenantIntegrationRow.id.desc()) + ).scalars().first() + if row is not None: + return row.tenant_id, row + + actor_tenant_id = _optional_tenant_id(actor) + if normalized_provider == "manual" and actor_tenant_id: + return actor_tenant_id, None + + raise HTTPException(status_code=403, detail="Payment provider integration not found") + + +def _payment_webhook_event_duplicate_response(session, event: SalesPaymentWebhookEventRow) -> dict[str, Any]: + payment = None + if event.external_payment_id: + payment = session.execute( + select(SalesPaymentRow).where( + SalesPaymentRow.tenant_id == event.tenant_id, + SalesPaymentRow.payment_provider == event.payment_provider, + SalesPaymentRow.external_payment_id == event.external_payment_id, + ) + ).scalar_one_or_none() + response = { + "status": "duplicate", + "event_status": event.event_status, + "payment_id": payment.payment_id if payment is not None else None, + "invoice_id": payment.invoice_id if payment is not None else None, + "external_event_id": event.external_event_id, + "external_payment_id": event.external_payment_id, + } + if event.normalized_payload_json: + normalized = _json_dict(event.normalized_payload_json) + result = normalized.get("result") if isinstance(normalized.get("result"), dict) else {} + response["payment_id"] = response["payment_id"] or result.get("payment_id") + response["invoice_id"] = response["invoice_id"] or result.get("invoice_id") + return response + + +def _find_existing_webhook_event( + session, + *, + tenant_id: str, + provider: str, + external_event_id: str | None, +) -> SalesPaymentWebhookEventRow | None: + if not external_event_id: + return None + return session.execute( + select(SalesPaymentWebhookEventRow).where( + SalesPaymentWebhookEventRow.tenant_id == tenant_id, + SalesPaymentWebhookEventRow.payment_provider == provider, + SalesPaymentWebhookEventRow.external_event_id == external_event_id, + ) + ).scalar_one_or_none() + + +def _mark_payment_webhook_event_failed(event: SalesPaymentWebhookEventRow, detail: Any) -> None: + event.event_status = "failed" + event.error_message = str(detail) + event.updated_at = utc_now_iso() + + +def _payment_metadata(payload: dict) -> dict: + metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {} + return dict(metadata) + + +def _payload_value(payload: dict, *keys: str) -> Any: + sources: list[dict] = [payload] + metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {} + sources.append(metadata) + payment = payload.get("payment") if isinstance(payload.get("payment"), dict) else {} + sources.append(payment) + data_object = payload.get("data", {}).get("object") if isinstance(payload.get("data"), dict) else {} + if isinstance(data_object, dict): + sources.append(data_object) + for source in sources: + for key in keys: + value = source.get(key) + if value is not None and str(value).strip() != "": + return value + return None + + +def _normalized_payment_payload(payload: dict, headers: dict[str, str]) -> dict[str, Any]: + provider = extract_payment_provider(payload, headers) + provider_status = _payload_value(payload, "provider_status", "status", "payment_status") + event_type = extract_event_type(payload, headers) + normalized_status = normalize_payment_status(provider, str(provider_status or "")) + normalized_event_type = normalize_payment_event_type(provider, str(provider_status or ""), event_type) + return { + "deal_id": _payload_value(payload, "deal_id"), + "invoice_id": _payload_value(payload, "invoice_id"), + "payment_provider": provider, + "provider_account_id": extract_provider_account_id(payload, headers), + "external_event_id": extract_external_event_id(payload, headers), + "external_payment_id": extract_external_payment_id(payload, headers), + "event_type": normalized_event_type, + "provider_event_type": event_type, + "provider_status": provider_status, + "status": normalized_status, + "amount": _payload_value(payload, "amount", "paid_amount"), + "currency": str(_payload_value(payload, "currency") or "KZT").upper(), + "paid_at": _payload_value(payload, "paid_at", "captured_at", "succeeded_at"), + "payment_method": _payload_value(payload, "payment_method", "method"), + "failure_reason": _payload_value(payload, "failure_reason", "error_message", "decline_reason"), + "metadata": _payment_metadata(payload), + } + + +def _coerce_payment_amount(value: Any) -> float: + try: + return float(value) + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=400, detail="Invalid payment amount") from exc + + +def _paid_sum_for_invoice(session, *, tenant_id: str, invoice_id: str) -> float: + value = session.execute( + select(func.coalesce(func.sum(SalesPaymentRow.amount), 0.0)).where( + SalesPaymentRow.tenant_id == tenant_id, + SalesPaymentRow.invoice_id == invoice_id, + SalesPaymentRow.status.in_(["success", "partial"]), + ) + ).scalar_one() + return float(value or 0.0) + + +def _cancel_paid_invoice_tasks(session, *, deal: SalesDealRow, invoice_id: str) -> None: + now = utc_now_iso() + rows = session.execute( + select(SalesAutomationTaskRow).where( + SalesAutomationTaskRow.tenant_id == deal.tenant_id, + SalesAutomationTaskRow.deal_id == deal.deal_id, + SalesAutomationTaskRow.task_type.in_({"send_invoice_reminder", "mark_invoice_overdue", "follow_up_customer"}), + SalesAutomationTaskRow.status == "pending", + ) + ).scalars().all() + for row in rows: + payload = _json_dict(row.payload_json) + if row.task_type != "follow_up_customer" or payload.get("invoice_id") == invoice_id or payload.get("reason") == "invoice_payment": + payload["canceled_reason"] = "invoice_paid" + row.payload_json = json.dumps(payload, ensure_ascii=False) + row.status = "canceled" + row.updated_at = now + + +def _publish_payment_received(session, *, tenant_id: str, deal: SalesDealRow, payment: SalesPaymentRow) -> None: + _publish_sales_event( + session, + tenant_id=tenant_id, + event_type=sales_event_types.PAYMENT_RECEIVED, + aggregate_type="payment", + aggregate_id=payment.payment_id, + actor_type="system", + actor_id=None, + payload={ + "deal_id": deal.deal_id, + "invoice_id": payment.invoice_id, + "payment_id": payment.payment_id, + "payment_provider": payment.payment_provider, + "external_payment_id": payment.external_payment_id, + "amount": payment.amount, + "currency": payment.currency, + "status": payment.status, + }, + ) + + +def _apply_payment_update( + session, + *, + tenant_id: str, + deal: SalesDealRow, + invoice: SalesInvoiceRow | None, + payment_provider: str, + external_payment_id: str | None, + amount: Any, + currency: str, + status: str, + paid_at: str | None, + payment_method: str | None, + failure_reason: str | None, + metadata: dict, + integration: TenantIntegrationRow | None = None, + existing_payment: SalesPaymentRow | None = None, +) -> SalesPaymentRow: + now = utc_now_iso() + normalized_status = normalize_payment_status(payment_provider, status) + if amount is None and normalized_status not in {"success", "partial"}: + amount_value = 0.0 + else: + amount_value = _coerce_payment_amount(amount) + normalized_currency = str(currency or "KZT").strip().upper() + if normalized_status in {"success", "partial"}: + if amount_value <= 0: + raise HTTPException(status_code=400, detail="Payment amount must be positive") + if deal.status == "lost": + raise HTTPException(status_code=400, detail="Cannot apply payment to lost deal") + if invoice is not None: + if invoice.status == "canceled": + raise HTTPException(status_code=400, detail="Cannot apply payment to canceled invoice") + if normalized_currency != str(invoice.currency or "").upper(): + raise HTTPException(status_code=400, detail="Payment currency does not match invoice currency") + if amount_value > float(invoice.amount or 0) and not _payment_allows_overpayment(integration, metadata): + raise HTTPException(status_code=400, detail="Payment amount exceeds invoice amount") + + row = existing_payment + previous_payment_status = row.status if row is not None else None + if row is None and external_payment_id: + row = session.execute( + select(SalesPaymentRow).where( + SalesPaymentRow.tenant_id == tenant_id, + SalesPaymentRow.payment_provider == payment_provider, + SalesPaymentRow.external_payment_id == external_payment_id, + ) + ).scalar_one_or_none() + previous_payment_status = row.status if row is not None else None + + if row is None: + row = SalesPaymentRow( + payment_id=new_id("pay"), + tenant_id=tenant_id, + deal_id=deal.deal_id, + invoice_id=invoice.invoice_id if invoice is not None else None, + payment_provider=payment_provider, + external_payment_id=external_payment_id, + amount=amount_value, + currency=normalized_currency, + status=normalized_status, + paid_at=paid_at or (now if normalized_status in {"success", "partial"} else None), + payment_method=payment_method, + failure_reason=failure_reason, + metadata_json=json.dumps(metadata, ensure_ascii=False), + created_at=now, + updated_at=now, + ) + session.add(row) + session.flush() + else: + row.deal_id = deal.deal_id + row.invoice_id = invoice.invoice_id if invoice is not None else row.invoice_id + row.amount = amount_value + row.currency = normalized_currency + row.status = normalized_status + row.paid_at = paid_at or row.paid_at or (now if normalized_status in {"success", "partial"} else None) + row.payment_method = payment_method + row.failure_reason = failure_reason + row.metadata_json = json.dumps(metadata, ensure_ascii=False) + row.updated_at = now + session.flush() + + if normalized_status in {"success", "partial"} and previous_payment_status != normalized_status: + _publish_payment_received(session, tenant_id=tenant_id, deal=deal, payment=row) + + if invoice is None: + return row + + previous_invoice_status = invoice.status + previous_deal_status = deal.status + lead = _get_lead(session, deal.lead_id, tenant_id) if deal.lead_id else None + counterparty = session.execute( + select(SalesCounterpartyRow).where(SalesCounterpartyRow.deal_id == deal.deal_id, SalesCounterpartyRow.tenant_id == tenant_id) + ).scalar_one_or_none() + + if normalized_status in {"success", "partial"}: + paid_sum = _paid_sum_for_invoice(session, tenant_id=tenant_id, invoice_id=invoice.invoice_id) + if paid_sum <= 0: + pass + elif paid_sum < float(invoice.amount or 0): + invoice.status = "partially_paid" + invoice.updated_at = now + _apply_payment_stage( + session, + deal, + "partially_paid", + reason="payment.partial", + metadata={ + "invoice_id": invoice.invoice_id, + "payment_id": row.payment_id, + "external_payment_id": row.external_payment_id, + "payment_provider": row.payment_provider, + "paid_sum": paid_sum, + }, + ) + else: + invoice.status = "paid" + invoice.paid_at = row.paid_at or now + invoice.updated_at = now + deal.final_amount = paid_sum + payment_metadata = { + "invoice_id": invoice.invoice_id, + "payment_id": row.payment_id, + "external_payment_id": row.external_payment_id, + "payment_provider": row.payment_provider, + "paid_sum": paid_sum, + } + if previous_deal_status != "won": + _apply_payment_stage(session, deal, "paid", reason="payment.received", metadata=payment_metadata) + _apply_payment_stage(session, deal, "won", reason="payment.received", metadata=payment_metadata) + _ensure_crm_customer(session, lead=lead, deal=deal, counterparty=counterparty) + if previous_invoice_status != "paid": + _publish_sales_event( + session, + tenant_id=tenant_id, + event_type=sales_event_types.INVOICE_PAID, + aggregate_type="invoice", + aggregate_id=invoice.invoice_id, + actor_type="system", + actor_id=None, + payload={ + "deal_id": deal.deal_id, + "invoice_id": invoice.invoice_id, + "paid_amount": paid_sum, + "currency": row.currency, + "paid_at": row.paid_at, + }, + ) + if previous_deal_status != "won": + _publish_sales_event( + session, + tenant_id=tenant_id, + event_type=sales_event_types.DEAL_WON, + aggregate_type="deal", + aggregate_id=deal.deal_id, + actor_type="system", + actor_id=None, + payload={ + "deal_id": deal.deal_id, + "invoice_id": invoice.invoice_id, + "payment_id": row.payment_id, + "final_amount": deal.final_amount, + "currency": deal.currency, + "won_reason": "payment_received", + }, + ) + _cancel_paid_invoice_tasks(session, deal=deal, invoice_id=invoice.invoice_id) + elif normalized_status in {"failed", "canceled"}: + invoice.updated_at = now + return row def _touch_deal_contact(deal: SalesDealRow) -> None: @@ -1408,6 +2108,11 @@ def _touch_deal_contact(deal: SalesDealRow) -> None: def _start_communication(session, *, deal: SalesDealRow, lead: SalesLeadRow | None, payload: SalesCommunicationStartIn, actor_user: str | None = None, metadata: dict | None = None) -> SalesCommunicationSessionRow: now = utc_now_iso() + metadata_payload = dict(metadata or {}) + channel_provider = str(metadata_payload.get("channel_provider") or "").strip() + if not channel_provider: + channel_provider = "voice" if payload.channel_type == "voice" else str(deal.current_channel if deal.current_channel != "voice" else (lead.preferred_channel if lead else "telegram") or "telegram") + metadata_payload["channel_provider"] = channel_provider agent_type = payload.agent_type or ("voice_ai" if payload.channel_type == "voice" else "text_ai") communication = SalesCommunicationSessionRow( communication_id=new_id("com"), @@ -1416,6 +2121,7 @@ def _start_communication(session, *, deal: SalesDealRow, lead: SalesLeadRow | No lead_id=lead.lead_id if lead else deal.lead_id, customer_id=deal.customer_id, channel_type=payload.channel_type, + channel_provider=channel_provider, direction=payload.direction, agent_type=agent_type, started_at=now, @@ -1429,7 +2135,7 @@ def _start_communication(session, *, deal: SalesDealRow, lead: SalesLeadRow | No next_action_at=payload.next_action_at, sentiment=None, result_code=None, - metadata_json=json.dumps(metadata or {}, ensure_ascii=False), + metadata_json=json.dumps(metadata_payload, ensure_ascii=False), created_at=now, updated_at=now, ) @@ -1701,6 +2407,10 @@ def _merge_communication_metadata(communication: SalesCommunicationSessionRow, v if changed: communication.metadata_json = json.dumps(metadata, ensure_ascii=False) communication.updated_at = utc_now_iso() + channel_provider = str(metadata.get("channel_provider") or "").strip() + if channel_provider and communication.channel_provider != channel_provider: + communication.channel_provider = channel_provider + communication.updated_at = utc_now_iso() return metadata @@ -1866,6 +2576,249 @@ def _get_or_create_voice_communication( ) +def _channel_type_for_value(value: str | None) -> str: + return "voice" if str(value or "").strip().lower() == "voice" else "text" + + +def _stage_code_for_deal(session, deal: SalesDealRow) -> str | None: + _, stage = _deal_pipeline_stage(session, deal) + return stage.code if stage is not None else None + + +def _maybe_transition_deal_for_channel_switch( + session, + *, + deal: SalesDealRow, + to_channel: str, + actor_type: str, + actor_id: str | None, + reason: str, + metadata: dict, +) -> None: + current_stage_code = _stage_code_for_deal(session, deal) + if current_stage_code not in {"active_text_communication", "active_voice_communication"}: + return + target_stage_code = "active_voice_communication" if to_channel == "voice" else "active_text_communication" + if current_stage_code == target_stage_code: + return + transition_deal_stage( + session, + tenant_id=deal.tenant_id, + deal_id=deal.deal_id, + target_stage_code=target_stage_code, + actor_type=actor_type, + actor_id=actor_id, + reason=reason, + metadata=metadata, + ) + + +def _create_switch_communication_session( + session, + *, + deal: SalesDealRow, + lead: SalesLeadRow | None, + to_channel: str, + reason: str, + actor_user: str | None, + metadata: dict, +) -> SalesCommunicationSessionRow: + now = utc_now_iso() + channel_provider = str(metadata.get("channel_provider") or ("voice" if to_channel == "voice" else "text")).strip() + payload = dict(metadata) + payload["channel_provider"] = channel_provider + communication = SalesCommunicationSessionRow( + communication_id=new_id("com"), + tenant_id=deal.tenant_id, + deal_id=deal.deal_id, + lead_id=lead.lead_id if lead else deal.lead_id, + customer_id=deal.customer_id, + channel_type=to_channel, + channel_provider=channel_provider, + direction="outbound", + agent_type="human", + started_at=now, + ended_at=None, + duration_sec=None, + subject=reason, + status="active", + summary=None, + transcript_id=None, + next_action_type=None, + next_action_at=None, + sentiment=None, + result_code=None, + metadata_json=json.dumps(payload, ensure_ascii=False), + created_at=now, + updated_at=now, + ) + session.add(communication) + _publish_sales_event( + session, + tenant_id=deal.tenant_id, + event_type=sales_event_types.COMMUNICATION_STARTED, + aggregate_type="communication", + aggregate_id=communication.communication_id, + actor_type="human", + actor_id=actor_user, + payload={ + "deal_id": deal.deal_id, + "lead_id": communication.lead_id, + "communication_session_id": communication.communication_id, + "channel_type": communication.channel_type, + "channel_provider": communication.channel_provider, + "direction": communication.direction, + "agent_type": communication.agent_type, + "subject": communication.subject, + }, + ) + return communication + + +def _complete_channel_switch_recommendation_task( + session, + *, + tenant_id: str, + deal_id: str, + task_id: str | None, + switch_id: str, +) -> None: + normalized = str(task_id or "").strip() + if not normalized: + return + task = session.execute( + select(SalesAutomationTaskRow).where( + SalesAutomationTaskRow.tenant_id == tenant_id, + SalesAutomationTaskRow.deal_id == deal_id, + SalesAutomationTaskRow.task_id == normalized, + SalesAutomationTaskRow.task_type == "recommend_channel_switch", + ) + ).scalar_one_or_none() + if task is None: + return + payload = _json_dict(task.payload_json) + payload["actual_switch_id"] = switch_id + task.payload_json = json.dumps(payload, ensure_ascii=False) + if task.status in {"pending", "running"}: + task.status = "completed" + task.completed_at = utc_now_iso() + task.updated_at = utc_now_iso() + + +def _perform_channel_switch( + session, + *, + deal: SalesDealRow, + payload: SalesCommunicationSwitchChannelIn, + actor_type: str, + actor_id: str | None, + communication: SalesCommunicationSessionRow | None = None, +) -> tuple[SalesChannelSwitchRow, SalesCommunicationSessionRow | None]: + if payload.to_channel not in {"text", "voice"}: + raise HTTPException(status_code=400, detail="Invalid target channel") + if payload.recommended_by_task_id: + existing = session.execute( + select(SalesChannelSwitchRow) + .where( + SalesChannelSwitchRow.tenant_id == deal.tenant_id, + SalesChannelSwitchRow.deal_id == deal.deal_id, + SalesChannelSwitchRow.recommended_by_task_id == payload.recommended_by_task_id, + ) + .order_by(SalesChannelSwitchRow.id.desc()) + ).scalars().first() + if existing is not None: + return existing, None + + now = utc_now_iso() + from_channel = _channel_type_for_value(communication.channel_type if communication is not None else deal.current_channel) + reason_text = payload.reason_text or payload.reason_for_channel_switch or payload.reason_code + reason_code = str(payload.reason_code or "human_decision").strip() or "human_decision" + metadata = dict(payload.metadata or {}) + metadata.update( + { + "reason_code": reason_code, + "scheduled_at": payload.scheduled_at, + "source_type": payload.source_type, + "source_id": payload.source_id, + "recommended_by_task_id": payload.recommended_by_task_id, + } + ) + new_communication = None + if payload.create_session: + lead = _get_lead(session, deal.lead_id, deal.tenant_id) if deal.lead_id else None + new_communication = _create_switch_communication_session( + session, + deal=deal, + lead=lead, + to_channel=payload.to_channel, + reason=reason_text, + actor_user=actor_id, + metadata=metadata, + ) + + row = SalesChannelSwitchRow( + switch_id=new_id("swc"), + tenant_id=deal.tenant_id, + deal_id=deal.deal_id, + communication_id=communication.communication_id if communication is not None else None, + communication_session_id=communication.communication_id if communication is not None else None, + previous_communication_session_id=communication.communication_id if communication is not None else None, + new_communication_session_id=new_communication.communication_id if new_communication is not None else None, + from_channel=from_channel, + to_channel=payload.to_channel, + reason_code=reason_code, + reason_text=payload.reason_text, + reason_for_channel_switch=reason_text, + initiated_by_type=actor_type, + initiated_by_id=actor_id, + source_type=payload.source_type, + source_id=payload.source_id, + recommended_by_task_id=payload.recommended_by_task_id, + switched_at=now, + created_at=now, + ) + session.add(row) + deal.current_channel = payload.to_channel + deal.updated_at = now + _maybe_transition_deal_for_channel_switch( + session, + deal=deal, + to_channel=payload.to_channel, + actor_type=actor_type, + actor_id=actor_id, + reason=reason_text, + metadata={"switch_id": row.switch_id, **metadata}, + ) + _complete_channel_switch_recommendation_task( + session, + tenant_id=deal.tenant_id, + deal_id=deal.deal_id, + task_id=payload.recommended_by_task_id, + switch_id=row.switch_id, + ) + _publish_sales_event( + session, + tenant_id=deal.tenant_id, + event_type=sales_event_types.COMMUNICATION_CHANNEL_SWITCHED, + aggregate_type="deal", + aggregate_id=deal.deal_id, + actor_type=actor_type, + actor_id=actor_id, + payload={ + "deal_id": deal.deal_id, + "switch_id": row.switch_id, + "from_channel": row.from_channel, + "to_channel": row.to_channel, + "reason_code": row.reason_code, + "reason_text": row.reason_text, + "communication_session_id": row.communication_session_id, + "new_communication_session_id": row.new_communication_session_id, + "recommended_by_task_id": row.recommended_by_task_id, + }, + ) + return row, new_communication + + def _build_workspace(session, deal: SalesDealRow) -> SalesWorkspaceOut: lead = _get_lead(session, deal.lead_id, deal.tenant_id) if deal.lead_id else None pipeline, stage = _deal_pipeline_stage(session, deal) @@ -1884,6 +2837,25 @@ def _build_workspace(session, deal: SalesDealRow) -> SalesWorkspaceOut: .where(SalesCallRow.deal_id == deal.deal_id, SalesCallRow.tenant_id == deal.tenant_id) .order_by(SalesCallRow.started_at.desc(), SalesCallRow.id.desc()) ).scalars().all() + call_ids = [row.call_id for row in calls] + transcripts = ( + session.execute( + select(SalesTranscriptRow) + .where(SalesTranscriptRow.tenant_id == deal.tenant_id, SalesTranscriptRow.call_id.in_(call_ids)) + .order_by(SalesTranscriptRow.updated_at.desc(), SalesTranscriptRow.id.desc()) + ).scalars().all() + if call_ids + else [] + ) + notes = session.execute( + select(SalesNoteRow) + .where( + SalesNoteRow.deal_id == deal.deal_id, + SalesNoteRow.tenant_id == deal.tenant_id, + SalesNoteRow.archived_at.is_(None), + ) + .order_by(SalesNoteRow.created_at.desc(), SalesNoteRow.id.desc()) + ).scalars().all() offers = session.execute( select(SalesOfferRow) .where(SalesOfferRow.deal_id == deal.deal_id, SalesOfferRow.tenant_id == deal.tenant_id) @@ -1922,6 +2894,18 @@ def _build_workspace(session, deal: SalesDealRow) -> SalesWorkspaceOut: .where(SalesAutomationTaskRow.deal_id == deal.deal_id, SalesAutomationTaskRow.tenant_id == deal.tenant_id) .order_by(SalesAutomationTaskRow.run_at.desc(), SalesAutomationTaskRow.id.desc()) ).scalars().all() + recommended_channel_switch = None + for task in tasks: + if task.task_type != "recommend_channel_switch": + continue + payload = _json_dict(task.payload_json) + result = payload.get("automation_result") if isinstance(payload.get("automation_result"), dict) else None + recommended_channel_switch = { + "task_id": task.task_id, + "status": task.status, + **(result or payload), + } + break stage_history = session.execute( select(SalesStageHistoryRow) .where(SalesStageHistoryRow.deal_id == deal.deal_id, SalesStageHistoryRow.tenant_id == deal.tenant_id) @@ -2012,7 +2996,28 @@ def _build_workspace(session, deal: SalesDealRow) -> SalesWorkspaceOut: meta={"switch_id": row.switch_id}, ) ) + for row in notes: + timeline.append( + SalesTimelineEventOut( + ts=row.updated_at or row.created_at, + kind="note", + title=f"Заметка: {row.note_type}", + body=row.content, + meta={"note_id": row.note_id, "source_type": row.source_type, "source_id": row.source_id}, + ) + ) + for row in escalations: + timeline.append( + SalesTimelineEventOut( + ts=row.updated_at or row.resolved_at or row.created_at, + kind="escalation", + title=f"Эскалация: {row.status}", + body=row.resolution_summary or row.reason, + meta={"escalation_id": row.escalation_id, "escalation_type": row.escalation_type}, + ) + ) timeline.sort(key=lambda item: item.ts, reverse=True) + active_escalation = next((row for row in escalations if row.status in {"open", "assigned", "in_progress"}), None) return SalesWorkspaceOut( lead=_lead_to_out(lead) if lead else None, @@ -2022,6 +3027,8 @@ def _build_workspace(session, deal: SalesDealRow) -> SalesWorkspaceOut: communications=[_communication_to_out(row) for row in communications], messages=[_message_to_out(row) for row in messages], calls=[_call_to_out(row) for row in calls], + transcripts=[_transcript_to_out(row) for row in transcripts], + notes=[_note_to_out(row) for row in notes], offers=[_offer_to_out(row) for row in offers], conditions=[_condition_to_out(row) for row in conditions], counterparty=_counterparty_to_out(counterparty) if counterparty else None, @@ -2029,9 +3036,11 @@ def _build_workspace(session, deal: SalesDealRow) -> SalesWorkspaceOut: invoices=[_invoice_to_out(row) for row in invoices], payments=[_payment_to_out(row) for row in payments], escalations=[_escalation_to_out(row) for row in escalations], + active_escalation=_escalation_to_out(active_escalation) if active_escalation else None, tasks=[_task_to_out(row) for row in tasks], stage_history=[_stage_history_to_out(row, stage_refs) for row in stage_history], channel_switches=[_channel_switch_to_out(row) for row in channel_switches], + recommended_channel_switch=recommended_channel_switch, timeline=timeline[:40], ) @@ -2783,18 +3792,20 @@ def change_stage( ) -> SalesDealOut: session = get_session() try: - deal = _get_deal(session, deal_id, _tenant_id(actor)) - if not payload.stage_id and not payload.stage_code: - raise HTTPException(status_code=400, detail="stage_id or stage_code is required") - ensure_default_pipeline(session, deal.tenant_id) - stage = resolve_stage_by_code_or_id( + deal = _get_deal_for_state_change(session, deal_id, _tenant_id(actor)) + target_stage_code = _resolve_stage_change_target_code(session, deal, payload) + actor_type = "admin" if payload.force and actor["role"] == Role.ADMIN.value else "human" + transition_deal_stage( session, tenant_id=deal.tenant_id, - pipeline_id=deal.pipeline_id, - stage_id=payload.stage_id, - stage_code=payload.stage_code, + deal_id=deal.deal_id, + target_stage_code=target_stage_code, + actor_type=actor_type, + actor_id=actor["user"], + reason=payload.reason, + metadata=payload.metadata, + force=payload.force, ) - apply_stage_by_id(session, deal, stage.stage_id, actor_type="human", actor_id=actor["user"], reason=payload.reason) session.commit() return _deal_to_out_with_refs(session, deal) finally: @@ -2880,6 +3891,120 @@ def schedule_next_action( session.close() +@app.post("/api/v1/deals/{deal_id}/notes", response_model=SalesNoteOut) +def create_deal_note( + deal_id: str, + payload: SalesNoteCreateIn, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesNoteOut: + session = get_session() + try: + tenant_id = _tenant_id(actor) + deal = _get_deal(session, deal_id, tenant_id) + now = utc_now_iso() + row = SalesNoteRow( + note_id=new_id("not"), + tenant_id=tenant_id, + deal_id=deal.deal_id, + author_type="human", + author_id=actor["user"], + note_type=payload.note_type, + content=payload.content, + source_type=payload.source_type, + source_id=payload.source_id, + created_at=now, + updated_at=now, + archived_at=None, + ) + session.add(row) + _publish_sales_event( + session, + tenant_id=tenant_id, + event_type=sales_event_types.DEAL_NOTE_CREATED, + aggregate_type="deal", + aggregate_id=deal.deal_id, + actor_type="human", + actor_id=actor["user"], + payload={ + "deal_id": deal.deal_id, + "note_id": row.note_id, + "note_type": row.note_type, + "source_type": row.source_type, + "source_id": row.source_id, + }, + ) + session.commit() + return _note_to_out(row) + finally: + session.close() + + +@app.get("/api/v1/deals/{deal_id}/notes", response_model=list[SalesNoteOut]) +def list_deal_notes( + deal_id: str, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)), +) -> list[SalesNoteOut]: + session = get_session() + try: + tenant_id = _tenant_id(actor) + _get_deal(session, deal_id, tenant_id) + rows = session.execute( + select(SalesNoteRow) + .where(SalesNoteRow.deal_id == deal_id, SalesNoteRow.tenant_id == tenant_id, SalesNoteRow.archived_at.is_(None)) + .order_by(SalesNoteRow.created_at.desc(), SalesNoteRow.id.desc()) + ).scalars().all() + return [_note_to_out(row) for row in rows] + finally: + session.close() + + +@app.patch("/api/v1/deals/{deal_id}/notes/{note_id}", response_model=SalesNoteOut) +def update_deal_note( + deal_id: str, + note_id: str, + payload: SalesNoteUpdateIn, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesNoteOut: + session = get_session() + try: + tenant_id = _tenant_id(actor) + deal = _get_deal(session, deal_id, tenant_id) + row = session.execute( + select(SalesNoteRow).where( + SalesNoteRow.note_id == note_id, + SalesNoteRow.deal_id == deal.deal_id, + SalesNoteRow.tenant_id == tenant_id, + SalesNoteRow.archived_at.is_(None), + ) + ).scalar_one_or_none() + if row is None: + raise HTTPException(status_code=404, detail="Note not found") + updates = payload.model_dump(exclude_unset=True) + for key, value in updates.items(): + setattr(row, key, value) + row.updated_at = utc_now_iso() + _publish_sales_event( + session, + tenant_id=tenant_id, + event_type=sales_event_types.DEAL_NOTE_UPDATED, + aggregate_type="deal", + aggregate_id=deal.deal_id, + actor_type="human", + actor_id=actor["user"], + payload={ + "deal_id": deal.deal_id, + "note_id": row.note_id, + "note_type": row.note_type, + "source_type": row.source_type, + "source_id": row.source_id, + }, + ) + session.commit() + return _note_to_out(row) + finally: + session.close() + + @app.post("/api/v1/deals/{deal_id}/close", response_model=SalesDealOut) def close_deal( deal_id: str, @@ -2964,7 +4089,21 @@ def escalate_deal( ) -> SalesEscalationOut: session = get_session() try: - deal = _get_deal(session, deal_id, _tenant_id(actor)) + tenant_id = _tenant_id(actor) + deal = _get_deal(session, deal_id, tenant_id) + existing = session.execute( + select(SalesEscalationRow) + .where( + SalesEscalationRow.tenant_id == tenant_id, + SalesEscalationRow.deal_id == deal.deal_id, + SalesEscalationRow.escalation_type == payload.escalation_type, + SalesEscalationRow.reason == payload.reason, + SalesEscalationRow.status.in_(["open", "assigned", "in_progress"]), + ) + .order_by(SalesEscalationRow.id.desc()) + ).scalars().first() + if existing is not None: + return _escalation_to_out(existing) now = utc_now_iso() row = SalesEscalationRow( escalation_id=new_id("esc"), @@ -2973,21 +4112,261 @@ def escalate_deal( escalation_type=payload.escalation_type, reason=payload.reason, severity=payload.severity, - status="open", + status="assigned" if payload.assigned_to_user_id else "open", assigned_to_user_id=payload.assigned_to_user_id, + assigned_at=now if payload.assigned_to_user_id else None, + started_at=None, created_at=now, resolved_at=None, + canceled_at=None, + resolution_code=None, + resolution_summary=None, + sla_due_at=payload.sla_due_at, + source_channel=payload.source_channel or _channel_type_for_value(deal.current_channel), + source_communication_session_id=payload.source_communication_session_id, + source_automation_task_id=payload.source_automation_task_id, + updated_at=now, ) session.add(row) deal.assigned_human_user_id = payload.assigned_to_user_id or actor["user"] deal.scenario_type = "custom_human_escalation" - _apply_stage( + try: + transition_deal_stage( + session, + tenant_id=tenant_id, + deal_id=deal.deal_id, + target_stage_code="transferred_to_support", + actor_type="human", + actor_id=actor["user"], + reason=payload.reason, + metadata={"escalation_id": row.escalation_id}, + ) + except DealStateMachineError as exc: + if exc.error != "invalid_stage_transition": + raise + transition_deal_stage( + session, + tenant_id=tenant_id, + deal_id=deal.deal_id, + target_stage_code="transferred_to_support", + actor_type="admin" if actor["role"] == Role.ADMIN.value else "system", + actor_id=actor["user"], + reason=payload.reason, + metadata={"escalation_id": row.escalation_id}, + force=True, + ) + _publish_sales_event( session, - deal, - "transferred_to_support", + tenant_id=tenant_id, + event_type=sales_event_types.DEAL_ESCALATION_REQUESTED, + aggregate_type="deal", + aggregate_id=deal.deal_id, actor_type="human", actor_id=actor["user"], - reason=payload.reason, + payload={ + "deal_id": deal.deal_id, + "escalation_id": row.escalation_id, + "escalation_type": row.escalation_type, + "severity": row.severity, + "assigned_to_user_id": row.assigned_to_user_id, + }, + ) + _publish_sales_event( + session, + tenant_id=tenant_id, + event_type=sales_event_types.ESCALATION_CREATED, + aggregate_type="escalation", + aggregate_id=row.escalation_id, + actor_type="human", + actor_id=actor["user"], + payload={ + "deal_id": deal.deal_id, + "escalation_id": row.escalation_id, + "escalation_type": row.escalation_type, + "status": row.status, + "severity": row.severity, + }, + ) + session.commit() + return _escalation_to_out(row) + finally: + session.close() + + +@app.get("/api/v1/deals/{deal_id}/escalations", response_model=list[SalesEscalationOut]) +def list_deal_escalations( + deal_id: str, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)), +) -> list[SalesEscalationOut]: + session = get_session() + try: + tenant_id = _tenant_id(actor) + _get_deal(session, deal_id, tenant_id) + rows = session.execute( + select(SalesEscalationRow) + .where(SalesEscalationRow.deal_id == deal_id, SalesEscalationRow.tenant_id == tenant_id) + .order_by(SalesEscalationRow.created_at.desc(), SalesEscalationRow.id.desc()) + ).scalars().all() + return [_escalation_to_out(row) for row in rows] + finally: + session.close() + + +def _get_escalation(session, escalation_id: str, tenant_id: str) -> SalesEscalationRow: + row = session.execute( + select(SalesEscalationRow).where(SalesEscalationRow.escalation_id == escalation_id, SalesEscalationRow.tenant_id == tenant_id) + ).scalar_one_or_none() + if row is None: + raise HTTPException(status_code=404, detail="Escalation not found") + return row + + +def _publish_escalation_lifecycle_event( + session, + *, + tenant_id: str, + event_type: str, + row: SalesEscalationRow, + actor_user: str, +) -> None: + _publish_sales_event( + session, + tenant_id=tenant_id, + event_type=event_type, + aggregate_type="escalation", + aggregate_id=row.escalation_id, + actor_type="human", + actor_id=actor_user, + payload={ + "deal_id": row.deal_id, + "escalation_id": row.escalation_id, + "escalation_type": row.escalation_type, + "status": row.status, + "assigned_to_user_id": row.assigned_to_user_id, + "resolution_code": row.resolution_code, + }, + ) + + +@app.post("/api/v1/escalations/{escalation_id}/assign", response_model=SalesEscalationOut) +def assign_escalation( + escalation_id: str, + payload: SalesEscalationAssignIn, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesEscalationOut: + session = get_session() + try: + tenant_id = _tenant_id(actor) + row = _get_escalation(session, escalation_id, tenant_id) + now = utc_now_iso() + row.assigned_to_user_id = payload.assigned_to_user_id + row.assigned_at = now + if row.status == "open": + row.status = "assigned" + row.updated_at = now + _publish_escalation_lifecycle_event( + session, + tenant_id=tenant_id, + event_type=sales_event_types.ESCALATION_ASSIGNED, + row=row, + actor_user=actor["user"], + ) + session.commit() + return _escalation_to_out(row) + finally: + session.close() + + +@app.post("/api/v1/escalations/{escalation_id}/start", response_model=SalesEscalationOut) +def start_escalation( + escalation_id: str, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesEscalationOut: + session = get_session() + try: + tenant_id = _tenant_id(actor) + row = _get_escalation(session, escalation_id, tenant_id) + if row.status in {"resolved", "canceled"}: + raise HTTPException(status_code=400, detail="Escalation is already closed") + now = utc_now_iso() + row.status = "in_progress" + row.started_at = row.started_at or now + row.updated_at = now + _publish_escalation_lifecycle_event( + session, + tenant_id=tenant_id, + event_type=sales_event_types.ESCALATION_STARTED, + row=row, + actor_user=actor["user"], + ) + session.commit() + return _escalation_to_out(row) + finally: + session.close() + + +@app.post("/api/v1/escalations/{escalation_id}/resolve", response_model=SalesEscalationOut) +def resolve_escalation( + escalation_id: str, + payload: SalesEscalationResolveIn, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesEscalationOut: + session = get_session() + try: + tenant_id = _tenant_id(actor) + row = _get_escalation(session, escalation_id, tenant_id) + now = utc_now_iso() + row.status = "resolved" + row.resolved_at = now + row.resolution_code = payload.resolution_code + row.resolution_summary = payload.resolution_summary + row.updated_at = now + if payload.target_stage_code: + transition_deal_stage( + session, + tenant_id=tenant_id, + deal_id=row.deal_id, + target_stage_code=payload.target_stage_code, + actor_type="human", + actor_id=actor["user"], + reason=payload.resolution_summary or payload.resolution_code, + metadata={"escalation_id": row.escalation_id, **payload.metadata}, + ) + _publish_escalation_lifecycle_event( + session, + tenant_id=tenant_id, + event_type=sales_event_types.ESCALATION_RESOLVED, + row=row, + actor_user=actor["user"], + ) + session.commit() + return _escalation_to_out(row) + finally: + session.close() + + +@app.post("/api/v1/escalations/{escalation_id}/cancel", response_model=SalesEscalationOut) +def cancel_escalation( + escalation_id: str, + payload: SalesEscalationCancelIn, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesEscalationOut: + session = get_session() + try: + tenant_id = _tenant_id(actor) + row = _get_escalation(session, escalation_id, tenant_id) + now = utc_now_iso() + row.status = "canceled" + row.canceled_at = now + row.resolution_code = payload.resolution_code + row.resolution_summary = payload.resolution_summary + row.updated_at = now + _publish_escalation_lifecycle_event( + session, + tenant_id=tenant_id, + event_type=sales_event_types.ESCALATION_CANCELED, + row=row, + actor_user=actor["user"], ) session.commit() return _escalation_to_out(row) @@ -3128,38 +4507,64 @@ def switch_channel( tenant_id = _tenant_id(actor) communication = _get_communication(session, communication_id, tenant_id) deal = _get_deal(session, communication.deal_id, tenant_id) - from_channel = "voice" if communication.channel_type == "voice" else deal.current_channel - now = utc_now_iso() - row = SalesChannelSwitchRow( - switch_id=new_id("swc"), - tenant_id=deal.tenant_id, - deal_id=deal.deal_id, - communication_id=communication.communication_id, - from_channel=from_channel, - to_channel=payload.to_channel, - reason_for_channel_switch=payload.reason_for_channel_switch, - switched_at=now, - ) - session.add(row) - deal.current_channel = payload.to_channel - deal.updated_at = now - _apply_stage( - session, - deal, - _infer_stage_for_channel(_infer_text_channel(payload.to_channel)), - actor_type="human", - actor_id=actor["user"], - reason=payload.reason_for_channel_switch, - ) - _schedule_task( + row, new_communication = _perform_channel_switch( session, deal=deal, - task_type=f"switch_to_{payload.to_channel}", - run_at=now, - payload={"reason": payload.reason_for_channel_switch, "communication_id": communication.communication_id}, + payload=payload, + actor_type="human", + actor_id=actor["user"], + communication=communication, ) session.commit() - return _channel_switch_to_out(row) + return _channel_switch_to_out_with_session(row, new_communication) + finally: + session.close() + + +@app.post("/api/v1/deals/{deal_id}/switch-channel", response_model=SalesChannelSwitchOut) +def switch_deal_channel( + deal_id: str, + payload: SalesCommunicationSwitchChannelIn, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> SalesChannelSwitchOut: + session = get_session() + try: + tenant_id = _tenant_id(actor) + deal = _get_deal(session, deal_id, tenant_id) + communication = session.execute( + select(SalesCommunicationSessionRow) + .where(SalesCommunicationSessionRow.deal_id == deal.deal_id, SalesCommunicationSessionRow.tenant_id == tenant_id) + .order_by(SalesCommunicationSessionRow.started_at.desc(), SalesCommunicationSessionRow.id.desc()) + ).scalars().first() + row, new_communication = _perform_channel_switch( + session, + deal=deal, + payload=payload, + actor_type="human", + actor_id=actor["user"], + communication=communication, + ) + session.commit() + return _channel_switch_to_out_with_session(row, new_communication) + finally: + session.close() + + +@app.get("/api/v1/deals/{deal_id}/channel-switches", response_model=list[SalesChannelSwitchOut]) +def list_deal_channel_switches( + deal_id: str, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)), +) -> list[SalesChannelSwitchOut]: + session = get_session() + try: + tenant_id = _tenant_id(actor) + _get_deal(session, deal_id, tenant_id) + rows = session.execute( + select(SalesChannelSwitchRow) + .where(SalesChannelSwitchRow.deal_id == deal_id, SalesChannelSwitchRow.tenant_id == tenant_id) + .order_by(SalesChannelSwitchRow.switched_at.desc(), SalesChannelSwitchRow.id.desc()) + ).scalars().all() + return [_channel_switch_to_out(row) for row in rows] finally: session.close() @@ -3267,6 +4672,7 @@ def sync_telegram_thread( direction=payload.direction, subject=(payload.text or payload.display_name or "Telegram conversation")[:120], metadata={ + "channel_provider": "telegram", "telegram_thread_id": payload.thread_id, "telegram_chat_id": payload.chat_id, "interaction_id": payload.interaction_id, @@ -3371,6 +4777,7 @@ def sync_voice_session( lead=lead, subject=(payload.summary or payload.caller_name or f"Voice call {payload.call_id}")[:120], metadata={ + "channel_provider": "voice", "voice_session_id": payload.voice_session_id, "ai_session_id": payload.ai_session_id, "interaction_id": payload.interaction_id, @@ -3532,7 +4939,7 @@ def inbound_message( subject=payload.body[:120], summary=None, ), - metadata=payload.metadata, + metadata={**(payload.metadata or {}), "channel_provider": payload.channel_provider}, ) message = SalesMessageRow( message_id=new_id("msg"), @@ -3552,6 +4959,12 @@ def inbound_message( created_at=utc_now_iso(), ) session.add(message) + _cancel_pending_automation_tasks( + session, + deal=deal, + task_types={"follow_up_customer"}, + reason="customer_replied", + ) _publish_sales_event( session, tenant_id=deal.tenant_id, @@ -3606,8 +5019,10 @@ def outbound_message( agent_type=payload.sender_type if payload.sender_type == "human" else "text_ai", subject=payload.body[:120], ), - metadata=payload.metadata, + metadata={**(payload.metadata or {}), "channel_provider": payload.channel_provider}, ) + else: + _merge_communication_metadata(communication, {"channel_provider": payload.channel_provider}) message = SalesMessageRow( message_id=new_id("msg"), tenant_id=deal.tenant_id, @@ -3628,6 +5043,14 @@ def outbound_message( session.add(message) _touch_deal_contact(deal) _bridge_telegram_outbound(communication, message, deal) + _schedule_unique_task( + session, + deal=deal, + task_type="follow_up_customer", + run_at=_automation_run_at_after(days=1), + payload={"message_id": message.message_id, "reason": "outbound_message.awaiting_reply"}, + match_payload={"message_id": message.message_id}, + ) _publish_sales_event( session, tenant_id=deal.tenant_id, @@ -3705,7 +5128,7 @@ def inbound_call( agent_type="voice_ai", subject=payload.subject or f"Входящий звонок {payload.phone_number}", ), - metadata={"provider": payload.provider}, + metadata={"provider": payload.provider, "channel_provider": "voice"}, ) row = SalesCallRow( call_id=new_id("cal"), @@ -3774,7 +5197,7 @@ def outbound_call( agent_type="voice_ai", subject=payload.subject or f"Исходящий звонок {payload.phone_number}", ), - metadata={"provider": payload.provider}, + metadata={"provider": payload.provider, "channel_provider": "voice"}, ) row = SalesCallRow( call_id=new_id("cal"), @@ -4033,6 +5456,14 @@ def send_offer( row.updated_at = utc_now_iso() deal = _get_deal(session, row.deal_id, tenant_id) _apply_stage(session, deal, "offer_sent", actor_type="system", actor_id=None, reason="offer.sent") + _schedule_unique_task( + session, + deal=deal, + task_type="follow_up_customer", + run_at=_automation_run_at_after(days=1), + payload={"offer_id": row.offer_id, "reason": "offer.sent"}, + match_payload={"offer_id": row.offer_id, "reason": "offer.sent"}, + ) _publish_sales_event( session, tenant_id=tenant_id, @@ -4394,6 +5825,14 @@ def send_document( row.updated_at = utc_now_iso() deal = _get_deal(session, row.deal_id, tenant_id) _apply_stage(session, deal, "document_sent", actor_type="system", actor_id=None, reason="document.sent") + _schedule_unique_task( + session, + deal=deal, + task_type="follow_up_customer", + run_at=_automation_run_at_after(days=1), + payload={"document_id": row.document_id, "reason": "document.sent"}, + match_payload={"document_id": row.document_id, "reason": "document.sent"}, + ) _publish_sales_event( session, tenant_id=tenant_id, @@ -4590,12 +6029,21 @@ def send_invoice( deal = _get_deal(session, row.deal_id, tenant_id) deal.next_action_type = "payment_follow_up" deal.next_action_at = row.due_date - _schedule_task( + _schedule_unique_task( session, deal=deal, - task_type="invoice_follow_up", - run_at=row.due_date, - payload={"invoice_id": row.invoice_id, "invoice_number": row.invoice_number}, + task_type="send_invoice_reminder", + run_at=_invoice_reminder_run_at(row.due_date), + payload={"invoice_id": row.invoice_id, "invoice_number": row.invoice_number, "reason": "invoice.sent"}, + match_payload={"invoice_id": row.invoice_id, "reason": "invoice.sent"}, + ) + _schedule_unique_task( + session, + deal=deal, + task_type="mark_invoice_overdue", + run_at=_invoice_overdue_run_at(row.due_date), + payload={"invoice_id": row.invoice_id, "invoice_number": row.invoice_number, "reason": "invoice.due_date"}, + match_payload={"invoice_id": row.invoice_id, "reason": "invoice.due_date"}, ) _apply_stage(session, deal, "invoice_sent", actor_type="system", actor_id=None, reason="invoice.sent") _publish_sales_event( @@ -4638,6 +6086,14 @@ def mark_invoice_overdue( row.updated_at = utc_now_iso() deal = _get_deal(session, row.deal_id, tenant_id) _apply_stage(session, deal, "payment_overdue", actor_type="system", actor_id=None, reason="invoice.overdue") + _schedule_unique_task( + session, + deal=deal, + task_type="send_invoice_reminder", + run_at=utc_now_iso(), + payload={"invoice_id": row.invoice_id, "invoice_number": row.invoice_number, "reason": "invoice.overdue"}, + match_payload={"invoice_id": row.invoice_id, "reason": "invoice.overdue"}, + ) _publish_sales_event( session, tenant_id=tenant_id, @@ -4661,153 +6117,185 @@ def mark_invoice_overdue( session.close() -@app.post("/api/v1/payments/webhook", response_model=SalesPaymentOut) -def payment_webhook( - payload: SalesPaymentWebhookIn, +@app.post("/api/v1/payments/webhook") +async def payment_webhook( + request: Request, actor: dict = Depends(get_actor), -) -> SalesPaymentOut: +): + raw_body = await request.body() + headers = normalize_headers(request.headers) + try: + raw_payload = safe_json_loads(raw_body) + except (json.JSONDecodeError, UnicodeDecodeError, ValueError) as exc: + raise HTTPException(status_code=400, detail="Invalid payment webhook payload") from exc + + normalized_payload = _normalized_payment_payload(raw_payload, headers) + provider = normalized_payload["payment_provider"] + provider_account_id = normalized_payload.get("provider_account_id") + external_event_id = normalized_payload.get("external_event_id") + external_payment_id = normalized_payload.get("external_payment_id") + event_type = normalized_payload["event_type"] session = get_session() try: - tenant_id = _resolve_provider_tenant_id( + tenant_id, integration = _resolve_payment_integration( session, actor, - provider_type="payment", - provider_name=payload.payment_provider, - metadata=payload.metadata, - external_identifier=payload.external_payment_id, + provider=provider, + provider_account_id=provider_account_id, + external_identifier=normalized_payload["metadata"].get("payment_link_id") + or normalized_payload["metadata"].get("invoice_external_reference"), ) - deal = _get_deal(session, payload.deal_id, tenant_id) + now = utc_now_iso() + existing_event = _find_existing_webhook_event( + session, + tenant_id=tenant_id, + provider=provider, + external_event_id=external_event_id, + ) + if existing_event is not None: + try: + signature_required = provider != "manual" or bool(_payment_webhook_secret(integration)) + verify_payment_webhook_signature( + raw_body=raw_body, + headers=headers, + secret=_payment_webhook_secret(integration), + replay_window_seconds=_payment_replay_window_seconds(integration), + required=signature_required, + ) + except PaymentWebhookVerificationError as exc: + raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc + return _payment_webhook_event_duplicate_response(session, existing_event) + + event = SalesPaymentWebhookEventRow( + tenant_id=tenant_id, + payment_provider=provider, + provider_account_id=provider_account_id, + external_event_id=external_event_id, + external_payment_id=external_payment_id, + event_type=event_type, + event_status="received", + signature_status="unchecked", + raw_payload_hash=raw_payload_hash(raw_body), + normalized_payload_json=json.dumps(normalized_payload, ensure_ascii=False), + headers_json=json.dumps(headers, ensure_ascii=False), + received_at=now, + processed_at=None, + error_message=None, + created_at=now, + updated_at=now, + ) + session.add(event) + session.flush() + + try: + signature_required = provider != "manual" or bool(_payment_webhook_secret(integration)) + event.signature_status = verify_payment_webhook_signature( + raw_body=raw_body, + headers=headers, + secret=_payment_webhook_secret(integration), + replay_window_seconds=_payment_replay_window_seconds(integration), + required=signature_required, + ) + except PaymentWebhookVerificationError as exc: + event.event_status = "rejected" + event.signature_status = exc.signature_status + event.error_message = exc.message + event.updated_at = utc_now_iso() + session.commit() + raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc + + event.event_status = "verified" + event.updated_at = utc_now_iso() + + deal_id = str(normalized_payload.get("deal_id") or "").strip() + invoice_id = str(normalized_payload.get("invoice_id") or "").strip() + if not deal_id and invoice_id: + invoice_for_deal = session.execute( + select(SalesInvoiceRow).where(SalesInvoiceRow.invoice_id == invoice_id, SalesInvoiceRow.tenant_id == tenant_id) + ).scalar_one_or_none() + if invoice_for_deal is not None: + deal_id = invoice_for_deal.deal_id + if not deal_id: + _mark_payment_webhook_event_failed(event, "Payment webhook must include deal_id or invoice_id") + session.commit() + raise HTTPException(status_code=400, detail="Payment webhook must include deal_id or invoice_id") + + try: + deal = _get_deal(session, deal_id, tenant_id) + except HTTPException as exc: + _mark_payment_webhook_event_failed(event, exc.detail) + session.commit() + raise invoice = None - if payload.invoice_id: + if invoice_id: invoice = session.execute( - select(SalesInvoiceRow).where(SalesInvoiceRow.invoice_id == payload.invoice_id, SalesInvoiceRow.tenant_id == tenant_id) + select(SalesInvoiceRow).where(SalesInvoiceRow.invoice_id == invoice_id, SalesInvoiceRow.tenant_id == tenant_id) ).scalar_one_or_none() if invoice is None: + _mark_payment_webhook_event_failed(event, "Invoice not found") + session.commit() raise HTTPException(status_code=404, detail="Invoice not found") if invoice.deal_id != deal.deal_id: + _mark_payment_webhook_event_failed(event, "Invoice does not belong to deal") + session.commit() raise HTTPException(status_code=400, detail="Invoice does not belong to deal") - now = utc_now_iso() - row = None - previous_payment_status = None - if payload.external_payment_id: - row = session.execute( - select(SalesPaymentRow).where( - SalesPaymentRow.tenant_id == tenant_id, - SalesPaymentRow.payment_provider == payload.payment_provider, - SalesPaymentRow.external_payment_id == payload.external_payment_id, - ) - ).scalar_one_or_none() - previous_payment_status = row.status if row is not None else None - previous_invoice_status = invoice.status if invoice is not None else None - previous_deal_status = deal.status - if row is None: - row = SalesPaymentRow( - payment_id=new_id("pay"), - tenant_id=deal.tenant_id, - deal_id=deal.deal_id, - invoice_id=payload.invoice_id, - payment_provider=payload.payment_provider, - external_payment_id=payload.external_payment_id, - amount=payload.amount, - currency=payload.currency, - status=payload.status, - paid_at=payload.paid_at or (now if payload.status in {"success", "partial"} else None), - payment_method=payload.payment_method, - failure_reason=payload.failure_reason, - metadata_json=json.dumps(payload.metadata, ensure_ascii=False), - created_at=now, - updated_at=now, + + try: + payment_metadata = dict(normalized_payload["metadata"]) + payment_metadata.update( + { + "external_event_id": external_event_id, + "event_type": event_type, + "webhook_event_row_id": event.id, + "raw_payload_hash": event.raw_payload_hash, + } ) - session.add(row) - else: - row.deal_id = deal.deal_id - row.invoice_id = payload.invoice_id - row.amount = payload.amount - row.currency = payload.currency - row.status = payload.status - row.paid_at = payload.paid_at or row.paid_at or (now if payload.status in {"success", "partial"} else None) - row.payment_method = payload.payment_method - row.failure_reason = payload.failure_reason - row.metadata_json = json.dumps(payload.metadata, ensure_ascii=False) - row.updated_at = now - should_publish_payment_received = payload.status in {"success", "partial"} and previous_payment_status != payload.status - if should_publish_payment_received: - _publish_sales_event( + row = _apply_payment_update( session, tenant_id=tenant_id, - event_type=sales_event_types.PAYMENT_RECEIVED, - aggregate_type="payment", - aggregate_id=row.payment_id, - actor_type="system", - actor_id=None, - payload={ - "deal_id": deal.deal_id, - "invoice_id": row.invoice_id, - "payment_id": row.payment_id, - "payment_provider": row.payment_provider, - "external_payment_id": row.external_payment_id, - "amount": row.amount, - "currency": row.currency, - "status": row.status, - }, + deal=deal, + invoice=invoice, + payment_provider=provider, + external_payment_id=external_payment_id, + amount=normalized_payload.get("amount"), + currency=normalized_payload.get("currency") or "KZT", + status=normalized_payload.get("status") or "pending", + paid_at=normalized_payload.get("paid_at"), + payment_method=normalized_payload.get("payment_method"), + failure_reason=normalized_payload.get("failure_reason"), + metadata=payment_metadata, + integration=integration, ) - lead = _get_lead(session, deal.lead_id, tenant_id) if deal.lead_id else None - counterparty = session.execute( - select(SalesCounterpartyRow).where(SalesCounterpartyRow.deal_id == deal.deal_id, SalesCounterpartyRow.tenant_id == tenant_id) - ).scalar_one_or_none() - if invoice is not None: - if payload.status == "success": - invoice.status = "paid" - invoice.paid_at = row.paid_at - invoice.updated_at = now - deal.status = "won" - deal.closed_at = row.paid_at - deal.final_amount = payload.amount - _apply_stage(session, deal, "won", actor_type="system", actor_id=None, reason="payment.received") - _ensure_crm_customer(session, lead=lead, deal=deal, counterparty=counterparty) - if previous_invoice_status != "paid": - _publish_sales_event( - session, - tenant_id=tenant_id, - event_type=sales_event_types.INVOICE_PAID, - aggregate_type="invoice", - aggregate_id=invoice.invoice_id, - actor_type="system", - actor_id=None, - payload={ - "deal_id": deal.deal_id, - "invoice_id": invoice.invoice_id, - "paid_amount": payload.amount, - "currency": payload.currency, - "paid_at": row.paid_at, - }, - ) - if previous_deal_status != "won": - _publish_sales_event( - session, - tenant_id=tenant_id, - event_type=sales_event_types.DEAL_WON, - aggregate_type="deal", - aggregate_id=deal.deal_id, - actor_type="system", - actor_id=None, - payload={ - "deal_id": deal.deal_id, - "invoice_id": invoice.invoice_id, - "payment_id": row.payment_id, - "final_amount": deal.final_amount, - "currency": deal.currency, - "won_reason": "payment_received", - }, - ) - elif payload.status == "partial": - invoice.status = "partially_paid" - invoice.updated_at = now - _apply_stage(session, deal, "partially_paid", actor_type="system", actor_id=None, reason="payment.partial") - elif payload.status == "failed": - invoice.updated_at = now + except HTTPException as exc: + _mark_payment_webhook_event_failed(event, exc.detail) + session.commit() + raise + + result = _payment_to_dict(row) + normalized_payload["result"] = { + "payment_id": row.payment_id, + "invoice_id": row.invoice_id, + "deal_id": row.deal_id, + "status": row.status, + } + event.normalized_payload_json = json.dumps(normalized_payload, ensure_ascii=False) + event.event_status = "processed" + event.processed_at = utc_now_iso() + event.updated_at = event.processed_at session.commit() - return _payment_to_out(row) + return result + except IntegrityError: + session.rollback() + resolved_tenant_id = tenant_id if "tenant_id" in locals() else "" + existing_event = _find_existing_webhook_event( + session, + tenant_id=resolved_tenant_id, + provider=provider, + external_event_id=external_event_id, + ) + if existing_event is not None: + return _payment_webhook_event_duplicate_response(session, existing_event) + raise finally: session.close() @@ -4834,7 +6322,7 @@ def list_payments( @app.post("/api/v1/payments/{payment_id}/reconcile", response_model=SalesPaymentOut) def reconcile_payment( payment_id: str, - payload: SalesPaymentReconcileIn, + payload: SalesPaymentReconcileIn | None = None, actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), ) -> SalesPaymentOut: session = get_session() @@ -4845,11 +6333,76 @@ def reconcile_payment( ).scalar_one_or_none() if row is None: raise HTTPException(status_code=404, detail="Payment not found") - row.status = payload.status - row.failure_reason = payload.failure_reason - row.metadata_json = json.dumps(payload.metadata, ensure_ascii=False) - row.updated_at = utc_now_iso() + deal = _get_deal(session, row.deal_id, tenant_id) + invoice = None + if row.invoice_id: + invoice = session.execute( + select(SalesInvoiceRow).where(SalesInvoiceRow.invoice_id == row.invoice_id, SalesInvoiceRow.tenant_id == tenant_id) + ).scalar_one_or_none() + if invoice is None: + raise HTTPException(status_code=404, detail="Invoice not found") + + provider = str(row.payment_provider or "manual").strip().lower() or "manual" + integration = session.execute( + select(TenantIntegrationRow) + .where( + TenantIntegrationRow.tenant_id == tenant_id, + func.lower(TenantIntegrationRow.provider_type) == "payment", + func.lower(TenantIntegrationRow.provider_name) == provider, + TenantIntegrationRow.is_active == True, # noqa: E712 + ) + .order_by(TenantIntegrationRow.id.desc()) + ).scalars().first() + + metadata = _json_dict(row.metadata_json) + failure_reason = payload.failure_reason if payload is not None else row.failure_reason + if payload is not None: + status = payload.status + metadata.update(payload.metadata or {}) + else: + adapter = get_payment_provider_adapter(provider) + if adapter is None: + raise HTTPException(status_code=400, detail="Payment provider adapter is not configured") + if not row.external_payment_id: + raise HTTPException(status_code=400, detail="Payment external_payment_id is required for reconcile") + provider_result = adapter.get_status( + row.external_payment_id, + context={ + "tenant_id": tenant_id, + "payment_id": row.payment_id, + "payment_provider": provider, + "integration_id": integration.integration_id if integration is not None else None, + }, + ) + if isinstance(provider_result, dict): + status = normalize_payment_status(provider, str(provider_result.get("status") or provider_result.get("provider_status") or "")) + failure_reason = str(provider_result.get("failure_reason") or failure_reason or "") or None + metadata.update(provider_result.get("metadata") if isinstance(provider_result.get("metadata"), dict) else {}) + if provider_result.get("amount") is not None: + row.amount = _coerce_payment_amount(provider_result.get("amount")) + if provider_result.get("currency"): + row.currency = str(provider_result.get("currency")).upper() + else: + status = normalize_payment_status(provider, str(provider_result)) + + updated = _apply_payment_update( + session, + tenant_id=tenant_id, + deal=deal, + invoice=invoice, + payment_provider=provider, + external_payment_id=row.external_payment_id, + amount=row.amount, + currency=row.currency, + status=status, + paid_at=row.paid_at, + payment_method=row.payment_method, + failure_reason=failure_reason, + metadata=metadata, + integration=integration, + existing_payment=row, + ) session.commit() - return _payment_to_out(row) + return _payment_to_out(updated) finally: session.close() diff --git a/services/sales_service/automation_worker.py b/services/sales_service/automation_worker.py new file mode 100644 index 0000000..e7a677b --- /dev/null +++ b/services/sales_service/automation_worker.py @@ -0,0 +1,695 @@ +from __future__ import annotations + +import json +import socket +from datetime import datetime, timedelta, timezone +from typing import Any, Callable + +from sqlalchemy import select + +from services.sales_service import sales_events as sales_event_types +from services.sales_service.deal_state_machine import DealStateMachineError, transition_deal_stage +from services.sales_service.event_publisher import SalesEventPublisher +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, + SalesChannelSwitchRow, + SalesCommunicationSessionRow, + SalesDealRow, + SalesEscalationRow, + SalesInvoiceRow, + SalesPipelineStageRow, +) + + +RETRY_DELAYS_SECONDS = { + 1: 60, + 2: 300, + 3: 900, +} +ACTIVE_TASK_STATUSES = {"pending", "running"} +TERMINAL_TASK_STATUSES = {"completed", "failed", "canceled"} + + +def _now_dt() -> datetime: + return datetime.now(timezone.utc).replace(microsecond=0) + + +def _iso(dt: datetime) -> str: + return dt.astimezone(timezone.utc).replace(microsecond=0).isoformat() + + +def _payload(row: SalesAutomationTaskRow) -> dict[str, Any]: + try: + parsed = json.loads(row.payload_json or "{}") + except json.JSONDecodeError: + return {} + return parsed if isinstance(parsed, dict) else {} + + +def _set_payload(row: SalesAutomationTaskRow, payload: dict[str, Any]) -> None: + row.payload_json = json.dumps(payload, ensure_ascii=False) + + +def _task_error_message(exc: Exception) -> str: + message = str(exc).strip() + return message[:1000] if message else exc.__class__.__name__ + + +class SalesAutomationWorker: + def __init__(self, *, worker_id: str | None = None, batch_size: int = 10) -> None: + self.worker_id = worker_id or f"sales-worker-{socket.gethostname()}-{new_id('wrk')}" + self.batch_size = batch_size + self.handlers: dict[str, Callable[[Any, SalesAutomationTaskRow], None]] = { + "follow_up_customer": self._handle_follow_up_customer, + "mark_invoice_overdue": self._handle_mark_invoice_overdue, + "send_invoice_reminder": self._handle_send_invoice_reminder, + "recommend_channel_switch": self._handle_recommend_channel_switch, + "close_stale_communication": self._handle_close_stale_communication, + "escalate_to_human": self._handle_escalate_to_human, + "post_sale_transfer": self._handle_post_sale_transfer, + } + + def claim_pending_tasks(self, session, *, limit: int | None = None, now: str | None = None) -> list[SalesAutomationTaskRow]: + claim_limit = limit or self.batch_size + now_text = now or utc_now_iso() + stmt = ( + select(SalesAutomationTaskRow) + .where( + SalesAutomationTaskRow.status == "pending", + SalesAutomationTaskRow.run_at <= now_text, + ) + .order_by(SalesAutomationTaskRow.run_at.asc(), SalesAutomationTaskRow.id.asc()) + .limit(claim_limit) + ) + if session.bind is not None and session.bind.dialect.name == "postgresql": + stmt = stmt.with_for_update(skip_locked=True) + rows = session.execute(stmt).scalars().all() + for row in rows: + row.status = "running" + row.locked_at = now_text + row.locked_by = self.worker_id + row.last_error = None + row.updated_at = now_text + session.flush() + return list(rows) + + def run_once(self, *, limit: int | None = None) -> int: + session = get_session() + try: + tasks = self.claim_pending_tasks(session, limit=limit) + task_ids = [task.task_id for task in tasks] + session.commit() + finally: + session.close() + + for task_id in task_ids: + self._execute_claimed_task(task_id) + return len(task_ids) + + def _execute_claimed_task(self, task_id: str) -> None: + session = get_session() + try: + task = session.execute( + select(SalesAutomationTaskRow).where( + SalesAutomationTaskRow.task_id == task_id, + SalesAutomationTaskRow.status == "running", + SalesAutomationTaskRow.locked_by == self.worker_id, + ) + ).scalar_one_or_none() + if task is None: + session.rollback() + return + try: + self._execute_task(session, task) + except Exception as exc: # noqa: BLE001 + self._mark_failed_or_retry(task, exc) + else: + self._mark_completed(task) + session.commit() + finally: + session.close() + + def _execute_task(self, session, task: SalesAutomationTaskRow) -> None: + handler = self.handlers.get(task.task_type) + if handler is None: + raise RuntimeError(f"Unsupported automation task type: {task.task_type}") + handler(session, task) + + def _mark_completed(self, task: SalesAutomationTaskRow) -> None: + now = utc_now_iso() + task.status = "completed" + task.completed_at = now + task.failed_at = None + task.locked_at = None + task.locked_by = None + task.last_error = None + task.updated_at = now + + def _mark_failed_or_retry(self, task: SalesAutomationTaskRow, exc: Exception) -> None: + now_dt = _now_dt() + task.retry_count += 1 + task.last_error = _task_error_message(exc) + task.locked_at = None + task.locked_by = None + task.updated_at = _iso(now_dt) + max_retries = int(task.max_retries or 3) + if task.retry_count < max_retries: + task.status = "pending" + delay_seconds = RETRY_DELAYS_SECONDS.get(task.retry_count, 900) + task.run_at = _iso(now_dt + timedelta(seconds=delay_seconds)) + return + task.status = "failed" + task.failed_at = _iso(now_dt) + + def _get_deal(self, session, task: SalesAutomationTaskRow) -> SalesDealRow: + row = session.execute( + select(SalesDealRow).where( + SalesDealRow.tenant_id == task.tenant_id, + SalesDealRow.deal_id == task.deal_id, + ) + ).scalar_one_or_none() + if row is None: + raise RuntimeError(f"Deal not found: {task.deal_id}") + return row + + def _get_invoice(self, session, task: SalesAutomationTaskRow, invoice_id: str) -> SalesInvoiceRow: + row = session.execute( + select(SalesInvoiceRow).where( + SalesInvoiceRow.tenant_id == task.tenant_id, + SalesInvoiceRow.invoice_id == invoice_id, + ) + ).scalar_one_or_none() + if row is None: + raise RuntimeError(f"Invoice not found: {invoice_id}") + if row.deal_id != task.deal_id: + raise RuntimeError("Invoice does not belong to task deal") + return row + + def _stage_code(self, session, deal: SalesDealRow) -> str | None: + stage = session.execute( + select(SalesPipelineStageRow).where( + SalesPipelineStageRow.tenant_id == deal.tenant_id, + SalesPipelineStageRow.stage_id == deal.stage_id, + ) + ).scalar_one_or_none() + return stage.code if stage is not None else None + + def _transition_stage( + self, + session, + *, + task: SalesAutomationTaskRow, + target_stage_code: str, + reason: str, + force_on_invalid: bool = True, + ) -> None: + metadata = {"automation_task_id": task.task_id, "task_type": task.task_type} + try: + transition_deal_stage( + session, + tenant_id=task.tenant_id, + deal_id=task.deal_id, + target_stage_code=target_stage_code, + actor_type="system", + reason=reason, + metadata=metadata, + ) + except DealStateMachineError as exc: + if not force_on_invalid or exc.error != "invalid_stage_transition": + raise + transition_deal_stage( + session, + tenant_id=task.tenant_id, + deal_id=task.deal_id, + target_stage_code=target_stage_code, + actor_type="system", + reason=reason, + metadata={**metadata, "force_reason": reason}, + force=True, + ) + + def _publish( + self, + session, + *, + task: SalesAutomationTaskRow, + event_type: str, + aggregate_type: str, + aggregate_id: str, + payload: dict[str, Any], + ) -> None: + SalesEventPublisher.publish_sales_event( + session, + tenant_id=task.tenant_id, + event_type=event_type, + aggregate_type=aggregate_type, + aggregate_id=aggregate_id, + actor_type="system", + actor_id=self.worker_id, + payload={ + "deal_id": task.deal_id, + "automation_task_id": task.task_id, + "task_type": task.task_type, + **payload, + }, + ) + + def _find_related_task( + self, + session, + *, + tenant_id: str, + deal_id: str, + task_type: str, + match_payload: dict[str, Any], + ) -> SalesAutomationTaskRow | None: + rows = session.execute( + select(SalesAutomationTaskRow) + .where( + SalesAutomationTaskRow.tenant_id == tenant_id, + SalesAutomationTaskRow.deal_id == deal_id, + SalesAutomationTaskRow.task_type == task_type, + SalesAutomationTaskRow.status.in_(["pending", "running", "completed"]), + ) + .order_by(SalesAutomationTaskRow.id.desc()) + ).scalars().all() + for row in rows: + payload = _payload(row) + if all(payload.get(key) == value for key, value in match_payload.items()): + return row + return None + + def _create_task_once( + self, + session, + *, + tenant_id: str, + deal_id: str, + task_type: str, + run_at: str | None = None, + payload: dict[str, Any] | None = None, + match_payload: dict[str, Any] | None = None, + ) -> SalesAutomationTaskRow: + payload_data = payload or {} + existing = self._find_related_task( + session, + tenant_id=tenant_id, + deal_id=deal_id, + task_type=task_type, + match_payload=match_payload or payload_data, + ) + if existing is not None: + return existing + 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_data, ensure_ascii=False), + run_at=run_at or now, + status="pending", + retry_count=0, + max_retries=3, + locked_at=None, + locked_by=None, + completed_at=None, + failed_at=None, + last_error=None, + created_at=now, + updated_at=now, + ) + session.add(row) + return row + + def _handle_follow_up_customer(self, session, task: SalesAutomationTaskRow) -> None: + deal = self._get_deal(session, task) + data = _payload(task) + if deal.status != "active": + data["automation_result"] = {"status": "skipped", "reason": f"deal_status_{deal.status}"} + _set_payload(task, data) + return + + now = utc_now_iso() + deal.next_action_type = str(data.get("next_action_type") or "follow_up_customer") + deal.next_action_at = now + deal.updated_at = now + current_stage = self._stage_code(session, deal) + preferred_channel = str(data.get("preferred_channel") or deal.current_channel or deal.preferred_channel or "text") + if current_stage == "follow_up_scheduled": + target = "active_voice_communication" if preferred_channel == "voice" else "active_text_communication" + self._transition_stage( + session, + task=task, + target_stage_code=target, + reason="automation.follow_up_customer", + ) + data["automation_result"] = { + "status": "follow_up_ready", + "next_action_type": deal.next_action_type, + "next_action_at": deal.next_action_at, + } + _set_payload(task, data) + self._publish( + session, + task=task, + event_type=sales_event_types.DEAL_FOLLOW_UP_READY, + aggregate_type="deal", + aggregate_id=deal.deal_id, + payload={"next_action_type": deal.next_action_type, "next_action_at": deal.next_action_at}, + ) + + def _handle_mark_invoice_overdue(self, session, task: SalesAutomationTaskRow) -> None: + data = _payload(task) + invoice_id = str(data.get("invoice_id") or "").strip() + if not invoice_id: + raise RuntimeError("invoice_id is required") + invoice = self._get_invoice(session, task, invoice_id) + deal = self._get_deal(session, task) + if invoice.status in {"paid", "canceled"}: + data["automation_result"] = {"status": "skipped", "invoice_status": invoice.status} + _set_payload(task, data) + return + + previous_status = invoice.status + now = utc_now_iso() + invoice.status = "overdue" + invoice.updated_at = now + if deal.status == "active": + self._transition_stage( + session, + task=task, + target_stage_code="payment_overdue", + reason="automation.mark_invoice_overdue", + ) + if previous_status != "overdue": + self._publish( + session, + task=task, + event_type=sales_event_types.INVOICE_OVERDUE, + aggregate_type="invoice", + aggregate_id=invoice.invoice_id, + payload={ + "invoice_id": invoice.invoice_id, + "invoice_number": invoice.invoice_number, + "amount": invoice.amount, + "currency": invoice.currency, + "due_date": invoice.due_date, + }, + ) + reminder = self._create_task_once( + session, + tenant_id=task.tenant_id, + deal_id=task.deal_id, + task_type="send_invoice_reminder", + payload={"invoice_id": invoice.invoice_id, "source_task_id": task.task_id, "reason": "invoice.overdue"}, + match_payload={"invoice_id": invoice.invoice_id, "reason": "invoice.overdue"}, + ) + data["automation_result"] = {"status": "invoice_overdue", "reminder_task_id": reminder.task_id} + _set_payload(task, data) + + def _handle_send_invoice_reminder(self, session, task: SalesAutomationTaskRow) -> None: + data = _payload(task) + invoice_id = str(data.get("invoice_id") or "").strip() + if not invoice_id: + raise RuntimeError("invoice_id is required") + invoice = self._get_invoice(session, task, invoice_id) + deal = self._get_deal(session, task) + if invoice.status in {"paid", "canceled"} or deal.status != "active": + data["automation_result"] = { + "status": "skipped", + "invoice_status": invoice.status, + "deal_status": deal.status, + } + _set_payload(task, data) + return + + now = utc_now_iso() + deal.next_action_type = "invoice_reminder" + deal.next_action_at = now + deal.updated_at = now + follow_up = self._create_task_once( + session, + tenant_id=task.tenant_id, + deal_id=task.deal_id, + task_type="follow_up_customer", + payload={"invoice_id": invoice.invoice_id, "source_task_id": task.task_id, "reason": "invoice.reminder"}, + match_payload={"invoice_id": invoice.invoice_id, "reason": "invoice.reminder"}, + ) + data["automation_result"] = { + "status": "invoice_reminder_ready", + "follow_up_task_id": follow_up.task_id, + } + _set_payload(task, data) + self._publish( + session, + task=task, + event_type=sales_event_types.INVOICE_REMINDER_SENT, + aggregate_type="invoice", + aggregate_id=invoice.invoice_id, + payload={ + "invoice_id": invoice.invoice_id, + "invoice_number": invoice.invoice_number, + "invoice_status": invoice.status, + "follow_up_task_id": follow_up.task_id, + }, + ) + + def _handle_recommend_channel_switch(self, session, task: SalesAutomationTaskRow) -> None: + deal = self._get_deal(session, task) + data = _payload(task) + recommendation = { + "recommended_from_channel": data.get("recommended_from_channel") or deal.current_channel, + "recommended_to_channel": data.get("recommended_to_channel") or data.get("to_channel") or "voice", + "reason": data.get("reason") or "automation_recommendation", + } + deal.next_action_type = "channel_switch_recommended" + deal.next_action_at = utc_now_iso() + deal.updated_at = deal.next_action_at + data["automation_result"] = recommendation + if data.get("auto_apply"): + existing_switch = session.execute( + select(SalesChannelSwitchRow) + .where( + SalesChannelSwitchRow.tenant_id == task.tenant_id, + SalesChannelSwitchRow.deal_id == task.deal_id, + SalesChannelSwitchRow.recommended_by_task_id == task.task_id, + ) + .order_by(SalesChannelSwitchRow.id.desc()) + ).scalars().first() + if existing_switch is None: + now = utc_now_iso() + to_channel = "voice" if recommendation["recommended_to_channel"] == "voice" else "text" + from_channel = "voice" if recommendation["recommended_from_channel"] == "voice" else "text" + existing_switch = SalesChannelSwitchRow( + switch_id=new_id("swc"), + tenant_id=task.tenant_id, + deal_id=task.deal_id, + communication_id=None, + communication_session_id=None, + previous_communication_session_id=None, + new_communication_session_id=None, + from_channel=from_channel, + to_channel=to_channel, + reason_code=str(data.get("reason_code") or "system_recommendation"), + reason_text=recommendation["reason"], + reason_for_channel_switch=recommendation["reason"], + initiated_by_type="system", + initiated_by_id=None, + source_type="automation_task", + source_id=task.task_id, + recommended_by_task_id=task.task_id, + switched_at=now, + created_at=now, + ) + session.add(existing_switch) + deal.current_channel = to_channel + deal.updated_at = now + current_stage = self._stage_code(session, deal) + target_stage = "active_voice_communication" if to_channel == "voice" else "active_text_communication" + if current_stage in {"active_text_communication", "active_voice_communication"} and current_stage != target_stage: + transition_deal_stage( + session, + tenant_id=task.tenant_id, + deal_id=task.deal_id, + target_stage_code=target_stage, + actor_type="system", + reason="automation.recommend_channel_switch", + metadata={"automation_task_id": task.task_id, "switch_id": existing_switch.switch_id}, + ) + self._publish( + session, + task=task, + event_type=sales_event_types.COMMUNICATION_CHANNEL_SWITCHED, + aggregate_type="deal", + aggregate_id=deal.deal_id, + payload={ + "switch_id": existing_switch.switch_id, + "from_channel": existing_switch.from_channel, + "to_channel": existing_switch.to_channel, + "reason_code": existing_switch.reason_code, + "recommended_by_task_id": task.task_id, + }, + ) + data["actual_switch_id"] = existing_switch.switch_id + _set_payload(task, data) + self._publish( + session, + task=task, + event_type=sales_event_types.DEAL_CHANNEL_SWITCH_RECOMMENDED, + aggregate_type="deal", + aggregate_id=deal.deal_id, + payload=recommendation, + ) + + def _handle_close_stale_communication(self, session, task: SalesAutomationTaskRow) -> None: + data = _payload(task) + communication_id = str(data.get("communication_id") or "").strip() + if not communication_id: + raise RuntimeError("communication_id is required") + communication = session.execute( + select(SalesCommunicationSessionRow).where( + SalesCommunicationSessionRow.tenant_id == task.tenant_id, + SalesCommunicationSessionRow.deal_id == task.deal_id, + SalesCommunicationSessionRow.communication_id == communication_id, + ) + ).scalar_one_or_none() + if communication is None: + raise RuntimeError(f"Communication not found: {communication_id}") + if communication.status != "completed": + now = utc_now_iso() + communication.status = "completed" + communication.ended_at = communication.ended_at or now + communication.result_code = communication.result_code or str(data.get("result_code") or "stale_closed") + communication.updated_at = now + follow_up = self._create_task_once( + session, + tenant_id=task.tenant_id, + deal_id=task.deal_id, + task_type="follow_up_customer", + payload={"communication_id": communication.communication_id, "source_task_id": task.task_id, "reason": "stale_communication"}, + match_payload={"communication_id": communication.communication_id, "reason": "stale_communication"}, + ) + data["automation_result"] = {"status": "communication_closed", "follow_up_task_id": follow_up.task_id} + _set_payload(task, data) + + def _handle_escalate_to_human(self, session, task: SalesAutomationTaskRow) -> None: + deal = self._get_deal(session, task) + data = _payload(task) + escalation_type = str(data.get("escalation_type") or "automation_escalation").strip() + reason = str(data.get("reason") or "automation.escalate_to_human").strip() + assigned_to = str(data.get("assigned_to_user_id") or deal.assigned_human_user_id or "").strip() or None + existing = session.execute( + select(SalesEscalationRow) + .where( + SalesEscalationRow.tenant_id == task.tenant_id, + SalesEscalationRow.deal_id == task.deal_id, + SalesEscalationRow.escalation_type == escalation_type, + SalesEscalationRow.status.in_(["open", "assigned", "in_progress"]), + ) + .order_by(SalesEscalationRow.id.desc()) + ).scalars().first() + now = utc_now_iso() + if existing is None: + existing = SalesEscalationRow( + escalation_id=new_id("esc"), + tenant_id=task.tenant_id, + deal_id=task.deal_id, + escalation_type=escalation_type, + reason=reason, + severity=str(data.get("severity") or "medium"), + status="assigned" if assigned_to else "open", + assigned_to_user_id=assigned_to, + assigned_at=now if assigned_to else None, + started_at=None, + created_at=now, + resolved_at=None, + canceled_at=None, + resolution_code=None, + resolution_summary=None, + sla_due_at=data.get("sla_due_at"), + source_channel=deal.current_channel, + source_communication_session_id=data.get("communication_id"), + source_automation_task_id=task.task_id, + updated_at=now, + ) + session.add(existing) + self._publish( + session, + task=task, + event_type=sales_event_types.ESCALATION_CREATED, + aggregate_type="escalation", + aggregate_id=existing.escalation_id, + payload={ + "escalation_id": existing.escalation_id, + "escalation_type": existing.escalation_type, + "status": existing.status, + "severity": existing.severity, + }, + ) + else: + existing.updated_at = now + deal.assigned_human_user_id = assigned_to + deal.scenario_type = "custom_human_escalation" + deal.updated_at = now + if self._stage_code(session, deal) != "transferred_to_support": + transition_deal_stage( + session, + tenant_id=task.tenant_id, + deal_id=task.deal_id, + target_stage_code="transferred_to_support", + actor_type="system", + reason="automation.escalate_to_human", + metadata={"automation_task_id": task.task_id, "escalation_id": existing.escalation_id}, + force=True, + ) + data["automation_result"] = {"status": "escalated", "escalation_id": existing.escalation_id} + _set_payload(task, data) + self._publish( + session, + task=task, + event_type=sales_event_types.DEAL_ESCALATION_REQUESTED, + aggregate_type="deal", + aggregate_id=deal.deal_id, + payload={ + "escalation_id": existing.escalation_id, + "escalation_type": existing.escalation_type, + "severity": existing.severity, + "assigned_to_user_id": existing.assigned_to_user_id, + }, + ) + + def _handle_post_sale_transfer(self, session, task: SalesAutomationTaskRow) -> None: + deal = self._get_deal(session, task) + current_stage = self._stage_code(session, deal) + data = _payload(task) + if current_stage == "transferred_to_execution": + data["automation_result"] = {"status": "skipped", "reason": "already_transferred"} + _set_payload(task, data) + return + if deal.status != "won": + data["automation_result"] = {"status": "skipped", "reason": f"deal_status_{deal.status}"} + _set_payload(task, data) + return + transition_deal_stage( + session, + tenant_id=task.tenant_id, + deal_id=task.deal_id, + target_stage_code="transferred_to_execution", + actor_type="system", + reason="automation.post_sale_transfer", + metadata={"automation_task_id": task.task_id}, + force=True, + ) + data["automation_result"] = {"status": "transferred_post_sale"} + _set_payload(task, data) + self._publish( + session, + task=task, + event_type=sales_event_types.DEAL_TRANSFERRED_POST_SALE, + aggregate_type="deal", + aggregate_id=deal.deal_id, + payload={"transfer_status": "stub_created"}, + ) diff --git a/services/sales_service/deal_state_machine.py b/services/sales_service/deal_state_machine.py new file mode 100644 index 0000000..b22ddd5 --- /dev/null +++ b/services/sales_service/deal_state_machine.py @@ -0,0 +1,648 @@ +from __future__ import annotations + +from typing import Any + +from sqlalchemy import select + +from services.sales_service import sales_events as sales_event_types +from services.sales_service.event_publisher import SalesEventPublisher +from services.shared.core import new_id, utc_now_iso +from services.shared.sales_sql_models import ( + SalesConditionRow, + SalesCounterpartyRow, + SalesDealRow, + SalesDocumentRow, + SalesInvoiceRow, + SalesOfferRow, + SalesPaymentRow, + SalesPipelineStageRow, + SalesStageHistoryRow, +) + + +ALLOWED_TRANSITIONS: dict[str, list[str]] = { + "new_qualified_lead": [ + "warm_lead", + "hot_lead", + "enrichment_required", + "active_text_communication", + "active_voice_communication", + "lost", + ], + "warm_lead": [ + "active_text_communication", + "active_voice_communication", + "enrichment_required", + "lost", + ], + "hot_lead": [ + "active_text_communication", + "active_voice_communication", + "offer_selection", + "lost", + ], + "enrichment_required": [ + "active_text_communication", + "active_voice_communication", + "need_clarification", + "lost", + ], + "active_text_communication": [ + "waiting_customer_reply", + "need_clarification", + "need_confirmed", + "active_voice_communication", + "follow_up_scheduled", + "transferred_to_support", + "lost", + ], + "active_voice_communication": [ + "waiting_customer_reply", + "need_clarification", + "need_confirmed", + "active_text_communication", + "follow_up_scheduled", + "transferred_to_support", + "lost", + ], + "waiting_customer_reply": [ + "active_text_communication", + "active_voice_communication", + "follow_up_scheduled", + "lost", + ], + "need_clarification": [ + "active_text_communication", + "active_voice_communication", + "need_confirmed", + "transferred_to_support", + "lost", + ], + "need_confirmed": [ + "offer_selection", + "offer_preparing", + "transferred_to_support", + "lost", + ], + "offer_selection": [ + "offer_preparing", + "conditions_negotiation", + "lost", + ], + "offer_preparing": [ + "offer_sent", + "conditions_negotiation", + "lost", + ], + "offer_sent": [ + "conditions_negotiation", + "counterparty_data_requested", + "follow_up_scheduled", + "lost", + ], + "conditions_negotiation": [ + "counterparty_data_requested", + "counterparty_data_received", + "document_preparing", + "invoice_preparing", + "transferred_to_support", + "lost", + ], + "counterparty_data_requested": [ + "counterparty_data_received", + "follow_up_scheduled", + "lost", + ], + "counterparty_data_received": [ + "document_preparing", + "invoice_preparing", + "lost", + ], + "document_preparing": [ + "document_sent", + "lost", + ], + "document_sent": [ + "document_under_review", + "document_confirmed", + "follow_up_scheduled", + "lost", + ], + "document_under_review": [ + "document_confirmed", + "transferred_to_support", + "lost", + ], + "document_confirmed": [ + "invoice_preparing", + "invoice_sent", + "lost", + ], + "invoice_preparing": [ + "invoice_sent", + "lost", + ], + "invoice_sent": [ + "payment_expected", + "partially_paid", + "paid", + "payment_overdue", + "lost", + ], + "payment_expected": [ + "partially_paid", + "paid", + "payment_overdue", + "lost", + ], + "partially_paid": [ + "paid", + "payment_overdue", + "lost", + ], + "payment_overdue": [ + "payment_expected", + "partially_paid", + "paid", + "lost", + ], + "paid": [ + "won", + "transferred_to_execution", + "transferred_to_support", + ], + "follow_up_scheduled": [ + "active_text_communication", + "active_voice_communication", + "waiting_customer_reply", + "lost", + ], + "postponed": [ + "follow_up_scheduled", + "active_text_communication", + "active_voice_communication", + "lost", + ], + "transferred_to_support": [ + "active_text_communication", + "active_voice_communication", + "conditions_negotiation", + "lost", + "won", + ], + "won": [], + "lost": [], + "transferred_to_execution": [], +} + +LEGACY_STAGE_ALIASES = { + "new": "new_qualified_lead", + "active_text": "active_text_communication", + "invoice": "invoice_sent", + "paid": "paid", + "won": "won", +} +TERMINAL_STAGE_CODES = {"won", "lost", "transferred_to_execution"} +SCENARIOS_REQUIRING_OFFER = {"quotation_based_sale", "booking_based_sale", "subscription_sale"} + + +class DealStateMachineError(Exception): + def __init__( + self, + *, + status_code: int, + error: str, + message: str, + details: dict[str, Any] | None = None, + ) -> None: + super().__init__(message) + self.status_code = status_code + self.error = error + self.message = message + self.details = details or {} + + def response(self) -> dict[str, Any]: + return {"error": self.error, "message": self.message, "details": self.details} + + +def _raise(status_code: int, error: str, message: str, details: dict[str, Any] | None = None) -> None: + raise DealStateMachineError(status_code=status_code, error=error, message=message, details=details) + + +def _normalize_stage_code(value: str | None) -> str: + code = str(value or "").strip() + return LEGACY_STAGE_ALIASES.get(code, code) + + +def _get_deal(session, *, tenant_id: str, deal_id: str) -> SalesDealRow: + row = session.execute( + select(SalesDealRow).where( + SalesDealRow.tenant_id == tenant_id, + SalesDealRow.deal_id == deal_id, + ) + ).scalar_one_or_none() + if row is None: + _raise(404, "deal_not_found", "Deal not found", {"deal_id": deal_id}) + return row + + +def _get_current_stage(session, deal: SalesDealRow) -> SalesPipelineStageRow: + row = session.execute( + select(SalesPipelineStageRow).where( + SalesPipelineStageRow.tenant_id == deal.tenant_id, + SalesPipelineStageRow.stage_id == deal.stage_id, + ) + ).scalar_one_or_none() + if row is None: + _raise( + 404, + "stage_not_found", + "Current deal stage not found", + {"deal_id": deal.deal_id, "stage_id": deal.stage_id}, + ) + if row.pipeline_id != deal.pipeline_id: + _raise( + 400, + "invalid_stage_pipeline", + "Current deal stage does not belong to deal pipeline", + { + "deal_id": deal.deal_id, + "stage_id": row.stage_id, + "stage_pipeline_id": row.pipeline_id, + "deal_pipeline_id": deal.pipeline_id, + }, + ) + return row + + +def _get_target_stage(session, deal: SalesDealRow, target_stage_code: str) -> SalesPipelineStageRow: + code = _normalize_stage_code(target_stage_code) + row = session.execute( + select(SalesPipelineStageRow).where( + SalesPipelineStageRow.tenant_id == deal.tenant_id, + SalesPipelineStageRow.pipeline_id == deal.pipeline_id, + SalesPipelineStageRow.code == code, + ) + ).scalar_one_or_none() + if row is None: + _raise( + 404, + "stage_not_found", + "Target stage not found", + {"deal_id": deal.deal_id, "target_stage_code": code}, + ) + if not row.is_active: + _raise( + 400, + "stage_inactive", + "Target stage is inactive", + {"stage_id": row.stage_id, "stage_code": row.code}, + ) + return row + + +def _first_offer(session, deal: SalesDealRow, statuses: set[str] | None = None) -> SalesOfferRow | None: + stmt = select(SalesOfferRow).where( + SalesOfferRow.tenant_id == deal.tenant_id, + SalesOfferRow.deal_id == deal.deal_id, + ) + if statuses is not None: + stmt = stmt.where(SalesOfferRow.status.in_(statuses)) + return session.execute(stmt.order_by(SalesOfferRow.updated_at.desc(), SalesOfferRow.id.desc())).scalars().first() + + +def _counterparty(session, deal: SalesDealRow) -> SalesCounterpartyRow | None: + return session.execute( + select(SalesCounterpartyRow).where( + SalesCounterpartyRow.tenant_id == deal.tenant_id, + SalesCounterpartyRow.deal_id == deal.deal_id, + ) + ).scalar_one_or_none() + + +def _first_document(session, deal: SalesDealRow, statuses: set[str] | None = None) -> SalesDocumentRow | None: + stmt = select(SalesDocumentRow).where( + SalesDocumentRow.tenant_id == deal.tenant_id, + SalesDocumentRow.deal_id == deal.deal_id, + ) + if statuses is not None: + stmt = stmt.where(SalesDocumentRow.status.in_(statuses)) + return session.execute(stmt.order_by(SalesDocumentRow.updated_at.desc(), SalesDocumentRow.id.desc())).scalars().first() + + +def _first_invoice(session, deal: SalesDealRow, statuses: set[str] | None = None) -> SalesInvoiceRow | None: + stmt = select(SalesInvoiceRow).where( + SalesInvoiceRow.tenant_id == deal.tenant_id, + SalesInvoiceRow.deal_id == deal.deal_id, + ) + if statuses is not None: + stmt = stmt.where(SalesInvoiceRow.status.in_(statuses)) + return session.execute(stmt.order_by(SalesInvoiceRow.updated_at.desc(), SalesInvoiceRow.id.desc())).scalars().first() + + +def _successful_payment(session, deal: SalesDealRow) -> SalesPaymentRow | None: + return session.execute( + select(SalesPaymentRow) + .where( + SalesPaymentRow.tenant_id == deal.tenant_id, + SalesPaymentRow.deal_id == deal.deal_id, + SalesPaymentRow.status == "success", + ) + .order_by(SalesPaymentRow.updated_at.desc(), SalesPaymentRow.id.desc()) + ).scalars().first() + + +def _confirmed_conditions(session, deal: SalesDealRow) -> SalesConditionRow | None: + return session.execute( + select(SalesConditionRow) + .where( + SalesConditionRow.tenant_id == deal.tenant_id, + SalesConditionRow.deal_id == deal.deal_id, + SalesConditionRow.confirmed_at.is_not(None), + ) + .order_by(SalesConditionRow.updated_at.desc(), SalesConditionRow.id.desc()) + ).scalars().first() + + +def _has_paid_invoice_or_payment(session, deal: SalesDealRow) -> bool: + return _first_invoice(session, deal, {"paid"}) is not None or _successful_payment(session, deal) is not None + + +def _scenario_allows_transition(deal: SalesDealRow, from_code: str, to_code: str) -> bool: + if deal.scenario_type == "quick_sale" and from_code == "need_confirmed": + return to_code in {"invoice_preparing", "invoice_sent"} + if deal.scenario_type == "custom_human_escalation" and to_code == "transferred_to_support": + return True + return False + + +def _validate_allowed_transition( + deal: SalesDealRow, + from_stage: SalesPipelineStageRow, + to_stage: SalesPipelineStageRow, +) -> None: + if from_stage.stage_id == to_stage.stage_id: + return + allowed = ALLOWED_TRANSITIONS.get(from_stage.code, []) + if to_stage.code in allowed or _scenario_allows_transition(deal, from_stage.code, to_stage.code): + return + _raise( + 400, + "invalid_stage_transition", + f"Cannot transition deal from {from_stage.code} to {to_stage.code}", + { + "deal_id": deal.deal_id, + "from_stage_code": from_stage.code, + "to_stage_code": to_stage.code, + }, + ) + + +def _validate_preconditions( + session, + *, + deal: SalesDealRow, + to_stage: SalesPipelineStageRow, + reason: str | None, + metadata: dict[str, Any], +) -> None: + target = to_stage.code + if target == "need_confirmed" and not str(deal.need_summary or "").strip(): + _raise( + 400, + "missing_required_need_summary", + "need_summary is required before moving deal to need_confirmed", + {"deal_id": deal.deal_id, "to_stage_code": target}, + ) + + if target == "invoice_preparing" and deal.scenario_type in SCENARIOS_REQUIRING_OFFER: + if _first_offer(session, deal) is None: + _raise( + 400, + "offer_required_before_invoice", + "Offer is required before invoice preparation for this scenario", + {"deal_id": deal.deal_id, "scenario_type": deal.scenario_type}, + ) + + if target == "offer_sent" and _first_offer(session, deal, {"draft", "ready", "sent"}) is None: + _raise( + 400, + "offer_required_before_offer_sent", + "Offer is required before moving deal to offer_sent", + {"deal_id": deal.deal_id, "to_stage_code": target}, + ) + + if target == "counterparty_data_received": + counterparty = _counterparty(session, deal) + status = str(counterparty.completeness_status if counterparty else "").strip().lower() + if status not in {"complete", "completed", "partial_acceptable"}: + _raise( + 400, + "counterparty_required_before_document", + "Complete or acceptable counterparty data is required", + {"deal_id": deal.deal_id, "to_stage_code": target}, + ) + + if target == "document_sent" and _first_document(session, deal, {"rendered", "sent"}) is None: + _raise( + 400, + "document_required_before_document_sent", + "Rendered or sent document is required before moving deal to document_sent", + {"deal_id": deal.deal_id, "to_stage_code": target}, + ) + + if target == "invoice_sent": + if _first_invoice(session, deal, {"issued", "sent"}) is None: + _raise( + 400, + "invoice_required_before_invoice_sent", + "Issued or sent invoice is required before moving deal to invoice_sent", + {"deal_id": deal.deal_id, "to_stage_code": target}, + ) + if deal.document_required and _first_document(session, deal, {"confirmed", "signed"}) is None: + _raise( + 400, + "document_confirmed_required_before_invoice_sent", + "Confirmed document is required before sending invoice", + {"deal_id": deal.deal_id, "to_stage_code": target}, + ) + + if target == "paid" and not _has_paid_invoice_or_payment(session, deal): + _raise( + 400, + "payment_required_before_paid", + "Successful payment or paid invoice is required before moving deal to paid", + {"deal_id": deal.deal_id, "to_stage_code": target}, + ) + + if target == "won": + if deal.payment_required and not _has_paid_invoice_or_payment(session, deal): + _raise( + 400, + "payment_required_before_won", + "Successful payment or paid invoice is required before moving deal to won", + {"deal_id": deal.deal_id, "to_stage_code": target}, + ) + if not deal.payment_required and _confirmed_conditions(session, deal) is None: + _raise( + 400, + "conditions_required_before_won", + "Confirmed conditions are required before moving deal to won without payment", + {"deal_id": deal.deal_id, "to_stage_code": target}, + ) + + if target == "lost": + close_reason = ( + str(metadata.get("lost_reason") or "").strip() + or str(metadata.get("close_reason") or "").strip() + or str(reason or "").strip() + or str(deal.lost_reason or "").strip() + or str(deal.close_reason or "").strip() + ) + if not close_reason: + _raise( + 400, + "lost_reason_required", + "lost_reason or close_reason is required before moving deal to lost", + {"deal_id": deal.deal_id, "to_stage_code": target}, + ) + + +def _record_stage_change( + session, + *, + deal: SalesDealRow, + from_stage_id: str | None, + to_stage_id: str, + actor_type: str, + actor_id: str | None, + reason: str | None, + changed_at: str, +) -> None: + session.add( + SalesStageHistoryRow( + history_id=new_id("sth"), + tenant_id=deal.tenant_id, + deal_id=deal.deal_id, + from_stage_id=from_stage_id, + to_stage_id=to_stage_id, + changed_by_type=actor_type, + changed_by_id=actor_id, + reason=reason, + changed_at=changed_at, + ) + ) + + +def _apply_terminal_state(deal: SalesDealRow, *, to_stage: SalesPipelineStageRow, reason: str | None, metadata: dict[str, Any], now: str) -> None: + if to_stage.code == "won": + deal.status = "won" + deal.closed_at = deal.closed_at or now + deal.won_reason = str(metadata.get("won_reason") or reason or deal.won_reason or "deal.won").strip() + return + + if to_stage.code == "lost": + close_reason = ( + str(metadata.get("lost_reason") or "").strip() + or str(metadata.get("close_reason") or "").strip() + or str(reason or "").strip() + or str(deal.lost_reason or "").strip() + or str(deal.close_reason or "").strip() + ) + deal.status = "lost" + deal.closed_at = deal.closed_at or now + deal.lost_reason = close_reason + deal.close_reason = deal.close_reason or close_reason + return + + if to_stage.code == "transferred_to_execution": + deal.status = "closed" + deal.closed_at = deal.closed_at or now + deal.close_reason = str(metadata.get("close_reason") or reason or deal.close_reason or "transferred_to_execution").strip() + return + + if deal.status in {"won", "lost", "closed"}: + deal.status = "active" + deal.closed_at = None + + +def transition_deal_stage( + session, + *, + tenant_id: str, + deal_id: str, + target_stage_code: str, + actor_type: str, + actor_id: str | None = None, + reason: str | None = None, + metadata: dict | None = None, + force: bool = False, +) -> SalesPipelineStageRow: + reason_text = str(reason or "").strip() or None + event_metadata = dict(metadata or {}) + if force: + if actor_type not in {"system", "admin"}: + _raise( + 403, + "force_transition_forbidden", + "force transition is allowed only for system or admin actors", + {"actor_type": actor_type}, + ) + if not reason_text: + _raise( + 400, + "force_transition_reason_required", + "reason is required for force transition", + {"deal_id": deal_id, "target_stage_code": target_stage_code}, + ) + event_metadata["force"] = True + + deal = _get_deal(session, tenant_id=tenant_id, deal_id=deal_id) + current_stage = _get_current_stage(session, deal) + target_stage = _get_target_stage(session, deal, target_stage_code) + + if not force: + _validate_allowed_transition(deal, current_stage, target_stage) + _validate_preconditions(session, deal=deal, to_stage=target_stage, reason=reason_text, metadata=event_metadata) + + now = utc_now_iso() + if current_stage.stage_id == target_stage.stage_id: + deal.updated_at = now + _apply_terminal_state(deal, to_stage=target_stage, reason=reason_text, metadata=event_metadata, now=now) + return target_stage + + previous_stage_id = deal.stage_id + deal.stage_id = target_stage.stage_id + _apply_terminal_state(deal, to_stage=target_stage, reason=reason_text, metadata=event_metadata, now=now) + deal.updated_at = now + + _record_stage_change( + session, + deal=deal, + from_stage_id=previous_stage_id, + to_stage_id=target_stage.stage_id, + actor_type=actor_type, + actor_id=actor_id, + reason=reason_text, + changed_at=now, + ) + SalesEventPublisher.publish_sales_event( + session, + tenant_id=deal.tenant_id, + event_type=sales_event_types.DEAL_STAGE_CHANGED, + aggregate_type="deal", + aggregate_id=deal.deal_id, + actor_type=actor_type, + actor_id=actor_id, + payload={ + "deal_id": deal.deal_id, + "pipeline_id": deal.pipeline_id, + "from_stage_id": previous_stage_id, + "from_stage_code": current_stage.code, + "to_stage_id": target_stage.stage_id, + "to_stage_code": target_stage.code, + "reason": reason_text, + "metadata": event_metadata, + }, + ) + return target_stage diff --git a/services/sales_service/payment_webhooks.py b/services/sales_service/payment_webhooks.py new file mode 100644 index 0000000..35653ff --- /dev/null +++ b/services/sales_service/payment_webhooks.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +import hashlib +import hmac +import json +from datetime import datetime, timezone +from typing import Any, Protocol + + +class PaymentWebhookVerificationError(Exception): + def __init__(self, signature_status: str, message: str, *, status_code: int = 400) -> None: + super().__init__(message) + self.signature_status = signature_status + self.message = message + self.status_code = status_code + + +class PaymentProviderAdapter(Protocol): + def get_status(self, external_payment_id: str, *, context: dict[str, Any] | None = None) -> dict[str, Any] | str: + ... + + +_PROVIDER_ADAPTERS: dict[str, PaymentProviderAdapter] = {} + + +def register_payment_provider_adapter(provider: str, adapter: PaymentProviderAdapter) -> None: + normalized = normalize_provider(provider) + if normalized: + _PROVIDER_ADAPTERS[normalized] = adapter + + +def get_payment_provider_adapter(provider: str | None) -> PaymentProviderAdapter | None: + return _PROVIDER_ADAPTERS.get(normalize_provider(provider)) + + +def normalize_provider(provider: str | None) -> str: + return str(provider or "").strip().lower() or "manual" + + +def raw_payload_hash(raw_body: bytes) -> str: + return hashlib.sha256(raw_body).hexdigest() + + +def safe_json_loads(raw_body: bytes) -> dict[str, Any]: + if not raw_body: + return {} + parsed = json.loads(raw_body.decode("utf-8")) + if not isinstance(parsed, dict): + raise ValueError("Webhook payload must be a JSON object") + return parsed + + +def normalize_headers(headers: Any) -> dict[str, str]: + return {str(key).lower(): str(value) for key, value in dict(headers).items()} + + +def header_value(headers: dict[str, str], *names: str) -> str | None: + for name in names: + value = headers.get(name.lower()) + normalized = str(value or "").strip() + if normalized: + return normalized + return None + + +def metadata_value(payload: dict[str, Any] | None, *keys: str) -> str | None: + data = payload or {} + metadata = data.get("metadata") if isinstance(data.get("metadata"), dict) else {} + candidates = [data, metadata] + payment = data.get("payment") if isinstance(data.get("payment"), dict) else {} + candidates.append(payment) + nested_metadata = payment.get("metadata") if isinstance(payment.get("metadata"), dict) else {} + candidates.append(nested_metadata) + data_object = data.get("data", {}).get("object") if isinstance(data.get("data"), dict) else {} + if isinstance(data_object, dict): + candidates.append(data_object) + + for source in candidates: + for key in keys: + value = source.get(key) + normalized = str(value or "").strip() + if normalized: + return normalized + return None + + +def extract_payment_provider(payload: dict[str, Any], headers: dict[str, str]) -> str: + return normalize_provider( + header_value(headers, "x-payment-provider", "x-provider") + or metadata_value(payload, "payment_provider", "provider") + ) + + +def extract_provider_account_id(payload: dict[str, Any], headers: dict[str, str]) -> str | None: + return header_value( + headers, + "x-provider-account-id", + "x-merchant-id", + "x-terminal-id", + "x-integration-id", + ) or metadata_value( + payload, + "provider_account_id", + "payment_provider_account_id", + "merchant_id", + "terminal_id", + "account_id", + "integration_id", + ) + + +def extract_external_event_id(payload: dict[str, Any], headers: dict[str, str]) -> str | None: + return header_value(headers, "x-webhook-event-id", "x-event-id") or metadata_value( + payload, + "external_event_id", + "webhook_event_id", + "event_id", + ) + + +def extract_external_payment_id(payload: dict[str, Any], headers: dict[str, str]) -> str | None: + return header_value(headers, "x-payment-id", "x-external-payment-id") or metadata_value( + payload, + "external_payment_id", + "external_payment_ref", + "transaction_id", + "provider_payment_id", + "payment_id", + "id", + ) + + +def extract_event_type(payload: dict[str, Any], headers: dict[str, str]) -> str: + return ( + header_value(headers, "x-webhook-event-type", "x-event-type") + or metadata_value(payload, "event_type", "type") + or normalize_payment_event_type(None, metadata_value(payload, "status", "provider_status")) + ) + + +def normalize_payment_status(provider: str | None, provider_status: str | None) -> str: + normalized = str(provider_status or "").strip().lower() + normalized = normalized.replace("-", "_").replace(" ", "_") + if normalized in {"succeeded", "success", "paid", "captured", "settled", "completed"}: + return "success" + if normalized in {"authorized", "authorised", "processing", "pending", "created", "new"}: + return "pending" + if normalized in {"failed", "declined", "error", "rejected"}: + return "failed" + if normalized in {"canceled", "cancelled", "voided", "expired"}: + return "canceled" + if normalized in {"partial", "partially_paid", "partial_paid"}: + return "partial" + return normalized if normalized in {"pending", "success", "failed", "canceled", "partial"} else "pending" + + +def normalize_payment_event_type(provider: str | None, provider_status: str | None, event_type: str | None = None) -> str: + raw_event_type = str(event_type or "").strip().lower() + if raw_event_type.startswith("payment."): + if raw_event_type in {"payment.succeeded", "payment.captured", "payment.paid"}: + return "payment.received" + if raw_event_type in {"payment.cancelled", "payment.canceled"}: + return "payment.canceled" + return raw_event_type + + status = normalize_payment_status(provider, provider_status) + if status in {"success", "partial"}: + return "payment.received" + if status == "failed": + return "payment.failed" + if status == "canceled": + return "payment.canceled" + return "payment.pending" + + +def _signature_candidates(signature_header: str) -> set[str]: + raw = str(signature_header or "").strip() + if not raw: + return set() + candidates = {raw} + if raw.startswith("sha256="): + candidates.add(raw.split("=", 1)[1]) + for part in raw.split(","): + key, sep, value = part.partition("=") + if sep and key.strip() in {"v1", "sha256"} and value.strip(): + candidates.add(value.strip()) + return candidates + + +def verify_payment_webhook_signature( + *, + raw_body: bytes, + headers: dict[str, str], + secret: str | None, + replay_window_seconds: int = 600, + now: datetime | None = None, + required: bool = True, +) -> str: + normalized_secret = str(secret or "").strip() + if not required and not normalized_secret: + return "not_required" + if not normalized_secret: + raise PaymentWebhookVerificationError("missing_secret", "Payment webhook secret is not configured", status_code=403) + + timestamp = header_value(headers, "x-webhook-timestamp", "x-provider-timestamp", "x-timestamp") + if timestamp: + try: + event_ts = int(float(timestamp)) + except ValueError as exc: + raise PaymentWebhookVerificationError("invalid_timestamp", "Invalid webhook timestamp") from exc + now_ts = int((now or datetime.now(timezone.utc)).timestamp()) + if abs(now_ts - event_ts) > max(int(replay_window_seconds), 1): + raise PaymentWebhookVerificationError("expired", "Webhook timestamp is outside the replay window") + + signature_header = header_value(headers, "x-webhook-signature", "x-signature", "stripe-signature") + if not signature_header: + raise PaymentWebhookVerificationError("missing", "Payment webhook signature is missing", status_code=403) + + body_to_sign = raw_body + if timestamp: + body_to_sign = f"{timestamp}.".encode("utf-8") + raw_body + expected = hmac.new(normalized_secret.encode("utf-8"), body_to_sign, hashlib.sha256).hexdigest() + for candidate in _signature_candidates(signature_header): + if hmac.compare_digest(candidate, expected): + return "valid" + raise PaymentWebhookVerificationError("invalid", "Payment webhook signature is invalid", status_code=403) diff --git a/services/sales_service/sales_events.py b/services/sales_service/sales_events.py index 22f5aab..7666049 100644 --- a/services/sales_service/sales_events.py +++ b/services/sales_service/sales_events.py @@ -9,8 +9,13 @@ DEAL_NEXT_ACTION_SCHEDULED = "deal.next_action_scheduled" DEAL_CLOSED = "deal.closed" DEAL_LOST = "deal.lost" DEAL_WON = "deal.won" +DEAL_ESCALATION_REQUESTED = "deal.escalation_requested" +DEAL_TRANSFERRED_POST_SALE = "deal.transferred_post_sale" +DEAL_CHANNEL_SWITCH_RECOMMENDED = "deal.channel_switch_recommended" +DEAL_FOLLOW_UP_READY = "deal.follow_up_ready" COMMUNICATION_STARTED = "communication.started" +COMMUNICATION_CHANNEL_SWITCHED = "communication.channel_switched" COMMUNICATION_SUMMARY_CREATED = "communication.summary_created" MESSAGE_RECEIVED = "message.received" MESSAGE_SENT = "message.sent" @@ -22,6 +27,8 @@ OFFER_SENT = "offer.sent" OFFER_ACCEPTED = "offer.accepted" OFFER_REJECTED = "offer.rejected" DEAL_CONDITIONS_CONFIRMED = "deal.conditions_confirmed" +DEAL_NOTE_CREATED = "deal.note.created" +DEAL_NOTE_UPDATED = "deal.note.updated" COUNTERPARTY_COMPLETED = "counterparty.completed" DOCUMENT_CREATED = "document.created" @@ -32,9 +39,16 @@ DOCUMENT_SIGNED = "document.signed" INVOICE_CREATED = "invoice.created" INVOICE_SENT = "invoice.sent" INVOICE_OVERDUE = "invoice.overdue" +INVOICE_REMINDER_SENT = "invoice.reminder_sent" PAYMENT_RECEIVED = "payment.received" INVOICE_PAID = "invoice.paid" +ESCALATION_CREATED = "escalation.created" +ESCALATION_ASSIGNED = "escalation.assigned" +ESCALATION_STARTED = "escalation.started" +ESCALATION_RESOLVED = "escalation.resolved" +ESCALATION_CANCELED = "escalation.canceled" + SALES_EVENT_TYPES = { LEAD_ENTERED_CRM, @@ -45,7 +59,12 @@ SALES_EVENT_TYPES = { DEAL_CLOSED, DEAL_LOST, DEAL_WON, + DEAL_ESCALATION_REQUESTED, + DEAL_TRANSFERRED_POST_SALE, + DEAL_CHANNEL_SWITCH_RECOMMENDED, + DEAL_FOLLOW_UP_READY, COMMUNICATION_STARTED, + COMMUNICATION_CHANNEL_SWITCHED, COMMUNICATION_SUMMARY_CREATED, MESSAGE_RECEIVED, MESSAGE_SENT, @@ -56,6 +75,8 @@ SALES_EVENT_TYPES = { OFFER_ACCEPTED, OFFER_REJECTED, DEAL_CONDITIONS_CONFIRMED, + DEAL_NOTE_CREATED, + DEAL_NOTE_UPDATED, COUNTERPARTY_COMPLETED, DOCUMENT_CREATED, DOCUMENT_SENT, @@ -64,6 +85,12 @@ SALES_EVENT_TYPES = { INVOICE_CREATED, INVOICE_SENT, INVOICE_OVERDUE, + INVOICE_REMINDER_SENT, PAYMENT_RECEIVED, INVOICE_PAID, + ESCALATION_CREATED, + ESCALATION_ASSIGNED, + ESCALATION_STARTED, + ESCALATION_RESOLVED, + ESCALATION_CANCELED, } diff --git a/services/shared/sales_models.py b/services/shared/sales_models.py index a1d6b83..4c9d6b4 100644 --- a/services/shared/sales_models.py +++ b/services/shared/sales_models.py @@ -38,9 +38,10 @@ SalesDocumentStatus = Literal["draft", "sent", "under_review", "confirmed", "sig SalesInvoiceStatus = Literal["draft", "issued", "sent", "partially_paid", "paid", "overdue", "canceled"] SalesPaymentStatus = Literal["pending", "success", "failed", "canceled", "partial"] SalesEscalationSeverity = Literal["low", "medium", "high", "critical"] -SalesEscalationStatus = Literal["open", "in_progress", "resolved"] +SalesEscalationStatus = Literal["open", "assigned", "in_progress", "resolved", "canceled"] SalesAutomationStatus = Literal["pending", "running", "completed", "failed", "canceled"] SalesPipelineStageCategory = Literal["entry", "communication", "commercial", "paperwork", "finance", "closing"] +SalesSwitchChannel = Literal["text", "voice"] class SalesStageRefOut(BaseModel): @@ -222,7 +223,11 @@ class SalesDealUpdate(BaseModel): class SalesDealStageChangeIn(BaseModel): stage_id: str | None = Field(default=None, min_length=2) stage_code: str | None = Field(default=None, min_length=2) + target_stage_id: str | None = Field(default=None, min_length=2) + target_stage_code: str | None = Field(default=None, min_length=2) reason: str | None = None + metadata: dict = Field(default_factory=dict) + force: bool = False class SalesDealScenarioIn(BaseModel): @@ -295,8 +300,16 @@ class SalesCommunicationSummaryIn(BaseModel): class SalesCommunicationSwitchChannelIn(BaseModel): - to_channel: SalesPreferredChannel - reason_for_channel_switch: str = Field(min_length=3) + to_channel: SalesSwitchChannel + reason_code: str = "human_decision" + reason_text: str | None = None + reason_for_channel_switch: str | None = None + create_session: bool = False + scheduled_at: str | None = None + metadata: dict = Field(default_factory=dict) + source_type: str | None = None + source_id: str | None = None + recommended_by_task_id: str | None = None class SalesCommunicationBindExternalIn(BaseModel): @@ -315,6 +328,7 @@ class SalesCommunicationOut(BaseModel): lead_id: str | None = None customer_id: str | None = None channel_type: SalesChannelType + channel_provider: str | None = None direction: SalesDirection agent_type: SalesAgentType started_at: str @@ -580,7 +594,11 @@ class SalesPaymentWebhookIn(BaseModel): deal_id: str invoice_id: str | None = None payment_provider: str = "manual" + provider_account_id: str | None = None + external_event_id: str | None = None external_payment_id: str | None = None + event_type: str | None = None + provider_status: str | None = None amount: float = Field(ge=0) currency: str = "KZT" status: SalesPaymentStatus = "success" @@ -618,6 +636,10 @@ class SalesEscalationCreateIn(BaseModel): reason: str = Field(min_length=3) severity: SalesEscalationSeverity = "medium" assigned_to_user_id: str | None = None + source_channel: str | None = None + source_communication_session_id: str | None = None + source_automation_task_id: str | None = None + sla_due_at: str | None = None class SalesEscalationOut(BaseModel): @@ -628,8 +650,35 @@ class SalesEscalationOut(BaseModel): severity: SalesEscalationSeverity status: SalesEscalationStatus assigned_to_user_id: str | None = None + assigned_at: str | None = None + started_at: str | None = None created_at: str resolved_at: str | None = None + canceled_at: str | None = None + resolution_code: str | None = None + resolution_summary: str | None = None + sla_due_at: str | None = None + source_channel: str | None = None + source_communication_session_id: str | None = None + source_automation_task_id: str | None = None + updated_at: str | None = None + + +class SalesEscalationAssignIn(BaseModel): + assigned_to_user_id: str = Field(min_length=1) + + +class SalesEscalationResolveIn(BaseModel): + resolution_code: str = Field(min_length=2) + resolution_summary: str | None = None + target_stage_code: str | None = None + metadata: dict = Field(default_factory=dict) + + +class SalesEscalationCancelIn(BaseModel): + resolution_code: str = "canceled" + resolution_summary: str | None = None + metadata: dict = Field(default_factory=dict) class SalesAutomationTaskOut(BaseModel): @@ -640,6 +689,11 @@ class SalesAutomationTaskOut(BaseModel): run_at: str status: SalesAutomationStatus retry_count: int = 0 + max_retries: int = 3 + locked_at: str | None = None + locked_by: str | None = None + completed_at: str | None = None + failed_at: str | None = None last_error: str | None = None created_at: str updated_at: str @@ -662,10 +716,50 @@ class SalesChannelSwitchOut(BaseModel): switch_id: str deal_id: str communication_id: str | None = None + communication_session_id: str | None = None + previous_communication_session_id: str | None = None + new_communication_session_id: str | None = None from_channel: str to_channel: str + reason_code: str + reason_text: str | None = None reason_for_channel_switch: str + initiated_by_type: str + initiated_by_id: str | None = None + source_type: str | None = None + source_id: str | None = None + recommended_by_task_id: str | None = None switched_at: str + created_at: str + new_communication: SalesCommunicationOut | None = None + + +class SalesNoteCreateIn(BaseModel): + note_type: str = "general" + content: str = Field(min_length=1) + source_type: str | None = None + source_id: str | None = None + + +class SalesNoteUpdateIn(BaseModel): + note_type: str | None = None + content: str | None = Field(default=None, min_length=1) + source_type: str | None = None + source_id: str | None = None + + +class SalesNoteOut(BaseModel): + note_id: str + deal_id: str + author_type: str + author_id: str | None = None + note_type: str + content: str + source_type: str | None = None + source_id: str | None = None + created_at: str + updated_at: str | None = None + archived_at: str | None = None class SalesTimelineEventOut(BaseModel): @@ -684,6 +778,8 @@ class SalesWorkspaceOut(BaseModel): communications: list[SalesCommunicationOut] = Field(default_factory=list) messages: list[SalesMessageOut] = Field(default_factory=list) calls: list[SalesCallOut] = Field(default_factory=list) + transcripts: list[SalesTranscriptOut] = Field(default_factory=list) + notes: list[SalesNoteOut] = Field(default_factory=list) offers: list[SalesOfferOut] = Field(default_factory=list) conditions: list[SalesConditionOut] = Field(default_factory=list) counterparty: SalesCounterpartyOut | None = None @@ -691,9 +787,11 @@ class SalesWorkspaceOut(BaseModel): invoices: list[SalesInvoiceOut] = Field(default_factory=list) payments: list[SalesPaymentOut] = Field(default_factory=list) escalations: list[SalesEscalationOut] = Field(default_factory=list) + active_escalation: SalesEscalationOut | None = None tasks: list[SalesAutomationTaskOut] = Field(default_factory=list) stage_history: list[SalesStageHistoryOut] = Field(default_factory=list) channel_switches: list[SalesChannelSwitchOut] = Field(default_factory=list) + recommended_channel_switch: dict | None = None timeline: list[SalesTimelineEventOut] = Field(default_factory=list) diff --git a/services/shared/sales_sql_models.py b/services/shared/sales_sql_models.py index e1abf50..f2edf4a 100644 --- a/services/shared/sales_sql_models.py +++ b/services/shared/sales_sql_models.py @@ -178,6 +178,7 @@ class SalesCommunicationSessionRow(Base): lead_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) channel_type: Mapped[str] = mapped_column(String(16), index=True) + channel_provider: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) direction: Mapped[str] = mapped_column(String(16), index=True) agent_type: Mapped[str] = mapped_column(String(32), index=True) started_at: Mapped[str] = mapped_column(String(64), index=True) @@ -275,7 +276,11 @@ class SalesNoteRow(Base): author_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) note_type: Mapped[str] = mapped_column(String(64), index=True) content: Mapped[str] = mapped_column(Text) + source_type: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + source_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) created_at: Mapped[str] = mapped_column(String(64), index=True) + updated_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + archived_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) class SalesOfferRow(Base): @@ -403,6 +408,15 @@ class SalesPaymentRow(Base): __tablename__ = "sales_payments" __table_args__ = ( Index("idx_sales_payments_deal_status", "deal_id", "status"), + Index( + "idx_sales_payments_tenant_external_payment_unique", + "tenant_id", + "payment_provider", + "external_payment_id", + unique=True, + sqlite_where=text("external_payment_id IS NOT NULL"), + postgresql_where=text("external_payment_id IS NOT NULL"), + ), ) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) @@ -423,6 +437,41 @@ class SalesPaymentRow(Base): updated_at: Mapped[str] = mapped_column(String(64), index=True) +class SalesPaymentWebhookEventRow(Base): + __tablename__ = "sales_payment_webhook_events" + __table_args__ = ( + Index("idx_sales_payment_webhook_events_received", "tenant_id", "received_at"), + Index("idx_sales_payment_webhook_events_payment", "tenant_id", "payment_provider", "external_payment_id"), + Index( + "idx_sales_payment_webhook_events_external_event_unique", + "tenant_id", + "payment_provider", + "external_event_id", + unique=True, + sqlite_where=text("external_event_id IS NOT NULL"), + postgresql_where=text("external_event_id IS NOT NULL"), + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + tenant_id: Mapped[str] = mapped_column(String(64), index=True) + payment_provider: Mapped[str] = mapped_column(String(64), index=True) + provider_account_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + external_event_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + external_payment_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + event_type: Mapped[str] = mapped_column(String(128), index=True) + event_status: Mapped[str] = mapped_column(String(32), index=True, default="received") + signature_status: Mapped[str] = mapped_column(String(32), index=True, default="unchecked") + raw_payload_hash: Mapped[str] = mapped_column(String(64), index=True) + normalized_payload_json: Mapped[str] = mapped_column(Text, default="{}") + headers_json: Mapped[str] = mapped_column(Text, default="{}") + received_at: Mapped[str] = mapped_column(String(64), index=True) + processed_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[str] = mapped_column(String(64), index=True) + updated_at: Mapped[str] = mapped_column(String(64), index=True) + + class SalesStageHistoryRow(Base): __tablename__ = "sales_stage_history" __table_args__ = ( @@ -456,14 +505,25 @@ class SalesEscalationRow(Base): severity: Mapped[str] = mapped_column(String(16), index=True) status: Mapped[str] = mapped_column(String(32), index=True, default="open") assigned_to_user_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + assigned_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + started_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) created_at: Mapped[str] = mapped_column(String(64), index=True) resolved_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + canceled_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + resolution_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + resolution_summary: Mapped[str | None] = mapped_column(Text, nullable=True) + sla_due_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + source_channel: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + source_communication_session_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + source_automation_task_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + updated_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) class SalesAutomationTaskRow(Base): __tablename__ = "sales_automation_tasks" __table_args__ = ( Index("idx_sales_tasks_run_status", "run_at", "status"), + Index("idx_sales_tasks_lock", "status", "locked_at", "locked_by"), ) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) @@ -475,6 +535,11 @@ class SalesAutomationTaskRow(Base): run_at: Mapped[str] = mapped_column(String(64), index=True) status: Mapped[str] = mapped_column(String(32), index=True, default="pending") retry_count: Mapped[int] = mapped_column(Integer, default=0) + max_retries: Mapped[int] = mapped_column(Integer, default=3) + locked_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + locked_by: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + completed_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + failed_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) last_error: Mapped[str | None] = mapped_column(Text, nullable=True) created_at: Mapped[str] = mapped_column(String(64), index=True) updated_at: Mapped[str] = mapped_column(String(64), index=True) @@ -491,10 +556,21 @@ class SalesChannelSwitchRow(Base): tenant_id: Mapped[str] = mapped_column(String(64), index=True) deal_id: Mapped[str] = mapped_column(String(64), index=True) communication_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + communication_session_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + previous_communication_session_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + new_communication_session_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) from_channel: Mapped[str] = mapped_column(String(32), index=True) to_channel: Mapped[str] = mapped_column(String(32), index=True) + reason_code: Mapped[str] = mapped_column(String(64), default="human_decision", index=True) + reason_text: Mapped[str | None] = mapped_column(Text, nullable=True) reason_for_channel_switch: Mapped[str] = mapped_column(Text) + initiated_by_type: Mapped[str] = mapped_column(String(32), default="human", index=True) + initiated_by_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + source_type: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + source_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + recommended_by_task_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) switched_at: Mapped[str] = mapped_column(String(64), index=True) + created_at: Mapped[str] = mapped_column(String(64), index=True) class SalesExternalLinkRow(Base): diff --git a/services/shared/sql_init.py b/services/shared/sql_init.py index faa539d..f090062 100644 --- a/services/shared/sql_init.py +++ b/services/shared/sql_init.py @@ -601,6 +601,145 @@ def _apply_runtime_schema_compatibility() -> None: ) ) + if "sales_automation_tasks" in table_names: + columns = _table_columns(inspector, "sales_automation_tasks") + _add_column_if_missing(conn, columns, "sales_automation_tasks", "max_retries", "INTEGER DEFAULT 3") + _add_column_if_missing(conn, columns, "sales_automation_tasks", "locked_at", "VARCHAR(64)") + _add_column_if_missing(conn, columns, "sales_automation_tasks", "locked_by", "VARCHAR(128)") + _add_column_if_missing(conn, columns, "sales_automation_tasks", "completed_at", "VARCHAR(64)") + _add_column_if_missing(conn, columns, "sales_automation_tasks", "failed_at", "VARCHAR(64)") + conn.execute( + text( + """ + UPDATE sales_automation_tasks + SET max_retries = 3 + WHERE max_retries IS NULL + """ + ) + ) + indexes = _table_indexes(inspector, "sales_automation_tasks") + if "idx_sales_tasks_lock" not in indexes: + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_sales_tasks_lock " + "ON sales_automation_tasks(status, locked_at, locked_by)" + ) + ) + + if "sales_communication_sessions" in table_names: + columns = _table_columns(inspector, "sales_communication_sessions") + _add_column_if_missing(conn, columns, "sales_communication_sessions", "channel_provider", "VARCHAR(32)") + indexes = _table_indexes(inspector, "sales_communication_sessions") + if "idx_sales_comm_channel_provider" not in indexes: + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_sales_comm_channel_provider " + "ON sales_communication_sessions(channel_provider)" + ) + ) + + if "sales_notes" in table_names: + columns = _table_columns(inspector, "sales_notes") + _add_column_if_missing(conn, columns, "sales_notes", "source_type", "VARCHAR(64)") + _add_column_if_missing(conn, columns, "sales_notes", "source_id", "VARCHAR(128)") + _add_column_if_missing(conn, columns, "sales_notes", "updated_at", "VARCHAR(64)") + _add_column_if_missing(conn, columns, "sales_notes", "archived_at", "VARCHAR(64)") + conn.execute( + text( + """ + UPDATE sales_notes + SET updated_at = created_at + WHERE updated_at IS NULL OR TRIM(updated_at) = '' + """ + ) + ) + + if "sales_escalations" in table_names: + columns = _table_columns(inspector, "sales_escalations") + _add_column_if_missing(conn, columns, "sales_escalations", "assigned_at", "VARCHAR(64)") + _add_column_if_missing(conn, columns, "sales_escalations", "started_at", "VARCHAR(64)") + _add_column_if_missing(conn, columns, "sales_escalations", "canceled_at", "VARCHAR(64)") + _add_column_if_missing(conn, columns, "sales_escalations", "resolution_code", "VARCHAR(64)") + _add_column_if_missing(conn, columns, "sales_escalations", "resolution_summary", "TEXT") + _add_column_if_missing(conn, columns, "sales_escalations", "sla_due_at", "VARCHAR(64)") + _add_column_if_missing(conn, columns, "sales_escalations", "source_channel", "VARCHAR(32)") + _add_column_if_missing(conn, columns, "sales_escalations", "source_communication_session_id", "VARCHAR(64)") + _add_column_if_missing(conn, columns, "sales_escalations", "source_automation_task_id", "VARCHAR(64)") + _add_column_if_missing(conn, columns, "sales_escalations", "updated_at", "VARCHAR(64)") + conn.execute( + text( + """ + UPDATE sales_escalations + SET updated_at = COALESCE(updated_at, resolved_at, created_at) + WHERE updated_at IS NULL OR TRIM(updated_at) = '' + """ + ) + ) + + if "sales_channel_switches" in table_names: + columns = _table_columns(inspector, "sales_channel_switches") + _add_column_if_missing(conn, columns, "sales_channel_switches", "communication_session_id", "VARCHAR(64)") + _add_column_if_missing(conn, columns, "sales_channel_switches", "previous_communication_session_id", "VARCHAR(64)") + _add_column_if_missing(conn, columns, "sales_channel_switches", "new_communication_session_id", "VARCHAR(64)") + _add_column_if_missing(conn, columns, "sales_channel_switches", "reason_code", "VARCHAR(64) DEFAULT 'human_decision'") + _add_column_if_missing(conn, columns, "sales_channel_switches", "reason_text", "TEXT") + _add_column_if_missing(conn, columns, "sales_channel_switches", "initiated_by_type", "VARCHAR(32) DEFAULT 'human'") + _add_column_if_missing(conn, columns, "sales_channel_switches", "initiated_by_id", "VARCHAR(128)") + _add_column_if_missing(conn, columns, "sales_channel_switches", "source_type", "VARCHAR(64)") + _add_column_if_missing(conn, columns, "sales_channel_switches", "source_id", "VARCHAR(128)") + _add_column_if_missing(conn, columns, "sales_channel_switches", "recommended_by_task_id", "VARCHAR(64)") + _add_column_if_missing(conn, columns, "sales_channel_switches", "created_at", "VARCHAR(64)") + conn.execute( + text( + """ + UPDATE sales_channel_switches + SET communication_session_id = COALESCE(communication_session_id, communication_id), + previous_communication_session_id = COALESCE(previous_communication_session_id, communication_id), + reason_code = COALESCE(reason_code, 'human_decision'), + reason_text = COALESCE(reason_text, reason_for_channel_switch), + initiated_by_type = COALESCE(initiated_by_type, 'human'), + created_at = COALESCE(created_at, switched_at) + WHERE created_at IS NULL OR TRIM(created_at) = '' + """ + ) + ) + + if "sales_payment_webhook_events" in table_names: + indexes = _table_indexes(inspector, "sales_payment_webhook_events") + if "idx_sales_payment_webhook_events_received" not in indexes: + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_sales_payment_webhook_events_received " + "ON sales_payment_webhook_events(tenant_id, received_at)" + ) + ) + if "idx_sales_payment_webhook_events_payment" not in indexes: + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_sales_payment_webhook_events_payment " + "ON sales_payment_webhook_events(tenant_id, payment_provider, external_payment_id)" + ) + ) + if "idx_sales_payment_webhook_events_external_event_unique" not in indexes: + conn.execute( + text( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_sales_payment_webhook_events_external_event_unique " + "ON sales_payment_webhook_events(tenant_id, payment_provider, external_event_id) " + "WHERE external_event_id IS NOT NULL" + ) + ) + + if "sales_payments" in table_names: + indexes = _table_indexes(inspector, "sales_payments") + if "idx_sales_payments_tenant_external_payment_unique" not in indexes: + conn.execute( + text( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_sales_payments_tenant_external_payment_unique " + "ON sales_payments(tenant_id, payment_provider, external_payment_id) " + "WHERE external_payment_id IS NOT NULL" + ) + ) + def init_sql_schema() -> None: global _BASE_SCHEMA_INITIALIZED diff --git a/tests/test_deal_state_machine.py b/tests/test_deal_state_machine.py new file mode 100644 index 0000000..29ec78d --- /dev/null +++ b/tests/test_deal_state_machine.py @@ -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" diff --git a/tests/test_sales_automation_worker.py b/tests/test_sales_automation_worker.py new file mode 100644 index 0000000..819ef2e --- /dev/null +++ b/tests/test_sales_automation_worker.py @@ -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") == [] diff --git a/tests/test_sales_events.py b/tests/test_sales_events.py index a4b5a14..6bcaf4c 100644 --- a/tests/test_sales_events.py +++ b/tests/test_sales_events.py @@ -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"}, diff --git a/tests/test_sales_omnichannel.py b/tests/test_sales_omnichannel.py new file mode 100644 index 0000000..26eabf0 --- /dev/null +++ b/tests/test_sales_omnichannel.py @@ -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} diff --git a/tests/test_sales_payment_webhooks.py b/tests/test_sales_payment_webhooks.py new file mode 100644 index 0000000..8130656 --- /dev/null +++ b/tests/test_sales_payment_webhooks.py @@ -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() diff --git a/tests/test_sales_pipeline_stages.py b/tests/test_sales_pipeline_stages.py index 07560b1..4441b76 100644 --- a/tests/test_sales_pipeline_stages.py +++ b/tests/test_sales_pipeline_stages.py @@ -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(): diff --git a/tests/test_sales_service.py b/tests/test_sales_service.py index b9c316f..56aee94 100644 --- a/tests/test_sales_service.py +++ b/tests/test_sales_service.py @@ -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) diff --git a/tests/test_sales_tenant_isolation.py b/tests/test_sales_tenant_isolation.py index 4471ab0..dd4816e 100644 --- a/tests/test_sales_tenant_isolation.py +++ b/tests/test_sales_tenant_isolation.py @@ -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",