Covers the pramaan-functions half of RFD-BILL-003 W1: POST /provision/self-serve,
the individual/firm org-kind model, firm invites, the D25 region attestation, and the
D28 payment-status lifecycle. The /signup surface itself is
pramaan-web (#2712); Razorpay money is W2/W3 (#2714, #2719).
payment_status='pending_payment': it can log in, browse, and invite —
it just cannot start billable intake, so it costs us nothing.
The lifecycle lives on business.firms.payment_status:
| Status | What the workspace can do |
|---|---|
pending_payment | Login, browse, invite. No intake. |
trial | Team-granted 14/30 days. Full use. |
active | Paid, mandate live. Full use. |
past_due | Renewal failed, inside the 14-day grace. Full use. |
paused | Grace ended or overage ceiling. Read and export only. |
archived | Export only. |
D28 says an unpaid workspace incurs zero vendor cost. The INTAKE ledger charge is
emitted after extraction succeeds — far too late, since by then the OCR and model
bills are already paid. So the gate sits at upload admission, in
DocumentService.presign_upload:
firm_id = await cls._current_firm_id(session) user_id = await cls._current_user_id(session) await assert_firm_may_intake(session, firm_id=firm_id) # <- no URL, no upload, no cost
src/shared/billing/intake_admission.py is the single chokepoint, so a future
upload caller cannot forget it. trial, active, and
past_due pass — cutting a lawyer off mid-matter over a failed mandate is a worse
failure than the arrears (D15). Reading the record never locks.
Lambda has no egress to api.clerk.com (ADR-167 no-NAT), so this API cannot ask
Clerk who owns an organization. It does not need to: the caller's Clerk session token already
carries an org_id claim that API Gateway verified (ADR-035).
if current_user.clerk_org_id != clerk_org_id:
raise PramaanForbiddenError(code="pramaan.provision.org_ownership_unproven")
A request that merely names someone else's org id gets nothing, and the mismatch is logged so a probing campaign is visible rather than silent. There is no permission that grants "may provision" — that is deliberate.
Migration 0195 adds two partial unique indexes to business.firms:
UNIQUE (created_by_user_id) WHERE org_kind = 'individual' UNIQUE (provision_attempt_id) WHERE provision_attempt_id IS NOT NULL
A replayed attempt_id — or a retry against an org already mirrored — returns the
tenant that exists, with alreadyProvisioned: true, rather than erroring on what
the user experienced as one click. A concurrent double submit cannot mint two workspaces
because the index, not a read-then-write, is the enforcement.
billing.plan already encodes the D3 rate card: business is
min_seats=3 / seat_price=25000, startup is
min_seats=1. Org kind maps onto those rows, and the minimum is read from
billing.plan.min_seats at provisioning time. The DTO validates only the
structural shape, so "three seats" is stated in exactly one place.
region, region_attested_at, and region_attested_source
land together. Immutability is a database trigger rather than a rule every future writer has to
remember:
CREATE TRIGGER firms_block_immutable_claims BEFORE UPDATE ON business.firms FOR EACH ROW EXECUTE FUNCTION business.tg_firms_block_immutable_claims()
Clerk still sends the invitation and still owns accounts; identity.firm_memberships
still records only accepted memberships. What Clerk cannot express is the D19 contract —
single-use, expiring, firm-scoped, with auditable refusal reasons. So
business.firm_invites is the authority and the Clerk invitation is the transport.
The raw token is returned once at mint time and never stored; only its SHA-256 hash is. A database read cannot be replayed into a working invite link.
At the moment someone accepts, they have no membership in the inviting firm — that is the
entire point of an invite — so neither firm_isolation_policy nor the staff grant
policy can see their row. Widening either would expose every firm's invitee emails to any
signed-in caller. Instead, redemption goes through one narrow function reachable only by
presenting the token hash:
business.redeem_firm_invite(p_token_hash text, p_user_id uuid) -> (outcome, firm_id, invite_id, role)
Single-use is the UPDATE's own WHERE accepted_at IS NULL: two concurrent accepts
contend on the row and exactly one wins. The loser gets already_used.
| Outcome | Meaning |
|---|---|
accepted | Joined. Audit row written in the same transaction. |
expired / already_used / revoked | SB-25 refusals. HTTP 200 with a plain-language next step — an expired link is an answer, not a server error. |
already_member | G5c structured outcome; the invite stays open for whoever it was meant for. |
unknown | No such token. |
Joining a firm adds a second tenant. Nothing moves between workspaces, because PRAMAAN has no cross-tenant move path at all — an invitee who already has their own workspace keeps it and its matters untouched. The acceptance test asserts exactly that.
business.firms accepts writes only under staff_write_policy
(app.is_staff = true, migration 0016), and self-serve provisioning writes the firm
row before any firm scope can exist. SelfServeProvisionDbDep opens that
bounded bracket. It is deliberately not a reuse of AppMeDbDep: its privilege is
strictly larger because it writes, and an authority that wide should be greppable. What bounds
it is the ownership check that runs before any write.
A firm without its default catalogs lands its owner in a workspace where no matter can be
created. Rather than duplicate that seeding, the staff-led path's block was lifted into
FirmService.seed_firm_defaults and both doors call it — so a new door cannot
forget one.
tests/features/self_serve_provisioning.feature cover SB-01, SB-02 (API side),
SB-06, SB-14, SB-25 (expired and revoked), SB-26, SB-40, SB-41 (both the second-workspace
refusal and the idempotent replay), SB-47, plus the two D28 intake legs.
business.firm_invites is registered in
_SUPPORT_CONFIDENTIAL_TABLES, so the RLS census proves support reaches invitee
emails only through a Tier-2 grant. Full suite 3503 passed; Alembic
upgrade/downgrade/upgrade/check round-trips clean.