Billing and paid organizations
shithub's first paid surface is organization billing. This document records the product and engineering contract that the PAYMENTS sprint series implements.
The current implementation already has the important shape for paid
organizations: orgs.plan is an enum with free, team, and
enterprise; organizations own repositories; organization members and
teams exist; branch protection and PR review gates exist; Actions has
schema for org/repo secrets, variables, and artifacts. Billing must
turn that substrate into a fair hosted service without taxing
public/open-source collaboration.
Product contract
As of 2026-05-12, GitHub's public pricing page presents Free at
$0, Team at $4/user/month, and Enterprise starting at
$21/user/month. shithub follows the same mental model but removes
Copilot/AI promises from the paid-org offering.
Initial decisions:
- Free organizations remain self-serve.
- Team is
$4per active organization member per month. - Active organization members, including owners, count as paid seats.
- Team has no launch trial.
- Enterprise is a visible contact-sales stub, not self-serve.
- Stripe Billing is the first payment processor.
- PayPal, manual invoices, SAML, SCIM, LDAP, enterprise account hierarchy, and contracts are deferred.
- Self-serve organization creation presents
/organizations/planas the canonical plan selector. Choosing Team creates the organization and immediately redirects the owner to hosted Stripe Checkout.
The fairness rule is explicit: public/open-source collaboration should stay generous. Paid gates focus on private collaboration, hosted cost, advanced organization controls, and support expectations.
Pricing copy rules
Pricing and onboarding pages must describe only features shithub can actually deliver on the hosted service. Before changing pricing copy, refresh the official GitHub pricing source and the Stripe Billing docs because both are time-sensitive inputs.
Rules for paid-org copy:
- Do not mention Copilot, AI agents, AI code review, or AI quotas.
- Do not promise SAML, SCIM, LDAP, managed users, audit exports, data residency, compliance attestations, contracts, or custom support until the matching implementation sprint ships.
- Do not advertise Packages, Pages, Wikis, Projects, Actions minutes, or storage quotas until those surfaces have enforcement and usage accounting.
- Use upgrade language for unavailable Team features instead of hiding existing data. Downgrades preserve configuration and make gated settings read-only where possible.
- Keep public/open-source collaboration generous in both copy and enforcement. Avoid copy that makes public repositories feel like a second-class Free tier.
- Enterprise is a contact-sales stub in v1. It should collect interest without promising contractual features.
Entitlement matrix
| Capability | Free | Team | Enterprise stub |
|---|---|---|---|
| Public org repositories | Included | Included | Contact sales |
| Basic private org repositories | Included | Included | Contact sales |
| Org members and invitations | Included | Billed by active member | Contact sales |
| Effective private org collaborators | Up to 3 | Unlimited while active/in grace | Contact sales |
| Visible teams | Included | Included | Contact sales |
| Secret teams | Upgrade | Included | Contact sales |
| Basic branch protection | Included | Included | Contact sales |
| Advanced private-repo branch protection | Upgrade | Included | Contact sales |
| Required reviewers on private org repos | Upgrade | Included | Contact sales |
| CODEOWNERS review | Deferred | Deferred | Deferred |
| Org-level Actions secrets | Upgrade | Included | Contact sales |
| Org-level Actions variables | Upgrade | Included | Contact sales |
| Actions minutes | Low quota once metered | Higher quota once metered | Contact sales |
| Actions artifacts/storage | Low quota once metered | Higher quota once metered | Contact sales |
| Packages storage | Deferred until Packages is active | Deferred until Packages is active | Deferred |
| Pages/Wikis/Projects | Do not promise until shipped | Do not promise until shipped | Deferred |
| Audit log export | Deferred | Deferred | Later Enterprise feature |
| SAML/SCIM/managed users | Deferred | Deferred | Later Enterprise feature |
| Data residency/compliance | Deferred | Deferred | Later Enterprise feature |
| Billing support | Basic instance support | Billing support after runbook exists | Contact sales |
Pro v1 user-tier matrix (PRO07)
Pro is the user-tier paid plan (single seat, $4/month). PRO01 ratified the v1 feature set; PRO07 wires the enforcement matrix. CODEOWNERS is a registered placeholder with a no-op enforce path until the parser ships.
| Capability | Free | Pro |
|---|---|---|
| Public/private personal repos | Included | Included |
| Required reviewers on private personal repos | Upgrade | Included |
| Multi-reviewer (>1 approvals) on private personal repos | Upgrade | Included |
| Advanced branch protection on private personal repos | Upgrade | Included |
| CODEOWNERS review | Deferred | Deferred |
| Profile pins | 6 | 100 |
Multi-reviewer is not a separate feature constant — the numeric
threshold lives in the deny payload of FeatureRequiredReviewers.
Per-feature enforcement flags
PRO05 plumbed user-kind report-only logging through every gating site.
PRO07 lights up the gates one feature at a time via
billing.enforce.* in the operator's config. Defaults are all false
(report-only). Each flag is a one-way deploy that operators can roll
back without code changes.
| Config key | Gate site | Default |
|---|---|---|
billing.enforce.user_required_reviewers |
internal/web/handlers/repo/settings_branches.go |
false |
billing.enforce.user_advanced_branch_protection |
internal/web/handlers/repo/settings_branches.go |
false |
billing.enforce.user_profile_pins_beyond_free |
internal/web/handlers/profile/pins.go |
false |
Rollout discipline:
- Deploy with all flags false. Run the report-only telemetry query for 7 days. Confirm zero unexpected user-kind would-denies.
- Flip one feature flag in staging. Soak 7 days.
- Flip the same feature flag in production.
- Repeat per feature.
PRO07's pitfall doc explicitly forbids enforcing a feature without an unenforce path. New gating sites land with their own flag; do not share flags across features.
Downgrade preservation
users.plan = 'free' after cancellation grandfather's existing gated
state — required-reviewer rules, profile pins above 6, advanced flags
on existing rules. The gate refuses to create new gated state on
Free, but never deletes prior configuration. This is the same
contract as the org-tier downgrade.
Current capability audit
Already present and safe to gate:
- Organizations with
planandbilling_email. - Organization members, owner role, and invitations.
- Teams, including
privacy='secret'. - Branch protection rules and required review counts.
- PR review and reviewer-request substrate.
- Org/repo Actions secrets and variables schema.
Present but missing enforcement or metering:
- Storage quota type exists, but quota persistence and enforcement are incomplete.
- Actions minutes, artifacts, and object usage need accounting before paid limits can be promised.
- Packages storage cannot be sold until the Packages sprint is active and quota enforcement exists.
Deferred:
- SAML, SCIM, LDAP, enterprise account hierarchy, audit-log export, data residency, compliance promises, and custom support SLAs.
- Copilot/AI features are intentionally outside shithub's paid-org product.
Billing architecture
Stripe is the payment source of truth. shithub is the entitlement source of truth.
The billing implementation should add a local billing domain that stores only Stripe IDs and payment summaries, never card data. Webhooks update local subscription state after signature verification. Policy and request handlers read local billing/entitlement state and must not call Stripe in hot paths.
Required local concepts:
- Stripe customer per billable organization.
- Subscription state per organization.
- Subscription item ID for seat quantity sync.
- Immutable webhook receipts with unique provider event IDs.
- Invoice/payment summaries for UI.
- Seat snapshots for auditability.
- Billing grace/lock state derived from processed subscription events.
PAYMENTS SP02 adds these as local database tables:
org_billing_statesstores the organization billing projection used by entitlement checks.billing_seat_snapshotsrecords active and billable seat counts over time.billing_invoicesstores invoice/payment summaries for billing UI.billing_webhook_eventsstores immutable provider event receipts for idempotent webhook processing.
New organizations receive a Free billing state from a database trigger,
and the migration backfills existing organizations as Free. Subscription
snapshot writes also keep orgs.plan synchronized as the
human-facing summary.
PAYMENTS SP03 adds the first Stripe operator contract:
billing.enabled=falsekeeps paid-org flows disabled while retaining the local billing tables.billing.stripe.secret_key,billing.stripe.webhook_secret, andbilling.stripe.team_price_idare required before Stripe routes are mounted.- Checkout success, Checkout cancel, and Billing Portal return URLs may
be overridden explicitly; otherwise the web layer derives absolute
organization URLs from
auth.base_url. billing.grace_periodcontrols how long failed-payment states may remain unlocked before paid entitlements are cut off.- When billing routes are mounted,
/settings/organizationslinks owner-managed organizations to their billing page's plan comparison. When billing is disabled, that action stays visibly unavailable instead of linking to an unmounted route.
The operator enablement flow is documented in
runbooks/stripe-billing.md.
PAYMENTS SP04 adds the self-serve onboarding flow:
/organizations/planis the canonical plan picker.- Free setup creates the organization locally without Stripe.
- Team setup creates the organization, creates or reuses the Stripe customer, counts billable seats, and redirects directly to hosted Stripe Checkout.
- Checkout success and cancel returns render shithub pages. Success tells the owner that activation waits for webhook processing; cancel keeps the organization on Free and offers a retry path.
PAYMENTS SP05 adds the local entitlement boundary. Product code must ask
internal/entitlements for feature decisions instead of inspecting
orgs.plan directly. The package derives access from
org_billing_states, understands billing-good-standing states, and
returns upgrade metadata for user-facing handlers.
PAYMENTS SP06 wires the first Team gates:
- Secret teams require Team to create. Existing secret teams remain visible to authorized viewers after downgrade; owners can remove members and repository grants, but adding members or granting more repository access is blocked until Team billing is active again.
- Required reviewers and advanced status-check branch protection are Team-only for private organization repositories. Public organization repositories keep those safety controls available on Free.
- Downgraded private organization repositories may delete protection rules or submit a rule update that clears the gated review/check settings.
- Org-level Actions secrets and variables require Team for create or update in both HTML settings and REST API routes. Delete stays available so owners can clean up gated configuration after downgrade.
- Org-level Actions secrets and variables API routes require organization owner or site-admin access before entitlement checks.
PAYMENTS SP07 completes the first self-serve billing settings surface:
GET /organizations/{org}/settings/billingis owner-only and is linked from organization settings when Stripe Billing is configured.- The page shows current local plan state, subscription status, payment source summary, recent Stripe-synced invoice snapshots, and actionable banners for past-due, grace-period, canceled, scheduled-cancel, and billing-action-needed states.
- Seat accounting is shown as three separate values: current active members, billable seats from the latest local billing state, and pending invitations. Pending invitations are explicitly not billed until accepted.
- Team organizations manage payment method, invoices, cancellation, and downgrade through Stripe Billing Portal. shithub never collects card data directly and downgrades continue to preserve paid configuration as read-only data.
- Normal organization owners do not see raw Stripe customer, subscription, or subscription-item IDs. Site admins see a debug panel with those IDs and the latest locally recorded webhook receipt state.
PAYMENTS SP06a adds the first private-collaboration limit:
- Free organizations may have up to 3 unique humans with effective access to at least one private organization repository.
- Team organizations with active, trialing, or in-grace subscriptions have unlimited private collaborators.
- The effective private-collaborator set counts org owners, direct collaborators on private org repos, and team members who inherit a private repo grant through direct team membership or one-level parent team inheritance. Plain org members do not count unless they gain private repo access through one of those paths.
- Public repository collaboration never counts toward the limit.
- Downgrades preserve existing access even when the org is already over the Free limit, but writes that add a new effective private collaborator are blocked until the org upgrades or removes access.
- Creating/importing a private org repository and changing an org repo from public to private are blocked on Free when the resulting private collaborator set would exceed the limit.
- Cleanup writes remain available: removing org members, team members, direct collaborators, team repo grants, and gated configuration must not require Team.
Entitlement architecture
Paid feature checks must live behind a central entitlement package, not
as scattered orgs.plan checks in handlers.
make lint-org-plan enforces this boundary. Schema/sqlc plumbing may
store and scan the plan value, but product behavior should ask the
entitlement package whether a feature key is available.
The package entrypoint is entitlements.ForOrg(ctx, deps, orgID),
which loads the local org_billing_states projection and returns a
request-scoped entitlement set. Callers then use CanUse(feature) for
feature decisions and Limit(name) for paid limit metadata. The legacy
CheckOrgFeature helper is a thin wrapper for handlers that need only
one feature. These calls are deterministic and never call Stripe.
Expected feature keys:
org.secret_teamsorg.advanced_branch_protectionorg.required_reviewersorg.actions_org_secretsorg.actions_org_variablesorg.private_collaboration_limitorg.storage_quotaorg.actions_minutes_quota
Authorization and entitlement are separate gates. A user must have both
the policy permission and the paid entitlement for gated writes. Denials
must preserve existing policy.Maybe404 behavior where existence leaks
matter.
Entitlement outcomes are:
- Free organizations receive
upgrade_requiredfor Team features. - Team organizations with
activeortrialingsubscriptions receive the feature. - Team organizations in
past_dueremain usable only while their local grace window has not expired. - Team organizations with incomplete, unpaid, paused, canceled, or
expired-grace billing receive
billing_action_needed. - Enterprise remains a contact-sales stub and receives
enterprise_contact_salesuntil a later Enterprise sprint defines a real feature set.
Handler upgrade helpers map paid-feature denials to HTTP 402 metadata and a billing-settings path, but handlers must still perform normal authorization first and preserve 404 masking for private resources.
Downgrade behavior
Downgrades must preserve customer data. Moving from Team to Free should not delete teams, secrets, variables, branch rules, or review settings. Existing gated resources become read-only where possible. Users can remove gated configuration, but cannot create or expand it until the organization upgrades again.
Open questions for implementation
- Exact Free and Team quota numbers for Actions and storage. These must come from real host-cost estimates before SP08.
Source references
- GitHub pricing:
https://github.com/pricing - GitHub plans docs:
https://docs.github.com/en/get-started/learning-about-github/githubs-plans - Stripe Billing:
https://docs.stripe.com/billing - Stripe pricing models:
https://docs.stripe.com/products-prices/pricing-models
PRO08 GA audit closure
Audit dates: 2026-05-13 (run + remediation in a single sprint).
Scope: PRO04 Stripe adapter + PRO05 entitlements Principal + PRO06 user-tier billing settings + PRO07 Pro v1 feature gates.
Methodology: four parallel security/correctness audit agents
ran read-only inspections against the PRO07 tip plus the deployed
host (shithub.sh). Findings were triaged per-bug; HIGH and
MEDIUM severity gaps were fixed inline on the payments-pro/08-pro-ga-audit
branch with locking tests. LOW-severity items were either fixed or
documented per the user directive "we don't want to take any chances
with payments."
Findings + remediation status
| # | Severity | Finding | Fix | Test |
|---|---|---|---|---|
| A1 | MED | guardPriceKindMatch bypassable on empty Items |
refuse empty-items when prices configured | TestBillingWebhookGuardRefusesEmptyItemsWhenPricesConfigured |
| A2 | HIGH | (subject_kind, subject_id) never written to receipts |
SetWebhookEventSubjectForPrincipal after every resolve; ListFailedWebhookEvents operator query |
TestSetWebhookEventSubjectForPrincipalRecordsAuditTrail, TestListFailedWebhookEventsReturnsErroredAndStuckEntries |
| A3 | MED | Concurrent dup-replay TOCTOU race | session-scoped advisory lock keyed on event id | TestBillingWebhookConcurrentReplayAppliesOnce |
| D1 | HIGH | Snapshot CTE wipes lock columns on past_due transitions | conditional preservation in ApplySubscriptionSnapshot + user analog |
TestApplySubscriptionSnapshotPreservesGraceLockOnPastDue (+ recovery + user-side mirror) |
| D2 | LOW | No charge.refunded handler |
new dispatch + MarkInvoiceRefunded query + enum + UI surface |
TestBillingWebhookChargeRefundedMarksInvoiceRefunded (+ unknown invoice + standalone refund) |
| D3 | MED | Two subscriptions per customer silently overwrite | guardSubscriptionOverwrite rejects different-sub apply |
TestBillingWebhookGuardRefusesSecondSubscriptionForSameCustomer |
| D4 | MED | No stale-event detection (reverse-order corruption) | last_event_at column + IsBillingEventStaleForPrincipal + handler guard + post-apply touch |
TestBillingWebhookDropsStaleEvent |
| D5 | LOW | customer.subscription.deleted for unknown sub 5xx's |
log + 200 no-op so Stripe stops retrying | TestBillingWebhookSubscriptionDeletedForUnknownSubIsNoOp |
| C1 | HIGH | require_signed_commits unreachable from UI |
form field + template checkbox + sqlc plumbing | covered by TestSettingsBranches_UserKindEnforceFlipPreventForcePushBlocks table |
| C2 | HIGH | Advanced BP gate fires on wrong inputs | rewire predicate to fire on prevent_*, signing, AND status-checks | table test |
| C3 | MED | Multi-reviewer deny copy indistinguishable | thread reviewer count → required-reviewers-multi-upgrade(-pro) codes |
TestSettingsBranches_UserKindEnforceFlipMultiReviewerBlocks |
| C4 | MED | Notice copy says "Team" / "organization" on user-tier denies | user-kind variants (-pro suffix) with /settings/billing href |
TestSettingsBranches_UserKindEnforceFlipRequiredReviewersBlocksSingle (Pro copy) |
| C5 | LOW | profilePinsRemaining hard-codes cap; under-counts for Pro |
thread entitled cap into the function | covered by existing profile_test suite + no regression |
Authorization audit (Agent B) found no cross-tenant data leak,
no invoice-scoping bleed, and no deny-shape 500 reachable from
production paths. Two err→500 branches in the entitlement gate
remain (in RequirePrincipalFeature and evaluateBranchProtectionFeature)
but are defended against by the AFTER-INSERT seed triggers on
both billing-state tables — pgx.ErrNoRows for a valid principal
is dead code in production.
Pre-existing failures NOT introduced by PRO08
These were noted across prior sprints and remain open:
TestPrivateCollaborationExpansionEnforcesFreeLimitAndTeamUnlimitedTestPrivateCollaborationUsageCountsEffectivePrivateAccessTestRepoPrivateVisibilityCountsRepoSpecificGrants
The fourth pre-existing failure
(TestSettingsBranchesAllowsDowngradedOrgToRemoveAdvancedSettings)
was fixed opportunistically while in the branch-protection cluster
(seed fixture needed AllowedPusherUserIds: []int64{} to satisfy
the NOT NULL constraint).
Go/no-go: GA
GO, conditional on the operator completing the Stripe Dashboard
checklist documented in docs/internal/runbooks/stripe-billing.md
under "Subject resolution chain" and "Per-feature enforcement flags"
sections. Production binary needs redeploy to pick up:
- Migrations 0077 (
last_event_at) + 0078 (refundedenum + column) - The 13 code fixes above
Deploy steps:
ssh root@shithub.sh "sudo -u shithub /usr/local/bin/shithubd migrate up"— applies 0077 + 0078- Restart
shithubd-web.serviceandshithubd-worker.service - Verify with:
SELECT version_id FROM goose_db_version ORDER BY version_id DESC LIMIT 1→ expect78 - Configure Stripe env vars per the runbook (still in test mode until ratified)
- Run the smoke checklist in
runbooks/stripe-billing.md#smoke-test - Flip enforce flags per feature after 7-day report-only soak
No production customers exist today (SELECT plan, count(*) FROM users WHERE deleted_at IS NULL → free: 2; same for orgs), so the deploy carries near-zero risk to existing customers — there are none on Pro/Team plans.
View source
| 1 | # Billing and paid organizations |
| 2 | |
| 3 | shithub's first paid surface is organization billing. This document |
| 4 | records the product and engineering contract that the PAYMENTS sprint |
| 5 | series implements. |
| 6 | |
| 7 | The current implementation already has the important shape for paid |
| 8 | organizations: `orgs.plan` is an enum with `free`, `team`, and |
| 9 | `enterprise`; organizations own repositories; organization members and |
| 10 | teams exist; branch protection and PR review gates exist; Actions has |
| 11 | schema for org/repo secrets, variables, and artifacts. Billing must |
| 12 | turn that substrate into a fair hosted service without taxing |
| 13 | public/open-source collaboration. |
| 14 | |
| 15 | ## Product contract |
| 16 | |
| 17 | As of 2026-05-12, GitHub's public pricing page presents Free at |
| 18 | `$0`, Team at `$4/user/month`, and Enterprise starting at |
| 19 | `$21/user/month`. shithub follows the same mental model but removes |
| 20 | Copilot/AI promises from the paid-org offering. |
| 21 | |
| 22 | Initial decisions: |
| 23 | |
| 24 | - Free organizations remain self-serve. |
| 25 | - Team is `$4` per active organization member per month. |
| 26 | - Active organization members, including owners, count as paid seats. |
| 27 | - Team has no launch trial. |
| 28 | - Enterprise is a visible contact-sales stub, not self-serve. |
| 29 | - Stripe Billing is the first payment processor. |
| 30 | - PayPal, manual invoices, SAML, SCIM, LDAP, enterprise account |
| 31 | hierarchy, and contracts are deferred. |
| 32 | - Self-serve organization creation presents `/organizations/plan` as |
| 33 | the canonical plan selector. Choosing Team creates the organization |
| 34 | and immediately redirects the owner to hosted Stripe Checkout. |
| 35 | |
| 36 | The fairness rule is explicit: public/open-source collaboration should |
| 37 | stay generous. Paid gates focus on private collaboration, hosted cost, |
| 38 | advanced organization controls, and support expectations. |
| 39 | |
| 40 | ## Pricing copy rules |
| 41 | |
| 42 | Pricing and onboarding pages must describe only features shithub can |
| 43 | actually deliver on the hosted service. Before changing pricing copy, |
| 44 | refresh the official GitHub pricing source and the Stripe Billing docs |
| 45 | because both are time-sensitive inputs. |
| 46 | |
| 47 | Rules for paid-org copy: |
| 48 | |
| 49 | - Do not mention Copilot, AI agents, AI code review, or AI quotas. |
| 50 | - Do not promise SAML, SCIM, LDAP, managed users, audit exports, data |
| 51 | residency, compliance attestations, contracts, or custom support |
| 52 | until the matching implementation sprint ships. |
| 53 | - Do not advertise Packages, Pages, Wikis, Projects, Actions minutes, |
| 54 | or storage quotas until those surfaces have enforcement and usage |
| 55 | accounting. |
| 56 | - Use upgrade language for unavailable Team features instead of hiding |
| 57 | existing data. Downgrades preserve configuration and make gated |
| 58 | settings read-only where possible. |
| 59 | - Keep public/open-source collaboration generous in both copy and |
| 60 | enforcement. Avoid copy that makes public repositories feel like a |
| 61 | second-class Free tier. |
| 62 | - Enterprise is a contact-sales stub in v1. It should collect interest |
| 63 | without promising contractual features. |
| 64 | |
| 65 | ## Entitlement matrix |
| 66 | |
| 67 | | Capability | Free | Team | Enterprise stub | |
| 68 | | --- | --- | --- | --- | |
| 69 | | Public org repositories | Included | Included | Contact sales | |
| 70 | | Basic private org repositories | Included | Included | Contact sales | |
| 71 | | Org members and invitations | Included | Billed by active member | Contact sales | |
| 72 | | Effective private org collaborators | Up to 3 | Unlimited while active/in grace | Contact sales | |
| 73 | | Visible teams | Included | Included | Contact sales | |
| 74 | | Secret teams | Upgrade | Included | Contact sales | |
| 75 | | Basic branch protection | Included | Included | Contact sales | |
| 76 | | Advanced private-repo branch protection | Upgrade | Included | Contact sales | |
| 77 | | Required reviewers on private org repos | Upgrade | Included | Contact sales | |
| 78 | | CODEOWNERS review | Deferred | Deferred | Deferred | |
| 79 | | Org-level Actions secrets | Upgrade | Included | Contact sales | |
| 80 | | Org-level Actions variables | Upgrade | Included | Contact sales | |
| 81 | | Actions minutes | Low quota once metered | Higher quota once metered | Contact sales | |
| 82 | | Actions artifacts/storage | Low quota once metered | Higher quota once metered | Contact sales | |
| 83 | | Packages storage | Deferred until Packages is active | Deferred until Packages is active | Deferred | |
| 84 | | Pages/Wikis/Projects | Do not promise until shipped | Do not promise until shipped | Deferred | |
| 85 | | Audit log export | Deferred | Deferred | Later Enterprise feature | |
| 86 | | SAML/SCIM/managed users | Deferred | Deferred | Later Enterprise feature | |
| 87 | | Data residency/compliance | Deferred | Deferred | Later Enterprise feature | |
| 88 | | Billing support | Basic instance support | Billing support after runbook exists | Contact sales | |
| 89 | |
| 90 | ## Pro v1 user-tier matrix (PRO07) |
| 91 | |
| 92 | Pro is the user-tier paid plan (single seat, $4/month). PRO01 ratified |
| 93 | the v1 feature set; PRO07 wires the enforcement matrix. CODEOWNERS is a |
| 94 | registered placeholder with a no-op enforce path until the parser ships. |
| 95 | |
| 96 | | Capability | Free | Pro | |
| 97 | | --- | --- | --- | |
| 98 | | Public/private personal repos | Included | Included | |
| 99 | | Required reviewers on private personal repos | Upgrade | Included | |
| 100 | | Multi-reviewer (>1 approvals) on private personal repos | Upgrade | Included | |
| 101 | | Advanced branch protection on private personal repos | Upgrade | Included | |
| 102 | | CODEOWNERS review | Deferred | Deferred | |
| 103 | | Profile pins | 6 | 100 | |
| 104 | |
| 105 | Multi-reviewer is **not** a separate feature constant — the numeric |
| 106 | threshold lives in the deny payload of `FeatureRequiredReviewers`. |
| 107 | |
| 108 | ### Per-feature enforcement flags |
| 109 | |
| 110 | PRO05 plumbed user-kind report-only logging through every gating site. |
| 111 | PRO07 lights up the gates one feature at a time via |
| 112 | `billing.enforce.*` in the operator's config. Defaults are all false |
| 113 | (report-only). Each flag is a one-way deploy that operators can roll |
| 114 | back without code changes. |
| 115 | |
| 116 | | Config key | Gate site | Default | |
| 117 | | --- | --- | --- | |
| 118 | | `billing.enforce.user_required_reviewers` | `internal/web/handlers/repo/settings_branches.go` | false | |
| 119 | | `billing.enforce.user_advanced_branch_protection` | `internal/web/handlers/repo/settings_branches.go` | false | |
| 120 | | `billing.enforce.user_profile_pins_beyond_free` | `internal/web/handlers/profile/pins.go` | false | |
| 121 | |
| 122 | Rollout discipline: |
| 123 | |
| 124 | 1. Deploy with all flags false. Run the report-only telemetry query |
| 125 | for 7 days. Confirm zero unexpected user-kind would-denies. |
| 126 | 2. Flip one feature flag in staging. Soak 7 days. |
| 127 | 3. Flip the same feature flag in production. |
| 128 | 4. Repeat per feature. |
| 129 | |
| 130 | PRO07's pitfall doc explicitly forbids enforcing a feature without an |
| 131 | unenforce path. New gating sites land with their own flag; do not |
| 132 | share flags across features. |
| 133 | |
| 134 | ### Downgrade preservation |
| 135 | |
| 136 | `users.plan = 'free'` after cancellation grandfather's existing gated |
| 137 | state — required-reviewer rules, profile pins above 6, advanced flags |
| 138 | on existing rules. The gate refuses to **create** new gated state on |
| 139 | Free, but never deletes prior configuration. This is the same |
| 140 | contract as the org-tier downgrade. |
| 141 | |
| 142 | ## Current capability audit |
| 143 | |
| 144 | Already present and safe to gate: |
| 145 | |
| 146 | - Organizations with `plan` and `billing_email`. |
| 147 | - Organization members, owner role, and invitations. |
| 148 | - Teams, including `privacy='secret'`. |
| 149 | - Branch protection rules and required review counts. |
| 150 | - PR review and reviewer-request substrate. |
| 151 | - Org/repo Actions secrets and variables schema. |
| 152 | |
| 153 | Present but missing enforcement or metering: |
| 154 | |
| 155 | - Storage quota type exists, but quota persistence and enforcement are |
| 156 | incomplete. |
| 157 | - Actions minutes, artifacts, and object usage need accounting before |
| 158 | paid limits can be promised. |
| 159 | - Packages storage cannot be sold until the Packages sprint is active |
| 160 | and quota enforcement exists. |
| 161 | |
| 162 | Deferred: |
| 163 | |
| 164 | - SAML, SCIM, LDAP, enterprise account hierarchy, audit-log export, data |
| 165 | residency, compliance promises, and custom support SLAs. |
| 166 | - Copilot/AI features are intentionally outside shithub's paid-org |
| 167 | product. |
| 168 | |
| 169 | ## Billing architecture |
| 170 | |
| 171 | Stripe is the payment source of truth. shithub is the entitlement source |
| 172 | of truth. |
| 173 | |
| 174 | The billing implementation should add a local billing domain that stores |
| 175 | only Stripe IDs and payment summaries, never card data. Webhooks update |
| 176 | local subscription state after signature verification. Policy and |
| 177 | request handlers read local billing/entitlement state and must not call |
| 178 | Stripe in hot paths. |
| 179 | |
| 180 | Required local concepts: |
| 181 | |
| 182 | - Stripe customer per billable organization. |
| 183 | - Subscription state per organization. |
| 184 | - Subscription item ID for seat quantity sync. |
| 185 | - Immutable webhook receipts with unique provider event IDs. |
| 186 | - Invoice/payment summaries for UI. |
| 187 | - Seat snapshots for auditability. |
| 188 | - Billing grace/lock state derived from processed subscription events. |
| 189 | |
| 190 | PAYMENTS SP02 adds these as local database tables: |
| 191 | |
| 192 | - `org_billing_states` stores the organization billing projection used |
| 193 | by entitlement checks. |
| 194 | - `billing_seat_snapshots` records active and billable seat counts over |
| 195 | time. |
| 196 | - `billing_invoices` stores invoice/payment summaries for billing UI. |
| 197 | - `billing_webhook_events` stores immutable provider event receipts for |
| 198 | idempotent webhook processing. |
| 199 | |
| 200 | New organizations receive a Free billing state from a database trigger, |
| 201 | and the migration backfills existing organizations as Free. Subscription |
| 202 | snapshot writes also keep `orgs.plan` synchronized as the |
| 203 | human-facing summary. |
| 204 | |
| 205 | PAYMENTS SP03 adds the first Stripe operator contract: |
| 206 | |
| 207 | - `billing.enabled=false` keeps paid-org flows disabled while retaining |
| 208 | the local billing tables. |
| 209 | - `billing.stripe.secret_key`, `billing.stripe.webhook_secret`, and |
| 210 | `billing.stripe.team_price_id` are required before Stripe routes are |
| 211 | mounted. |
| 212 | - Checkout success, Checkout cancel, and Billing Portal return URLs may |
| 213 | be overridden explicitly; otherwise the web layer derives absolute |
| 214 | organization URLs from `auth.base_url`. |
| 215 | - `billing.grace_period` controls how long failed-payment states may |
| 216 | remain unlocked before paid entitlements are cut off. |
| 217 | - When billing routes are mounted, `/settings/organizations` links |
| 218 | owner-managed organizations to their billing page's plan comparison. |
| 219 | When billing is disabled, that action stays visibly unavailable |
| 220 | instead of linking to an unmounted route. |
| 221 | |
| 222 | The operator enablement flow is documented in |
| 223 | [`runbooks/stripe-billing.md`](./runbooks/stripe-billing.md). |
| 224 | |
| 225 | PAYMENTS SP04 adds the self-serve onboarding flow: |
| 226 | |
| 227 | - `/organizations/plan` is the canonical plan picker. |
| 228 | - Free setup creates the organization locally without Stripe. |
| 229 | - Team setup creates the organization, creates or reuses the Stripe |
| 230 | customer, counts billable seats, and redirects directly to hosted |
| 231 | Stripe Checkout. |
| 232 | - Checkout success and cancel returns render shithub pages. Success |
| 233 | tells the owner that activation waits for webhook processing; cancel |
| 234 | keeps the organization on Free and offers a retry path. |
| 235 | |
| 236 | PAYMENTS SP05 adds the local entitlement boundary. Product code must ask |
| 237 | `internal/entitlements` for feature decisions instead of inspecting |
| 238 | `orgs.plan` directly. The package derives access from |
| 239 | `org_billing_states`, understands billing-good-standing states, and |
| 240 | returns upgrade metadata for user-facing handlers. |
| 241 | |
| 242 | PAYMENTS SP06 wires the first Team gates: |
| 243 | |
| 244 | - Secret teams require Team to create. Existing secret teams remain |
| 245 | visible to authorized viewers after downgrade; owners can remove |
| 246 | members and repository grants, but adding members or granting more |
| 247 | repository access is blocked until Team billing is active again. |
| 248 | - Required reviewers and advanced status-check branch protection are |
| 249 | Team-only for private organization repositories. Public organization |
| 250 | repositories keep those safety controls available on Free. |
| 251 | - Downgraded private organization repositories may delete protection |
| 252 | rules or submit a rule update that clears the gated review/check |
| 253 | settings. |
| 254 | - Org-level Actions secrets and variables require Team for create or |
| 255 | update in both HTML settings and REST API routes. Delete stays |
| 256 | available so owners can clean up gated configuration after downgrade. |
| 257 | - Org-level Actions secrets and variables API routes require |
| 258 | organization owner or site-admin access before entitlement checks. |
| 259 | |
| 260 | PAYMENTS SP07 completes the first self-serve billing settings surface: |
| 261 | |
| 262 | - `GET /organizations/{org}/settings/billing` is owner-only and is |
| 263 | linked from organization settings when Stripe Billing is configured. |
| 264 | - The page shows current local plan state, subscription status, payment |
| 265 | source summary, recent Stripe-synced invoice snapshots, and actionable |
| 266 | banners for past-due, grace-period, canceled, scheduled-cancel, and |
| 267 | billing-action-needed states. |
| 268 | - Seat accounting is shown as three separate values: current active |
| 269 | members, billable seats from the latest local billing state, and |
| 270 | pending invitations. Pending invitations are explicitly not billed |
| 271 | until accepted. |
| 272 | - Team organizations manage payment method, invoices, cancellation, and |
| 273 | downgrade through Stripe Billing Portal. shithub never collects card |
| 274 | data directly and downgrades continue to preserve paid configuration |
| 275 | as read-only data. |
| 276 | - Normal organization owners do not see raw Stripe customer, |
| 277 | subscription, or subscription-item IDs. Site admins see a debug panel |
| 278 | with those IDs and the latest locally recorded webhook receipt state. |
| 279 | |
| 280 | PAYMENTS SP06a adds the first private-collaboration limit: |
| 281 | |
| 282 | - Free organizations may have up to 3 unique humans with effective |
| 283 | access to at least one private organization repository. |
| 284 | - Team organizations with active, trialing, or in-grace subscriptions |
| 285 | have unlimited private collaborators. |
| 286 | - The effective private-collaborator set counts org owners, direct |
| 287 | collaborators on private org repos, and team members who inherit a |
| 288 | private repo grant through direct team membership or one-level parent |
| 289 | team inheritance. Plain org members do not count unless they gain |
| 290 | private repo access through one of those paths. |
| 291 | - Public repository collaboration never counts toward the limit. |
| 292 | - Downgrades preserve existing access even when the org is already over |
| 293 | the Free limit, but writes that add a new effective private |
| 294 | collaborator are blocked until the org upgrades or removes access. |
| 295 | - Creating/importing a private org repository and changing an org repo |
| 296 | from public to private are blocked on Free when the resulting private |
| 297 | collaborator set would exceed the limit. |
| 298 | - Cleanup writes remain available: removing org members, team members, |
| 299 | direct collaborators, team repo grants, and gated configuration must |
| 300 | not require Team. |
| 301 | |
| 302 | ## Entitlement architecture |
| 303 | |
| 304 | Paid feature checks must live behind a central entitlement package, not |
| 305 | as scattered `orgs.plan` checks in handlers. |
| 306 | |
| 307 | `make lint-org-plan` enforces this boundary. Schema/sqlc plumbing may |
| 308 | store and scan the plan value, but product behavior should ask the |
| 309 | entitlement package whether a feature key is available. |
| 310 | |
| 311 | The package entrypoint is `entitlements.ForOrg(ctx, deps, orgID)`, |
| 312 | which loads the local `org_billing_states` projection and returns a |
| 313 | request-scoped entitlement set. Callers then use `CanUse(feature)` for |
| 314 | feature decisions and `Limit(name)` for paid limit metadata. The legacy |
| 315 | `CheckOrgFeature` helper is a thin wrapper for handlers that need only |
| 316 | one feature. These calls are deterministic and never call Stripe. |
| 317 | |
| 318 | Expected feature keys: |
| 319 | |
| 320 | - `org.secret_teams` |
| 321 | - `org.advanced_branch_protection` |
| 322 | - `org.required_reviewers` |
| 323 | - `org.actions_org_secrets` |
| 324 | - `org.actions_org_variables` |
| 325 | - `org.private_collaboration_limit` |
| 326 | - `org.storage_quota` |
| 327 | - `org.actions_minutes_quota` |
| 328 | |
| 329 | Authorization and entitlement are separate gates. A user must have both |
| 330 | the policy permission and the paid entitlement for gated writes. Denials |
| 331 | must preserve existing `policy.Maybe404` behavior where existence leaks |
| 332 | matter. |
| 333 | |
| 334 | Entitlement outcomes are: |
| 335 | |
| 336 | - Free organizations receive `upgrade_required` for Team features. |
| 337 | - Team organizations with `active` or `trialing` subscriptions receive |
| 338 | the feature. |
| 339 | - Team organizations in `past_due` remain usable only while their local |
| 340 | grace window has not expired. |
| 341 | - Team organizations with incomplete, unpaid, paused, canceled, or |
| 342 | expired-grace billing receive `billing_action_needed`. |
| 343 | - Enterprise remains a contact-sales stub and receives |
| 344 | `enterprise_contact_sales` until a later Enterprise sprint defines a |
| 345 | real feature set. |
| 346 | |
| 347 | Handler upgrade helpers map paid-feature denials to HTTP 402 metadata |
| 348 | and a billing-settings path, but handlers must still perform normal |
| 349 | authorization first and preserve 404 masking for private resources. |
| 350 | |
| 351 | ## Downgrade behavior |
| 352 | |
| 353 | Downgrades must preserve customer data. Moving from Team to Free should |
| 354 | not delete teams, secrets, variables, branch rules, or review settings. |
| 355 | Existing gated resources become read-only where possible. Users can |
| 356 | remove gated configuration, but cannot create or expand it until the |
| 357 | organization upgrades again. |
| 358 | |
| 359 | ## Open questions for implementation |
| 360 | |
| 361 | - Exact Free and Team quota numbers for Actions and storage. These must |
| 362 | come from real host-cost estimates before SP08. |
| 363 | |
| 364 | ## Source references |
| 365 | |
| 366 | - GitHub pricing: `https://github.com/pricing` |
| 367 | - GitHub plans docs: |
| 368 | `https://docs.github.com/en/get-started/learning-about-github/githubs-plans` |
| 369 | - Stripe Billing: `https://docs.stripe.com/billing` |
| 370 | - Stripe pricing models: |
| 371 | `https://docs.stripe.com/products-prices/pricing-models` |
| 372 | |
| 373 | ## PRO08 GA audit closure |
| 374 | |
| 375 | **Audit dates**: 2026-05-13 (run + remediation in a single sprint). |
| 376 | |
| 377 | **Scope**: PRO04 Stripe adapter + PRO05 entitlements Principal + |
| 378 | PRO06 user-tier billing settings + PRO07 Pro v1 feature gates. |
| 379 | |
| 380 | **Methodology**: four parallel security/correctness audit agents |
| 381 | ran read-only inspections against the PRO07 tip plus the deployed |
| 382 | host (`shithub.sh`). Findings were triaged per-bug; HIGH and |
| 383 | MEDIUM severity gaps were fixed inline on the `payments-pro/08-pro-ga-audit` |
| 384 | branch with locking tests. LOW-severity items were either fixed or |
| 385 | documented per the user directive "we don't want to take any chances |
| 386 | with payments." |
| 387 | |
| 388 | ### Findings + remediation status |
| 389 | |
| 390 | | # | Severity | Finding | Fix | Test | |
| 391 | |---|---|---|---|---| |
| 392 | | A1 | MED | `guardPriceKindMatch` bypassable on empty `Items` | refuse empty-items when prices configured | `TestBillingWebhookGuardRefusesEmptyItemsWhenPricesConfigured` | |
| 393 | | A2 | HIGH | `(subject_kind, subject_id)` never written to receipts | `SetWebhookEventSubjectForPrincipal` after every resolve; `ListFailedWebhookEvents` operator query | `TestSetWebhookEventSubjectForPrincipalRecordsAuditTrail`, `TestListFailedWebhookEventsReturnsErroredAndStuckEntries` | |
| 394 | | A3 | MED | Concurrent dup-replay TOCTOU race | session-scoped advisory lock keyed on event id | `TestBillingWebhookConcurrentReplayAppliesOnce` | |
| 395 | | D1 | HIGH | Snapshot CTE wipes lock columns on past_due transitions | conditional preservation in `ApplySubscriptionSnapshot` + user analog | `TestApplySubscriptionSnapshotPreservesGraceLockOnPastDue` (+ recovery + user-side mirror) | |
| 396 | | D2 | LOW | No `charge.refunded` handler | new dispatch + `MarkInvoiceRefunded` query + enum + UI surface | `TestBillingWebhookChargeRefundedMarksInvoiceRefunded` (+ unknown invoice + standalone refund) | |
| 397 | | D3 | MED | Two subscriptions per customer silently overwrite | `guardSubscriptionOverwrite` rejects different-sub apply | `TestBillingWebhookGuardRefusesSecondSubscriptionForSameCustomer` | |
| 398 | | D4 | MED | No stale-event detection (reverse-order corruption) | `last_event_at` column + `IsBillingEventStaleForPrincipal` + handler guard + post-apply touch | `TestBillingWebhookDropsStaleEvent` | |
| 399 | | D5 | LOW | `customer.subscription.deleted` for unknown sub 5xx's | log + 200 no-op so Stripe stops retrying | `TestBillingWebhookSubscriptionDeletedForUnknownSubIsNoOp` | |
| 400 | | C1 | HIGH | `require_signed_commits` unreachable from UI | form field + template checkbox + sqlc plumbing | covered by `TestSettingsBranches_UserKindEnforceFlipPreventForcePushBlocks` table | |
| 401 | | C2 | HIGH | Advanced BP gate fires on wrong inputs | rewire predicate to fire on prevent_*, signing, AND status-checks | table test | |
| 402 | | C3 | MED | Multi-reviewer deny copy indistinguishable | thread reviewer count → `required-reviewers-multi-upgrade(-pro)` codes | `TestSettingsBranches_UserKindEnforceFlipMultiReviewerBlocks` | |
| 403 | | C4 | MED | Notice copy says "Team" / "organization" on user-tier denies | user-kind variants (`-pro` suffix) with `/settings/billing` href | `TestSettingsBranches_UserKindEnforceFlipRequiredReviewersBlocksSingle` (Pro copy) | |
| 404 | | C5 | LOW | `profilePinsRemaining` hard-codes cap; under-counts for Pro | thread entitled cap into the function | covered by existing profile_test suite + no regression | |
| 405 | |
| 406 | Authorization audit (Agent B) found **no** cross-tenant data leak, |
| 407 | no invoice-scoping bleed, and no deny-shape 500 reachable from |
| 408 | production paths. Two `err→500` branches in the entitlement gate |
| 409 | remain (in `RequirePrincipalFeature` and `evaluateBranchProtectionFeature`) |
| 410 | but are defended against by the AFTER-INSERT seed triggers on |
| 411 | both billing-state tables — `pgx.ErrNoRows` for a valid principal |
| 412 | is dead code in production. |
| 413 | |
| 414 | ### Pre-existing failures NOT introduced by PRO08 |
| 415 | |
| 416 | These were noted across prior sprints and remain open: |
| 417 | |
| 418 | - `TestPrivateCollaborationExpansionEnforcesFreeLimitAndTeamUnlimited` |
| 419 | - `TestPrivateCollaborationUsageCountsEffectivePrivateAccess` |
| 420 | - `TestRepoPrivateVisibilityCountsRepoSpecificGrants` |
| 421 | |
| 422 | The fourth pre-existing failure |
| 423 | (`TestSettingsBranchesAllowsDowngradedOrgToRemoveAdvancedSettings`) |
| 424 | was fixed opportunistically while in the branch-protection cluster |
| 425 | (seed fixture needed `AllowedPusherUserIds: []int64{}` to satisfy |
| 426 | the NOT NULL constraint). |
| 427 | |
| 428 | ### Go/no-go: GA |
| 429 | |
| 430 | **GO**, conditional on the operator completing the Stripe Dashboard |
| 431 | checklist documented in `docs/internal/runbooks/stripe-billing.md` |
| 432 | under "Subject resolution chain" and "Per-feature enforcement flags" |
| 433 | sections. Production binary needs redeploy to pick up: |
| 434 | |
| 435 | - Migrations 0077 (`last_event_at`) + 0078 (`refunded` enum + column) |
| 436 | - The 13 code fixes above |
| 437 | |
| 438 | Deploy steps: |
| 439 | |
| 440 | 1. `ssh root@shithub.sh "sudo -u shithub /usr/local/bin/shithubd migrate up"` — applies 0077 + 0078 |
| 441 | 2. Restart `shithubd-web.service` and `shithubd-worker.service` |
| 442 | 3. Verify with: `SELECT version_id FROM goose_db_version ORDER BY version_id DESC LIMIT 1` → expect `78` |
| 443 | 4. Configure Stripe env vars per the runbook (still in test mode until ratified) |
| 444 | 5. Run the smoke checklist in `runbooks/stripe-billing.md#smoke-test` |
| 445 | 6. Flip enforce flags per feature after 7-day report-only soak |
| 446 | |
| 447 | **No production customers exist today** (`SELECT plan, count(*) FROM users WHERE deleted_at IS NULL` → `free: 2`; same for orgs), so the deploy carries near-zero risk to existing customers — there are none on Pro/Team plans. |