Quorentra

Quorentra CRM Opportunity Management: Building from Zero — Part 13

Series: Building Quorentra CRM from Zero — A Modular, ChatGPT-Native AI CRM

Building the first sales pipeline with tenant-owned opportunities, stages, values, probabilities, company relationships, RBAC, pipeline metrics, and automated tests.

Quorentra Opportunity Management: Building from Zero — Part 13
Quorentra Opportunity Management: Building from Zero — Part 13

1. Introduction

Quorentra now has the beginnings of a genuine CRM domain.

In Part 11 we introduced:

Companies

In Part 12 we added:

Contacts

Our current business model looks like:

Organization
├── Companies
│ └── Contacts
└── Standalone Contacts

Quorentra can answer two important questions:

Which companies do we work with?

and:

Who do we know at those companies?

But the most important commercial question is still missing:

What business are we trying to win?

That is the responsibility of the Opportunity module.

In Part 13, we will build the first Quorentra sales pipeline.

By the end of this article, authorized users will be able to:

Create Opportunity
Read Opportunity
List Opportunities
Update Opportunity
Delete Opportunity
Assign Opportunity to Company
Move Opportunity Through Pipeline
Mark Opportunity Won
Mark Opportunity Lost
Calculate Pipeline Value
Calculate Weighted Pipeline

while Quorentra continues enforcing:

Authentication
+
TenantContext
+
RBAC
+
Tenant Isolation
+
Tenant-Safe Relationships

Quorentra will reach:

Quorentra 0.0.11 — Opportunity Management


2. What Is an Opportunity?

An Opportunity represents potential business.

For example:

Contoso
└── Microsoft 365 Migration
├── €75,000
├── Proposal
├── 60% probability
└── Expected close: 2026-10-31

The Company tells us:

Who?

The Opportunity tells us:

What business?
How much?
How likely?
At what stage?
When might it close?

This transforms Quorentra from basic relationship management into a sales CRM.


3. Our First Sales Pipeline

Enterprise CRMs often support highly configurable pipelines.

We will not build that yet.

For the MVO, Quorentra will use six stages:

qualification
discovery
proposal
negotiation
won
lost

The pipeline is:

Qualification
Discovery
Proposal
Negotiation
├───────────────┐
▼ ▼
Won Lost

This is intentionally simple.

It gives us enough structure to build useful CRM behavior without introducing pipeline configuration infrastructure too early.


4. Why Fixed Stages First?

Eventually Quorentra may support:

Custom pipelines
Custom stages
Stage ordering
Stage probabilities
Multiple pipelines
Sales processes
Stage-specific validation
Workflow triggers
AI stage recommendations

But none of those are required to prove Opportunity Management.

Our modular principle remains:

Build the smallest useful sales pipeline first.

Then extend it.


5. Opportunity Ownership

Every Opportunity belongs directly to a Quorentra organization.

Therefore:

Opportunity
├── id
├── organization_id
└── ...

As with Companies and Contacts:

organization_id

is the primary tenant boundary.

The client never chooses it.

It comes from:

TenantContext

6. Opportunity-to-Company Relationship

For our first implementation, every Opportunity must belong to a Company.

Conceptually:

Organization
Company
├── Contacts
└── Opportunities

For example:

Contoso
├── Alice Johnson
├── Robert Smith
└── Microsoft 365 Migration

Unlike Contacts, we will make:

company_id

mandatory for Opportunities.

A sales opportunity without a customer or prospect Company is not useful enough for our first CRM pipeline.


7. Tenant-Safe Opportunity Relationships

Part 12 introduced an important rule:

Every client-supplied reference to a tenant-owned entity must be resolved inside the current TenantContext.

We apply that rule again.

If the client supplies:

company_id

Quorentra verifies:

Company.id
+
Company.organization_id
=
Current Tenant

before creating the Opportunity.

This prevents:

Tenant A Opportunity
Tenant B Company

from ever being created.


8. Define the Opportunity Model

Our MVO Opportunity will contain:

Opportunity
├── id
├── organization_id
├── company_id
├── name
├── stage
├── amount
├── currency
├── probability
├── expected_close_date
├── description
├── created_at
└── updated_at

This is enough to create a useful sales pipeline.


9. Why Amount Uses Decimal

Money should not normally be represented with binary floating-point values.

Avoid:

amount: float

Instead, use:

Decimal

with a PostgreSQL numeric column.

Conceptually:

75000.00

should remain exactly:

75000.00

rather than being subject to floating-point representation artifacts.


10. Currency

We will store:

currency

as a short code such as:

EUR
USD
GBP
JPY

For the MVO, we will default to:

EUR

but allow another three-character currency code.

Later, we can introduce stronger ISO 4217 validation.


11. Probability

Probability represents the estimated chance that the Opportunity will be won.

For example:

Qualification 20%
Discovery 40%
Proposal 60%
Negotiation 80%
Won 100%
Lost 0%

For now, the user may set probability manually.

Quorentra validates:

0 <= probability <= 100

Later, stage defaults or AI recommendations can improve this.


12. Weighted Pipeline

Once we know:

amount

and:

probability

we can calculate:

Weighted Value
=
Amount × Probability

For example:

Opportunity Amount = €100,000
Probability = 60%

Weighted value:

€100,000 × 0.60
=
€60,000

This gives Quorentra its first meaningful sales metric.


13. Pipeline Value

Suppose the tenant has:

Opportunity A €50,000
Opportunity B €75,000
Opportunity C €100,000

Then:

Open Pipeline
=
€225,000

assuming all three are still open.

We should exclude:

won
lost

from the active pipeline.


14. Weighted Pipeline Example

Suppose:

Opportunity A
€50,000 × 20% = €10,000
Opportunity B
€75,000 × 60% = €45,000
Opportunity C
€100,000 × 80% = €80,000

Then:

Weighted Pipeline
=
€135,000

This is much more informative than simply counting Opportunities.


15. Starting Checkpoint

Part 13 assumes Part 12 is working.

Run:

cd quorentra\backend
python -m pytest

All tests should pass.

Then:

python -m uvicorn app.main:app --reload

Check:

GET /api/v1/health

Expected:

{
"status": "healthy",
"service": "quorentra-api",
"version": "0.0.10",
"database": "connected"
}

We are ready to add the sales pipeline.


16. Define Opportunity Permissions

Open:

app/modules/authorization/permissions.py

Verify or add:

OPPORTUNITIES_READ = "opportunities.read"
OPPORTUNITIES_CREATE = "opportunities.create"
OPPORTUNITIES_UPDATE = "opportunities.update"
OPPORTUNITIES_DELETE = "opportunities.delete"

Our MVO role policy should be:

                       Owner  Admin  Member  Viewer

opportunities.read        ✓      ✓      ✓       ✓
opportunities.create      ✓      ✓      ✓       ✗
opportunities.update      ✓      ✓      ✓       ✗
opportunities.delete      ✓      ✓      ✗       ✗

This follows the Company and Contact patterns.


17. Create the Opportunities Module

Create:

app/modules/opportunities/

From:

quorentra/backend

run:

mkdir app\modules\opportunities
New-Item app\modules\opportunities\__init__.py -ItemType File
New-Item app\modules\opportunities\model.py -ItemType File
New-Item app\modules\opportunities\repository.py -ItemType File
New-Item app\modules\opportunities\service.py -ItemType File
New-Item app\modules\opportunities\schemas.py -ItemType File
New-Item app\modules\opportunities\router.py -ItemType File
New-Item app\modules\opportunities\exceptions.py -ItemType File
New-Item app\modules\opportunities\enums.py -ItemType File

Our module becomes:

opportunities/
├── __init__.py
├── enums.py
├── exceptions.py
├── model.py
├── repository.py
├── router.py
├── schemas.py
└── service.py

18. Define Opportunity Stages

Open:

app/modules/opportunities/enums.py

Add:

from enum import StrEnum
class OpportunityStage(StrEnum):
QUALIFICATION = "qualification"
DISCOVERY = "discovery"
PROPOSAL = "proposal"
NEGOTIATION = "negotiation"
WON = "won"
LOST = "lost"

Using an enum prevents arbitrary stage strings from spreading throughout the application.


19. Open and Closed Stages

We can also define:

OPEN_OPPORTUNITY_STAGES = {
OpportunityStage.QUALIFICATION,
OpportunityStage.DISCOVERY,
OpportunityStage.PROPOSAL,
OpportunityStage.NEGOTIATION,
}

and:

CLOSED_OPPORTUNITY_STAGES = {
OpportunityStage.WON,
OpportunityStage.LOST,
}

This gives us a single source of truth for pipeline status.


20. Stage Probability Defaults

We can define optional defaults:

DEFAULT_STAGE_PROBABILITY = {
OpportunityStage.QUALIFICATION: 20,
OpportunityStage.DISCOVERY: 40,
OpportunityStage.PROPOSAL: 60,
OpportunityStage.NEGOTIATION: 80,
OpportunityStage.WON: 100,
OpportunityStage.LOST: 0,
}

For the MVO, these defaults can be used when a probability is not explicitly supplied.

This keeps Opportunity creation simple.


21. Create the Opportunity Model

Open:

app/modules/opportunities/model.py

Add imports similar to:

from datetime import date
from decimal import Decimal
from uuid import UUID, uuid4
from sqlalchemy import (
Date,
ForeignKey,
Integer,
Numeric,
String,
Text,
)
from sqlalchemy.orm import (
Mapped,
mapped_column,
relationship,
)
from app.db.base import Base
from app.modules.opportunities.enums import (
OpportunityStage,
)

Then define the model.


22. Opportunity SQLAlchemy Model

Conceptually:

class Opportunity(Base):
__tablename__ = "opportunities"
id: Mapped[UUID] = mapped_column(
primary_key=True,
default=uuid4,
)
organization_id: Mapped[UUID] = mapped_column(
ForeignKey(
"organizations.id",
ondelete="CASCADE",
),
nullable=False,
index=True,
)
company_id: Mapped[UUID] = mapped_column(
ForeignKey(
"companies.id",
ondelete="CASCADE",
),
nullable=False,
index=True,
)
name: Mapped[str] = mapped_column(
String(255),
nullable=False,
)
stage: Mapped[str] = mapped_column(
String(50),
nullable=False,
index=True,
)
amount: Mapped[Decimal] = mapped_column(
Numeric(18, 2),
nullable=False,
default=Decimal("0.00"),
)
currency: Mapped[str] = mapped_column(
String(3),
nullable=False,
default="EUR",
)
probability: Mapped[int] = mapped_column(
Integer,
nullable=False,
)
expected_close_date: Mapped[date | None] = (
mapped_column(
Date,
nullable=True,
index=True,
)
)
description: Mapped[str | None] = mapped_column(
Text,
nullable=True,
)

Use the existing timestamp mixin if available.


23. Add Timestamp Support

If Quorentra uses:

TimestampMixin

define:

class Opportunity(
TimestampMixin,
Base,
):
__tablename__ = "opportunities"

The Opportunity then automatically receives:

created_at
updated_at

consistent with Company and Contact.


24. Why Company Deletion Cascades Opportunities

For Contacts we used:

ON DELETE SET NULL

because a Contact can remain useful without a Company.

For Opportunities, our MVO decision is different.

An Opportunity is explicitly business with a Company.

Therefore:

Delete Company
Delete Company's Opportunities

using:

ON DELETE CASCADE

is acceptable for the first implementation.

Later, we may replace hard deletion with archival or soft deletion.


25. Add the Company Relationship

In Opportunity:

company = relationship(
"Company",
back_populates="opportunities",
)

Then update:

app/modules/companies/model.py

with:

opportunities = relationship(
"Opportunity",
back_populates="company",
)

The domain relationship becomes:

Company
├── Contacts
└── Opportunities

26. Register the Model

Open the central model registration file, for example:

app/db/models.py

Add:

from app.modules.opportunities.model import Opportunity

Ensure Alembic can discover:

Company
Contact
Opportunity

before generating the migration.


27. Generate the Migration

Run:

alembic revision --autogenerate -m "create opportunities table"

Review the generated migration carefully.

It should create:

opportunities
├── id
├── organization_id
├── company_id
├── name
├── stage
├── amount
├── currency
├── probability
├── expected_close_date
├── description
├── created_at
└── updated_at

28. Verify Constraints and Indexes

Verify foreign keys:

opportunities.organization_id
organizations.id

and:

opportunities.company_id
companies.id

Verify indexes for at least:

organization_id
company_id
stage
expected_close_date

These will become common query dimensions.


29. Apply the Migration

Run:

alembic upgrade head

Then:

alembic current

Verify the latest revision is active.


30. Verify in pgAdmin

Navigate to:

Schemas
public
Tables
opportunities

Inspect:

Columns
Foreign Keys
Indexes
Constraints

Quorentra now has the persistence layer for its first sales pipeline.


31. Create Opportunity Schemas

Open:

app/modules/opportunities/schemas.py

Add:

from datetime import date, datetime
from decimal import Decimal
from uuid import UUID
from pydantic import (
BaseModel,
ConfigDict,
Field,
)
from app.modules.opportunities.enums import (
OpportunityStage,
)

We need:

OpportunityCreate
OpportunityUpdate
OpportunityResponse
PipelineSummary

32. Opportunity Create Schema

Add:

class OpportunityCreate(BaseModel):
company_id: UUID
name: str = Field(
min_length=1,
max_length=255,
)
stage: OpportunityStage = (
OpportunityStage.QUALIFICATION
)
amount: Decimal = Field(
default=Decimal("0.00"),
ge=0,
max_digits=18,
decimal_places=2,
)
currency: str = Field(
default="EUR",
min_length=3,
max_length=3,
)
probability: int | None = Field(
default=None,
ge=0,
le=100,
)
expected_close_date: date | None = None
description: str | None = None

Again:

organization_id

is deliberately absent.


33. Opportunity Update Schema

Add:

class OpportunityUpdate(BaseModel):
company_id: UUID | None = None
name: str | None = Field(
default=None,
min_length=1,
max_length=255,
)
stage: OpportunityStage | None = None
amount: Decimal | None = Field(
default=None,
ge=0,
max_digits=18,
decimal_places=2,
)
currency: str | None = Field(
default=None,
min_length=3,
max_length=3,
)
probability: int | None = Field(
default=None,
ge=0,
le=100,
)
expected_close_date: date | None = None
description: str | None = None

We will use:

model_dump(exclude_unset=True)

to preserve PATCH semantics.


34. Opportunity Response Schema

Add:

class OpportunityResponse(BaseModel):
model_config = ConfigDict(
from_attributes=True
)
id: UUID
organization_id: UUID
company_id: UUID
name: str
stage: OpportunityStage
amount: Decimal
currency: str
probability: int
expected_close_date: date | None
description: str | None
created_at: datetime
updated_at: datetime

This is enough for the initial API.


35. Pipeline Summary Schema

Add:

class PipelineSummary(BaseModel):
currency: str
open_opportunities: int
total_pipeline: Decimal
weighted_pipeline: Decimal

A response might look like:

{
"currency": "EUR",
"open_opportunities": 7,
"total_pipeline": "425000.00",
"weighted_pipeline": "238500.00"
}

This is our first analytics-oriented CRM response.


36. A Currency Limitation

There is an important issue.

We cannot correctly calculate:

€50,000
+
$75,000
+
£20,000

without currency conversion.

Therefore our first pipeline summary should be calculated per currency.

For example:

EUR pipeline
USD pipeline
GBP pipeline

This avoids pretending different currencies are directly additive.


37. Create Opportunity Exceptions

Open:

app/modules/opportunities/exceptions.py

Add:

class OpportunityError(Exception):
pass
class OpportunityNotFoundError(
OpportunityError
):
pass
class InvalidOpportunityCompanyError(
OpportunityError
):
pass

We may add richer stage-transition errors later.

For the MVO, these are sufficient.


38. Build the Opportunity Repository

Open:

app/modules/opportunities/repository.py

Add:

from uuid import UUID
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.modules.opportunities.model import (
Opportunity,
)

Then:

class OpportunityRepository:
def __init__(
self,
db: Session,
) -> None:
self.db = db

39. Create Opportunity

Add:

def create(
self,
opportunity: Opportunity,
) -> Opportunity:
self.db.add(opportunity)
self.db.flush()
self.db.refresh(opportunity)
return opportunity

Transaction ownership remains in the service.


40. List Opportunities by Tenant

Add:

def list_by_organization(
self,
organization_id: UUID,
) -> list[Opportunity]:
statement = (
select(Opportunity)
.where(
Opportunity.organization_id
== organization_id
)
.order_by(
Opportunity.created_at.desc()
)
)
return list(
self.db.scalars(statement).all()
)

As always, tenant scope is explicit.


41. Get Opportunity by Tenant

Add:

def get_by_id_and_organization(
self,
opportunity_id: UUID,
organization_id: UUID,
) -> Opportunity | None:
statement = select(Opportunity).where(
Opportunity.id == opportunity_id,
Opportunity.organization_id
== organization_id,
)
return self.db.scalar(statement)

We continue avoiding generic get_by_id() methods for tenant-owned CRM records.


42. List Opportunities for Company

Add:

def list_by_company_and_organization(
self,
company_id: UUID,
organization_id: UUID,
) -> list[Opportunity]:
statement = (
select(Opportunity)
.where(
Opportunity.company_id
== company_id,
Opportunity.organization_id
== organization_id,
)
.order_by(
Opportunity.created_at.desc()
)
)
return list(
self.db.scalars(statement).all()
)

Both relationship and tenant scope are explicit.


43. List by Stage

Add:

def list_by_stage_and_organization(
self,
stage: str,
organization_id: UUID,
) -> list[Opportunity]:
statement = (
select(Opportunity)
.where(
Opportunity.organization_id
== organization_id,
Opportunity.stage == stage,
)
.order_by(
Opportunity.created_at.desc()
)
)
return list(
self.db.scalars(statement).all()
)

This enables queries such as:

Show all proposal-stage opportunities.

That will later become useful to ChatGPT.


44. Delete Opportunity

Add:

def delete(
self,
opportunity: Opportunity,
) -> None:
self.db.delete(opportunity)
self.db.flush()

As before, authorization occurs outside the repository.


45. Build the Opportunity Service

Open:

app/modules/opportunities/service.py

Add imports:

from decimal import Decimal
from uuid import UUID
from sqlalchemy.orm import Session
from app.modules.companies.repository import (
CompanyRepository,
)
from app.modules.opportunities.enums import (
DEFAULT_STAGE_PROBABILITY,
OPEN_OPPORTUNITY_STAGES,
OpportunityStage,
)
from app.modules.opportunities.exceptions import (
InvalidOpportunityCompanyError,
OpportunityNotFoundError,
)
from app.modules.opportunities.model import (
Opportunity,
)
from app.modules.opportunities.repository import (
OpportunityRepository,
)
from app.modules.opportunities.schemas import (
OpportunityCreate,
OpportunityUpdate,
PipelineSummary,
)
from app.modules.tenants.context import (
TenantContext,
)

Then define the service.


46. Opportunity Service Constructor

Add:

class OpportunityService:
def __init__(
self,
db: Session,
) -> None:
self.db = db
self.repository = (
OpportunityRepository(db)
)
self.companies = CompanyRepository(
db
)

The service coordinates Opportunities and Companies.


47. Validate Company Relationship

Add:

def _validate_company(
self,
tenant: TenantContext,
company_id: UUID,
) -> None:
company = (
self.companies
.get_by_id_and_organization(
company_id=company_id,
organization_id=(
tenant.organization.id
),
)
)
if company is None:
raise InvalidOpportunityCompanyError

This reuses the tenant-safe relationship pattern from Part 12.


48. Normalize Currency

Add a small helper:

def _normalize_currency(
self,
currency: str,
) -> str:
return currency.strip().upper()

Thus:

eur
EUR
Eur

all become:

EUR

Later we can validate against a formal currency registry.


49. Resolve Probability

Add:

def _resolve_probability(
self,
stage: OpportunityStage,
probability: int | None,
) -> int:
if probability is not None:
return probability
return DEFAULT_STAGE_PROBABILITY[
stage
]

Now a user can create:

stage = proposal

without manually specifying:

probability = 60

Quorentra supplies the default.


50. Create Opportunity

Add:

def create_opportunity(
self,
tenant: TenantContext,
data: OpportunityCreate,
) -> Opportunity:
self._validate_company(
tenant,
data.company_id,
)
probability = (
self._resolve_probability(
data.stage,
data.probability,
)
)
opportunity = Opportunity(
organization_id=(
tenant.organization.id
),
company_id=data.company_id,
name=data.name.strip(),
stage=data.stage.value,
amount=data.amount,
currency=self._normalize_currency(
data.currency
),
probability=probability,
expected_close_date=(
data.expected_close_date
),
description=data.description,
)
opportunity = self.repository.create(
opportunity
)
self.db.commit()
self.db.refresh(opportunity)
return opportunity

The Opportunity is now tenant-owned and Company-linked.


51. Create an Example Opportunity

Suppose Contoso exists.

We can create:

{
"company_id": "<contoso-id>",
"name": "Microsoft 365 Migration",
"stage": "proposal",
"amount": "75000.00",
"currency": "EUR",
"expected_close_date": "2026-10-31",
"description": "Migration of 1,500 users."
}

Because probability is omitted, Quorentra uses:

proposal
60%

automatically.


52. List Opportunities

Add:

def list_opportunities(
self,
tenant: TenantContext,
) -> list[Opportunity]:
return (
self.repository
.list_by_organization(
tenant.organization.id
)
)

This returns only Opportunities inside the current tenant.


53. Get Opportunity

Add:

def get_opportunity(
self,
tenant: TenantContext,
opportunity_id: UUID,
) -> Opportunity:
opportunity = (
self.repository
.get_by_id_and_organization(
opportunity_id=opportunity_id,
organization_id=(
tenant.organization.id
),
)
)
if opportunity is None:
raise OpportunityNotFoundError
return opportunity

Cross-tenant records again appear nonexistent.


54. List Company Opportunities

Add:

def list_company_opportunities(
self,
tenant: TenantContext,
company_id: UUID,
) -> list[Opportunity]:
self._validate_company(
tenant,
company_id,
)
return (
self.repository
.list_by_company_and_organization(
company_id=company_id,
organization_id=(
tenant.organization.id
),
)
)

This gives us:

Contoso
All Opportunities

55. List Opportunities by Stage

Add:

def list_opportunities_by_stage(
self,
tenant: TenantContext,
stage: OpportunityStage,
) -> list[Opportunity]:
return (
self.repository
.list_by_stage_and_organization(
stage=stage.value,
organization_id=(
tenant.organization.id
),
)
)

This will support queries such as:

proposal opportunities
negotiation opportunities
won opportunities

56. Update Opportunity

Add:

def update_opportunity(
self,
tenant: TenantContext,
opportunity_id: UUID,
data: OpportunityUpdate,
) -> Opportunity:
opportunity = self.get_opportunity(
tenant,
opportunity_id,
)
update_data = data.model_dump(
exclude_unset=True
)
if "company_id" in update_data:
company_id = update_data[
"company_id"
]
if company_id is not None:
self._validate_company(
tenant,
company_id,
)
if "name" in update_data:
update_data["name"] = (
update_data["name"].strip()
)
if "currency" in update_data:
update_data["currency"] = (
self._normalize_currency(
update_data["currency"]
)
)
if "stage" in update_data:
stage = update_data["stage"]
update_data["stage"] = (
stage.value
)
if "probability" not in update_data:
update_data["probability"] = (
DEFAULT_STAGE_PROBABILITY[
stage
]
)
for field, value in update_data.items():
setattr(
opportunity,
field,
value,
)
self.db.commit()
self.db.refresh(opportunity)
return opportunity

This gives us our first pipeline movement behavior.


57. Stage Changes Update Probability

Suppose:

stage = discovery
probability = 40

Then the user moves the Opportunity to:

proposal

without specifying probability.

Quorentra automatically changes probability to:

60

This keeps the stage and default probability aligned.


58. Explicit Probability Overrides the Default

If the user sends:

{
"stage": "proposal",
"probability": 75
}

Quorentra keeps:

75%

rather than replacing it with the default:

60%

This gives users flexibility while maintaining useful defaults.


59. Mark an Opportunity Won

A user can update:

{
"stage": "won"
}

Quorentra automatically sets:

probability = 100

The Opportunity leaves the open pipeline.


60. Mark an Opportunity Lost

Similarly:

{
"stage": "lost"
}

results in:

probability = 0

The Opportunity also leaves the open pipeline.


61. Should We Enforce Strict Stage Transitions?

Not yet.

For the MVO, we allow:

qualification → proposal
proposal → discovery
negotiation → qualification
lost → discovery

if the user explicitly requests it.

Later we may introduce:

Stage transition rules
Reopen workflows
Approval requirements
Audit history
Automation triggers

But enforcing them now would add complexity before we have proven the basic pipeline.


62. Delete Opportunity

Add:

def delete_opportunity(
self,
tenant: TenantContext,
opportunity_id: UUID,
) -> None:
opportunity = self.get_opportunity(
tenant,
opportunity_id,
)
self.repository.delete(
opportunity
)
self.db.commit()

Again, tenant-scoped retrieval protects the destructive operation.


63. Calculate Open Pipeline

Add:

def get_pipeline_summary(
self,
tenant: TenantContext,
currency: str,
) -> PipelineSummary:
currency = self._normalize_currency(
currency
)
opportunities = (
self.repository
.list_by_organization(
tenant.organization.id
)
)
open_opportunities = [
opportunity
for opportunity in opportunities
if (
OpportunityStage(
opportunity.stage
)
in OPEN_OPPORTUNITY_STAGES
and opportunity.currency
== currency
)
]
total = sum(
(
opportunity.amount
for opportunity
in open_opportunities
),
Decimal("0.00"),
)
weighted = sum(
(
opportunity.amount
* Decimal(
opportunity.probability
)
/ Decimal("100")
for opportunity
in open_opportunities
),
Decimal("0.00"),
)
return PipelineSummary(
currency=currency,
open_opportunities=len(
open_opportunities
),
total_pipeline=total,
weighted_pipeline=weighted,
)

For the MVO, this application-level calculation is sufficient.


64. Why Not Aggregate Directly in SQL Yet?

We certainly could calculate pipeline metrics using:

SUM
COUNT
GROUP BY

inside PostgreSQL.

That will eventually be preferable for large datasets.

But right now our priority is:

Correctness
Clarity
Testability
Working MVO

Once the pipeline grows, we can optimize the repository without changing the API contract.

That is another benefit of separating service and repository layers.


65. Build the Opportunity Router

Open:

app/modules/opportunities/router.py

Add the required imports for:

FastAPI
Session
TenantContext
Permissions
Schemas
Service
Exceptions
OpportunityStage

Then:

router = APIRouter(
prefix="/opportunities",
tags=["opportunities"],
)

66. Create Opportunity Endpoint

Add:

@router.post(
"",
response_model=OpportunityResponse,
status_code=status.HTTP_201_CREATED,
)
def create_opportunity(
data: OpportunityCreate,
tenant: TenantContext = Depends(
require_permission(
Permission.OPPORTUNITIES_CREATE
)
),
db: Session = Depends(get_db),
) -> OpportunityResponse:
service = OpportunityService(db)
try:
return service.create_opportunity(
tenant,
data,
)
except InvalidOpportunityCompanyError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Company not found.",
) from exc

The Company relationship is validated before creation.


67. List Opportunities Endpoint

Add:

@router.get(
"",
response_model=list[
OpportunityResponse
],
)
def list_opportunities(
tenant: TenantContext = Depends(
require_permission(
Permission.OPPORTUNITIES_READ
)
),
db: Session = Depends(get_db),
) -> list[OpportunityResponse]:
service = OpportunityService(db)
return service.list_opportunities(
tenant
)

68. Get Opportunity Endpoint

Add:

@router.get(
"/{opportunity_id}",
response_model=OpportunityResponse,
)
def get_opportunity(
opportunity_id: UUID,
tenant: TenantContext = Depends(
require_permission(
Permission.OPPORTUNITIES_READ
)
),
db: Session = Depends(get_db),
) -> OpportunityResponse:
service = OpportunityService(db)
try:
return service.get_opportunity(
tenant,
opportunity_id,
)
except OpportunityNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Opportunity not found.",
) from exc

69. Update Opportunity Endpoint

Add:

@router.patch(
"/{opportunity_id}",
response_model=OpportunityResponse,
)
def update_opportunity(
opportunity_id: UUID,
data: OpportunityUpdate,
tenant: TenantContext = Depends(
require_permission(
Permission.OPPORTUNITIES_UPDATE
)
),
db: Session = Depends(get_db),
) -> OpportunityResponse:
service = OpportunityService(db)
try:
return service.update_opportunity(
tenant,
opportunity_id,
data,
)
except OpportunityNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Opportunity not found.",
) from exc
except InvalidOpportunityCompanyError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Company not found.",
) from exc

70. Delete Opportunity Endpoint

Add:

@router.delete(
"/{opportunity_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
def delete_opportunity(
opportunity_id: UUID,
tenant: TenantContext = Depends(
require_permission(
Permission.OPPORTUNITIES_DELETE
)
),
db: Session = Depends(get_db),
) -> Response:
service = OpportunityService(db)
try:
service.delete_opportunity(
tenant,
opportunity_id,
)
except OpportunityNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Opportunity not found.",
) from exc
return Response(
status_code=status.HTTP_204_NO_CONTENT
)

71. Company Opportunities Endpoint

Add:

@router.get(
"/company/{company_id}",
response_model=list[
OpportunityResponse
],
)
def list_company_opportunities(
company_id: UUID,
tenant: TenantContext = Depends(
require_permission(
Permission.OPPORTUNITIES_READ
)
),
db: Session = Depends(get_db),
) -> list[OpportunityResponse]:
service = OpportunityService(db)
try:
return (
service.list_company_opportunities(
tenant,
company_id,
)
)
except InvalidOpportunityCompanyError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Company not found.",
) from exc

72. Stage Filtering Endpoint

We can expose:

GET /opportunities/stage/{stage}

For example:

GET /opportunities/stage/proposal

which returns all proposal-stage Opportunities for the current tenant.

This will later be useful for both the React UI and ChatGPT.


73. Pipeline Summary Endpoint

Add:

GET /opportunities/pipeline/summary?currency=EUR

The endpoint uses:

opportunities.read

and returns:

{
"currency": "EUR",
"open_opportunities": 5,
"total_pipeline": "350000.00",
"weighted_pipeline": "197500.00"
}

This is Quorentra’s first CRM analytics endpoint.


74. Route Ordering Matters

Be careful with routes such as:

/{opportunity_id}

and:

/pipeline/summary

Depending on how routes are defined, FastAPI may attempt to interpret:

pipeline

as an Opportunity UUID.

Define specific static routes before generic parameter routes where necessary.

A clean ordering is:

/
/pipeline/summary
/stage/{stage}
/company/{company_id}
/{opportunity_id}

75. Register the Opportunity Router

Open:

app/api/v1/router.py

Add:

from app.modules.opportunities.router import (
router as opportunities_router,
)

Then:

api_router.include_router(
opportunities_router
)

Restart the application.


76. Inspect Swagger

Open:

http://127.0.0.1:8000/docs

You should now see:

opportunities

with endpoints for:

Create
List
Retrieve
Update
Delete
Company Opportunities
Stage Filtering
Pipeline Summary

Quorentra now exposes its first sales pipeline API.


77. Create the First Opportunity

Create or retrieve Contoso.

Then send:

POST /api/v1/opportunities

with:

{
"company_id": "<contoso-id>",
"name": "Microsoft 365 Migration",
"stage": "qualification",
"amount": "75000.00",
"currency": "EUR",
"expected_close_date": "2026-10-31",
"description": "Migration and adoption project."
}

Expected:

201 Created

78. Inspect the Response

The response should resemble:

{
"id": "...",
"organization_id": "...",
"company_id": "...",
"name": "Microsoft 365 Migration",
"stage": "qualification",
"amount": "75000.00",
"currency": "EUR",
"probability": 20,
"expected_close_date": "2026-10-31",
"description": "Migration and adoption project.",
"created_at": "...",
"updated_at": "..."
}

Notice:

organization_id

was assigned by the backend.

And:

probability = 20

was derived from the stage.


79. Verify PostgreSQL

In pgAdmin:

SELECT
id,
organization_id,
company_id,
name,
stage,
amount,
currency,
probability
FROM opportunities;

Verify the tenant and Company relationships.


80. Move the Opportunity to Discovery

Send:

PATCH /api/v1/opportunities/{id}

with:

{
"stage": "discovery"
}

Expected:

stage = discovery
probability = 40

Our first sales pipeline transition has occurred.


81. Move to Proposal

Send:

{
"stage": "proposal"
}

Expected:

stage = proposal
probability = 60

The Opportunity is moving through the pipeline.


82. Override Probability

Suppose the sales team believes the deal is stronger than normal.

Send:

{
"probability": 75
}

Expected:

stage = proposal
probability = 75

The default remains a convenience rather than a rigid rule.


83. Move to Negotiation

Send:

{
"stage": "negotiation"
}

Because probability is not supplied:

probability = 80

Quorentra now has a late-stage Opportunity.


84. Mark the Opportunity Won

Send:

{
"stage": "won"
}

Expected:

stage = won
probability = 100

The Opportunity should disappear from the open pipeline summary.


85. Create a Lost Opportunity

Create another Opportunity and eventually update:

{
"stage": "lost"
}

Expected:

probability = 0

It also disappears from the active pipeline.


86. Test the Pipeline Summary

Create:

Opportunity A
€50,000
qualification
20%
Opportunity B
€75,000
proposal
60%
Opportunity C
€100,000
negotiation
80%

Call:

GET /opportunities/pipeline/summary?currency=EUR

Expected:

Open Opportunities = 3
Total Pipeline
=
€225,000

Weighted:

€10,000
+
€45,000
+
€80,000
=
€135,000

Expected response:

{
"currency": "EUR",
"open_opportunities": 3,
"total_pipeline": "225000.00",
"weighted_pipeline": "135000.00"
}

87. Verify Won Opportunities Are Excluded

Mark Opportunity A:

won

Then recalculate.

Only B and C should remain in the open pipeline.

This is our first test of CRM state affecting analytics.


88. Verify Lost Opportunities Are Excluded

Mark Opportunity B:

lost

The open pipeline should now contain only Opportunity C.

Pipeline metrics must reflect current business state.


89. Test Currency Isolation

Create:

€50,000 EUR
$100,000 USD

Then request:

?currency=EUR

Only EUR Opportunities should contribute.

Request:

?currency=USD

Only USD Opportunities should contribute.

Never add them together without exchange-rate logic.


90. Test Cross-Tenant Company Assignment

Create:

Tenant A
└── Company A
Tenant B
└── User B

As Tenant B, attempt to create an Opportunity using:

company_id = Company A

Expected:

404 Not Found

No Opportunity should be created.


91. Test Cross-Tenant Opportunity Retrieval

Create:

Tenant A
└── Opportunity A

As Tenant B:

GET /opportunities/{opportunity-a-id}

Expected:

404 Not Found

The existence of Opportunity A is not disclosed.


92. Test Cross-Tenant Update

As Tenant B:

PATCH /opportunities/{opportunity-a-id}

Expected:

404

Verify Opportunity A remains unchanged.


93. Test Cross-Tenant Delete

As Tenant B:

DELETE /opportunities/{opportunity-a-id}

Expected:

404

Verify Opportunity A still exists.


94. Test Viewer Permissions

Viewer:

GET Opportunities ✓
GET Opportunity ✓
GET Pipeline Summary ✓
GET Company Opportunities ✓
POST Opportunity ✗
PATCH Opportunity ✗
DELETE Opportunity ✗

This gives viewers read-only pipeline visibility.


95. Test Member Permissions

Member:

Read ✓
Create ✓
Update ✓
Delete ✗

This allows normal sales users to manage the pipeline without destructive deletion rights.


96. Test Owner and Admin Permissions

Owner and Admin:

Read ✓
Create ✓
Update ✓
Delete ✓

Again, the Opportunity module relies on permissions rather than hard-coded roles.


97. Create Opportunity Tests

Create:

tests/api/test_opportunities.py

Cover:

create
list
retrieve
update
delete
company relationship
cross-tenant company rejection
stage defaults
stage changes
probability overrides
won
lost
pipeline total
weighted pipeline
currency filtering
RBAC
tenant isolation

This becomes the most business-oriented test suite so far.


98. Test Default Stage

Create an Opportunity without supplying:

stage

Expected:

qualification

and:

probability = 20

This verifies our default sales process.


99. Test Proposal Default Probability

Create:

{
"stage": "proposal"
}

Expected:

probability = 60

100. Test Explicit Probability

Create:

{
"stage": "proposal",
"probability": 72
}

Expected:

probability = 72

Explicit user input wins over the stage default.


101. Test Won State

Update:

{
"stage": "won"
}

Expected:

stage = won
probability = 100

Then verify the Opportunity is excluded from the open pipeline.


102. Test Lost State

Update:

{
"stage": "lost"
}

Expected:

stage = lost
probability = 0

Verify it is excluded from the open pipeline.


103. Test Pipeline Mathematics

Given:

A = €100 × 20%
B = €200 × 50%
C = €300 × 80%

Expected total:

€600

Expected weighted:

€20 + €100 + €240
=
€360

Automated tests should assert exact Decimal values.


104. Why Exact Financial Tests Matter

A CRM will eventually use Opportunity values for:

Dashboards
Forecasts
Revenue projections
AI analysis
Reports
Executive summaries

Small arithmetic errors become larger business errors.

Financial calculations should therefore be deterministic and explicitly tested.


105. Run the Full Test Suite

Run:

python -m pytest

We now expect coverage across:

Platform
Identity
Authentication
Multi-Tenancy
Authorization
Companies
├── CRUD
├── RBAC
└── Tenant Isolation
Contacts
├── CRUD
├── Company Relationships
├── RBAC
└── Tenant Isolation
Opportunities
├── CRUD
├── Company Relationships
├── Stages
├── Probabilities
├── Pipeline Metrics
├── RBAC
└── Tenant Isolation

All tests should pass.


106. Update Quorentra Version

Open:

app/core/constants.py

Change:

APP_VERSION = "0.0.10"

to:

APP_VERSION = "0.0.11"

Restart:

python -m uvicorn app.main:app --reload

Check:

GET /api/v1/health

Expected:

{
"status": "healthy",
"service": "quorentra-api",
"version": "0.0.11",
"database": "connected"
}

107. Quorentra 0.0.11

Our application status is now:

Repository ✓
FastAPI ✓
PostgreSQL ✓
SQLAlchemy ✓
Alembic ✓
Organizations ✓
Users ✓
Memberships ✓
Registration ✓
Authentication ✓
JWT ✓
Tenant Context ✓
Tenant Isolation ✓
Roles ✓
Permissions ✓
RBAC ✓
Companies ✓
Company CRUD ✓
Contacts ✓
Contact CRUD ✓
Company Relationships ✓
Opportunities ✓
Opportunity CRUD ✓
Sales Pipeline ✓
Opportunity Stages ✓
Probability ✓
Pipeline Value ✓
Weighted Pipeline ✓
Tenant Isolation ✓
Activities -
Tasks -
React -
MCP -
ChatGPT -
Apps SDK -
AI -

This is an important milestone.

Quorentra now has the core of a functioning sales CRM.


108. Our CRM Domain

After Part 13:

                    Organization
                         │
              ┌──────────┴──────────┐
              │                     │
              ▼                     ▼
           Company            Standalone
              │                 Contacts
       ┌──────┴──────┐
       │             │
       ▼             ▼
   Contacts      Opportunities

We can now represent:

Contoso
├── Alice Johnson — CTO
├── Robert Smith — Procurement
├── Microsoft 365 Migration
│ ├── €75,000
│ ├── Proposal
│ └── 60%
└── Azure Modernization
├── €150,000
├── Discovery
└── 40%

This is recognizably a CRM.


109. The MVO Business Flow

Quorentra can now support:

Register
Create Organization
Login
Select Tenant
Create Company
Create Contacts
Create Opportunity
Move Through Pipeline
Win or Lose

That is a complete, minimal sales workflow.


110. Why This Is the Right Time to Pause Feature Expansion

We could immediately continue with:

Activities
Tasks
Meetings
Emails
Notes
Documents
Dashboards
Workflows

But doing that would repeat the mistake this series is deliberately avoiding:

Building a huge CRM before proving the complete product experience.

We now have enough business functionality to create a minimal CRM vertical slice.

That should come next.


111. The Three Core Business Objects

Our first product slice consists of:

Company
+
Contact
+
Opportunity

These three objects answer:

Who is the customer?
Who do we know?
What business are we pursuing?

That is enough to make Quorentra useful.

More importantly, it is enough to make the ChatGPT-native architecture meaningful.


112. Preparing for ChatGPT

Imagine asking:

Show my open opportunities.

Quorentra already has the service capability.

Or:

What opportunities do we have with Contoso?

Again, the application capability exists.

Or:

Create a €50,000 proposal-stage opportunity for Contoso.

The underlying business operation exists.

Or:

Move the Microsoft 365 Migration opportunity to negotiation.

Again, the service can already perform it.

We are approaching the point where natural language can become another application interface.


113. The Important Architectural Principle

ChatGPT should not become the CRM business layer.

Instead:

ChatGPT
Tool
Quorentra Application Service
Repository
Database

The same:

Tenant Isolation
RBAC
Validation
Business Rules

must apply regardless of whether the caller is:

React UI
REST API
ChatGPT
MCP Client
Future Mobile App
Integration

This is why we built the backend first.


114. Future Tool Capabilities

Our application can eventually expose capabilities such as:

Companies
├── list_companies
├── get_company
├── create_company
└── update_company
Contacts
├── list_contacts
├── get_contact
├── create_contact
└── update_contact
Opportunities
├── list_opportunities
├── get_opportunity
├── create_opportunity
├── update_opportunity
└── get_pipeline_summary

These are excellent candidates for a ChatGPT-native tool interface.


115. Example Future Conversation

A user might ask:

What is my current EUR pipeline?

ChatGPT could call:

get_pipeline_summary

Quorentra returns:

Open opportunities: 8
Pipeline: €620,000
Weighted pipeline: €347,000

ChatGPT can then explain the result conversationally.

The CRM remains the source of truth.


116. Another Future Conversation

The user asks:

Create a €75,000 opportunity for Contoso
called Azure Modernization.

The flow becomes:

User
ChatGPT
Find Contoso
create_opportunity
RBAC
Tenant Validation
OpportunityService
PostgreSQL

This is what we mean by a:

ChatGPT-native CRM

not merely a CRM with a chatbot bolted onto it.


117. But We Should Not Connect ChatGPT Yet

There is one important step before MCP and the Apps SDK.

We should prove that:

Company
Contact
Opportunity

work together as one coherent CRM slice.

That means Part 14 should focus on integration rather than another business entity.

We should test complete workflows.


118. The Minimal CRM Vertical Slice

The target becomes:

User
Organization
Company
├── Contact
└── Opportunity
Pipeline

This is our first complete MVO.

Once this is stable, we can expose it to ChatGPT.


119. What Part 14 Should Validate

Part 14 should verify:

Registration
Authentication
Tenant selection
RBAC
Company creation
Contact creation
Opportunity creation
Company relationships
Contact relationships
Opportunity relationships
Pipeline updates
Pipeline summary
Cross-tenant isolation
Permission boundaries
Complete end-to-end tests

It should also clean up inconsistencies accumulated during the first thirteen parts.


120. Why Integration Before More Features?

Modular development does not mean:

Keep adding modules forever.

It means:

Build module
Test module
Integrate module
Prove working product
Add next capability

We now have enough modules to perform that integration checkpoint.


121. Acceptance Criteria

Part 13 is complete when:

✓ opportunities module exists
✓ OpportunityStage exists
✓ open and closed stages are defined
✓ default stage probabilities exist
✓ Opportunity SQLAlchemy model exists
✓ opportunities table exists
✓ Opportunity belongs to Organization
✓ Opportunity belongs to Company
✓ organization_id is indexed
✓ company_id is indexed
✓ stage is indexed
✓ expected_close_date is indexed
✓ OpportunityCreate exists
✓ OpportunityUpdate exists
✓ OpportunityResponse exists
✓ PipelineSummary exists
✓ client cannot assign organization_id
✓ client must supply company_id
✓ Company relationship is tenant-validated
✓ Decimal is used for monetary values
✓ currency is normalized
✓ probability is constrained 0–100
✓ OpportunityRepository exists
✓ OpportunityService exists
✓ Opportunity creation works
✓ Opportunity listing works
✓ Opportunity retrieval works
✓ Opportunity update works
✓ Opportunity deletion works
✓ Company Opportunity listing works
✓ stage filtering works
✓ qualification works
✓ discovery works
✓ proposal works
✓ negotiation works
✓ won works
✓ lost works
✓ default probabilities work
✓ explicit probability overrides work
✓ won defaults to 100%
✓ lost defaults to 0%
✓ open pipeline calculation works
✓ weighted pipeline calculation works
✓ closed Opportunities are excluded
✓ pipeline calculations are currency-specific
✓ opportunities.read is enforced
✓ opportunities.create is enforced
✓ opportunities.update is enforced
✓ opportunities.delete is enforced
✓ viewer is read-only
✓ member can create and update
✓ member cannot delete
✓ owner/admin can perform full CRUD
✓ cross-tenant Company assignment is blocked
✓ cross-tenant Opportunity retrieval is blocked
✓ cross-tenant Opportunity update is blocked
✓ cross-tenant Opportunity deletion is blocked
✓ automated Opportunity tests pass
✓ complete regression suite passes
✓ Quorentra reports version 0.0.11

Most importantly:

Quorentra now has a working tenant-aware sales pipeline.


122. Architecture After Part 13

The business architecture is now:

                    ┌─────────────────┐
                    │  Organization   │
                    └────────┬────────┘
                             │
                  Tenant Ownership
                             │
               ┌─────────────┴─────────────┐
               │                           │
               ▼                           ▼
        ┌─────────────┐             ┌─────────────┐
        │   Company   │             │   Contact   │
        └──────┬──────┘             └─────────────┘
               │
        ┌──────┴────────┐
        │               │
        ▼               ▼
 ┌─────────────┐  ┌───────────────┐
 │   Contact   │  │  Opportunity  │
 └─────────────┘  └───────┬───────┘
                           │
                           ▼
                    ┌─────────────┐
                    │Sales Pipeline│
                    └─────────────┘

The runtime architecture remains:

Client
FastAPI
Authentication
TenantContext
RBAC
Application Services
├── CompanyService
├── ContactService
└── OpportunityService
Repositories
PostgreSQL

This is now a compact but credible CRM backend.


123. The Most Important Achievement So Far

The important result is not the number of endpoints.

It is that we now have:

Identity
+
Tenant Context
+
Authorization
+
CRM Domain
+
Sales Pipeline

working together.

We have moved from:

Infrastructure

to:

Application Platform

to:

Working CRM Core

That is exactly what the modular build-from-zero strategy was intended to accomplish.


124. Next Article

In Part 14, we will build:

The Minimal CRM Vertical Slice — Integrating Companies, Contacts, and Opportunities

Instead of adding another large feature, we will consolidate what already exists.

Part 14 will cover:

  • complete CRM workflow integration;
  • Company → Contact → Opportunity workflows;
  • tenant-safe relationship validation across modules;
  • API consistency;
  • shared error conventions;
  • pagination foundations;
  • query and filtering conventions;
  • standardized API responses where useful;
  • transaction boundaries;
  • service-layer integration;
  • database relationship verification;
  • end-to-end CRM tests;
  • cross-tenant attack tests;
  • role-based workflow tests;
  • Company deletion behavior;
  • Contact relationship behavior;
  • Opportunity lifecycle verification;
  • pipeline summary verification;
  • OpenAPI cleanup;
  • application health verification;
  • MVO readiness checklist;
  • architecture cleanup before external tool exposure;
  • preparation for MCP;
  • preparation for the ChatGPT Apps SDK;
  • preparation for the first ChatGPT-native Quorentra experience.

At the end of Part 14, we should be able to say:

Quorentra has a complete, runnable Minimum Viable CRM core.

Only then do we cross the next architectural boundary:

Quorentra CRM Core
Tool Interface
MCP
ChatGPT
Apps SDK

That is where the project starts becoming what this series set out to build:

A Modular, ChatGPT-Native AI CRM.

Quorentra

Contact

Menu

Designed with WordPress

Discover more from Quorentra

Subscribe now to keep reading and get access to the full archive.

Continue reading