Skip to main content

max / makenotwork

11.1 KB · 318 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 # `_private/docs/mnw/server-internal/business/assumptions.toml` so docs, Rust
31 # enum, and Stripe stay in lockstep. If you change prices, edit that toml
32 # (and `CreatorTier::price_cents`), then re-run this.
33 #
34 # Output: prints CREATOR_TIER_*_PRICE_ID env-var assignments suitable for
35 # pasting into the server's environment file.
36
37 set -euo pipefail
38
39 DRY_RUN=0
40 ARCHIVE_OLD=0
41 for arg in "$@"; do
42 case "$arg" in
43 --dry-run) DRY_RUN=1 ;;
44 --archive-old-on-tier-products) ARCHIVE_OLD=1 ;;
45 *) echo "unknown arg: $arg" >&2; exit 2 ;;
46 esac
47 done
48
49 : "${STRIPE_SECRET_KEY:?STRIPE_SECRET_KEY must be set}"
50
51 API="https://api.stripe.com/v1"
52 AUTH=(-u "${STRIPE_SECRET_KEY}:")
53
54 # Monthly prices in whole dollars. Founder = exactly 50% of standard. Annual =
55 # monthly * 12 * 0.9, rounded to the nearest whole dollar (matches the displayed
56 # prices in site-docs/about/pricing.md and site-docs/guide/tiers.md).
57 #
58 # Tiers: basic small_files big_files everything
59 declare -A STANDARD_MONTHLY=(
60 [basic]=16
61 [small_files]=24
62 [big_files]=36
63 [everything]=60
64 )
65
66 # Product display names — these appear on Stripe dashboard and customer
67 # receipts. One product per tier; four prices hang off each.
68 declare -A PRODUCT_NAME=(
69 [basic]="Creator Tier — Basic"
70 [small_files]="Creator Tier — Small Files"
71 [big_files]="Creator Tier — Big Files"
72 [everything]="Creator Tier — Everything"
73 )
74
75 # Round monthly cents to the displayed annual price (whole dollars).
76 annual_cents() {
77 local monthly_cents=$1
78 # bash arithmetic is integer-only, so compute in cents using nearest-int rounding.
79 # exact = monthly * 12 * 90 / 100; we round to nearest whole dollar (multiple of 100 cents).
80 local exact=$(( monthly_cents * 12 * 90 / 100 ))
81 # Round to nearest 100 cents.
82 local remainder=$(( exact % 100 ))
83 if (( remainder >= 50 )); then
84 echo $(( exact - remainder + 100 ))
85 else
86 echo $(( exact - remainder ))
87 fi
88 }
89
90 # stripe_get path query_string
91 stripe_get() {
92 local path=$1
93 local query=${2:-}
94 curl -sS -G "${AUTH[@]}" "${API}/${path}" ${query:+--data-urlencode "$query"}
95 }
96
97 # Find or create the Product for a tier. Reuses an existing product by metadata
98 # tag `mnw_tier=<slug>` so re-running doesn't duplicate.
99 ensure_product() {
100 local tier_slug=$1
101 local name=${PRODUCT_NAME[$tier_slug]}
102
103 # Search by metadata.mnw_tier.
104 local resp
105 resp=$(stripe_get "products/search" "query=metadata['mnw_tier']:'${tier_slug}' AND active:'true'")
106 local existing
107 existing=$(echo "$resp" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['data'][0]['id'] if d.get('data') else '')")
108 if [[ -n "$existing" ]]; then
109 echo "$existing"
110 return
111 fi
112
113 if (( DRY_RUN )); then
114 echo "prod_DRYRUN_${tier_slug}"
115 return
116 fi
117
118 local created
119 created=$(curl -sS "${AUTH[@]}" "${API}/products" \
120 -d "name=${name}" \
121 -d "metadata[mnw_tier]=${tier_slug}")
122 echo "$created" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])"
123 }
124
125 # Find a price by lookup_key. Echoes "id|unit_amount" or empty.
126 find_price_by_lookup() {
127 local key=$1
128 local resp
129 resp=$(stripe_get "prices" "lookup_keys[]=${key}")
130 echo "$resp" | python3 -c "
131 import json,sys
132 d = json.load(sys.stdin)
133 if d.get('data'):
134 p = d['data'][0]
135 print(f\"{p['id']}|{p['unit_amount']}|{p.get('active', True)}\")
136 "
137 }
138
139 # Create a price with lookup_key. If transfer_lookup_key is 1, archives whichever
140 # price currently holds the key.
141 create_price() {
142 local product_id=$1
143 local unit_amount_cents=$2
144 local interval=$3 # month | year
145 local lookup=$4
146 local transfer=$5 # 0 | 1
147
148 if (( DRY_RUN )); then
149 echo "price_DRYRUN_${lookup}"
150 return
151 fi
152
153 local args=(
154 -d "currency=usd"
155 -d "product=${product_id}"
156 -d "unit_amount=${unit_amount_cents}"
157 -d "recurring[interval]=${interval}"
158 -d "lookup_key=${lookup}"
159 )
160 if (( transfer )); then
161 args+=(-d "transfer_lookup_key=true")
162 fi
163
164 local resp
165 resp=$(curl -sS "${AUTH[@]}" "${API}/prices" "${args[@]}")
166 local price_id
167 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)
168 if [[ -z "$price_id" ]]; then
169 echo "FAILED to create price ${lookup}: $resp" >&2
170 exit 1
171 fi
172 echo "$price_id"
173 }
174
175 archive_price() {
176 local price_id=$1
177 if (( DRY_RUN )); then
178 echo "(dry-run) would archive ${price_id}" >&2
179 return
180 fi
181 curl -sS "${AUTH[@]}" "${API}/prices/${price_id}" -d "active=false" >/dev/null
182 echo "archived ${price_id}" >&2
183 }
184
185 # Ensure a price with the given lookup_key, amount, and interval exists. Echoes the price ID.
186 ensure_price() {
187 local product_id=$1
188 local unit_amount_cents=$2
189 local interval=$3
190 local lookup=$4
191
192 local found
193 found=$(find_price_by_lookup "$lookup")
194 if [[ -n "$found" ]]; then
195 local existing_id existing_amount existing_active
196 IFS='|' read -r existing_id existing_amount existing_active <<<"$found"
197 if [[ "$existing_amount" == "$unit_amount_cents" && "$existing_active" == "True" ]]; then
198 echo "$existing_id"
199 return
200 fi
201 # Wrong amount or inactive: transfer the key onto a fresh price and archive the old one.
202 echo "lookup_key ${lookup}: existing ${existing_id} amount=${existing_amount}, want ${unit_amount_cents}; rotating" >&2
203 local new_id
204 new_id=$(create_price "$product_id" "$unit_amount_cents" "$interval" "$lookup" 1)
205 archive_price "$existing_id"
206 echo "$new_id"
207 return
208 fi
209
210 create_price "$product_id" "$unit_amount_cents" "$interval" "$lookup" 0
211 }
212
213 # uppercase a tier slug for env var name
214 upper_slug() {
215 echo "$1" | tr '[:lower:]' '[:upper:]'
216 }
217
218 # env var name for (tier, rate, interval).
219 env_var_name() {
220 local tier_slug=$1
221 local rate=$2 # standard | founder
222 local interval=$3 # monthly | annual
223 local tier_uc
224 tier_uc=$(upper_slug "$tier_slug")
225 local prefix="CREATOR_TIER_${tier_uc}"
226 case "${rate}_${interval}" in
227 standard_monthly) echo "${prefix}_PRICE_ID" ;;
228 standard_annual) echo "${prefix}_ANNUAL_PRICE_ID" ;;
229 founder_monthly) echo "${prefix}_FOUNDER_PRICE_ID" ;;
230 founder_annual) echo "${prefix}_FOUNDER_ANNUAL_PRICE_ID" ;;
231 esac
232 }
233
234 main() {
235 local TIERS=(basic small_files big_files everything)
236 local env_lines=()
237 local wanted_lookup_keys=()
238
239 for tier in "${TIERS[@]}"; do
240 local std_monthly_cents=$(( STANDARD_MONTHLY[$tier] * 100 ))
241 local founder_monthly_cents=$(( std_monthly_cents / 2 ))
242 local std_annual_cents
243 std_annual_cents=$(annual_cents "$std_monthly_cents")
244 local founder_annual_cents
245 founder_annual_cents=$(annual_cents "$founder_monthly_cents")
246
247 echo "==> ${tier}: standard \$${STANDARD_MONTHLY[$tier]}/mo, founder \$$(( STANDARD_MONTHLY[$tier] / 2 ))/mo" >&2
248 echo " annual cents: standard=${std_annual_cents}, founder=${founder_annual_cents}" >&2
249
250 local product_id
251 product_id=$(ensure_product "$tier")
252 echo " product: ${product_id}" >&2
253
254 for rate in standard founder; do
255 for interval in monthly annual; do
256 local cents stripe_interval
257 if [[ "$rate" == "standard" && "$interval" == "monthly" ]]; then
258 cents=$std_monthly_cents
259 elif [[ "$rate" == "standard" && "$interval" == "annual" ]]; then
260 cents=$std_annual_cents
261 elif [[ "$rate" == "founder" && "$interval" == "monthly" ]]; then
262 cents=$founder_monthly_cents
263 else
264 cents=$founder_annual_cents
265 fi
266 if [[ "$interval" == "monthly" ]]; then
267 stripe_interval=month
268 else
269 stripe_interval=year
270 fi
271
272 local lookup="creator_tier_${tier}_${rate}_${interval}"
273 wanted_lookup_keys+=("$lookup")
274 local price_id
275 price_id=$(ensure_price "$product_id" "$cents" "$stripe_interval" "$lookup")
276 local var
277 var=$(env_var_name "$tier" "$rate" "$interval")
278 env_lines+=("${var}=${price_id}")
279 echo " ${lookup} -> ${price_id} (\$$(awk "BEGIN { printf \"%.2f\", $cents/100 }"))" >&2
280 done
281 done
282
283 if (( ARCHIVE_OLD )); then
284 echo " archiving stale prices on product ${product_id}..." >&2
285 # List all active prices on this product, archive any whose lookup_key isn't in the wanted set
286 # (or whose lookup_key is null, which means it was created manually before lookup_keys existed).
287 local resp
288 resp=$(stripe_get "prices" "product=${product_id}&active=true&limit=100")
289 # Build a python literal of the wanted keys for this tier
290 local tier_wanted_py="["
291 for k in "${wanted_lookup_keys[@]}"; do
292 if [[ "$k" == "creator_tier_${tier}_"* ]]; then
293 tier_wanted_py+="'$k',"
294 fi
295 done
296 tier_wanted_py+="]"
297 local stale_ids
298 stale_ids=$(echo "$resp" | python3 -c "
299 import json, sys
300 d = json.load(sys.stdin)
301 wanted = set(${tier_wanted_py})
302 for p in d.get('data', []):
303 if p.get('lookup_key') not in wanted:
304 print(p['id'])
305 ")
306 for sid in $stale_ids; do
307 archive_price "$sid"
308 done
309 fi
310 done
311
312 echo
313 echo "# Paste into the server environment (and restart):"
314 printf '%s\n' "${env_lines[@]}" | sort
315 }
316
317 main "$@"
318