Complete contract signing and escrow payment flow from bid acceptance through final payout release.
ContractBuilder screen. Uses useMutation(api.contracts.contracts.generate) via internal trigger from acceptBid.
ContractDocument component renders markdown. Contract status: pending_contractor. Sent via notification + alert.
ContractDetailScreen for contractor. Uses useQuery(api.contracts.contracts.get). Status: pending_contractor.
SignaturePad modal + ContractDocument. Calls signAsContractor mutation. IP via useClientIp.
Shared ContractDetailScreen. Status: fully_executed. Job transitions to pending_funding. PDF via getContractPdfUrl.
FundEscrow screen. Calls createPaymentIntent then escrow.fund. Stripe Elements for card input.
EscrowDashboard. Uses useQuery(api.payments.escrow.getForJob). Escrow status: hold_period.
PaymentTimeline. Uses getForJob + calculatePayouts from financial.ts. Bank via Stripe Connect.
CompletionForm. Calls requestCompletion mutation. Job: in_progress → pending_completion.
CompletionReview. Calls approveCompletion + submitReview. Escrow: first_payout_sent → released.
jobId: Id<jobs>
bidId: Id<jobBids>
posterId: Id<posterUsers>
contractorId: Id<contractorUsers>
status: pending_contractor | pending_poster | fully_executed | voided
contractContent: string (markdown)
platformAddendum: string
contractorSignature: {signatureImage, signedAt, ipAddress, userAgent}
posterSignature: {signatureImage, signedAt, ipAddress, userAgent}
fullyExecutedAt: number?
jobId: Id<jobs>
status: pending | hold_period | funded | first_payout_pending | first_payout_sent | second_payout_pending | released | refunded | disputed
totalBidAmount: number
escrowBalance: number
platformFee: number
contractorNetAmount: number
holdPeriodEndsAt: number?
firstPayoutAmount/At: number?
secondPayoutAmount/At: number?
status: draft | open | pending_contract | pending_funding | in_progress | change_order | pending_completion | completed | cancelled | disputed
contractorId: Id<contractorUsers>?
totalBidAmount: number?
completedAt: number?
pending_contractor → pending_poster → fully_executed
↓ ↓
voided voided
7-day expiration auto-voids unsigned contracts
pending → hold_period (24h) → funded
→ first_payout_pending → first_payout_sent
→ second_payout_pending → released
Any stage can → disputed or refunded
Platform Fee: 8.5% = $357.00
Contractor Net: = $3,843.00
1st Payout: 40% = $1,537.20 (after 24h hold)
2nd Payout: 60% = $2,305.80 (after completion)
Constants: PLATFORM_FEE_RATE=0.085, FIRST_PAYOUT_RATE=0.4
HOLD_PERIOD_MS = 24 * 60 * 60 * 1000
Optimistic locking (version field)
TOCTOU race condition prevention
Rate limiting on sign mutations
Signature input validation (size + format)
IP + User-Agent audit logging
State machine transition validation
Stripe webhook idempotency
useQuery(api.contracts.contracts.get)
useQuery(api.contracts.contracts.getForJob)
useQuery(api.contracts.contracts.listForUser)
useQuery(api.contracts.pdf.getContractPdfUrl)
useQuery(api.payments.escrow.getForJob)
useMutation(api.contracts.contracts.signAsContractor)
useMutation(api.contracts.contracts.signAsPoster)
useMutation(api.contracts.contracts.voidContract)
useMutation(api.payments.escrow.fund)
useClientIp() — IP for signature audit
Stripe: PaymentIntent, Transfers (Connect)
createPaymentIntent → fund → webhooks
createTransfer → payout to contractor bank
DocuSeal: Signature capture + legal binding
SignaturePad component (base64 PNG)
Slack: Contract alerts
sendContractGenerationFailedAlert
sendContractExpiredAlert
Scheduler: 7-day contract expiry
24h hold period expiry (processHoldPeriodExpiry)