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 withbillingMode: 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
- Flow Summary
- Database Schema
- GraphQL Schema
- Resolver Implementation
- Contract PDF Generation
- Email Notifications
- Cron Jobs & Scheduled Tasks
- Invoice Calculation Engine
- Validation & Business Rules
- Security
- 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:
-
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.
-
DRAWN signature: The frontend captures the canvas drawing as a base64 PNG. Decode and overlay on the PDF.
-
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
| # | Event | Recipient | Subject Line | Body Summary | Attachments |
|---|---|---|---|---|---|
| 1 | Request submitted | Admin (all super admins) | New Enterprise Request: {{org}} | Company details, org#, size, industry, estimated budget | — |
| 2 | Request submitted | Customer | Enterprise Request Received | "We received your request. Review within 2 business days." | — |
| 3 | Status → UNDER_REVIEW | Customer | Enterprise Request Under Review | "Your request is now being reviewed by our team." | — |
| 4 | More info requested | Customer | Additional Information Needed | Admin's message. CTA: "Reply in your billing page" | — |
| 5 | Proposal sent | Customer | Enterprise Plan Proposal | Pricing summary, limits, contract term. CTA: "View Proposal" | — |
| 6 | Proposal accepted | Admin | Proposal Accepted: {{org}} | "Contract generated. Awaiting signatures." | — |
| 7 | Proposal declined | Admin | Proposal Declined: {{org}} | Customer's message (if any). "Revise and resend." | — |
| 8 | Contract ready | Customer | Enterprise Contract Ready for Signing | Key terms summary. CTA: "Review & Sign Contract" | Contract PDF |
| 9 | Contract pre-signed | Customer | Pre-Approved Enterprise Contract | "Your contract is pre-signed. Sign to activate immediately." CTA: "Sign Now" | Pre-signed PDF |
| 10 | Customer signed | Admin | Contract Signed: {{org}} | Signer details (name, title, email). CTA: "Countersign" | — |
| 11 | Contract countersigned (fully executed) | Customer | Enterprise Plan Activated | "Contract fully executed. Your subscription is now active." | Fully signed PDF |
| 12 | Contract countersigned (fully executed) | Admin | Enterprise Activated: {{org}} | Confirmation. Subscription details. | — |
| 13 | Contract expiring soon (7 days) | Customer | Contract Signing Reminder | "Your contract expires in 7 days. Please sign." | — |
| 14 | Contract expiring soon (3 days) | Customer | Urgent: Contract Expires in 3 Days | "Sign before {{expiresAt}} to avoid delays." | — |
| 15 | Contract expired | Customer + Admin | Enterprise Contract Expired | "The contract has expired. Contact us to proceed." | — |
| 16 | Contract voided | Customer | Enterprise Contract Voided | Reason. "A new contract will be generated if applicable." | — |
| 17 | Contract renewal reminder | Customer + Admin | Enterprise Contract Renewal | "Contract expires on {{endDate}}. {{noticeDays}} days notice." | — |
| 18 | Request rejected | Customer | Enterprise Request Not Approved | Reason. "Contact support for questions." | — |
| 19 | Invoice generated | Customer (billing email) | Invoice #{{number}} — {{period}} | Amount, due date, payment details | Invoice PDF |
| 20 | Invoice overdue | Customer + Admin | Invoice #{{number}} Overdue | "Payment was due on {{dueDate}}. Please remit." | — |
| 21 | Payment received | Customer | Payment 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
| Action | Auth Required |
|---|---|
| Submit request | Authenticated user |
| View own request | Authenticated user (owner) |
| Accept/decline proposal | Authenticated user (owner of request) |
| Sign contract | Authenticated user (owner of request) |
| Download contract PDF | Authenticated user (owner) OR SuperAdmin |
| Cancel request | Authenticated user (owner), only if SUBMITTED or UNDER_REVIEW |
| All admin mutations | SuperAdmin role |
| View internalNotes | SuperAdmin only — NEVER returned in user-facing resolvers |
10. Security
| Concern | Implementation |
|---|---|
| Contract PDF access | Pre-signed S3 URLs with 15-minute expiry. Generated on-demand per request, never cached in CDN. |
| Contract integrity | SHA-256 hash stored in DB. Verified on download. Any S3 corruption is detectable. |
| Signature data | Base64 encoded, stored encrypted at rest (DB-level encryption or application-level AES-256). |
| IP capture | Extracted from X-Forwarded-For header (first non-private IP) or req.socket.remoteAddress. |
| PII protection | Billing address, VAT number, org number encrypted at rest. Never logged in plaintext. |
| Internal notes | internalNotes field on requests and proposals is stripped from all user-facing resolvers. Only returned in admin* queries. |
| Rate limiting | 3 submissions / 24h per user. Enforced at resolver level. |
| RBAC | All admin* mutations check isSuperAdmin from auth context. Return FORBIDDEN for non-admins. |
| Input sanitization | additionalNotes, 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_requeststable + indexes + unique constraint - GraphQL:
EnterpriseRequesttype,EnterpriseRequestStatusenum,BillingCycleenum,PaymentMethodPreferenceenum,RiskLevelenum - GraphQL:
SubmitEnterpriseRequestInputinput 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_proposalstable + indexes - GraphQL:
EnterpriseProposaltype,ProposalStatusenum - GraphQL:
AdminCreateEnterpriseProposalInputinput 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_contractstable + indexes - GraphQL:
EnterpriseContracttype,ContractStatusenum,SignatureMethodenum - GraphQL:
SignEnterpriseContractInputinput 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
invoicestable with new columns - DB: Extend
subscriptionstable 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:
internalNotesstripped 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_CREDITSpattern)