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.

1. Introduction
Quorentra now has the beginnings of a genuine CRM domain.
Companies
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 OpportunityRead OpportunityList OpportunitiesUpdate OpportunityDelete OpportunityAssign Opportunity to CompanyMove Opportunity Through PipelineMark Opportunity WonMark Opportunity LostCalculate Pipeline ValueCalculate 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:
qualificationdiscoveryproposalnegotiationwonlost
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 pipelinesCustom stagesStage orderingStage probabilitiesMultiple pipelinesSales processesStage-specific validationWorkflow triggersAI 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:
EURUSDGBPJPY
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,000Probability = 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,000Opportunity B €75,000Opportunity C €100,000
Then:
Open Pipeline=€225,000
assuming all three are still open.
We should exclude:
wonlost
from the active pipeline.
14. Weighted Pipeline Example
Suppose:
Opportunity A€50,000 × 20% = €10,000Opportunity B€75,000 × 60% = €45,000Opportunity 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\backendpython -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\opportunitiesNew-Item app\modules\opportunities\__init__.py -ItemType FileNew-Item app\modules\opportunities\model.py -ItemType FileNew-Item app\modules\opportunities\repository.py -ItemType FileNew-Item app\modules\opportunities\service.py -ItemType FileNew-Item app\modules\opportunities\schemas.py -ItemType FileNew-Item app\modules\opportunities\router.py -ItemType FileNew-Item app\modules\opportunities\exceptions.py -ItemType FileNew-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 StrEnumclass 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 datefrom decimal import Decimalfrom uuid import UUID, uuid4from sqlalchemy import ( Date, ForeignKey, Integer, Numeric, String, Text,)from sqlalchemy.orm import ( Mapped, mapped_column, relationship,)from app.db.base import Basefrom 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_atupdated_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:
CompanyContactOpportunity
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_idcompany_idstageexpected_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:
ColumnsForeign KeysIndexesConstraints
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, datetimefrom decimal import Decimalfrom uuid import UUIDfrom pydantic import ( BaseModel, ConfigDict, Field,)from app.modules.opportunities.enums import ( OpportunityStage,)
We need:
OpportunityCreateOpportunityUpdateOpportunityResponsePipelineSummary
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 pipelineUSD pipelineGBP pipeline
This avoids pretending different currencies are directly additive.
37. Create Opportunity Exceptions
Open:
app/modules/opportunities/exceptions.py
Add:
class OpportunityError(Exception): passclass OpportunityNotFoundError( OpportunityError): passclass 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 UUIDfrom sqlalchemy import selectfrom sqlalchemy.orm import Sessionfrom 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 Decimalfrom uuid import UUIDfrom sqlalchemy.orm import Sessionfrom 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:
eurEUR 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 opportunitiesnegotiation opportunitieswon 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 = discoveryprobability = 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 → proposalproposal → discoverynegotiation → qualificationlost → discovery
if the user explicitly requests it.
Later we may introduce:
Stage transition rulesReopen workflowsApproval requirementsAudit historyAutomation 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:
SUMCOUNTGROUP BY
inside PostgreSQL.
That will eventually be preferable for large datasets.
But right now our priority is:
CorrectnessClarityTestabilityWorking 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:
FastAPISessionTenantContextPermissionsSchemasServiceExceptionsOpportunityStage
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:
CreateListRetrieveUpdateDeleteCompany OpportunitiesStage FilteringPipeline 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, probabilityFROM opportunities;
Verify the tenant and Company relationships.
80. Move the Opportunity to Discovery
Send:
PATCH /api/v1/opportunities/{id}
with:
{ "stage": "discovery"}
Expected:
stage = discoveryprobability = 40
Our first sales pipeline transition has occurred.
81. Move to Proposal
Send:
{ "stage": "proposal"}
Expected:
stage = proposalprobability = 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 = proposalprobability = 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 = wonprobability = 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,000qualification20%Opportunity B€75,000proposal60%Opportunity C€100,000negotiation80%
Call:
GET /opportunities/pipeline/summary?currency=EUR
Expected:
Open Opportunities = 3Total 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 ATenant 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:
createlistretrieveupdatedeletecompany relationshipcross-tenant company rejectionstage defaultsstage changesprobability overrideswonlostpipeline totalweighted pipelinecurrency filteringRBACtenant 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 = wonprobability = 100
Then verify the Opportunity is excluded from the open pipeline.
102. Test Lost State
Update:
{ "stage": "lost"}
Expected:
stage = lostprobability = 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:
DashboardsForecastsRevenue projectionsAI analysisReportsExecutive 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:
PlatformIdentityAuthenticationMulti-TenancyAuthorizationCompanies├── CRUD├── RBAC└── Tenant IsolationContacts├── CRUD├── Company Relationships├── RBAC└── Tenant IsolationOpportunities├── 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:
ActivitiesTasksMeetingsEmailsNotesDocumentsDashboardsWorkflows
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 IsolationRBACValidationBusiness Rules
must apply regardless of whether the caller is:
React UIREST APIChatGPTMCP ClientFuture Mobile AppIntegration
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_companyContacts├── list_contacts├── get_contact├── create_contact└── update_contactOpportunities├── 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: 8Pipeline: €620,000Weighted 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 Contosocalled 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:
CompanyContactOpportunity
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:
RegistrationAuthenticationTenant selectionRBACCompany creationContact creationOpportunity creationCompany relationshipsContact relationshipsOpportunity relationshipsPipeline updatesPipeline summaryCross-tenant isolationPermission boundariesComplete 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.