Skip to main content

Enterprise Plan — Backend Implementation Guide (Phase 2)

STATUS: This document describes the PHASE 2 design with questionnaire, proposals, and contract signing.

Phase 1 is already implemented — see the backend docs at docs.aiqlick.com/docs/platform/billing/enterprise-postpaid. Phase 1 uses the existing Subscription model with billingMode: POSTPAID, unlimited access, AIOperationLog-based invoicing, and simple approve/reject flow.

This document describes what the backend needs to add for Phase 2 (custom limits, org verification, proposals, contracts). For frontend design and UI flow, see ENTERPRISE_PLAN_DESIGN.md.


Table of Contents

  1. Flow Summary
  2. Database Schema
  3. GraphQL Schema
  4. Resolver Implementation
  5. Contract PDF Generation
  6. Email Notifications
  7. Cron Jobs & Scheduled Tasks
  8. Invoice Calculation Engine
  9. Validation & Business Rules
  10. Security
  11. Implementation Checklist

1. Flow Summary

The enterprise plan is a POSTPAID billing model where the full lifecycle is:

User submits request (with questionnaire)

[SUBMITTED]

Admin picks up request

[UNDER_REVIEW] ←──── Admin may request more info (stays UNDER_REVIEW)

Admin creates pricing proposal ──or── Admin rejects → [REJECTED]

[PROPOSAL_SENT]

User accepts proposal ──or── User declines (admin notified, can revise)

Backend generates contract PDF

[CONTRACT_PENDING]

User signs contract

[CUSTOMER_SIGNED]

Admin countersigns contract

[CONTRACT_SIGNED]

Subscription activated (POSTPAID, ACTIVE)

[APPROVED / ACTIVE]

Monthly/quarterly/annual invoicing begins

Direct approval shortcut (trusted orgs): Admin approves directly → contract generated with admin pre-signature → sent to customer → customer signs → subscription activates immediately (no second admin step needed).


2. Database Schema

2.1 New Table: enterprise_requests

CREATE TABLE enterprise_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
company_id UUID REFERENCES companies(id) ON DELETE SET NULL,
subscription_id UUID REFERENCES subscriptions(id) ON DELETE SET NULL,
plan_id UUID NOT NULL REFERENCES plans(id),

-- Status
status VARCHAR(20) NOT NULL DEFAULT 'SUBMITTED'
CHECK (status IN (
'SUBMITTED', 'UNDER_REVIEW', 'PROPOSAL_SENT',
'CONTRACT_PENDING', 'CUSTOMER_SIGNED', 'CONTRACT_SIGNED',
'APPROVED', 'REJECTED', 'CANCELLED'
)),

-- Organization Details (from questionnaire Step 1)
organization_legal_name VARCHAR(255) NOT NULL,
organization_number VARCHAR(100) NOT NULL,
country VARCHAR(100) NOT NULL,
industry VARCHAR(100) NOT NULL,
company_size VARCHAR(20) NOT NULL
CHECK (company_size IN ('1-10', '11-50', '51-200', '201-500', '500+')),
website VARCHAR(500),
billing_email VARCHAR(255) NOT NULL,
billing_address TEXT NOT NULL, -- JSON string: { street, city, state, zip, country }
vat_number VARCHAR(100),

-- Feature Requirements (from questionnaire Step 2)
-- Stored as JSON. Each key maps to a plan limit field.
-- Value is a number (specific limit) or -1 (unlimited).
requested_limits JSONB NOT NULL,

-- Billing Preferences (from questionnaire Step 3)
billing_cycle VARCHAR(20) NOT NULL DEFAULT 'MONTHLY'
CHECK (billing_cycle IN ('MONTHLY', 'QUARTERLY', 'ANNUALLY')),
payment_method_preference VARCHAR(20) NOT NULL DEFAULT 'INVOICE'
CHECK (payment_method_preference IN ('INVOICE', 'CREDIT_CARD', 'DIRECT_DEBIT')),
estimated_budget VARCHAR(50), -- e.g., "<500", "500-2000", "2000-5000", "5000+"
purchase_order_required BOOLEAN NOT NULL DEFAULT false,
purchase_order_number VARCHAR(100),
additional_notes TEXT,

-- Admin review
reviewed_by UUID REFERENCES users(id),
reviewed_at TIMESTAMPTZ,
rejection_reason TEXT,
internal_notes TEXT, -- admin-only, never exposed to customer

-- Organization verification
org_verified BOOLEAN NOT NULL DEFAULT false,
org_verified_by UUID REFERENCES users(id),
org_verified_at TIMESTAMPTZ,
email_verified BOOLEAN NOT NULL DEFAULT false,
risk_level VARCHAR(10) CHECK (risk_level IN ('LOW', 'MEDIUM', 'HIGH')),

-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Indexes
CREATE INDEX idx_enterprise_requests_user_id ON enterprise_requests(user_id);
CREATE INDEX idx_enterprise_requests_company_id ON enterprise_requests(company_id);
CREATE INDEX idx_enterprise_requests_status ON enterprise_requests(status);
CREATE INDEX idx_enterprise_requests_created_at ON enterprise_requests(created_at DESC);

-- Enforce max 1 active (non-terminal) request per user
CREATE UNIQUE INDEX idx_enterprise_requests_one_active_per_user
ON enterprise_requests(user_id)
WHERE status NOT IN ('REJECTED', 'CANCELLED', 'APPROVED');

requested_limits JSON structure:

{
"maxCompanies": 5,
"maxUsersPerCompany": -1,
"maxJobs": -1,
"maxContacts": 10000,
"maxPipelines": -1,
"maxTalentPools": 50,
"maxInterviewsPerMonth": -1,
"maxMeetingsPerMonth": -1,
"maxCvCollections": 20,
"maxSharedTalentLists": -1,
"maxCollaborationInvites": -1,
"maxConnectionRequestsPerMonth": -1,
"maxNewsPerMonth": 100,
"maxContactNotes": -1,
"maxCvParsesPerMonth": 500,
"maxAgentMessagesPerMonth": 2000,
"maxTextRewritesPerMonth": -1,
"creditsPerMonth": 5000
}

-1 = unlimited. Any positive integer = specific cap. Fields not present = use plan default.


2.2 New Table: enterprise_proposals

CREATE TABLE enterprise_proposals (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
enterprise_request_id UUID NOT NULL REFERENCES enterprise_requests(id) ON DELETE CASCADE,

-- Pricing
base_price INTEGER NOT NULL, -- cents per billing cycle
credit_rate NUMERIC(10,4), -- price per credit (e.g., 0.0250 = $0.025)
billing_cycle VARCHAR(20) NOT NULL DEFAULT 'MONTHLY'
CHECK (billing_cycle IN ('MONTHLY', 'QUARTERLY', 'ANNUALLY')),
contract_term_months INTEGER NOT NULL DEFAULT 0, -- 0 = month-to-month
minimum_spend INTEGER, -- cents
discount_percent NUMERIC(5,2), -- e.g., 15.00 = 15%
setup_fee INTEGER, -- cents, one-time
currency VARCHAR(3) NOT NULL DEFAULT 'USD',

-- Approved feature limits (admin may adjust from requested)
approved_limits JSONB NOT NULL, -- same structure as requested_limits

-- Communication
proposal_notes TEXT, -- visible to customer
internal_notes TEXT, -- admin-only

-- Status
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT'
CHECK (status IN ('DRAFT', 'SENT', 'ACCEPTED', 'DECLINED', 'EXPIRED')),
sent_at TIMESTAMPTZ,
responded_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ, -- e.g., 30 days after sent_at

created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_enterprise_proposals_request_id ON enterprise_proposals(enterprise_request_id);
CREATE INDEX idx_enterprise_proposals_status ON enterprise_proposals(status);
CREATE INDEX idx_enterprise_proposals_expires_at ON enterprise_proposals(expires_at)
WHERE status = 'SENT';

2.3 New Table: enterprise_contracts

CREATE TABLE enterprise_contracts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
enterprise_request_id UUID NOT NULL REFERENCES enterprise_requests(id) ON DELETE CASCADE,
proposal_id UUID NOT NULL REFERENCES enterprise_proposals(id) ON DELETE CASCADE,

-- Document
contract_pdf_url VARCHAR(1000), -- S3 key: unsigned draft PDF
signed_pdf_url VARCHAR(1000), -- S3 key: fully executed PDF (both signatures)
template_id VARCHAR(100), -- contract template variant (e.g., "standard_v1")
version INTEGER NOT NULL DEFAULT 1, -- incremented on regeneration

-- Contract terms snapshot (frozen at generation time — never mutated after)
terms JSONB NOT NULL,

-- Customer signature
customer_signed_at TIMESTAMPTZ,
customer_signer_name VARCHAR(255),
customer_signer_title VARCHAR(255),
customer_signer_email VARCHAR(255),
customer_signature_data TEXT, -- base64 encoded (typed render / canvas / uploaded image)
customer_signature_method VARCHAR(10)
CHECK (customer_signature_method IN ('TYPED', 'DRAWN', 'UPLOADED')),
customer_signer_ip VARCHAR(45), -- IPv4 or IPv6

-- AiQlick countersignature
admin_signed_at TIMESTAMPTZ,
admin_signer_id UUID REFERENCES users(id),
admin_signer_name VARCHAR(255),
admin_signer_title VARCHAR(255),
admin_signature_data TEXT,

-- Status
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT'
CHECK (status IN ('DRAFT', 'SENT', 'CUSTOMER_SIGNED', 'FULLY_EXECUTED', 'EXPIRED', 'VOIDED')),
sent_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ, -- e.g., 14 days to sign
voided_reason TEXT,

-- Audit
viewed_by_customer_at TIMESTAMPTZ,

created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_enterprise_contracts_request_id ON enterprise_contracts(enterprise_request_id);
CREATE INDEX idx_enterprise_contracts_proposal_id ON enterprise_contracts(proposal_id);
CREATE INDEX idx_enterprise_contracts_status ON enterprise_contracts(status);
CREATE INDEX idx_enterprise_contracts_expires_at ON enterprise_contracts(expires_at)
WHERE status IN ('DRAFT', 'SENT');

terms JSON structure:

{
"parties": {
"provider": {
"name": "AiQlick AS",
"address": "...",
"orgNumber": "NO-123456789",
"representative": "CEO Name"
},
"customer": {
"name": "Acme Corp",
"address": "123 Business St, Oslo, Norway",
"orgNumber": "NO-987654321",
"representative": null
}
},
"service": {
"planName": "Enterprise Plan",
"featureLimits": { "maxCompanies": 5, "maxJobs": -1, "...": "..." },
"billingCycle": "MONTHLY",
"basePrice": 99900,
"creditRate": 0.025,
"minimumSpend": 50000,
"discountPercent": 10.0,
"setupFee": 0,
"currency": "USD"
},
"duration": {
"startDate": "2026-05-01T00:00:00Z",
"endDate": "2027-04-30T23:59:59Z",
"contractTermMonths": 12,
"autoRenew": true,
"renewalNoticeDays": 30
},
"payment": {
"paymentTermDays": 30,
"paymentMethod": "INVOICE",
"purchaseOrderNumber": "PO-2026-001"
},
"termination": {
"noticePeriodDays": 30,
"earlyTerminationFee": null
}
}

2.4 Extend Existing Table: subscriptions

Add these columns:

ALTER TABLE subscriptions ADD COLUMN enterprise_request_id UUID REFERENCES enterprise_requests(id);
ALTER TABLE subscriptions ADD COLUMN contract_id UUID REFERENCES enterprise_contracts(id);
ALTER TABLE subscriptions ADD COLUMN billing_cycle VARCHAR(20)
CHECK (billing_cycle IN ('MONTHLY', 'QUARTERLY', 'ANNUALLY'));
ALTER TABLE subscriptions ADD COLUMN contract_term_months INTEGER;
ALTER TABLE subscriptions ADD COLUMN contract_start_date TIMESTAMPTZ;
ALTER TABLE subscriptions ADD COLUMN contract_end_date TIMESTAMPTZ;
ALTER TABLE subscriptions ADD COLUMN base_price INTEGER; -- cents
ALTER TABLE subscriptions ADD COLUMN credit_rate NUMERIC(10,4);
ALTER TABLE subscriptions ADD COLUMN minimum_spend INTEGER; -- cents
ALTER TABLE subscriptions ADD COLUMN discount_percent NUMERIC(5,2);
ALTER TABLE subscriptions ADD COLUMN purchase_order_number VARCHAR(100);
ALTER TABLE subscriptions ADD COLUMN billing_email VARCHAR(255);
ALTER TABLE subscriptions ADD COLUMN billing_address TEXT; -- JSON

2.5 Extend Existing Table: invoices

Add these columns:

ALTER TABLE invoices ADD COLUMN purchase_order_number VARCHAR(100);
ALTER TABLE invoices ADD COLUMN billing_address TEXT; -- JSON
ALTER TABLE invoices ADD COLUMN vat_number VARCHAR(100);
ALTER TABLE invoices ADD COLUMN billing_email_override VARCHAR(255);
ALTER TABLE invoices ADD COLUMN line_items JSONB; -- detailed breakdown
ALTER TABLE invoices ADD COLUMN notes TEXT;
ALTER TABLE invoices ADD COLUMN due_in_days INTEGER DEFAULT 30;
ALTER TABLE invoices ADD COLUMN sent_at TIMESTAMPTZ;
ALTER TABLE invoices ADD COLUMN paid_at TIMESTAMPTZ;
ALTER TABLE invoices ADD COLUMN payment_method VARCHAR(20);
ALTER TABLE invoices ADD COLUMN bank_reference VARCHAR(255);

line_items JSON structure:

{
"items": [
{
"description": "Enterprise Plan Base Fee (May 2026)",
"quantity": 1,
"unitPrice": 99900,
"total": 99900
},
{
"description": "AI Credits Used (487 credits × $0.025)",
"quantity": 487,
"unitPrice": 25,
"total": 12175
},
{
"description": "Volume Discount (10%)",
"quantity": 1,
"unitPrice": -11208,
"total": -11208
}
]
}

3. GraphQL Schema

3.1 Enums

enum EnterpriseRequestStatus {
SUBMITTED
UNDER_REVIEW
PROPOSAL_SENT
CONTRACT_PENDING
CUSTOMER_SIGNED
CONTRACT_SIGNED
APPROVED
REJECTED
CANCELLED
}

enum ProposalStatus {
DRAFT
SENT
ACCEPTED
DECLINED
EXPIRED
}

enum ContractStatus {
DRAFT
SENT
CUSTOMER_SIGNED
FULLY_EXECUTED
EXPIRED
VOIDED
}

enum SignatureMethod {
TYPED
DRAWN
UPLOADED
}

enum BillingCycle {
MONTHLY
QUARTERLY
ANNUALLY
}

enum PaymentMethodPreference {
INVOICE
CREDIT_CARD
DIRECT_DEBIT
}

enum RiskLevel {
LOW
MEDIUM
HIGH
}

3.2 Types

type EnterpriseRequest {
id: ID!
userId: ID!
companyId: ID
subscriptionId: ID
status: EnterpriseRequestStatus!

# Organization
organizationLegalName: String!
organizationNumber: String!
country: String!
industry: String!
companySize: String!
website: String
billingEmail: String!
billingAddress: String!
vatNumber: String

# Feature requirements (JSON)
requestedLimits: JSON!

# Billing prefs
billingCycle: BillingCycle!
paymentMethodPreference: PaymentMethodPreference!
estimatedBudget: String
purchaseOrderRequired: Boolean!
purchaseOrderNumber: String
additionalNotes: String

# Verification
orgVerified: Boolean!
emailVerified: Boolean!
riskLevel: RiskLevel

# Relations (resolved via DataLoader / joins)
user: User
company: Company
subscription: Subscription
proposals: [EnterpriseProposal!]
activeContract: EnterpriseContract # most recent non-voided contract

# Admin fields (only returned for SuperAdmin queries)
reviewedBy: User
reviewedAt: DateTime
rejectionReason: String
internalNotes: String # NEVER expose to non-admin

createdAt: DateTime!
updatedAt: DateTime!
}

type EnterpriseProposal {
id: ID!
enterpriseRequestId: ID!
basePrice: Int!
creditRate: Float
billingCycle: BillingCycle!
contractTermMonths: Int!
minimumSpend: Int
discountPercent: Float
setupFee: Int
currency: String!
approvedLimits: JSON!
proposalNotes: String # visible to customer
# internalNotes: String — NEVER exposed in this type; only via admin detail query
status: ProposalStatus!
sentAt: DateTime
expiresAt: DateTime
respondedAt: DateTime
contract: EnterpriseContract # generated when ACCEPTED
createdAt: DateTime!
updatedAt: DateTime!
}

type EnterpriseContract {
id: ID!
enterpriseRequestId: ID!
proposalId: ID!
status: ContractStatus!
version: Int!

# Document URLs — return pre-signed S3 URLs (15-min expiry)
contractPdfUrl: String
signedPdfUrl: String
terms: JSON!

# Customer signature (visible to admin; customer sees own)
customerSignedAt: DateTime
customerSignerName: String
customerSignerTitle: String
customerSignerEmail: String
customerSignatureMethod: SignatureMethod

# Admin countersignature
adminSignedAt: DateTime
adminSignerName: String
adminSignerTitle: String

# Lifecycle
sentAt: DateTime
expiresAt: DateTime
viewedByCustomerAt: DateTime

createdAt: DateTime!
updatedAt: DateTime!
}

3.3 Inputs

# ──────────────────────────────────
# User-facing inputs
# ──────────────────────────────────

input SubmitEnterpriseRequestInput {
planId: ID!
companyId: ID

# Step 1 — Organization
organizationLegalName: String!
organizationNumber: String!
country: String!
industry: String!
companySize: String!
website: String
billingEmail: String!
billingAddress: String!
vatNumber: String

# Step 2 — Feature requirements
requestedLimits: JSON!

# Step 3 — Billing
billingCycle: BillingCycle!
paymentMethodPreference: PaymentMethodPreference!
estimatedBudget: String
purchaseOrderRequired: Boolean
purchaseOrderNumber: String
additionalNotes: String
}

input SignEnterpriseContractInput {
contractId: ID!
signerName: String!
signerTitle: String!
signerEmail: String!
signatureData: String! # base64 (typed render, canvas drawing, or uploaded image)
signatureMethod: SignatureMethod!
agreedToTerms: Boolean! # must be true
authorizedToSign: Boolean! # must be true
}

# ──────────────────────────────────
# Admin inputs
# ──────────────────────────────────

input AdminCreateEnterpriseProposalInput {
requestId: ID!
basePrice: Int! # cents
creditRate: Float
billingCycle: BillingCycle!
contractTermMonths: Int! # 0 = month-to-month
minimumSpend: Int # cents
discountPercent: Float
setupFee: Int # cents
currency: String # defaults to "USD"
approvedLimits: JSON!
proposalNotes: String # visible to customer
internalNotes: String # admin-only
expiresInDays: Int # default 30
sendImmediately: Boolean # default true
}

input AdminApproveEnterpriseDirectlyInput {
requestId: ID!
basePrice: Int!
creditRate: Float
billingCycle: BillingCycle!
contractTermMonths: Int # 0 or null = month-to-month
approvedLimits: JSON!
billingOwnerId: ID!
preSignContract: Boolean # default true
}

input AdminGenerateDetailedInvoiceInput {
companyId: ID!
subscriptionId: ID!
billingPeriodStart: DateTime!
billingPeriodEnd: DateTime!
lineItems: [InvoiceLineItemInput!] # if null, auto-calculate from usage
notes: String
dueInDays: Int # default 30
purchaseOrderNumber: String
sendToEmail: String # override billing email
}

input InvoiceLineItemInput {
description: String!
quantity: Int!
unitPrice: Int! # cents
}

3.4 Queries & Mutations

# ════════════════════════════════════
# USER-FACING
# ════════════════════════════════════

type Query {
# Returns the user's most recent active (non-cancelled, non-rejected) enterprise request.
# Includes proposals and activeContract.
# Returns null if no active request exists.
myEnterpriseRequest: EnterpriseRequest

# Get contract details. Marks viewedByCustomerAt on first access.
# Auth: must own the parent enterprise request.
getEnterpriseContract(contractId: ID!): EnterpriseContract!

# Get a pre-signed S3 URL for the fully executed contract PDF.
# Only available when contract status = FULLY_EXECUTED.
getSignedContractPdf(contractId: ID!): String!
}

type Mutation {
# Submit a new enterprise request with full questionnaire data.
# Validates: org number format, max 1 active request per user, required fields.
# Sets status = SUBMITTED. Sends email to admin + confirmation to customer.
submitEnterpriseRequest(input: SubmitEnterpriseRequestInput!): EnterpriseRequest!

# Accept a pricing proposal. Triggers:
# 1. Proposal status → ACCEPTED
# 2. Contract PDF generated from proposal terms
# 3. Contract status → SENT
# 4. Request status → CONTRACT_PENDING
# 5. Email sent to customer with contract PDF
acceptEnterpriseProposal(proposalId: ID!): EnterpriseRequest!

# Decline a proposal with optional message.
# Proposal status → DECLINED. Admin notified.
declineEnterpriseProposal(proposalId: ID!, message: String): EnterpriseRequest!

# Cancel a pending request. Only allowed when status is SUBMITTED or UNDER_REVIEW.
cancelEnterpriseRequest(requestId: ID!): EnterpriseRequest!

# Sign the enterprise contract. Validates:
# - agreedToTerms and authorizedToSign must both be true
# - Contract must be in SENT status (or DRAFT for pre-signed path)
# - Caller must own the parent enterprise request
# On success:
# 1. Stores signature data, signer info, IP, timestamp
# 2. Embeds signature into contract PDF (new version with customer sig)
# 3. Contract status → CUSTOMER_SIGNED
# 4. Request status → CUSTOMER_SIGNED
# 5. Email sent to admin: "[Company] signed the contract"
# 6. If contract was pre-signed by admin: skip to FULLY_EXECUTED → activate subscription
signEnterpriseContract(input: SignEnterpriseContractInput!): EnterpriseContract!
}

# ════════════════════════════════════
# ADMIN (SuperAdmin only)
# ════════════════════════════════════

type AdminEnterpriseRequestsResult {
requests: [EnterpriseRequest!]!
total: Int!
}

type AdminEnterpriseDashboardStats {
pendingRequests: Int!
activeEnterpriseAccounts: Int!
enterpriseMRR: Int!
avgEnterpriseValue: Int!
outstandingInvoiceAmount: Int!
overdueInvoiceCount: Int!
}

type Query {
# Paginated list of enterprise requests. Supports status filtering.
adminEnterpriseRequests(
status: EnterpriseRequestStatus
page: Int
limit: Int
): AdminEnterpriseRequestsResult!

# Single request with all details, relations, proposals, contracts.
adminEnterpriseRequest(id: ID!): EnterpriseRequest!

# Aggregate stats for the enterprise dashboard widgets.
adminEnterpriseDashboard: AdminEnterpriseDashboardStats!
}

type Mutation {
# ── Request management ──

# Transition request status. Validates allowed transitions (see Section 9).
adminUpdateEnterpriseRequestStatus(
requestId: ID!
status: EnterpriseRequestStatus!
internalNotes: String
): EnterpriseRequest!

# Mark organization as verified / not verified.
adminVerifyOrganization(
requestId: ID!
verified: Boolean!
notes: String
): EnterpriseRequest!

# Reject a request. Sets status → REJECTED, sends email with reason.
adminRejectEnterpriseRequest(
requestId: ID!
reason: String!
): EnterpriseRequest!

# Ask customer for more information. Sends email with admin message.
# Keeps status at UNDER_REVIEW (or transitions to it if SUBMITTED).
adminRequestEnterpriseInfo(
requestId: ID!
message: String!
): EnterpriseRequest!

# ── Proposal management ──

# Create a pricing proposal. If sendImmediately=true:
# 1. Proposal status → SENT, sent_at = now, expires_at = now + expiresInDays
# 2. Request status → PROPOSAL_SENT
# 3. Email sent to customer
adminCreateEnterpriseProposal(
input: AdminCreateEnterpriseProposalInput!
): EnterpriseProposal!

# Skip proposal flow. Creates proposal + contract in one step.
# If preSignContract=true: admin signature is applied immediately.
# Contract sent to customer for their signature.
adminApproveEnterpriseDirectly(
input: AdminApproveEnterpriseDirectlyInput!
): EnterpriseRequest!

# ── Contract management ──

# Countersign a customer-signed contract. Triggers:
# 1. Admin signature embedded in PDF → final signed PDF generated
# 2. Contract status → FULLY_EXECUTED
# 3. Subscription created & activated (see Section 4.6)
# 4. Request status → CONTRACT_SIGNED / APPROVED
# 5. Fully executed PDF emailed to both parties
adminCountersignContract(contractId: ID!): EnterpriseContract!

# Pre-sign a contract (admin signs first). Used in direct approval path.
# Customer receives a contract that already has the admin signature —
# when they sign, subscription activates immediately.
adminPreSignContract(contractId: ID!): EnterpriseContract!

# Void existing contract and create a new version.
# Old contract → VOIDED, new contract generated with version+1.
# Customer notified that a new contract is available.
adminRegenerateContract(contractId: ID!, reason: String): EnterpriseContract!

# Void a contract without regenerating. Deal fell through.
adminVoidContract(contractId: ID!, reason: String!): EnterpriseContract!

# ── Invoicing ──

# Generate a detailed enterprise invoice.
# If lineItems is null, auto-calculate from usage data (see Section 8).
adminGenerateDetailedInvoice(
input: AdminGenerateDetailedInvoiceInput!
): Invoice!

# Mark an invoice as paid. Sets status → PAID, paid_at = now.
adminMarkInvoicePaid(invoiceId: ID!): Invoice!
}

4. Resolver Implementation

4.1 submitEnterpriseRequest

Auth: Authenticated user
Validation:
1. User must not have an existing active request (status NOT IN REJECTED, CANCELLED, APPROVED)
2. planId must reference an active POSTPAID plan
3. organizationNumber: basic format check per country (see Section 9.3)
4. billingEmail: valid email format
5. requestedLimits: validate all keys are known limit fields, values are integers or -1
6. If purchaseOrderRequired=true, purchaseOrderNumber must be non-empty

Steps:
1. INSERT into enterprise_requests with status='SUBMITTED'
2. Run risk assessment (async, see Section 9.4)
3. Send email → admin: "New enterprise request from [org]"
4. Send email → customer: "We received your request"
5. Return the created EnterpriseRequest

4.2 acceptEnterpriseProposal

Auth: Authenticated user, must own the parent enterprise request
Validation:
1. Proposal status must be SENT
2. Proposal must not be expired (expires_at > now)
3. Parent request status must be PROPOSAL_SENT

Steps:
1. UPDATE proposal: status='ACCEPTED', responded_at=now
2. Generate contract PDF (see Section 5)
3. INSERT into enterprise_contracts with:
- terms = snapshot from proposal + request org details
- status = 'SENT'
- sent_at = now
- expires_at = now + 14 days
4. Upload PDF to S3, store URL in contract_pdf_url
5. UPDATE request: status='CONTRACT_PENDING'
6. Send email → customer: "Your contract is ready for signing" + PDF attachment
7. Send email → admin: "[Company] accepted the proposal"
8. Return updated EnterpriseRequest

4.3 signEnterpriseContract

Auth: Authenticated user, must own the parent enterprise request
Validation:
1. agreedToTerms must be true
2. authorizedToSign must be true
3. Contract status must be SENT (normal flow) or have admin pre-signature (direct approval)
4. signatureData must be non-empty, valid base64
5. signerName, signerTitle, signerEmail must be non-empty

Steps:
1. Capture client IP from request headers (X-Forwarded-For or remoteAddress)
2. UPDATE contract:
- customer_signed_at = now
- customer_signer_name, customer_signer_title, customer_signer_email = input
- customer_signature_data = input.signatureData
- customer_signature_method = input.signatureMethod
- customer_signer_ip = captured IP
- status = 'CUSTOMER_SIGNED'
3. Generate new PDF with customer signature overlaid (see Section 5.3)
4. Upload updated PDF to S3
5. UPDATE request: status='CUSTOMER_SIGNED'

6. CHECK: Was this contract pre-signed by admin?
IF admin_signed_at IS NOT NULL:
→ Contract already has both signatures
→ Set status = 'FULLY_EXECUTED'
→ Generate final signed PDF with both signatures
→ Upload to signed_pdf_url
→ Run subscription activation (Section 4.6)
→ Request status → 'CONTRACT_SIGNED' / 'APPROVED'
→ Email both parties the fully executed PDF
ELSE:
→ Email admin: "[Company] has signed. Please countersign."

7. Return updated EnterpriseContract

4.4 adminCountersignContract

Auth: SuperAdmin
Validation:
1. Contract status must be CUSTOMER_SIGNED
2. Customer signature fields must all be present

Steps:
1. Get admin user from auth context
2. UPDATE contract:
- admin_signed_at = now
- admin_signer_id = current admin user ID
- admin_signer_name = admin full name
- admin_signer_title = "AiQlick Representative" (or admin's title)
- admin_signature_data = system-generated admin signature (or admin's stored signature)
- status = 'FULLY_EXECUTED'
3. Generate final signed PDF with both signatures overlaid
4. Upload to S3 → signed_pdf_url
5. Run subscription activation (Section 4.6)
6. UPDATE request: status='CONTRACT_SIGNED'
7. Email customer: "Contract fully executed. Subscription active." + signed PDF
8. Email admin: confirmation
9. Return updated EnterpriseContract

4.5 adminApproveEnterpriseDirectly

Auth: SuperAdmin
Validation:
1. Request status must be SUBMITTED or UNDER_REVIEW
2. billingOwnerId must reference a valid user

Steps:
1. Create proposal (status='ACCEPTED', skipping SENT)
2. Generate contract PDF
3. INSERT contract with status='SENT'
4. If preSignContract (default true):
a. Apply admin signature to contract PDF
b. Set admin_signed_at, admin_signer_* fields
c. Note: contract status stays 'SENT' — waiting for customer
5. Upload PDF to S3
6. UPDATE request: status='CONTRACT_PENDING'
7. Email customer: "Your pre-approved contract is ready. Sign to activate."
8. Return updated EnterpriseRequest

When customer signs (via signEnterpriseContract):
→ Both signatures present → FULLY_EXECUTED → subscription activates immediately

4.6 Subscription Activation

This is a shared internal function called when a contract reaches FULLY_EXECUTED status.

function activateEnterpriseSubscription(contract, request, proposal):

1. Look up the enterprise plan (request.plan_id)

2. Create a CUSTOM PLAN (clone of the base enterprise plan) with:
- All limit fields set from proposal.approved_limits
- billingMode = 'POSTPAID'
- price = proposal.base_price
- interval = proposal.billing_cycle
- name = "[Base Plan Name] — [Organization Name]"
- active = true (but hidden from public plan listing)

3. INSERT subscription:
- user_id = request.user_id
- plan_id = custom plan ID
- status = 'ACTIVE'
- is_active = true
- start_date = contract.terms.duration.startDate (or now)
- end_date = contract.terms.duration.endDate (or null for month-to-month)
- enterprise_request_id = request.id
- contract_id = contract.id
- billing_cycle = proposal.billing_cycle
- contract_term_months = proposal.contract_term_months
- contract_start_date = start_date
- contract_end_date = end_date
- base_price = proposal.base_price
- credit_rate = proposal.credit_rate
- minimum_spend = proposal.minimum_spend
- discount_percent = proposal.discount_percent
- purchase_order_number = request.purchase_order_number
- billing_email = request.billing_email
- billing_address = request.billing_address

4. UPDATE request: subscription_id = new subscription ID, status = 'APPROVED'

5. Grant initial credits:
- If proposal.approved_limits.creditsPerMonth > 0:
INSERT credit_transaction (type='PLAN_ALLOCATION', amount=creditsPerMonth)

6. Cancel any existing subscription for this user/company (if upgrading)

7. Return subscription

5. Contract PDF Generation

5.1 Technology Choice

Use Puppeteer (headless Chrome) or @react-pdf/renderer to convert an HTML/React template to PDF. Puppeteer is recommended for pixel-perfect output with CSS support.

Alternative: PDFKit for a pure Node.js approach (no browser dependency), or a hosted service like DocSpring / Anvil if you want managed contract generation.

5.2 Contract Template

Create an HTML template with Handlebars-style placeholders. The template should include:

┌──────────────────────────────────────────────────────────┐
│ AiQlick Logo │
│ │
│ ENTERPRISE SERVICE AGREEMENT │
│ Agreement No: ENT-2026-001 │
│ Date: May 1, 2026 │
│ │
│ ─── 1. PARTIES ─── │
│ Provider: AiQlick AS, Org# NO-123456789 │
│ Customer: {{customer.name}}, Org# {{customer.orgNumber}}│
│ Address: {{customer.address}} │
│ │
│ ─── 2. SERVICES ─── │
│ AiQlick Enterprise Plan with the following limits: │
│ • Companies: {{limits.maxCompanies}} │
│ • Team Members: {{limits.maxUsersPerCompany}} │
│ • Jobs: {{limits.maxJobs}} │
│ • [... all approved limits ...] │
│ │
│ ─── 3. PRICING ─── │
│ Base Fee: {{pricing.basePrice}} / {{pricing.cycle}} │
│ Credit Rate: {{pricing.creditRate}} per credit │
│ Minimum Monthly Spend: {{pricing.minimumSpend}} │
│ Discount: {{pricing.discountPercent}}% │
│ Setup Fee: {{pricing.setupFee}} (one-time) │
│ │
│ ─── 4. TERM ─── │
│ Start: {{duration.startDate}} │
│ End: {{duration.endDate}} │
│ Auto-renewal: {{duration.autoRenew}} │
│ Notice period: {{duration.renewalNoticeDays}} days │
│ │
│ ─── 5. PAYMENT TERMS ─── │
│ Payment due: Net {{payment.paymentTermDays}} │
│ Method: {{payment.paymentMethod}} │
│ PO Number: {{payment.purchaseOrderNumber}} │
│ │
│ ─── 6. TERMINATION ─── │
│ Notice period: {{termination.noticePeriodDays}} days │
│ Early termination fee: {{termination.fee}} │
│ │
│ ─── 7. SERVICE LEVEL AGREEMENT ─── │
│ [Standard SLA terms — static text] │
│ • 99.9% uptime guarantee │
│ • Priority support response within 4 hours │
│ • Dedicated account manager │
│ │
│ ─── 8. DATA PROCESSING ─── │
│ [Standard GDPR/DPA terms — static text] │
│ │
│ ─── 9. LIABILITY ─── │
│ [Standard liability terms — static text] │
│ │
│ ─── 10. GENERAL TERMS ─── │
│ [Governing law, dispute resolution — static text] │
│ │
│ ─── SIGNATURES ─── │
│ │
│ For AiQlick AS: For {{customer.name}}: │
│ │
│ ________________________ ________________________ │
│ Name: [admin name] Name: [customer name] │
│ Title: [admin title] Title: [customer title] │
│ Date: [admin sign date] Date: [customer sign date] │
│ IP: — IP: [customer IP] │
│ │
└──────────────────────────────────────────────────────────┘

5.3 Signature Embedding

When a party signs:

  1. TYPED signature: Render the signer's name in a cursive/signature font (e.g., "Dancing Script", "Great Vibes") as an image or SVG, then overlay it on the PDF at the signature field coordinates.

  2. DRAWN signature: The frontend captures the canvas drawing as a base64 PNG. Decode and overlay on the PDF.

  3. UPLOADED signature: The frontend sends the uploaded image as base64. Decode and overlay.

Implementation:

// Pseudocode for signature overlay
async function embedSignature(pdfBuffer, signatureBase64, position, signerInfo) {
const pdfDoc = await PDFDocument.load(pdfBuffer) // pdf-lib
const signatureImage = await pdfDoc.embedPng(Buffer.from(signatureBase64, 'base64'))

const page = pdfDoc.getPages()[pdfDoc.getPageCount() - 1] // last page (signatures page)
page.drawImage(signatureImage, {
x: position.x,
y: position.y,
width: 200,
height: 60,
})

// Add signer metadata text below signature
page.drawText(`Name: ${signerInfo.name}`, { x: position.x, y: position.y - 15, size: 9 })
page.drawText(`Title: ${signerInfo.title}`, { x: position.x, y: position.y - 27, size: 9 })
page.drawText(`Date: ${signerInfo.date}`, { x: position.x, y: position.y - 39, size: 9 })
if (signerInfo.ip) {
page.drawText(`IP: ${signerInfo.ip}`, { x: position.x, y: position.y - 51, size: 8 })
}

return await pdfDoc.save()
}

Recommended library: pdf-lib (pure JS, no native dependencies) for signature overlay. Use Puppeteer only for the initial HTML→PDF generation.

5.4 Integrity / Checksum

After generating the final signed PDF:

const crypto = require('crypto')
const hash = crypto.createHash('sha256').update(pdfBuffer).digest('hex')
// Store hash in enterprise_contracts.checksum column (add this column)
// Verify on download: regenerate hash from stored PDF and compare

5.5 S3 Storage

Bucket: aiqlick-contracts (or a prefix in the existing bucket)

Key pattern:
contracts/{requestId}/draft_v{version}.pdf
contracts/{requestId}/customer_signed_v{version}.pdf
contracts/{requestId}/fully_executed_v{version}.pdf

Pre-signed URL generation:
- Expiry: 15 minutes
- Used for all PDF access (view + download)
- Generated on-demand in resolvers, never stored permanently

6. Email Notifications

6.1 All Triggers

#EventRecipientSubject LineBody SummaryAttachments
1Request submittedAdmin (all super admins)New Enterprise Request: {{org}}Company details, org#, size, industry, estimated budget
2Request submittedCustomerEnterprise Request Received"We received your request. Review within 2 business days."
3Status → UNDER_REVIEWCustomerEnterprise Request Under Review"Your request is now being reviewed by our team."
4More info requestedCustomerAdditional Information NeededAdmin's message. CTA: "Reply in your billing page"
5Proposal sentCustomerEnterprise Plan ProposalPricing summary, limits, contract term. CTA: "View Proposal"
6Proposal acceptedAdminProposal Accepted: {{org}}"Contract generated. Awaiting signatures."
7Proposal declinedAdminProposal Declined: {{org}}Customer's message (if any). "Revise and resend."
8Contract readyCustomerEnterprise Contract Ready for SigningKey terms summary. CTA: "Review & Sign Contract"Contract PDF
9Contract pre-signedCustomerPre-Approved Enterprise Contract"Your contract is pre-signed. Sign to activate immediately." CTA: "Sign Now"Pre-signed PDF
10Customer signedAdminContract Signed: {{org}}Signer details (name, title, email). CTA: "Countersign"
11Contract countersigned (fully executed)CustomerEnterprise Plan Activated"Contract fully executed. Your subscription is now active."Fully signed PDF
12Contract countersigned (fully executed)AdminEnterprise Activated: {{org}}Confirmation. Subscription details.
13Contract expiring soon (7 days)CustomerContract Signing Reminder"Your contract expires in 7 days. Please sign."
14Contract expiring soon (3 days)CustomerUrgent: Contract Expires in 3 Days"Sign before {{expiresAt}} to avoid delays."
15Contract expiredCustomer + AdminEnterprise Contract Expired"The contract has expired. Contact us to proceed."
16Contract voidedCustomerEnterprise Contract VoidedReason. "A new contract will be generated if applicable."
17Contract renewal reminderCustomer + AdminEnterprise Contract Renewal"Contract expires on {{endDate}}. {{noticeDays}} days notice."
18Request rejectedCustomerEnterprise Request Not ApprovedReason. "Contact support for questions."
19Invoice generatedCustomer (billing email)Invoice #{{number}} — {{period}}Amount, due date, payment detailsInvoice PDF
20Invoice overdueCustomer + AdminInvoice #{{number}} Overdue"Payment was due on {{dueDate}}. Please remit."
21Payment receivedCustomerPayment Received — Invoice #{{number}}"Thank you. Your payment has been recorded."

6.2 Email Template Structure

Use a shared base template with:

  • AiQlick header/logo
  • Content area with dynamic body
  • CTA button (links to relevant in-app page)
  • Footer with support contact info

All emails should include:

  • Organization name in subject (when relevant)
  • Link to the relevant page in the app (billing page for customers, admin enterprise tab for admins)

7. Cron Jobs & Scheduled Tasks

7.1 Proposal Expiry Check

Schedule: Daily at 02:00 UTC
Query: SELECT * FROM enterprise_proposals WHERE status='SENT' AND expires_at < NOW()
Action: UPDATE status='EXPIRED'. Send email #15-equivalent to customer.

7.2 Contract Expiry Check

Schedule: Daily at 02:00 UTC

-- Expire unsigned contracts
Query: SELECT * FROM enterprise_contracts
WHERE status IN ('DRAFT', 'SENT') AND expires_at < NOW()
Action: UPDATE status='EXPIRED'. Send email #15 to customer + admin.

-- 7-day warning
Query: SELECT * FROM enterprise_contracts
WHERE status IN ('DRAFT', 'SENT')
AND expires_at BETWEEN NOW() AND NOW() + INTERVAL '7 days'
AND expires_at > NOW() + INTERVAL '6 days'
Action: Send email #13 to customer.

-- 3-day warning
Query: SELECT * FROM enterprise_contracts
WHERE status IN ('DRAFT', 'SENT')
AND expires_at BETWEEN NOW() AND NOW() + INTERVAL '3 days'
AND expires_at > NOW() + INTERVAL '2 days'
Action: Send email #14 to customer.

7.3 Contract Renewal Check

Schedule: Daily at 02:00 UTC
Query: SELECT s.*, c.terms->>'duration' as duration
FROM subscriptions s
JOIN enterprise_contracts c ON s.contract_id = c.id
WHERE s.status = 'ACTIVE'
AND s.contract_end_date IS NOT NULL
AND s.contract_end_date BETWEEN NOW() AND NOW() + INTERVAL '{{renewalNoticeDays}} days'
Action: Send email #17 to customer + admin.
Only send once (track via a sent_renewal_reminder flag or notification log).

7.4 Invoice Auto-Generation

Schedule: 1st of each month at 06:00 UTC

-- Monthly billing
Query: SELECT * FROM subscriptions
WHERE status = 'ACTIVE'
AND billing_cycle = 'MONTHLY'
AND enterprise_request_id IS NOT NULL
Action: For each subscription:
1. Calculate usage for previous month (see Section 8)
2. Generate invoice with line items
3. Generate invoice PDF
4. Send email #19 to billing_email

-- Quarterly billing (1st of Jan, Apr, Jul, Oct)
Same logic but: WHERE billing_cycle = 'QUARTERLY'
and: Only run on quarter-start months

-- Annual billing
Query by: contract_start_date anniversary
Calculate: Full year of usage

7.5 Invoice Overdue Check

Schedule: Daily at 08:00 UTC
Query: SELECT * FROM invoices
WHERE status = 'PENDING'
AND created_at + (due_in_days * INTERVAL '1 day') < NOW()
Action: UPDATE status='OVERDUE'. Send email #20 to customer + admin.

Escalation:
- 30+ days overdue: Flag for admin review (could trigger access blocking)
- 60+ days overdue: Consider automatic ADMIN_BLOCK_ENTERPRISE_ACCESS

7.6 Risk Assessment (On Submission)

Trigger: After submitEnterpriseRequest succeeds
Runs: Async (don't block the mutation response)

Scoring:
+0 LOW base
+1 if email domain doesn't match website domain
+1 if company doesn't exist in the system yet
+1 if org number format doesn't match expected pattern for the country
+1 if estimated budget is "<500" (low value, unusual for enterprise)
+1 if company size is "1-10" (small for enterprise)

0-1 points → LOW
2-3 points → MEDIUM
4+ points → HIGH

UPDATE enterprise_requests SET risk_level = calculated_level WHERE id = request_id

8. Invoice Calculation Engine

8.1 Auto-Calculation Flow

When adminGenerateDetailedInvoice is called without lineItems, or when the monthly cron runs:

async function calculateInvoice(subscription, periodStart, periodEnd) {
const lineItems = []

// 1. Base fee
const baseFee = subscription.base_price // already in cents per cycle
lineItems.push({
description: `Enterprise Plan Base Fee (${formatPeriod(periodStart, periodEnd)})`,
quantity: 1,
unitPrice: baseFee,
total: baseFee,
})

// 2. Credit usage
if (subscription.credit_rate) {
const creditsUsed = await getCreditConsumption(
subscription.user_id,
subscription.company_id, // if company subscription
periodStart,
periodEnd
)
// getCreditConsumption: SUM(amount) FROM credit_transactions
// WHERE type='CONSUMPTION' AND created_at BETWEEN periodStart AND periodEnd
// AND (user_id = X OR company_id = Y)

if (creditsUsed > 0) {
const creditCostCents = Math.round(creditsUsed * subscription.credit_rate * 100)
lineItems.push({
description: `AI Credits Used (${creditsUsed} credits × $${subscription.credit_rate.toFixed(4)})`,
quantity: creditsUsed,
unitPrice: Math.round(subscription.credit_rate * 100),
total: creditCostCents,
})
}
}

// 3. Setup fee (first invoice only)
if (subscription.setup_fee && isFirstInvoice(subscription.id)) {
lineItems.push({
description: 'One-Time Setup Fee',
quantity: 1,
unitPrice: subscription.setup_fee,
total: subscription.setup_fee,
})
}

// 4. Subtotal
let subtotal = lineItems.reduce((sum, item) => sum + item.total, 0)

// 5. Minimum spend enforcement
if (subscription.minimum_spend && subtotal < subscription.minimum_spend) {
const adjustment = subscription.minimum_spend - subtotal
lineItems.push({
description: 'Minimum Spend Adjustment',
quantity: 1,
unitPrice: adjustment,
total: adjustment,
})
subtotal = subscription.minimum_spend
}

// 6. Volume discount
if (subscription.discount_percent && subscription.discount_percent > 0) {
const discount = Math.round(subtotal * (subscription.discount_percent / 100))
lineItems.push({
description: `Volume Discount (${subscription.discount_percent}%)`,
quantity: 1,
unitPrice: -discount,
total: -discount,
})
subtotal -= discount
}

return {
lineItems,
total: subtotal,
currency: subscription.currency || 'USD',
}
}

8.2 getCreditConsumption Query

SELECT COALESCE(SUM(ABS(amount)), 0) as total_consumed
FROM credit_transactions
WHERE type = 'CONSUMPTION'
AND created_at >= $periodStart
AND created_at < $periodEnd
AND (
(user_id = $userId AND company_id IS NULL) -- personal credits
OR company_id = $companyId -- company credits
)

8.3 Invoice PDF Generation

Use the same Puppeteer/HTML→PDF approach as contracts, but with an invoice template:

┌──────────────────────────────────────┐
│ AiQlick Logo │
│ │
│ INVOICE │
│ Invoice #: INV-2026-0042 │
│ Date: June 1, 2026 │
│ Due Date: July 1, 2026 │
│ PO #: PO-2026-001 │
│ │
│ Bill To: │
│ Acme Corp │
│ 123 Business St │
│ Oslo, Norway │
│ VAT: NO-987654321MVA │
│ │
│ ──────────────────────────────── │
│ Description Qty Amount │
│ ──────────────────────────────── │
│ Enterprise Base Fee 1 $999.00 │
│ AI Credits (487×$0.025)487 $12.18 │
│ Volume Discount (10%) 1 -$101.12 │
│ ──────────────────────────────── │
│ TOTAL DUE: $910.06 │
│ │
│ Payment Terms: Net 30 │
│ Payment Method: Bank Transfer │
│ │
│ Bank Details: │
│ AiQlick AS │
│ IBAN: NO12 3456 7890 1234 │
│ BIC/SWIFT: DNBANOKKXXX │
│ Reference: INV-2026-0042 │
└──────────────────────────────────────┘

9. Validation & Business Rules

9.1 Status Transition Matrix

Only these transitions are allowed. Any other transition must be rejected:

SUBMITTED       → UNDER_REVIEW, REJECTED, CANCELLED
UNDER_REVIEW → PROPOSAL_SENT, REJECTED, CANCELLED
PROPOSAL_SENT → CONTRACT_PENDING, UNDER_REVIEW (if proposal declined, admin revises)
CONTRACT_PENDING → CUSTOMER_SIGNED, CANCELLED (void contract)
CUSTOMER_SIGNED → CONTRACT_SIGNED
CONTRACT_SIGNED → APPROVED
APPROVED → (terminal, but subscription can be cancelled separately)
REJECTED → (terminal)
CANCELLED → (terminal)

Implementation:

const ALLOWED_TRANSITIONS = {
SUBMITTED: ['UNDER_REVIEW', 'REJECTED', 'CANCELLED'],
UNDER_REVIEW: ['PROPOSAL_SENT', 'REJECTED', 'CANCELLED'],
PROPOSAL_SENT: ['CONTRACT_PENDING', 'UNDER_REVIEW'],
CONTRACT_PENDING: ['CUSTOMER_SIGNED', 'CANCELLED'],
CUSTOMER_SIGNED: ['CONTRACT_SIGNED'],
CONTRACT_SIGNED: ['APPROVED'],
APPROVED: [],
REJECTED: [],
CANCELLED: [],
}

function validateTransition(currentStatus, newStatus) {
if (!ALLOWED_TRANSITIONS[currentStatus]?.includes(newStatus)) {
throw new Error(`Cannot transition from ${currentStatus} to ${newStatus}`)
}
}

9.2 Contract Status Transition Matrix

DRAFT           → SENT, VOIDED
SENT → CUSTOMER_SIGNED, EXPIRED, VOIDED
CUSTOMER_SIGNED → FULLY_EXECUTED, VOIDED
FULLY_EXECUTED → (terminal)
EXPIRED → (terminal — admin can regenerate, creating a new contract)
VOIDED → (terminal — admin can regenerate, creating a new contract)

9.3 Organization Number Validation

Basic regex validation per country. Not exhaustive — admin still manually verifies.

const ORG_NUMBER_PATTERNS = {
// Norway: 9 digits
'Norway': /^\d{9}$/,
'NO': /^\d{9}$/,

// Sweden: 10 digits (XXXXXX-XXXX)
'Sweden': /^\d{6}-?\d{4}$/,
'SE': /^\d{6}-?\d{4}$/,

// UK: 8 digits or 2 letters + 6 digits
'United Kingdom': /^([A-Z]{2}\d{6}|\d{8})$/i,
'GB': /^([A-Z]{2}\d{6}|\d{8})$/i,

// US EIN: XX-XXXXXXX
'United States': /^\d{2}-?\d{7}$/,
'US': /^\d{2}-?\d{7}$/,

// Germany: HRB XXXXX
'Germany': /^HRB?\s?\d{1,6}$/i,
'DE': /^HRB?\s?\d{1,6}$/i,

// Fallback: at least 4 characters
'default': /^.{4,}$/,
}

function validateOrgNumber(country, orgNumber) {
const pattern = ORG_NUMBER_PATTERNS[country] || ORG_NUMBER_PATTERNS['default']
if (!pattern.test(orgNumber.trim())) {
throw new Error(`Invalid organization number format for ${country}`)
}
}

9.4 Concurrency & Rate Limiting

  • Max 1 active request per user: Enforced by unique partial index (see SQL above)
  • Rate limiting: Max 3 submissions per user per 24 hours (to prevent abuse after cancel+resubmit)
  • Proposal acceptance: Idempotent — if proposal already ACCEPTED, return existing contract
  • Contract signing: Idempotent — if already CUSTOMER_SIGNED, return existing state
  • Countersigning: Idempotent — if already FULLY_EXECUTED, return existing state

9.5 Who Can Do What

ActionAuth Required
Submit requestAuthenticated user
View own requestAuthenticated user (owner)
Accept/decline proposalAuthenticated user (owner of request)
Sign contractAuthenticated user (owner of request)
Download contract PDFAuthenticated user (owner) OR SuperAdmin
Cancel requestAuthenticated user (owner), only if SUBMITTED or UNDER_REVIEW
All admin mutationsSuperAdmin role
View internalNotesSuperAdmin only — NEVER returned in user-facing resolvers

10. Security

ConcernImplementation
Contract PDF accessPre-signed S3 URLs with 15-minute expiry. Generated on-demand per request, never cached in CDN.
Contract integritySHA-256 hash stored in DB. Verified on download. Any S3 corruption is detectable.
Signature dataBase64 encoded, stored encrypted at rest (DB-level encryption or application-level AES-256).
IP captureExtracted from X-Forwarded-For header (first non-private IP) or req.socket.remoteAddress.
PII protectionBilling address, VAT number, org number encrypted at rest. Never logged in plaintext.
Internal notesinternalNotes field on requests and proposals is stripped from all user-facing resolvers. Only returned in admin* queries.
Rate limiting3 submissions / 24h per user. Enforced at resolver level.
RBACAll admin* mutations check isSuperAdmin from auth context. Return FORBIDDEN for non-admins.
Input sanitizationadditionalNotes, proposalNotes, rejectionReason — sanitize HTML, limit to 5000 chars.
File upload (signature)Validate base64 decodes to a valid image (PNG/JPEG). Max size 500KB after decode. Reject other formats.

11. Implementation Checklist

Phase 1: Core Request Flow

  • DB: Create enterprise_requests table + indexes + unique constraint
  • GraphQL: EnterpriseRequest type, EnterpriseRequestStatus enum, BillingCycle enum, PaymentMethodPreference enum, RiskLevel enum
  • GraphQL: SubmitEnterpriseRequestInput input type
  • Resolver: submitEnterpriseRequest — validate, insert, risk assessment, emails
  • Resolver: myEnterpriseRequest — return active request for authenticated user
  • Resolver: cancelEnterpriseRequest — validate status, update
  • Resolver: adminEnterpriseRequests — paginated list with status filter
  • Resolver: adminEnterpriseRequest — single request with relations
  • Resolver: adminUpdateEnterpriseRequestStatus — validate transitions
  • Resolver: adminVerifyOrganization — set org_verified flag
  • Resolver: adminRejectEnterpriseRequest — set REJECTED + email
  • Resolver: adminRequestEnterpriseInfo — email to customer
  • Email: Templates #1, #2, #3, #4, #18 (submit, review, info request, reject)
  • Validation: Org number format check, max 1 request per user, required fields
  • Risk assessment: Async scoring function

Phase 2: Proposal Flow

  • DB: Create enterprise_proposals table + indexes
  • GraphQL: EnterpriseProposal type, ProposalStatus enum
  • GraphQL: AdminCreateEnterpriseProposalInput input type
  • Resolver: adminCreateEnterpriseProposal — create, optionally send, update request status
  • Resolver: acceptEnterpriseProposal — validate ownership + expiry, update statuses, trigger contract generation
  • Resolver: declineEnterpriseProposal — update status, notify admin
  • Cron: Proposal expiry check (daily)
  • Email: Templates #5, #6, #7 (proposal sent, accepted, declined)

Phase 3: Contract Signing

  • DB: Create enterprise_contracts table + indexes
  • GraphQL: EnterpriseContract type, ContractStatus enum, SignatureMethod enum
  • GraphQL: SignEnterpriseContractInput input type
  • Service: Contract PDF generation (HTML template → Puppeteer → PDF)
  • Service: Signature embedding (pdf-lib overlay)
  • Service: S3 upload + pre-signed URL generation for contracts
  • Service: SHA-256 hash generation + verification
  • Resolver: getEnterpriseContract — return contract, mark viewed, generate pre-signed URL
  • Resolver: signEnterpriseContract — validate, store signature, embed in PDF, update statuses, check for pre-signed path
  • Resolver: getSignedContractPdf — pre-signed URL for fully executed PDF
  • Resolver: adminCountersignContract — embed admin sig, finalize PDF, activate subscription
  • Resolver: adminPreSignContract — embed admin sig first (direct approval path)
  • Resolver: adminRegenerateContract — void old, create new version
  • Resolver: adminVoidContract — set VOIDED + reason
  • Resolver: adminApproveEnterpriseDirectly — create proposal+contract in one step, pre-sign
  • Function: activateEnterpriseSubscription — create custom plan, create subscription, grant credits
  • Cron: Contract expiry check (daily) + 7-day and 3-day reminders
  • Cron: Contract renewal check (daily)
  • Email: Templates #8–#17 (contract lifecycle)

Phase 4: Invoicing

  • DB: Extend invoices table with new columns
  • DB: Extend subscriptions table with enterprise columns
  • Service: Invoice calculation engine (Section 8)
  • Service: Invoice PDF generation (HTML template → PDF)
  • Resolver: adminGenerateDetailedInvoice — calculate or use provided line items, generate PDF, send
  • Resolver: adminMarkInvoicePaid — update status, send confirmation
  • Resolver: adminEnterpriseDashboard — aggregate stats query
  • Cron: Monthly/quarterly/annual invoice auto-generation
  • Cron: Invoice overdue check (daily)
  • Cron: Overdue access review (weekly — flag for admin)
  • Email: Templates #19, #20, #21 (invoice, overdue, payment received)

Cross-Cutting

  • Auth guards: All admin resolvers check isSuperAdmin
  • Auth guards: All user resolvers verify request ownership
  • Field protection: internalNotes stripped from user-facing responses
  • Rate limiting: 3 submissions per user per 24h
  • Input validation: Sanitize text fields, validate base64 signatures, size limits
  • Logging: Audit log entries for all status transitions, signatures, approvals
  • Error codes: Return specific codes for billing errors (reuse existing INSUFFICIENT_CREDITS pattern)