Skip to main content

max / makenotwork

12.6 KB · 362 lines History Blame Raw
1 #!/usr/bin/env bash
2 #
3 # Idempotently create the 16 Stripe Prices that back creator-tier subscriptions
4 # (4 tiers x {standard, founder} x {monthly, annual}). Re-running is safe: each
5 # price is keyed by `lookup_key`, so an existing price with the right key and
6 # the right amount is left alone.
7 #
8 # What it does on each price:
9 # - If no price exists with the lookup_key -> create it.
10 # - If a price exists with that key and the right amount -> reuse it.
11 # - If a price exists with that key but a different amount -> archive the old
12 # one (active=false) and create a new price with `transfer_lookup_key=true`,
13 # which moves the key onto the new price atomically.
14 #
15 # Existing subscriptions are not affected: Stripe Subscriptions are pinned to
16 # the specific Price ID at signup, regardless of whether that Price is later
17 # archived or its lookup_key is transferred. Only new checkouts pick up the new
18 # price.
19 #
20 # Old manually-created prices that don't have a lookup_key are NOT touched.
21 # Run with --archive-old-on-tier-products to also archive every non-matching
22 # active price on the four tier products (use after verifying the new set
23 # works end-to-end).
24 #
25 # Usage:
26 # STRIPE_SECRET_KEY=sk_live_... ./deploy/stripe_seed_creator_tier_prices.sh
27 # STRIPE_SECRET_KEY=sk_test_... ./deploy/stripe_seed_creator_tier_prices.sh --dry-run
28 #
29 # Reads the tier-price source of truth from
30 # `docs/business/assumptions.toml` so docs, Rust enum, and Stripe stay in
31 # lockstep. A pricing change is a one-line edit to that toml + a re-run of
32 # this script + a server restart (to load the new TierPrices global).
33 # The CreatorTier accessors read from the toml at boot; there is no longer
34 # a Rust `match` arm to update.
35 #
36 # Output: prints CREATOR_TIER_*_PRICE_ID env-var assignments suitable for
37 # pasting into the server's environment file.
38
39 set -euo pipefail
40
41 DRY_RUN=0
42 ARCHIVE_OLD=0
43 for arg in "$@"; do
44 case "$arg" in
45 --dry-run) DRY_RUN=1 ;;
46 --archive-old-on-tier-products) ARCHIVE_OLD=1 ;;
47 *) echo "unknown arg: $arg" >&2; exit 2 ;;
48 esac
49 done
50
51 : "${STRIPE_SECRET_KEY:?STRIPE_SECRET_KEY must be set}"
52
53 API="https://api.stripe.com/v1"
54 AUTH=(-u "${STRIPE_SECRET_KEY}:")
55
56 # Monthly prices in whole dollars, loaded from assumptions.toml. Founder =
57 # exactly 50% of standard. Annual = monthly * 12 * 0.9, rounded to the nearest
58 # whole dollar (matches the displayed prices rendered by docengine).
59 #
60 # ASSUMPTIONS_PATH lets an ops caller point at a non-default toml (dry-runs
61 # against a proposed change, or a private-repo checkout). Defaults to the
62 # canonical location relative to this script.
63 SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
64 ASSUMPTIONS_PATH="${ASSUMPTIONS_PATH:-${SCRIPT_DIR}/../docs/business/assumptions.toml}"
65
66 if [[ ! -f "$ASSUMPTIONS_PATH" ]]; then
67 echo "assumptions.toml not found at $ASSUMPTIONS_PATH" >&2
68 echo "set ASSUMPTIONS_PATH= to override" >&2
69 exit 2
70 fi
71
72 declare -A STANDARD_MONTHLY
73 while IFS='=' read -r tier value; do
74 STANDARD_MONTHLY[$tier]=$value
75 done < <(python3 - "$ASSUMPTIONS_PATH" <<'PY'
76 import sys
77 # tomllib is stdlib on 3.11+; tomli is the drop-in for 3.9/3.10.
78 try:
79 import tomllib
80 except ImportError:
81 try:
82 import tomli as tomllib
83 except ImportError:
84 sys.stderr.write(
85 "tomllib (Python 3.11+) or tomli (pip install tomli) required\n"
86 )
87 sys.exit(3)
88 with open(sys.argv[1], "rb") as f:
89 doc = tomllib.load(f)
90 std = doc["tiers"]["standard"]
91 for tier in ("basic", "small_files", "big_files", "everything"):
92 v = std[tier]
93 if not isinstance(v, int):
94 sys.stderr.write(
95 f"tiers.standard.{tier} = {v!r} is not an integer (whole dollars required)\n"
96 )
97 sys.exit(3)
98 print(f"{tier}={v}")
99 PY
100 )
101
102 # Sanity-check we got all four tiers.
103 for tier in basic small_files big_files everything; do
104 if [[ -z "${STANDARD_MONTHLY[$tier]:-}" ]]; then
105 echo "missing tiers.standard.${tier} in $ASSUMPTIONS_PATH" >&2
106 exit 2
107 fi
108 done
109
110 # Product display names: these appear on Stripe dashboard and customer
111 # receipts. One product per tier; four prices hang off each.
112 declare -A PRODUCT_NAME=(
113 [basic]="Creator Tier, Basic"
114 [small_files]="Creator Tier, Small Files"
115 [big_files]="Creator Tier, Big Files"
116 [everything]="Creator Tier, Everything"
117 )
118
119 # Round monthly cents to the displayed annual price (whole dollars).
120 annual_cents() {
121 local monthly_cents=$1
122 # bash arithmetic is integer-only, so compute in cents using nearest-int rounding.
123 # exact = monthly * 12 * 90 / 100; we round to nearest whole dollar (multiple of 100 cents).
124 local exact=$(( monthly_cents * 12 * 90 / 100 ))
125 # Round to nearest 100 cents.
126 local remainder=$(( exact % 100 ))
127 if (( remainder >= 50 )); then
128 echo $(( exact - remainder + 100 ))
129 else
130 echo $(( exact - remainder ))
131 fi
132 }
133
134 # stripe_get path query_string
135 stripe_get() {
136 local path=$1
137 local query=${2:-}
138 curl -sS -G "${AUTH[@]}" "${API}/${path}" ${query:+--data-urlencode "$query"}
139 }
140
141 # Find or create the Product for a tier. Reuses an existing product by metadata
142 # tag `mnw_tier=<slug>` so re-running doesn't duplicate.
143 ensure_product() {
144 local tier_slug=$1
145 local name=${PRODUCT_NAME[$tier_slug]}
146
147 # Search by metadata.mnw_tier.
148 local resp
149 resp=$(stripe_get "products/search" "query=metadata['mnw_tier']:'${tier_slug}' AND active:'true'")
150 local existing
151 existing=$(echo "$resp" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['data'][0]['id'] if d.get('data') else '')")
152 if [[ -n "$existing" ]]; then
153 echo "$existing"
154 return
155 fi
156
157 if (( DRY_RUN )); then
158 echo "prod_DRYRUN_${tier_slug}"
159 return
160 fi
161
162 local created
163 created=$(curl -sS "${AUTH[@]}" "${API}/products" \
164 -d "name=${name}" \
165 -d "metadata[mnw_tier]=${tier_slug}")
166 echo "$created" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])"
167 }
168
169 # Find a price by lookup_key. Echoes "id|unit_amount" or empty.
170 find_price_by_lookup() {
171 local key=$1
172 local resp
173 resp=$(stripe_get "prices" "lookup_keys[]=${key}")
174 echo "$resp" | python3 -c "
175 import json,sys
176 d = json.load(sys.stdin)
177 if d.get('data'):
178 p = d['data'][0]
179 print(f\"{p['id']}|{p['unit_amount']}|{p.get('active', True)}\")
180 "
181 }
182
183 # Create a price with lookup_key. If transfer_lookup_key is 1, archives whichever
184 # price currently holds the key.
185 create_price() {
186 local product_id=$1
187 local unit_amount_cents=$2
188 local interval=$3 # month | year
189 local lookup=$4
190 local transfer=$5 # 0 | 1
191
192 if (( DRY_RUN )); then
193 echo "price_DRYRUN_${lookup}"
194 return
195 fi
196
197 local args=(
198 -d "currency=usd"
199 -d "product=${product_id}"
200 -d "unit_amount=${unit_amount_cents}"
201 -d "recurring[interval]=${interval}"
202 -d "lookup_key=${lookup}"
203 )
204 if (( transfer )); then
205 args+=(-d "transfer_lookup_key=true")
206 fi
207
208 local resp
209 resp=$(curl -sS "${AUTH[@]}" "${API}/prices" "${args[@]}")
210 local price_id
211 price_id=$(echo "$resp" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('id') or ''); sys.exit(0 if d.get('id') else 1)" || true)
212 if [[ -z "$price_id" ]]; then
213 echo "FAILED to create price ${lookup}: $resp" >&2
214 exit 1
215 fi
216 echo "$price_id"
217 }
218
219 archive_price() {
220 local price_id=$1
221 if (( DRY_RUN )); then
222 echo "(dry-run) would archive ${price_id}" >&2
223 return
224 fi
225 curl -sS "${AUTH[@]}" "${API}/prices/${price_id}" -d "active=false" >/dev/null
226 echo "archived ${price_id}" >&2
227 }
228
229 # Ensure a price with the given lookup_key, amount, and interval exists. Echoes the price ID.
230 ensure_price() {
231 local product_id=$1
232 local unit_amount_cents=$2
233 local interval=$3
234 local lookup=$4
235
236 local found
237 found=$(find_price_by_lookup "$lookup")
238 if [[ -n "$found" ]]; then
239 local existing_id existing_amount existing_active
240 IFS='|' read -r existing_id existing_amount existing_active <<<"$found"
241 if [[ "$existing_amount" == "$unit_amount_cents" && "$existing_active" == "True" ]]; then
242 echo "$existing_id"
243 return
244 fi
245 # Wrong amount or inactive: transfer the key onto a fresh price and archive the old one.
246 echo "lookup_key ${lookup}: existing ${existing_id} amount=${existing_amount}, want ${unit_amount_cents}; rotating" >&2
247 local new_id
248 new_id=$(create_price "$product_id" "$unit_amount_cents" "$interval" "$lookup" 1)
249 archive_price "$existing_id"
250 echo "$new_id"
251 return
252 fi
253
254 create_price "$product_id" "$unit_amount_cents" "$interval" "$lookup" 0
255 }
256
257 # uppercase a tier slug for env var name
258 upper_slug() {
259 echo "$1" | tr '[:lower:]' '[:upper:]'
260 }
261
262 # env var name for (tier, rate, interval).
263 env_var_name() {
264 local tier_slug=$1
265 local rate=$2 # standard | founder
266 local interval=$3 # monthly | annual
267 local tier_uc
268 tier_uc=$(upper_slug "$tier_slug")
269 local prefix="CREATOR_TIER_${tier_uc}"
270 case "${rate}_${interval}" in
271 standard_monthly) echo "${prefix}_PRICE_ID" ;;
272 standard_annual) echo "${prefix}_ANNUAL_PRICE_ID" ;;
273 founder_monthly) echo "${prefix}_FOUNDER_PRICE_ID" ;;
274 founder_annual) echo "${prefix}_FOUNDER_ANNUAL_PRICE_ID" ;;
275 esac
276 }
277
278 main() {
279 local TIERS=(basic small_files big_files everything)
280 local env_lines=()
281 local wanted_lookup_keys=()
282
283 for tier in "${TIERS[@]}"; do
284 local std_monthly_cents=$(( STANDARD_MONTHLY[$tier] * 100 ))
285 local founder_monthly_cents=$(( std_monthly_cents / 2 ))
286 local std_annual_cents
287 std_annual_cents=$(annual_cents "$std_monthly_cents")
288 local founder_annual_cents
289 founder_annual_cents=$(annual_cents "$founder_monthly_cents")
290
291 echo "==> ${tier}: standard \$${STANDARD_MONTHLY[$tier]}/mo, founder \$$(( STANDARD_MONTHLY[$tier] / 2 ))/mo" >&2
292 echo " annual cents: standard=${std_annual_cents}, founder=${founder_annual_cents}" >&2
293
294 local product_id
295 product_id=$(ensure_product "$tier")
296 echo " product: ${product_id}" >&2
297
298 for rate in standard founder; do
299 for interval in monthly annual; do
300 local cents stripe_interval
301 if [[ "$rate" == "standard" && "$interval" == "monthly" ]]; then
302 cents=$std_monthly_cents
303 elif [[ "$rate" == "standard" && "$interval" == "annual" ]]; then
304 cents=$std_annual_cents
305 elif [[ "$rate" == "founder" && "$interval" == "monthly" ]]; then
306 cents=$founder_monthly_cents
307 else
308 cents=$founder_annual_cents
309 fi
310 if [[ "$interval" == "monthly" ]]; then
311 stripe_interval=month
312 else
313 stripe_interval=year
314 fi
315
316 local lookup="creator_tier_${tier}_${rate}_${interval}"
317 wanted_lookup_keys+=("$lookup")
318 local price_id
319 price_id=$(ensure_price "$product_id" "$cents" "$stripe_interval" "$lookup")
320 local var
321 var=$(env_var_name "$tier" "$rate" "$interval")
322 env_lines+=("${var}=${price_id}")
323 echo " ${lookup} -> ${price_id} (\$$(awk "BEGIN { printf \"%.2f\", $cents/100 }"))" >&2
324 done
325 done
326
327 if (( ARCHIVE_OLD )); then
328 echo " archiving stale prices on product ${product_id}..." >&2
329 # List all active prices on this product, archive any whose lookup_key isn't in the wanted set
330 # (or whose lookup_key is null, which means it was created manually before lookup_keys existed).
331 local resp
332 resp=$(stripe_get "prices" "product=${product_id}&active=true&limit=100")
333 # Build a python literal of the wanted keys for this tier
334 local tier_wanted_py="["
335 for k in "${wanted_lookup_keys[@]}"; do
336 if [[ "$k" == "creator_tier_${tier}_"* ]]; then
337 tier_wanted_py+="'$k',"
338 fi
339 done
340 tier_wanted_py+="]"
341 local stale_ids
342 stale_ids=$(echo "$resp" | python3 -c "
343 import json, sys
344 d = json.load(sys.stdin)
345 wanted = set(${tier_wanted_py})
346 for p in d.get('data', []):
347 if p.get('lookup_key') not in wanted:
348 print(p['id'])
349 ")
350 for sid in $stale_ids; do
351 archive_price "$sid"
352 done
353 fi
354 done
355
356 echo
357 echo "# Paste into the server environment (and restart):"
358 printf '%s\n' "${env_lines[@]}" | sort
359 }
360
361 main "$@"
362