carlos / platform Public

Prepare the Stripe catalog provisioning plan and print-only script

stripe-provisioning.md is the ordered live plan: three products with the
approved PaaS tax codes, twelve EUR yearly prices with deterministic
idempotency keys, the 50 percent public code and the 100 percent house
coupon, the locked portal configuration the console verifies, the webhook
endpoint and its pinned API version, the minimal rk_live permission list
derived from every endpoint internal/billingstripe calls, the console
environment, verification, a test-mode rehearsal, release order, rollback
and the scoped-approval request. stripe-provision.sh only prints commands:
verified with a stub stripe on PATH that it never invokes the CLI in the
provision, check or rollback modes. Product names and the code word are
arguments because the copy is not approved. Nothing was provisioned.

Written by a Sonnet worker interrupted before committing; reviewed and
committed by the lead.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 2d55a252e6a21141858f8f0da7046e111f6dade1 parent 9a3df4f
3 files changed, +1038 −6
  • implementation/carloku-paid-launch/stripe-live-readiness.json +10 −6
  • implementation/carloku-paid-launch/stripe-provision.sh +500 −0
  • implementation/carloku-paid-launch/stripe-provisioning.md +528 −0

CI passed — run details

diff --git a/implementation/carloku-paid-launch/stripe-live-readiness.json b/implementation/carloku-paid-launch/stripe-live-readiness.json
index 6a9ac00ea..0b3f47e2b 100644
--- a/implementation/carloku-paid-launch/stripe-live-readiness.json
+++ b/implementation/carloku-paid-launch/stripe-live-readiness.json
@@ -84,12 +84,16 @@
]
},
"remaining_before_live_mutation": [
- "Land and deploy the approved plan-specific PaaS tax-code mapping before enabling billing.",
- "Approve customer-facing product names and billing copy.",
- "Create and verify a restricted live runtime key with only the API permissions the console needs.",
- "Choose the deployed webhook URL and create the endpoint and signing secret.",
- "Create and verify the locked customer portal configuration.",
- "Provision the three products and twelve immutable prices, then record their IDs in CONSOLE_STRIPE_PRICES."
+ "Every item below is planned in implementation/carloku-paid-launch/stripe-provisioning.md; stripe-provision.sh prints the commands and executes nothing. No item has been started.",
+ "Get scoped approval for the live Stripe work (approval text at the end of stripe-provisioning.md).",
+ "Land and deploy the approved plan-specific PaaS tax-code mapping before enabling billing (CONSOLE_STRIPE_TAX_CODES, plan step 7).",
+ "Approve customer-facing product names, coupon names, the promotion code word and billing copy (plan steps 1 and 3; the script takes them as arguments).",
+ "Create and verify a restricted live runtime key with only the API permissions the console needs (plan step 6, permission table and check 9 probes).",
+ "Choose the deployed webhook URL and create the endpoint and signing secret (plan step 5: console.carloku.com/billing/webhook, six events, API version 2025-02-24.acacia).",
+ "Create and verify the locked customer portal configuration (plan step 4).",
+ "Provision the three products and twelve immutable prices, then record their IDs in CONSOLE_STRIPE_PRICES (plan steps 1 and 2).",
+ "Create the 50 percent public coupon and promotion code and the 100 percent house coupon, with no promotion code for the house coupon (plan step 3).",
+ "Run the read-only verification (stripe-provision.sh --check) and a test-mode rehearsal before CONSOLE_BILLING_MODE is switched to live."
],
"mutations_performed": true,
"mutations": [
diff --git a/implementation/carloku-paid-launch/stripe-provision.sh b/implementation/carloku-paid-launch/stripe-provision.sh
new file mode 100755
index 000000000..c2c742d09
--- /dev/null
+++ b/implementation/carloku-paid-launch/stripe-provision.sh
@@ -0,0 +1,500 @@
+#!/usr/bin/env bash
+#
+# stripe-provision.sh: PRINT-ONLY planner for the Carloku Stripe catalog.
+#
+# This script prints the exact `stripe` CLI commands, in order, that a person
+# with scoped approval would run to provision Carloku (acct_1UD7LXI4Nrr6AEsO,
+# Team Tito Limited, EUR, Ireland). It EXECUTES NOTHING. It never invokes the
+# stripe CLI, never makes a network call, never reads a credential or a
+# config file, and has no --apply mode and never will. The operator copies the
+# printed commands into their own shell after the approval in
+# stripe-provisioning.md has been given.
+#
+# Product names, the coupon names and the promotion code are copy that is still
+# pending review, so they are arguments and are never baked into this file.
+#
+# Usage:
+# stripe-provision.sh --mode live|test \
+# --personal-name NAME --community-name NAME --business-name NAME \
+# --promo-code WORD --promo-coupon-name NAME --house-coupon-name NAME \
+# [--webhook-url URL] [--portal-headline TEXT] [--privacy-url URL] \
+# [--terms-url URL]
+# Prints the ordered provisioning commands.
+#
+# stripe-provision.sh --check --mode live|test [--promo-code WORD] \
+# [--webhook-url URL] [--account acct_...]
+# Prints the read-only verification GETs, matching what the console's
+# startBillingCheckout, verifyPortalConfiguration and verifyHouseCoupon
+# check, plus the runtime key probes.
+#
+# stripe-provision.sh --rollback --mode live|test
+# Prints the archive and disable commands (nothing is deleted).
+#
+# Notes on the Stripe CLI that the printed commands rely on:
+# - `stripe post|get <path> -d key=value` maps one to one onto the REST API.
+# - `--live` selects live mode. The CLI has no `--test` flag: test mode is
+# the absence of `--live`, so test output carries a comment saying so.
+# - `--idempotency KEY` is the CLI's flag for the Idempotency-Key header.
+# - `--project-name carloku` selects the carloku CLI profile.
+#
+set -euo pipefail
+
+readonly TERMS="2026-09-07-v1"
+readonly API_VERSION="2025-02-24.acacia"
+readonly DEFAULT_ACCOUNT="acct_1UD7LXI4Nrr6AEsO"
+readonly DEFAULT_WEBHOOK_URL="https://console.carloku.com/billing/webhook"
+readonly WEBHOOK_EVENTS=(
+ checkout.session.completed
+ customer.subscription.created
+ customer.subscription.updated
+ customer.subscription.deleted
+ invoice.paid
+ invoice.payment_failed
+)
+readonly PLANS=(personal community business)
+readonly BANDS=(A B C D)
+
+# Unit amounts in euro cents. These mirror stripe-live-readiness.json
+# required_catalog and internal/plans/plans.json (annual base times band factor).
+declare -A CENTS=(
+ [personal/A]=2400 [personal/B]=1800 [personal/C]=1200 [personal/D]=840
+ [community/A]=500 [community/B]=375 [community/C]=250 [community/D]=175
+ [business/A]=5000 [business/B]=3750 [business/C]=2500 [business/D]=1750
+)
+declare -A TAX_CODE=(
+ [personal]=txcd_10102001 [community]=txcd_10102001 [business]=txcd_10102000
+)
+
+die() { printf 'stripe-provision.sh: %s\n' "$*" >&2; exit 2; }
+
+usage() { sed -n '3,/^set -euo/p' "${BASH_SOURCE[0]}" | sed -e '/^set -euo/d' -e 's/^# \{0,1\}//'; }
+
+mode="" action="provision" account="$DEFAULT_ACCOUNT" webhook_url="$DEFAULT_WEBHOOK_URL"
+name_personal="" name_community="" name_business=""
+promo_code="" promo_coupon_name="" house_coupon_name=""
+portal_headline="" privacy_url="" terms_url=""
+
+need_value() { [[ $# -ge 2 && -n "$2" ]] || die "$1 needs a value"; }
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --help|-h) usage; exit 0 ;;
+ --check) action="check"; shift ;;
+ --rollback) action="rollback"; shift ;;
+ --apply|--execute|--run) die "there is no $1 mode: this script only prints commands and will never execute them" ;;
+ --mode) need_value "$@"; mode="$2"; shift 2 ;;
+ --account) need_value "$@"; account="$2"; shift 2 ;;
+ --webhook-url) need_value "$@"; webhook_url="$2"; shift 2 ;;
+ --personal-name) need_value "$@"; name_personal="$2"; shift 2 ;;
+ --community-name) need_value "$@"; name_community="$2"; shift 2 ;;
+ --business-name) need_value "$@"; name_business="$2"; shift 2 ;;
+ --promo-code) need_value "$@"; promo_code="$2"; shift 2 ;;
+ --promo-coupon-name) need_value "$@"; promo_coupon_name="$2"; shift 2 ;;
+ --house-coupon-name) need_value "$@"; house_coupon_name="$2"; shift 2 ;;
+ --portal-headline) need_value "$@"; portal_headline="$2"; shift 2 ;;
+ --privacy-url) need_value "$@"; privacy_url="$2"; shift 2 ;;
+ --terms-url) need_value "$@"; terms_url="$2"; shift 2 ;;
+ *) die "unknown argument: $1 (try --help)" ;;
+ esac
+done
+
+[[ "$mode" == "live" || "$mode" == "test" ]] || die "--mode live or --mode test is required; there is no default"
+[[ "$account" =~ ^acct_[A-Za-z0-9]+$ ]] || die "--account must look like acct_..."
+[[ "$webhook_url" =~ ^https://[A-Za-z0-9.-]+(:[0-9]+)?/[A-Za-z0-9._~/%-]*$ ]] || die "--webhook-url must be an https URL with a path"
+if [[ "$mode" == "live" && "$webhook_url" != "$DEFAULT_WEBHOOK_URL" ]]; then
+ die "live mode uses $DEFAULT_WEBHOOK_URL; change this script, not the flag, if the console host moves"
+fi
+
+# Free text must be one printable line: no control characters, no newlines.
+check_text() {
+ local label="$1" value="$2" max="$3"
+ [[ -n "$value" ]] || die "$label is required"
+ [[ "$value" != *[[:cntrl:]]* ]] || die "$label contains a control character"
+ ((${#value} <= max)) || die "$label is longer than $max characters"
+}
+check_url() {
+ [[ -z "$2" || "$2" =~ ^https://[A-Za-z0-9.-]+(:[0-9]+)?(/[A-Za-z0-9._~/%?=\&-]*)?$ ]] || die "$1 must be an https URL"
+}
+
+if [[ "$action" == "provision" ]]; then
+ check_text --personal-name "$name_personal" 250
+ check_text --community-name "$name_community" 250
+ check_text --business-name "$name_business" 250
+ check_text --promo-coupon-name "$promo_coupon_name" 40
+ check_text --house-coupon-name "$house_coupon_name" 40
+ [[ "$promo_code" =~ ^[A-Za-z0-9]{3,32}$ ]] || die "--promo-code must be 3 to 32 letters and digits"
+ [[ -z "$portal_headline" ]] || check_text --portal-headline "$portal_headline" 60
+ check_url --privacy-url "$privacy_url"
+ check_url --terms-url "$terms_url"
+fi
+if [[ "$action" == "check" && -n "$promo_code" ]]; then
+ [[ "$promo_code" =~ ^[A-Za-z0-9]{3,32}$ ]] || die "--promo-code must be 3 to 32 letters and digits"
+fi
+
+if [[ "$mode" == "live" ]]; then
+ MODE_WORDS=(--project-name carloku --live --stripe-version "$API_VERSION")
+ MODE_TEXT="--project-name carloku --live --stripe-version $API_VERSION"
+ LIVE_JSON=true
+else
+ MODE_WORDS=(--project-name carloku --stripe-version "$API_VERSION")
+ MODE_TEXT="--project-name carloku --stripe-version $API_VERSION"
+ LIVE_JSON=false
+fi
+
+readonly SQ="'\\''"
+
+# quote prints one word so it survives being pasted into bash. Words that start
+# with RAW: are printed verbatim so the operator's own $VARIABLES expand.
+quote() {
+ local w="$1"
+ if [[ "$w" == RAW:* ]]; then
+ printf '%s' "${w#RAW:}"
+ elif [[ "$w" =~ ^[A-Za-z0-9_@%+=:,./-]+$ ]]; then
+ printf '%s' "$w"
+ else
+ printf "'%s'" "${w//\'/$SQ}"
+ fi
+}
+
+# words prints one command across continuation lines, with no final newline.
+words() {
+ local first=1 w
+ for w in "$@"; do
+ if ((first)); then
+ printf '%s' "$w"
+ first=0
+ elif [[ "$w" == "-d" || "$w" == "--idempotency" ]]; then
+ # A new continuation line starts at each -d or --idempotency; its value follows on the same line.
+ printf ' \\\n %s' "$w"
+ else
+ printf ' %s' "$(quote "$w")"
+ fi
+ done
+}
+emit() { words "$@"; printf '\n\n'; }
+
+# capture prints VAR="$( command | jq -r EXPR )" plus a guard that the value has
+# the expected Stripe id prefix, so a failed call stops the paste.
+capture() {
+ local var="$1" prefix="$2" expr="$3"
+ shift 3
+ printf '%s="$(' "$var"
+ words "$@"
+ printf ' \\\n | jq -r %s)"\n' "'$expr'"
+ printf 'case "$%s" in %s*) echo "%s=$%s" ;; *) echo "%s: unexpected response, stop" >&2; exit 1 ;; esac\n\n' "$var" "$prefix" "$var" "$var" "$var"
+}
+
+section() { printf '# ---------------------------------------------------------------------------\n# %s\n# ---------------------------------------------------------------------------\n' "$1"; }
+note() { printf '# %s\n' "$1"; }
+
+header() {
+ printf '# PRINT-ONLY OUTPUT of stripe-provision.sh (%s, mode=%s, terms %s).\n' "$action" "$mode" "$TERMS"
+ note "Nothing below has been run. Run it only after the scoped approval in"
+ note "stripe-provisioning.md, one block at a time, reading each result."
+ note "Use a throwaway shell (bash --noprofile --norc): the guards call exit when a result is wrong."
+ if [[ "$mode" == "live" ]]; then
+ note "LIVE MODE on $account. Every command carries --live."
+ else
+ note "TEST MODE. The Stripe CLI has no --test flag: a command without --live is a test-mode command."
+ note "Use this only against an isolated sandbox or the test side of an approved account."
+ fi
+ note "Requires the stripe CLI logged in under the carloku project, and jq. The CLI login credential"
+ note "is for provisioning only. It is NOT the console runtime key (that must be rk_live_, see the plan)."
+ printf 'set -o pipefail\n\n'
+}
+
+lookup_key() { printf 'carloku-%s-%s-%s' "$TERMS" "$1" "$2"; }
+
+print_provision() {
+ header
+ section "Step 0. Scratch space and read-back of the empty starting state"
+ note "Scratch holds the webhook response only (it carries the signing secret). Mode 0700, removed in step 5."
+ printf 'umask 077\nSCRATCH="$(mktemp -d "${TMPDIR:-$HOME/.cache}/carloku-stripe.XXXXXX")"\n\n'
+ note "Expect: account id $account, 0 active products, 0 active prices, 0 webhook endpoints."
+ emit stripe get /v1/account "${MODE_WORDS[@]}"
+ emit stripe get /v1/products "${MODE_WORDS[@]}" -d active=true -d limit=100
+ emit stripe get /v1/prices "${MODE_WORDS[@]}" -d active=true -d limit=100
+ emit stripe get /v1/webhook_endpoints "${MODE_WORDS[@]}" -d limit=100
+
+ section "Step 1. Three products (names are pending copy approval)"
+ local plan name band
+ for plan in "${PLANS[@]}"; do
+ case "$plan" in
+ personal) name="$name_personal" ;;
+ community) name="$name_community" ;;
+ *) name="$name_business" ;;
+ esac
+ capture "PRODUCT_ID_${plan^^}" prod_ .id \
+ stripe post /v1/products "${MODE_WORDS[@]}" \
+ --idempotency "carloku-product-$TERMS-$plan" \
+ -d "name=$name" \
+ -d "tax_code=${TAX_CODE[$plan]}" \
+ -d "metadata[plan]=$plan" \
+ -d "metadata[terms_version]=$TERMS"
+ done
+
+ section "Step 2. Twelve prices (EUR, exclusive tax, yearly, licensed, per unit, no currency_options)"
+ note "Prices are immutable. A mistake is fixed by archiving the price and making a new one under a new terms version."
+ for plan in "${PLANS[@]}"; do
+ for band in "${BANDS[@]}"; do
+ capture "PRICE_ID_${plan^^}_${band}" price_ .id \
+ stripe post /v1/prices "${MODE_WORDS[@]}" \
+ --idempotency "carloku-price-$TERMS-$plan-$band" \
+ -d "RAW:\"product=\$PRODUCT_ID_${plan^^}\"" \
+ -d currency=eur \
+ -d "unit_amount=${CENTS[$plan/$band]}" \
+ -d billing_scheme=per_unit \
+ -d tax_behavior=exclusive \
+ -d "recurring[interval]=year" \
+ -d "recurring[interval_count]=1" \
+ -d "recurring[usage_type]=licensed" \
+ -d "lookup_key=$(lookup_key "$plan" "$band")" \
+ -d "metadata[terms_version]=$TERMS" \
+ -d "metadata[plan]=$plan" \
+ -d "metadata[band]=$band"
+ done
+ done
+ note "Assemble the CONSOLE_STRIPE_PRICES value. The ids are not secret. Keep the printed JSON for the console environment."
+ local lk=()
+ for plan in "${PLANS[@]}"; do
+ for band in "${BANDS[@]}"; do
+ lk+=(-d "lookup_keys[]=$(lookup_key "$plan" "$band")")
+ done
+ done
+ printf 'CONSOLE_STRIPE_PRICES="$('
+ words stripe get /v1/prices "${MODE_WORDS[@]}" -d active=true -d limit=100 "${lk[@]}"
+ printf ' \\\n | jq -c %s)"\n' "'[.data[] | {key: (\"$TERMS/\" + .metadata.plan + \"/\" + .metadata.band), value: .id}] | from_entries'"
+ printf 'echo "$CONSOLE_STRIPE_PRICES" | jq -e "length == 12" >/dev/null || { echo "expected 12 prices" >&2; exit 1; }\necho "$CONSOLE_STRIPE_PRICES"\n\n'
+
+ section "Step 3. Coupons and the one public promotion code"
+ note "Public 50% coupon, forever. Unlimited redemptions: no max_redemptions and no expiry on the promotion code."
+ emit stripe post /v1/coupons "${MODE_WORDS[@]}" \
+ --idempotency "carloku-coupon-public-50-forever-v1" \
+ -d id=carloku-public-50-forever \
+ -d percent_off=50 \
+ -d duration=forever \
+ -d "name=$promo_coupon_name" \
+ -d "metadata[terms_version]=$TERMS"
+ emit stripe post /v1/promotion_codes "${MODE_WORDS[@]}" \
+ --idempotency "carloku-promo-${promo_code,,}-v1" \
+ -d coupon=carloku-public-50-forever \
+ -d "code=$promo_code" \
+ -d active=true
+ note "House coupon: 100% off forever. A COUPON ONLY. Never create a promotion code for it."
+ emit stripe post /v1/coupons "${MODE_WORDS[@]}" \
+ --idempotency "carloku-coupon-house-100-forever-v1" \
+ -d id=carloku-house-100-forever \
+ -d percent_off=100 \
+ -d duration=forever \
+ -d "name=$house_coupon_name" \
+ -d "metadata[terms_version]=$TERMS"
+ printf 'CONSOLE_STRIPE_HOUSE_COUPON=carloku-house-100-forever\n\n'
+
+ section "Step 4. Locked Customer Portal configuration"
+ note "subscription_update off, cancel at period end, payment method update on, invoice history on,"
+ note "customer_update limited to email, name and tax_id (never address or shipping: they steer tax)."
+ local portal=(stripe post /v1/billing_portal/configurations "${MODE_WORDS[@]}"
+ --idempotency "carloku-portal-config-$TERMS"
+ -d "features[customer_update][enabled]=true"
+ -d "features[customer_update][allowed_updates][]=email"
+ -d "features[customer_update][allowed_updates][]=name"
+ -d "features[customer_update][allowed_updates][]=tax_id"
+ -d "features[invoice_history][enabled]=true"
+ -d "features[payment_method_update][enabled]=true"
+ -d "features[subscription_cancel][enabled]=true"
+ -d "features[subscription_cancel][mode]=at_period_end"
+ -d "features[subscription_cancel][proration_behavior]=none"
+ -d "features[subscription_update][enabled]=false"
+ -d "metadata[terms_version]=$TERMS"
+ -d "metadata[purpose]=carloku-console-locked")
+ [[ -z "$portal_headline" ]] || portal+=(-d "business_profile[headline]=$portal_headline")
+ [[ -z "$privacy_url" ]] || portal+=(-d "business_profile[privacy_policy_url]=$privacy_url")
+ [[ -z "$terms_url" ]] || portal+=(-d "business_profile[terms_of_service_url]=$terms_url")
+ capture CONSOLE_STRIPE_PORTAL_CONFIGURATION bpc_ .id "${portal[@]}"
+
+ section "Step 5. Webhook endpoint, pinned to API version $API_VERSION"
+ note "The signing secret is returned ONCE, in this response. The response goes to a 0600 file, never the terminal."
+ note "The console must already be deployed with CONSOLE_BILLING_MODE=off, so the URL exists (it answers 404 until billing is on)."
+ local hook=(stripe post /v1/webhook_endpoints "${MODE_WORDS[@]}"
+ --idempotency "carloku-webhook-$TERMS"
+ -d "url=$webhook_url"
+ -d "api_version=$API_VERSION"
+ -d "description=carloku console billing webhook"
+ -d "metadata[terms_version]=$TERMS")
+ local ev
+ for ev in "${WEBHOOK_EVENTS[@]}"; do
+ hook+=(-d "enabled_events[]=$ev")
+ done
+ words "${hook[@]}"
+ printf ' \\\n > "$SCRATCH/webhook.json"\n'
+ printf '%s\n' "jq -e '.id | startswith(\"we_\")' \"\$SCRATCH/webhook.json\" >/dev/null"
+ printf 'echo "webhook endpoint id: $(jq -r .id "$SCRATCH/webhook.json")"\n'
+ note "Hand the secret to the secret store without printing it, for example (adapt to the store in use):"
+ note " jq -r .secret \"\$SCRATCH/webhook.json\" | <secret-store write, reading stdin>"
+ note "It becomes CARLOS_STRIPE_WEBHOOK_SECRET in the pinfra EnvironmentFile. Then remove the scratch:"
+ printf 'rm -rf "$SCRATCH"\n\n'
+
+ note "Step 6 of the plan, the restricted runtime key, is cut by a person in the Dashboard and is not scripted."
+ section "Step 7. Values for the console environment (none of these is secret)"
+ printf 'echo "CONSOLE_STRIPE_ACCOUNT=%s"\n' "$account"
+ printf '%s\n' "echo 'CONSOLE_STRIPE_TAX_CODES={\"personal\":\"txcd_10102001\",\"community\":\"txcd_10102001\",\"business\":\"txcd_10102000\"}'"
+ printf 'echo "CONSOLE_STRIPE_PORTAL_CONFIGURATION=$CONSOLE_STRIPE_PORTAL_CONFIGURATION"\n'
+ printf 'echo "CONSOLE_STRIPE_HOUSE_COUPON=$CONSOLE_STRIPE_HOUSE_COUPON"\n'
+ printf 'echo "CONSOLE_STRIPE_PRICES=$CONSOLE_STRIPE_PRICES"\n'
+ note "CARLOS_STRIPE_SECRET_KEY (an rk_${mode}_ key) and CARLOS_STRIPE_WEBHOOK_SECRET come from the secret store and are never printed here."
+ note "Next: run this script with --check and read every result before CONSOLE_BILLING_MODE is switched on."
+}
+
+print_check() {
+ header
+ note "Read-only. Set these first (ids are not secret):"
+ note " export CONSOLE_STRIPE_PRICES='{...12 keys...}' PORTAL_CONFIG_ID=bpc_..."
+ note " export HOUSE_COUPON_ID=carloku-house-100-forever PUBLIC_COUPON_ID=carloku-public-50-forever"
+ printf 'LIVE=%s\nEXPECTED_ACCOUNT=%s\nTERMS=%s\n' "$LIVE_JSON" "$account" "$TERMS"
+ printf ': "${CONSOLE_STRIPE_PRICES:?set CONSOLE_STRIPE_PRICES}" "${PORTAL_CONFIG_ID:?set PORTAL_CONFIG_ID}" "${HOUSE_COUPON_ID:?set HOUSE_COUPON_ID}" "${PUBLIC_COUPON_ID:?set PUBLIC_COUPON_ID}"\n\n'
+
+ section "Check 1. Merchant identity (startBillingCheckout: the account must equal CONSOLE_STRIPE_ACCOUNT)"
+ words stripe get /v1/account "${MODE_WORDS[@]}"
+ printf ' \\\n | jq -e --arg a "$EXPECTED_ACCOUNT" %s\n\n' "'.id == \$a and .country == \"IE\" and .default_currency == \"eur\" and .charges_enabled == true'"
+
+ section "Check 2. Counts: exactly 3 active products and 12 active recurring prices"
+ words stripe get /v1/products "${MODE_WORDS[@]}" -d active=true -d limit=100
+ printf ' \\\n | jq -e %s\n' "'(.data | length) == 3 and (.has_more == false)'"
+ words stripe get /v1/prices "${MODE_WORDS[@]}" -d active=true -d limit=100
+ printf ' \\\n | jq -e %s\n\n' "'(.data | length) == 12 and (.has_more == false)'"
+
+ section "Check 3. Every price and its product (the GetPrice and GetProduct fields the console compares)"
+ note "Console rules: active, livemode matches mode, eur, exclusive, year x1, licensed, per_unit, no transform_quantity,"
+ note "no currency_options, unit_amount equals the book, metadata terms_version/plan/band; product active, livemode, tax_code per plan."
+ local tmpl
+ tmpl=$(
+ cat <<'EOF'
+declare -A CENTS=(
+ [personal/A]=2400 [personal/B]=1800 [personal/C]=1200 [personal/D]=840
+ [community/A]=500 [community/B]=375 [community/C]=250 [community/D]=175
+ [business/A]=5000 [business/B]=3750 [business/C]=2500 [business/D]=1750
+)
+declare -A TAX=([personal]=txcd_10102001 [community]=txcd_10102001 [business]=txcd_10102000)
+for key in "${!CENTS[@]}"; do
+ plan="${key%/*}"; band="${key#*/}"
+ id="$(jq -er --arg k "$TERMS/$key" '.[$k]' <<<"$CONSOLE_STRIPE_PRICES")"
+ price="$(stripe get "/v1/prices/$id" @FLAGS@ -d 'expand[]=currency_options')"
+ jq -e --argjson live "$LIVE" --arg plan "$plan" --arg band "$band" --arg terms "$TERMS" --argjson cents "${CENTS[$key]}" --arg id "$id" '
+ .id == $id and .active == true and .livemode == $live and .currency == "eur"
+ and .tax_behavior == "exclusive" and .billing_scheme == "per_unit" and .transform_quantity == null
+ and .unit_amount == $cents and .recurring.interval == "year" and .recurring.interval_count == 1
+ and .recurring.usage_type == "licensed" and ((.currency_options // {}) | length) == 0
+ and .metadata.terms_version == $terms and .metadata.plan == $plan and .metadata.band == $band' \
+ <<<"$price" >/dev/null || { echo "PRICE CHECK FAILED: $key $id" >&2; exit 1; }
+ product="$(jq -r .product <<<"$price")"
+ stripe get "/v1/products/$product" @FLAGS@ \
+ | jq -e --argjson live "$LIVE" --arg tax "${TAX[$plan]}" --arg plan "$plan" --arg terms "$TERMS" '
+ .active == true and .livemode == $live and .tax_code == $tax
+ and .metadata.plan == $plan and .metadata.terms_version == $terms' \
+ >/dev/null || { echo "PRODUCT CHECK FAILED: $key $product" >&2; exit 1; }
+ echo "ok $key $id $product"
+done
+echo "all 12 prices and their products verified"
+EOF
+ )
+ printf '%s\n\n' "${tmpl//@FLAGS@/$MODE_TEXT}"
+
+ section "Check 4. Tax registrations (ActiveTaxRegistrations: IE standard and IE oss_union, active and started)"
+ words stripe get /v1/tax/registrations "${MODE_WORDS[@]}" -d status=active -d limit=100
+ printf ' \\\n | jq -e --argjson now "$(date +%%s)" %s\n\n' "'(.has_more == false) and ([.data[] | select(.country == \"IE\" and .status == \"active\" and .active_from <= \$now) | .country_options.ie.type] | (index(\"standard\") != null) and (index(\"oss_union\") != null))'"
+
+ section "Check 5. Portal configuration (verifyPortalConfiguration)"
+ printf 'stripe get "/v1/billing_portal/configurations/$PORTAL_CONFIG_ID" %s \\\n' "$MODE_TEXT"
+ printf ' | jq -e --arg id "$PORTAL_CONFIG_ID" --argjson live "$LIVE" %s\n\n' "'.id == \$id and .active == true and .livemode == \$live and .features.subscription_update.enabled == false and .features.subscription_cancel.enabled == true and .features.subscription_cancel.mode == \"at_period_end\" and .features.payment_method_update.enabled == true and .features.invoice_history.enabled == true and (if .features.customer_update.enabled then ((.features.customer_update.allowed_updates // []) | all(. == \"email\" or . == \"name\" or . == \"phone\" or . == \"tax_id\")) else true end)'"
+
+ section "Check 6. House coupon (verifyHouseCoupon) and the rule that it has no promotion code"
+ printf 'stripe get "/v1/coupons/$HOUSE_COUPON_ID" %s \\\n' "$MODE_TEXT"
+ printf ' | jq -e --arg id "$HOUSE_COUPON_ID" --argjson live "$LIVE" %s\n' "'.id == \$id and .valid == true and .livemode == \$live and .percent_off == 100 and .amount_off == null and .duration == \"forever\"'"
+ printf 'stripe get /v1/promotion_codes %s -d "coupon=$HOUSE_COUPON_ID" -d limit=10 \\\n' "$MODE_TEXT"
+ printf ' | jq -e %s\n' "'(.data | length) == 0'"
+ note "If the house coupon ever gains applies_to.products, it must include the product of every plan a house account buys."
+ note "The plan creates it with no applies_to, which passes for all three products."
+ printf '\n'
+
+ section "Check 7. Public 50% coupon and its single promotion code"
+ printf 'stripe get "/v1/coupons/$PUBLIC_COUPON_ID" %s \\\n' "$MODE_TEXT"
+ printf ' | jq -e --argjson live "$LIVE" %s\n' "'.valid == true and .livemode == \$live and .percent_off == 50 and .duration == \"forever\" and .amount_off == null'"
+ printf 'stripe get /v1/promotion_codes %s -d "coupon=$PUBLIC_COUPON_ID" -d limit=10 \\\n' "$MODE_TEXT"
+ if [[ -n "$promo_code" ]]; then
+ printf ' | jq -e --arg code %s %s\n\n' "$(quote "$promo_code")" "'(.data | length) == 1 and .data[0].code == \$code and .data[0].active == true and .data[0].max_redemptions == null and .data[0].expires_at == null'"
+ else
+ printf ' | jq -e %s\n' "'(.data | length) == 1 and .data[0].active == true and .data[0].max_redemptions == null and .data[0].expires_at == null'"
+ note "(pass --promo-code WORD to also compare the code text)"
+ printf '\n'
+ fi
+
+ section "Check 8. Webhook endpoint: one endpoint, the exact event list, the pinned API version"
+ words stripe get /v1/webhook_endpoints "${MODE_WORDS[@]}" -d limit=100
+ printf ' \\\n | jq -e --arg url %s --arg v %s --argjson live "$LIVE" %s\n\n' "$(quote "$webhook_url")" "$API_VERSION" "'.has_more == false and (.data | length) == 1 and (.data[0] | .url == \$url and .status == \"enabled\" and .api_version == \$v and .livemode == \$live and ((.enabled_events | sort) == ([\"checkout.session.completed\",\"customer.subscription.created\",\"customer.subscription.deleted\",\"customer.subscription.updated\",\"invoice.paid\",\"invoice.payment_failed\"] | sort)))'"
+
+ section "Check 9. Runtime key probes (run with the candidate key; it must be rk_${mode}_)"
+ note "Put the key in CARLOKU_RK WITHOUT echoing it, for example: read -rs CARLOKU_RK; export CARLOKU_RK"
+ printf ': "${CARLOKU_RK:?set CARLOKU_RK}"\ncase "$CARLOKU_RK" in rk_%s_*) ;; *) echo "not an rk_%s_ key, stop" >&2; exit 1 ;; esac\n' "$mode" "$mode"
+ note "Allowed reads (each must succeed). Output is reduced to booleans so no customer data is printed."
+ local keyflags="--api-key \"\$CARLOKU_RK\" --stripe-version $API_VERSION"
+ printf 'stripe get /v1/account %s | jq -e %s >/dev/null\n' "$keyflags" "'.id | startswith(\"acct_\")'"
+ printf 'stripe get /v1/products %s -d limit=1 | jq -e %s >/dev/null\n' "$keyflags" "'.data | length >= 0'"
+ printf 'stripe get /v1/prices %s -d limit=1 | jq -e %s >/dev/null\n' "$keyflags" "'.data | length >= 0'"
+ printf 'stripe get /v1/tax/registrations %s -d status=active -d limit=1 | jq -e %s >/dev/null\n' "$keyflags" "'.data | length >= 0'"
+ printf 'stripe get "/v1/coupons/$HOUSE_COUPON_ID" %s | jq -e %s >/dev/null\n' "$keyflags" "'.valid == true'"
+ printf 'stripe get "/v1/billing_portal/configurations/$PORTAL_CONFIG_ID" %s | jq -e %s >/dev/null\n' "$keyflags" "'.active == true'"
+ printf 'stripe get /v1/subscriptions %s -d status=all -d limit=1 | jq -e %s >/dev/null\n' "$keyflags" "'.data | length >= 0'"
+ printf 'stripe get /v1/customers %s -d limit=1 | jq -e %s >/dev/null\n' "$keyflags" "'.data | length >= 0'"
+ printf 'stripe get /v1/invoices %s -d limit=1 | jq -e %s >/dev/null\n' "$keyflags" "'.data | length >= 0'"
+ printf 'stripe get /v1/checkout/sessions %s -d limit=1 | jq -e %s >/dev/null\n' "$keyflags" "'.data | length >= 0'"
+ note "Denied reads (each must FAIL with a permission error; a success means the key is too broad and must be re-cut)."
+ note "A denied read must fail for a permission reason. Any other failure is reported so it is not mistaken for a pass."
+ cat <<EOF
+denied() {
+ local out
+ if out="\$(stripe get "\$1" $keyflags 2>&1)"; then echo "TOO BROAD: \$1 is readable" >&2; exit 1; fi
+ case "\$out" in
+ *ermission*|*"not allowed"*|*"does not have the required"*) echo "denied as expected: \$1" ;;
+ *) echo "\$1 failed, but not clearly for permissions: run it by hand and read the error" >&2; exit 1 ;;
+ esac
+}
+EOF
+ local denied
+ for denied in /v1/balance /v1/charges /v1/payouts /v1/refunds /v1/webhook_endpoints /v1/events /v1/promotion_codes /v1/payment_intents; do
+ printf 'denied %s\n' "$denied"
+ done
+ printf 'unset CARLOKU_RK\n'
+}
+
+print_rollback() {
+ header
+ note "Nothing is deleted. Prices cannot be deleted in Stripe, only archived. Existing subscriptions keep renewing on an"
+ note "archived price, but the console refuses new checkouts on it, which is the point."
+ note "First, in the console environment: set CONSOLE_BILLING_MODE=off and restart the console. That alone stops all new checkouts."
+ note "Set these from the provisioning record: WEBHOOK_ENDPOINT_ID, PORTAL_CONFIG_ID, PROMO_CODE_ID, CONSOLE_STRIPE_PRICES."
+ printf ': "${WEBHOOK_ENDPOINT_ID:?}" "${PORTAL_CONFIG_ID:?}" "${PROMO_CODE_ID:?}" "${CONSOLE_STRIPE_PRICES:?}"\n\n'
+
+ section "Rollback 1. Disable the webhook endpoint (reversible: set disabled=false)"
+ printf 'stripe post "/v1/webhook_endpoints/$WEBHOOK_ENDPOINT_ID" %s \\\n --idempotency "carloku-rollback-webhook-$WEBHOOK_ENDPOINT_ID-disable" \\\n -d disabled=true\n\n' "$MODE_TEXT"
+
+ section "Rollback 2. Deactivate the public promotion code (promotion codes cannot be deleted)"
+ printf 'stripe post "/v1/promotion_codes/$PROMO_CODE_ID" %s \\\n --idempotency "carloku-rollback-promo-$PROMO_CODE_ID-deactivate" \\\n -d active=false\n\n' "$MODE_TEXT"
+
+ section "Rollback 3. Deactivate the portal configuration"
+ printf 'stripe post "/v1/billing_portal/configurations/$PORTAL_CONFIG_ID" %s \\\n --idempotency "carloku-rollback-portal-$PORTAL_CONFIG_ID-deactivate" \\\n -d active=false\n\n' "$MODE_TEXT"
+
+ section "Rollback 4. Archive the twelve prices, then the three products"
+ printf 'for id in $(jq -r %s <<<"$CONSOLE_STRIPE_PRICES"); do\n' "'.[]'"
+ printf ' stripe post "/v1/prices/$id" %s \\\n --idempotency "carloku-rollback-price-$id-archive" \\\n -d active=false >/dev/null && echo "archived $id"\ndone\n' "$MODE_TEXT"
+ printf 'for plan in personal community business; do\n'
+ printf ' pid="$(stripe get /v1/products %s -d active=true -d limit=100 \\\n | jq -r --arg p "$plan" %s)"\n' "$MODE_TEXT" "'.data[] | select(.metadata.plan == \$p) | .id'"
+ printf ' stripe post "/v1/products/$pid" %s \\\n --idempotency "carloku-rollback-product-$pid-archive" \\\n -d active=false >/dev/null && echo "archived $pid"\ndone\n\n' "$MODE_TEXT"
+ note "The coupons stay: a coupon attaches to nothing on its own, and the house coupon has no promotion code."
+ note "Rotating the webhook secret or the runtime key is a separate action: see stripe-provisioning.md."
+}
+
+case "$action" in
+ provision) print_provision ;;
+ check) print_check ;;
+ rollback) print_rollback ;;
+esac
diff --git a/implementation/carloku-paid-launch/stripe-provisioning.md b/implementation/carloku-paid-launch/stripe-provisioning.md
new file mode 100644
index 000000000..e8328b32f
--- /dev/null
+++ b/implementation/carloku-paid-launch/stripe-provisioning.md
@@ -0,0 +1,528 @@
+# Carloku Stripe live provisioning plan
+
+Status: prepared, not executed. Nothing in this document has been run. No
+Stripe API call, CLI call or login was made while writing it. Every fact about
+the live account comes from `stripe-live-readiness.json` and the "Stripe live
+account state" section of `AGENT_HANDOFF.md`, both recorded on 2026-09-21. The
+code facts come from commit `9acaf6f6`.
+
+This is the ordered plan for a person who holds scoped approval. The companion
+`stripe-provision.sh` prints the exact `stripe` CLI commands for each step and
+executes nothing. Run it with `--help` for its arguments.
+
+The target is Stripe account `acct_1UD7LXI4Nrr6AEsO`: Team Tito Limited,
+business profile Carloku, Ireland, EUR, charges and payouts enabled.
+
+## Where this stands
+
+Already true on the live account:
+
+- Stripe Tax is active with Irish `standard` (`taxreg_1UI9NEI4Nrr6AEsOPpkwK80n`)
+ and Irish `oss_union` (`taxreg_1UI9NQI4Nrr6AEsOrBwcY1rE`) registrations. These
+ are the only live mutations so far.
+- There are no products, recurring prices, portal configurations or webhook
+ endpoints.
+- The account default tax code is the generic `txcd_10000000`. Carloku must not
+ use it. Paul approved Platform as a Service on 2026-09-21: Personal and
+ Community use `txcd_10102001`, Business uses `txcd_10102000`.
+
+Still pending and not decided here:
+
+- Customer-facing product names (three), the name shown for the 50% coupon, the
+ name of the house coupon, the public promotion code word, and any portal
+ headline or legal URLs. All of these are copy and go through the copy-review
+ gate. The script takes each one as an argument so nothing unreviewed is baked
+ in. The code word `HALF` used in examples is a placeholder.
+- The restricted runtime key, which does not exist yet.
+- The webhook endpoint and its signing secret.
+
+## What the console checks, and therefore what must exist
+
+The console fails closed. These are the checks, from
+`internal/console/billingservice.go` and `cmd/console/billing.go`:
+
+| Check | Where | What must be true |
+| --- | --- | --- |
+| Key shape | `billingConfig` | `CARLOS_STRIPE_SECRET_KEY` starts with `rk_live_` in live mode (`rk_test_` in test mode). Anything else stops the console at startup. |
+| Merchant | `startBillingCheckout` | `GET /v1/account` returns the id in `CONSOLE_STRIPE_ACCOUNT`. |
+| Price | `startBillingCheckout` | The price from `CONSOLE_STRIPE_PRICES` is active, live-mode matches, EUR, `tax_behavior` exclusive, `billing_scheme` per_unit, no `transform_quantity`, no `currency_options`, yearly with interval count 1, `usage_type` licensed, metadata `terms_version`, `plan` and `band` match, and `unit_amount` equals the plan book price per unit in cents. |
+| Product | `startBillingCheckout` | The price's product is active, live-mode matches, and its `tax_code` equals the code configured for the plan. |
+| Tax registrations | `startBillingCheckout` | `GET /v1/tax/registrations?status=active` holds an active, already started `IE` registration with `country_options.ie.type` of `standard` and another of `oss_union`. |
+| Portal | `verifyPortalConfiguration` | The configuration is active, live-mode matches, `subscription_update` is off, `subscription_cancel` is on with mode `at_period_end`, `payment_method_update` is on, `invoice_history` is on, and if `customer_update` is on every allowed field is one of `email`, `name`, `phone`, `tax_id`. |
+| House coupon | `verifyHouseCoupon` | Only for house accounts. The coupon is valid, live-mode matches, `percent_off` is exactly 100, `amount_off` is unset, `duration` is `forever`, and if it has `applies_to.products` it includes the product being bought. |
+| Webhook | `handleBillingWebhook` | The signature verifies against one of the configured secrets and `event.livemode` matches the console mode. |
+
+Two startup facts matter for the order of work. First, with
+`CONSOLE_BILLING_MODE` set to anything but `off`, `billingConfig` needs every
+variable below to parse, and a bad value is `log.Fatal`, so the console will
+crash-loop rather than run with billing half configured. Second, the webhook
+route answers 404 until billing is on.
+
+## Order of work
+
+1. Console is deployed with billing mode off and no Stripe variables. The
+ webhook URL exists and answers 404.
+2. Test-mode rehearsal in an isolated Stripe environment (see the rehearsal
+ section). Its findings can change the key permissions and the portal
+ configuration below, so it comes before anything live.
+3. Approval for the live scope (text at the end).
+4. Live provisioning, in this order: products, prices, coupons and promotion
+ code, portal configuration, webhook endpoint, restricted runtime key.
+5. Read-only verification, all checks green.
+6. Environment delivered to the console unit, then billing mode flipped.
+7. First live proof, separately approved (see release order).
+
+The provisioning steps are numbered below. The script prints them in the same order and its --check mode prints the verification.
+
+## Step 1. Products
+
+Three products, one per plan. Never several tiers on one product: invoice lines
+and Checkout render the product name.
+
+| Plan | Name | Tax code | Metadata | Idempotency key |
+| --- | --- | --- | --- | --- |
+| personal | pending approved copy | `txcd_10102001` | `plan=personal`, `terms_version=2026-09-07-v1` | `carloku-product-2026-09-07-v1-personal` |
+| community | pending approved copy | `txcd_10102001` | `plan=community`, `terms_version=2026-09-07-v1` | `carloku-product-2026-09-07-v1-community` |
+| business | pending approved copy | `txcd_10102000` | `plan=business`, `terms_version=2026-09-07-v1` | `carloku-product-2026-09-07-v1-business` |
+
+The console verifies only the product's `active`, `livemode` and `tax_code`.
+The product metadata is for people reading the Dashboard and for the
+verification script. The script passes `--personal-name`, `--community-name`
+and `--business-name` through unchanged.
+
+Endpoint: `POST /v1/products`. The result ids (`prod_...`) are needed for the
+prices and are not secret.
+
+## Step 2. Prices
+
+Twelve prices, exactly the `required_catalog` in `stripe-live-readiness.json`.
+Every price has these fixed fields: `currency=eur`, `tax_behavior=exclusive`,
+`billing_scheme=per_unit`, `recurring[interval]=year`,
+`recurring[interval_count]=1`, `recurring[usage_type]=licensed`. No
+`currency_options` on any price (it would switch off Adaptive Pricing, and the
+console refuses a price that has any). Never pass `payment_method_types`
+anywhere.
+
+The unit amount is per seat. Personal has quantity 1. Community and Business
+use the member count as the subscription quantity, with a floor of 10.
+
+| Plan | Band A | Band B | Band C | Band D |
+| --- | --- | --- | --- | --- |
+| personal | 2400 | 1800 | 1200 | 840 |
+| community | 500 | 375 | 250 | 175 |
+| business | 5000 | 3750 | 2500 | 1750 |
+
+These agree with `internal/plans/plans.json`: annual base (24, 5 and 50 euro)
+times the band factors 1.00, 0.75, 0.50 and 0.35.
+
+Each price carries `metadata[terms_version]=2026-09-07-v1`, `metadata[plan]` and
+`metadata[band]`; the console compares all three.
+
+Conventions:
+
+- Idempotency key: `carloku-price-2026-09-07-v1-<plan>-<band>`, for example
+ `carloku-price-2026-09-07-v1-personal-A`.
+- `lookup_key`: `carloku-2026-09-07-v1-<plan>-<band>`. Stripe keeps a lookup key
+ unique across the account, so it also stops a duplicate price being created
+ after the 24-hour idempotency window has passed. The console never reads
+ lookup keys. The script uses them to assemble `CONSOLE_STRIPE_PRICES`.
+ Never pass `transfer_lookup_key`. A corrected price is made under a new terms
+ version and so a new key.
+- Prices are immutable. A mistake is fixed by archiving the price
+ (`active=false`) and creating a new one, and the new price id goes into
+ `CONSOLE_STRIPE_PRICES`.
+
+`CONSOLE_STRIPE_PRICES` is a JSON object with exactly 12 keys of the form
+`2026-09-07-v1/<plan>/<band>` whose values are distinct `price_...` ids. The
+script prints the `GET /v1/prices` query and the `jq` that builds it.
+
+## Step 3. Coupons and the promotion code
+
+Two coupons and one promotion code (spec build step 9).
+
+| Object | Parameters | Idempotency key |
+| --- | --- | --- |
+| Public coupon | `id=carloku-public-50-forever`, `percent_off=50`, `duration=forever`, `name` pending copy, no `applies_to`, no `max_redemptions` | `carloku-coupon-public-50-forever-v1` |
+| Public promotion code | `coupon=carloku-public-50-forever`, `code` pending copy (placeholder `HALF`), `active=true`, no `max_redemptions`, no `expires_at`, no restrictions | `carloku-promo-<code in lower case>-v1` |
+| House coupon | `id=carloku-house-100-forever`, `percent_off=100`, `duration=forever`, `name` pending copy, no `applies_to` | `carloku-coupon-house-100-forever-v1` |
+
+The house coupon is a coupon only. There is no promotion code for it, ever: a
+100% forever code that leaked would be a very bad day. The verification step
+asserts that `GET /v1/promotion_codes?coupon=carloku-house-100-forever` is
+empty.
+
+Fixed ids give a second guard against duplicates, since Stripe refuses a coupon
+id that already exists. `CONSOLE_STRIPE_HOUSE_COUPON=carloku-house-100-forever`.
+The console sends the public path `allow_promotion_codes=true` and the house
+path `discounts[0][coupon]`, never both, because Stripe refuses a session with
+both.
+
+The coupon `name` is shown to customers on Checkout and invoices, so it is copy
+and waits for review. The house coupon name is shown to house account holders
+only but is still taken as an argument.
+
+## Step 4. Customer Portal configuration
+
+One locked configuration, created through the API so its id can be pinned. The
+console passes the configuration id on every portal session, so the account
+default configuration is never used.
+
+| Feature | Setting | Reason |
+| --- | --- | --- |
+| `subscription_update` | `enabled=false` | Member changes go through the console, never the portal. The console refuses the configuration if this is on. |
+| `subscription_cancel` | `enabled=true`, `mode=at_period_end`, `proration_behavior=none` | Cancellation keeps the paid term. The console requires `enabled` and `at_period_end`. |
+| `payment_method_update` | `enabled=true` | Card updates. Required. |
+| `invoice_history` | `enabled=true` | Invoice access. Required. |
+| `customer_update` | `enabled=true`, `allowed_updates` of `email`, `name`, `tax_id` | Restricted. `address` and `shipping` are never allowed because they steer the tax location the console set up and cleared. `phone` would also pass the console check but is not needed. |
+
+Idempotency key `carloku-portal-config-2026-09-07-v1`. Endpoint:
+`POST /v1/billing_portal/configurations`. Result `bpc_...` becomes
+`CONSOLE_STRIPE_PORTAL_CONFIGURATION` (the console requires the `bpc_` prefix).
+
+Optional `business_profile` fields (headline, privacy policy URL, terms of
+service URL) are copy or legal text and are taken as script arguments if
+Stripe requires them. If Stripe refuses the create for missing public business
+details, stop and ask, do not invent text.
+
+One uncertainty to settle in the rehearsal: whether Stripe accepts `name` as an
+`allowed_updates` value at API version `2025-02-24.acacia`. The console accepts
+it, but the comment in `internal/billingstripe/portal.go` lists the enum without
+`name`. If Stripe rejects it, drop it. The console accepts any subset.
+
+## Step 5. Webhook endpoint
+
+- URL: `https://console.carloku.com/billing/webhook` (route registered in
+ `internal/console/server.go`).
+- Enabled events, exactly these six, from `handleBillingWebhook`:
+ `checkout.session.completed`, `customer.subscription.created`,
+ `customer.subscription.updated`, `customer.subscription.deleted`,
+ `invoice.paid`, `invoice.payment_failed`. Every other event type is
+ acknowledged with 204 and ignored, so subscribing to more only adds traffic.
+- API version: pin the endpoint to `2025-02-24.acacia` with the `api_version`
+ parameter, the same version `internal/billingstripe/client.go` pins on every
+ request it sends. This matters. The handler reads `subscription` straight off
+ the invoice object for `invoice.paid` and `invoice.payment_failed`, and later
+ API versions move that field, so an endpoint left on the account default would
+ deliver events the handler cannot attribute. Read the endpoint back after
+ creation and confirm the version. If Stripe refuses to create an endpoint at
+ that version, stop: do not fall back to the default.
+- Idempotency key: `carloku-webhook-2026-09-07-v1`.
+- The signing secret (`whsec_...`) is returned once, in the create response. The
+ script writes that response to a mode 0600 file in a fresh 0700 directory
+ under `$TMPDIR` (on disk, never directly in `/tmp`), checks the id, and deletes
+ the directory. The secret is piped from the file into the secret store on
+ stdin. It is never printed, never put in a command line, never committed and
+ never put in the deployment bucket.
+- The console verifies with `VerifyEvent`, which accepts any of the configured
+ secrets against any `v1` signature in the header, with a 5 minute timestamp
+ tolerance.
+
+Rotation. Roll the secret in the Dashboard with an expiry window for the old
+secret (confirm the option in the Dashboard at the time), because during the
+window Stripe signs with both and the console accepts either. Then:
+
+1. Set `CARLOS_STRIPE_WEBHOOK_SECRET` to the new secret and
+ `CARLOS_STRIPE_PREVIOUS_WEBHOOK_SECRETS` to the old one (comma separated for
+ more), and restart the console.
+2. When the window has closed, empty `CARLOS_STRIPE_PREVIOUS_WEBHOOK_SECRETS`
+ and restart again.
+
+The console accepts a primary plus up to two previous secrets (three in total),
+every one must start with `whsec_`, and duplicates are refused. A bad value
+stops the console at startup, so check the file before restarting.
+
+Until the first checkout exists no events flow, so creating the endpoint before
+the console flips to live costs nothing. Stripe retries deliveries that got a 404
+for a few days, but none are generated.
+
+## Step 6. The restricted runtime key
+
+The runtime key is a Stripe restricted key, created by a person in the Dashboard
+(Developers, API keys, Create restricted key) as `carloku-console-runtime`. It
+is shown once.
+
+What it is not:
+
+- The Stripe CLI device-auth login credential is not a runtime key. It is
+ broader than the console needs and it expires. It is for the operator
+ running this plan, nothing else.
+- The 1Password item `Carloku/Stripe Production Keys/Live Key` is a full
+ `sk_live` provisioning key. It is not a runtime key and must never be placed
+ in console configuration.
+- The console refuses anything not prefixed `rk_live_` (`cmd/console/billing.go`)
+ and the client refuses anything not prefixed `rk_` (`billingstripe.NewClient`).
+ That prefix check is necessary and not sufficient. The CLI's own live key may
+ also look like `rk_live_...`, so the prefix cannot tell the two apart. The
+ denied-read probes in check 9 of the verification can.
+
+Permissions, derived from every `/v1/` path that `internal/billingstripe` calls
+and that the console reaches. Names follow the Dashboard's resource labels. If a
+label differs, choose the one covering the path listed.
+
+| Permission | Level | API paths | Console callers |
+| --- | --- | --- | --- |
+| Checkout Sessions | Write | `POST /v1/checkout/sessions`, `POST /v1/checkout/sessions/{id}/expire`, `GET /v1/checkout/sessions/{id}` (subscription expanded), `GET /v1/checkout/sessions` (per customer, windowed) | `CreateCheckout`, `ExpireCheckout`, `GetCheckout`, `ListCheckoutsForCustomer` |
+| Customers | Write | `POST /v1/customers`, `POST /v1/customers/{id}`, `GET /v1/customers/{id}` (tax expanded) | `CreateCustomer`, `UpdateCustomerLocation`, `GetCustomer` |
+| Subscriptions | Read | `GET /v1/subscriptions/{id}`, `GET /v1/subscriptions` | `GetSubscription`, `ListSubscriptions` |
+| Prices | Read | `GET /v1/prices/{id}` (`currency_options` expanded) | `GetPrice` |
+| Products | Read | `GET /v1/products/{id}` | `GetProduct` |
+| Tax registrations | Read | `GET /v1/tax/registrations` | `ActiveTaxRegistrations` |
+| Customer portal | Write | `POST /v1/billing_portal/sessions`, `GET /v1/billing_portal/configurations/{id}` | `CreatePortal`, `GetPortalConfiguration` |
+| Coupons | Read | `GET /v1/coupons/{id}` | `GetCoupon` |
+| Invoices | Write | `POST /v1/invoices`, `POST /v1/invoices/{id}/finalize`, `POST /v1/invoices/{id}/pay`, `GET /v1/invoices/{id}`, `GET /v1/invoices/{id}/lines`, `GET /v1/invoices` | `CreateDraftInvoice`, `FinalizeInvoice`, `PayInvoice`, `GetInvoice`, `ListInvoiceLines`, `ListInvoicesForCustomer` |
+| Invoice items | Write | `POST /v1/invoiceitems` | `CreateInvoiceItem` |
+| Account | Read | `GET /v1/account` | `MerchantAccount` |
+
+Write includes read in Stripe's model. The Account row may need no explicit
+permission: the rehearsal shows whether a restricted key can read its own
+account. If it gets a permission error, add whichever Dashboard permission
+covers `GET /v1/account`.
+
+Deliberately not granted:
+
+- Subscription items write. `UpdateQuantity`
+ (`POST /v1/subscription_items/{id}`) exists in `internal/billingstripe` but
+ nothing in the console calls it at `9acaf6f6`. Add the permission in the same
+ change that wires the call, not before.
+- Events. Nothing calls `/v1/events`. The webhook handler authenticates by
+ signature and reconciles by reading the subscription.
+- Promotion codes. Checkout resolves `allow_promotion_codes` on Stripe's side.
+- Webhook endpoints, API keys, balance, charges, payouts, refunds, payment
+ intents, customer deletion, Connect and everything else.
+
+Two things the rehearsal must prove, because they are not visible from the
+paths alone: that `POST /v1/invoices/{id}/pay` and `automatic_tax` on invoices
+and Checkout work with exactly this permission set and no more. The live key is
+cut to the set the rehearsal proved, nothing wider.
+
+If the Dashboard offers a way to restrict the key to the console box egress
+addresses, use it. Do not rely on that being available.
+
+Handling: the key goes from the Dashboard straight to the secret store. Never
+into a chat, a commit, a ticket or a shell history. Rotation is create new, deliver,
+restart, confirm, then delete the old key in the Dashboard.
+
+## Step 7. Console environment
+
+Delivered through the pinfra EnvironmentFile for the console unit, exactly as
+`CARLOS_RELEASE_KEY` is (spec build step 18). Never in the deployment bucket,
+never in `apps/*/config.json`, never in a repository. This repository does not
+hold the EnvironmentFile path or content: that lives in pinfra and is changed
+through the normal roll workflow.
+
+| Variable | Value | Secret |
+| --- | --- | --- |
+| `CONSOLE_BILLING_MODE` | `live` (`off` until the flip, `test` for the rehearsal) | no |
+| `CARLOS_STRIPE_SECRET_KEY` | the `rk_live_` key from step 6 | yes |
+| `CONSOLE_STRIPE_ACCOUNT` | `acct_1UD7LXI4Nrr6AEsO` | no |
+| `CONSOLE_STRIPE_PORTAL_CONFIGURATION` | the `bpc_...` id from step 4 | no |
+| `CONSOLE_STRIPE_HOUSE_COUPON` | `carloku-house-100-forever` | no |
+| `CONSOLE_STRIPE_TAX_CODES` | `{"personal":"txcd_10102001","community":"txcd_10102001","business":"txcd_10102000"}` | no |
+| `CARLOS_STRIPE_WEBHOOK_SECRET` | the `whsec_` secret from step 5 | yes |
+| `CARLOS_STRIPE_PREVIOUS_WEBHOOK_SECRETS` | empty, or up to two earlier `whsec_` values, comma separated | yes |
+| `CONSOLE_STRIPE_PRICES` | JSON with the 12 keys `2026-09-07-v1/<plan>/<band>` | no |
+
+Notes from `billingConfig`: `CONSOLE_STRIPE_TAX_CODES` must have exactly three
+entries (`personal`, `community`, `business`) and must not be combined with the
+older single `CONSOLE_STRIPE_TAX_CODE`, which is refused alongside it. The
+prices JSON is capped at 16 KiB, the tax JSON at 1 KiB. `CONSOLE_STRIPE_HOUSE_COUPON`
+is only consulted for house accounts, but set it, since a house checkout without it
+is refused.
+
+## Verification after provisioning
+
+Run `stripe-provision.sh --check --mode live`. Every command is a read-only GET.
+Each check asserts the same conditions the console asserts, so a green run means
+`startBillingCheckout` will not refuse on catalog grounds. Read each result, do not
+skim.
+
+1. Account id, country `IE`, currency `eur`, charges enabled.
+2. Exactly 3 active products and 12 active prices, no second page.
+3. For each of the 12 prices: every field in the Price row of the checks table near the top, and
+ for its product: active, livemode, the plan's tax code and the plan metadata.
+4. The two tax registrations, `standard` and `oss_union`, active and started. This
+ reads `country_options.ie.type`. The readiness file records `type` at the
+ top level of each registration, which is a summary, so this is the first time
+ the real path is exercised against the live account.
+5. Portal configuration fields, as in the portal table.
+6. House coupon fields, and no promotion code attached to it.
+7. Public coupon at 50 forever, and exactly one promotion code with no redemption
+ cap and no expiry.
+8. Exactly one webhook endpoint, at the right URL, enabled, pinned to
+ `2025-02-24.acacia`, with exactly the six events.
+9. Runtime key probes using the real `rk_live_` key: each allowed read succeeds,
+ each denied read (balance, charges, payouts, refunds, webhook endpoints, events,
+ promotion codes, payment intents) fails with a permission error. A success on any
+ denied read means the key is too broad: cut a new one.
+
+The script's `jq` filters were exercised against canned responses with a stub in
+place of the CLI, including deliberately broken prices and products, to confirm they
+fail when they should. The stub was never a Stripe call.
+
+What a read cannot prove: that Checkout actually collects tax (only a real purchase
+shows a Collecting registration at work) and that the webhook signing secret in the
+console matches the endpoint. Both are covered by the first live proof.
+
+## Test-mode rehearsal
+
+The rehearsal runs the same script with `--mode test` (no `--live`; the Stripe CLI has
+no `--test` flag, so test mode is the absence of it) against an isolated
+environment. Prefer a Stripe sandbox, which has its own keys, registrations and data.
+The test side of the live account also works, but it is the same account, so it needs
+the same scoped approval.
+
+It needs:
+
+- Test-mode Stripe Tax settings and the same two Irish registrations (`standard`,
+ `oss_union`) in that environment, because the console insists on both.
+- The script's output for `--mode test`, with throwaway product names.
+- A restricted `rk_test_` key cut with exactly the permission table above.
+- A console with `CONSOLE_BILLING_MODE=test` and the matching variables, reachable
+ from Stripe for webhooks (a test endpoint at a dev URL via `--webhook-url`, or
+ `stripe listen` with its forwarding secret as `CARLOS_STRIPE_WEBHOOK_SECRET`).
+- Stripe test clocks for renewal and failure timing.
+
+What it proves:
+
+- The permission set is sufficient, and does not need more, including
+ `GET /v1/account`, invoice `pay` and `automatic_tax`.
+- Stripe accepts every parameter at API version `2025-02-24.acacia`, in particular
+ `adaptive_pricing[enabled]`, `discounts[0][coupon]`, `tax[validate_location]` and the
+ portal `allowed_updates` value `name`.
+- The price, product, tax registration, portal and coupon verification all pass.
+- Checkout to `checkout.session.completed` to entitlement, end to end, including the
+ public 50% code, the house account at 100% (no PaymentIntent, entitlement from
+ webhooks alone), portal cancellation at period end, a renewal and a failed renewal
+ on a test clock, and the overage invoice draft, finalise and pay path.
+- Signature verification with the pinned version, and secret rotation with two
+ secrets.
+
+What it does not prove: that the live registrations collect tax, that the live key
+has the same permissions as the test key (test and live keys are separate), live
+card behaviour, Adaptive Pricing availability on the live account, or live webhook
+delivery to the real console. Those wait for the first live proof.
+
+## Release order
+
+1. Deploy the console build with `CONSOLE_BILLING_MODE` off. The Stripe variables
+ are not set. Confirm `/billing/webhook` answers 404.
+2. Rehearse in test mode. Fix anything it finds in the plan first.
+3. Get the live scoped approval.
+4. Provision live (steps 1 to 6), reading each result before the next block.
+5. Run `--check --mode live`. Stop on any failure.
+6. Put the nine variables in the pinfra EnvironmentFile, validate the file shape,
+ restart the console, flip `CONSOLE_BILLING_MODE=live`. Do this on one box first if
+ the console runs on several. A bad value crash-loops the unit, so keep a way to set
+ `CONSOLE_BILLING_MODE=off` ready (a crash-looping console needs `reset-failed` over
+ SSM, as on 2026-09-11).
+7. First live proof, only with its own approval because it moves money: a house
+ account checkout (100% off, no charge) to prove checkout, webhook and entitlement,
+ then one real smallest-band Personal purchase by Paul, refunded from the
+ Dashboard (the runtime key cannot refund). Confirm a Collecting tax line on the
+ invoice.
+8. Only then announce prices or accept customers.
+
+Other launch gates in the handoff (copy approval, plan-specific tax mapping deployed,
+independent reviews, CI, release gates, live entitlement authority preflight) are
+unchanged by this plan.
+
+## Rollback
+
+- First move: set `CONSOLE_BILLING_MODE=off` and restart the console. That stops every
+ new checkout and portal session immediately, and needs no Stripe change.
+- Disable the webhook endpoint (`disabled=true`). Reversible.
+- Deactivate the promotion code and the portal configuration.
+- Archive prices, then products (`active=false`). Prices cannot be deleted. Archiving
+ means the console refuses new checkouts on them. Existing subscriptions keep
+ renewing on an archived price, so do not treat archiving as cancelling customers.
+- Coupons stay. A coupon attaches to nothing by itself, and the house coupon has no
+ promotion code.
+- Nothing is deleted. `stripe-provision.sh --rollback --mode live` prints these
+ commands.
+- If a subscription was created in error, cancel it in the Dashboard. That is a
+ customer-affecting action and is outside this plan.
+
+## Scoped approval request
+
+Text for Paul, per AGENTS.md "Production infrastructure needs scoped approval".
+
+> I am asking for approval to create the Carloku billing catalog in the live Stripe
+> account.
+>
+> Account: `acct_1UD7LXI4Nrr6AEsO`, Team Tito Limited, business profile Carloku,
+> Ireland, EUR. Live mode. Not an AWS account: no AWS, DNS, SES, IAM or bucket change.
+>
+> Domain: the webhook endpoint will point at `https://console.carloku.com/billing/webhook`,
+> a real company domain. The endpoint receives nothing until a checkout exists, and the
+> console answers 404 there while billing is off.
+>
+> Exact resource changes in Stripe, all created through the API with fixed idempotency
+> keys and read back:
+>
+> - 3 products with tax codes `txcd_10102001` (Personal, Community) and `txcd_10102000`
+> (Business). Names pending copy approval.
+> - 12 yearly EUR prices at the amounts in step 2, tax exclusive, no currency options.
+> - 2 coupons (50% forever, 100% forever) and 1 public promotion code for the 50% coupon.
+> The 100% coupon has no promotion code.
+> - 1 locked Customer Portal configuration.
+> - 1 webhook endpoint with six events, pinned to API version `2025-02-24.acacia`.
+> - 1 restricted runtime key, `rk_live_`, with the permissions listed in step 6 and no
+> others, created in the Dashboard by you or by me with you watching.
+>
+> Permissions and traffic: the provisioning uses the Stripe CLI login under the carloku
+> project, for the length of the session. The runtime key can create checkout sessions,
+> customers, portal sessions and invoices and read the catalog, subscriptions and tax
+> registrations. It cannot refund, pay out, read balance or charges, or manage webhooks.
+> Once the console is switched to live, it sends and receives billing traffic to Stripe and
+> the webhook. No customer is charged by this work.
+>
+> Cost: creating these objects costs nothing. Stripe Tax and Billing fees apply per
+> transaction once real customers pay, which is outside this approval.
+>
+> Duration: one operator session of about an hour. The objects persist until archived.
+>
+> Verification: the read-only checks in `stripe-provisioning.md`, all of which must pass
+> before billing mode is flipped, plus the runtime key probes.
+>
+> Cleanup and rollback: set billing mode off, disable the endpoint, deactivate the
+> promotion code and portal configuration, archive prices and products. Nothing is
+> deleted. Scratch files (the webhook response that carries the signing secret) live
+> in a mode 0700 directory under `$TMPDIR` and are removed in the same step.
+>
+> Not covered by this approval and asked for separately: delivering the secrets to the
+> console unit and flipping billing to live (the normal roll), and the first real
+> purchase and refund.
+
+## Where the code and the spec disagree
+
+Found while deriving this plan. None blocks it, each should be decided.
+
+1. Spec build step 18 says the keys are read in `cmd/console/main.go` `serve()`. They are
+ read by `billingConfig` in `cmd/console/billing.go`, which `serve()` calls. The spec
+ names two variables; the console needs nine, and any invalid one is fatal at startup.
+2. The spec says member changes update the subscription quantity, with additions
+ prorated. `billingstripe.UpdateQuantity` exists, but no non-test code in `internal`
+ or `cmd` calls it at `9acaf6f6`. So the plan leaves the Subscriptions write and
+ Subscription items permissions out, and the spec's behaviour is not yet wired.
+3. The spec says `automatic_tax` is enabled once registrations exist. The code always
+ sends it and, before creating any session, refuses unless both Irish registrations are
+ active and already started. The effect is the same and the code is stricter.
+4. The spec does not mention the webhook API version. The handler depends on the
+ pre-`basil` invoice shape, so the endpoint version pin is load-bearing, not cosmetic.
+5. The console accepts portal `allowed_updates` of `name` but the comment in
+ `portal.go` lists the Stripe enum without it. Settle it in the rehearsal.
+6. The console checks only price metadata. The product metadata the plan sets
+ (`plan`, `terms_version`) is informational.
+7. `stripe-live-readiness.json` records tax registration `type` at the top level. The
+ console reads `country_options.ie.type`. The record is a summary, not a copy of the API
+ shape, but the real path has not been exercised against the live account yet.
+8. The console prefix check (`rk_live_`) does not prevent use of the CLI's own live key.
+
+## Decisions for Paul
+
+- The copy: three product names, coupon names, the promotion code word, portal headline
+ and legal URLs.
+- Whether `customer_update` should allow `name` and `tax_id`, or be off entirely.
+- Whether `phone` should be allowed in the portal.
+- Whether to wire `UpdateQuantity` before launch (finding 2).
+- Whether the first live proof includes a real purchase and refund.