Skip to content

Commit bee8581

Browse files
rtibblesclaude
andcommitted
Add Stripe subscription integration for paid storage upgrades
Users can purchase additional storage (1-50 GB at $15/GB/year) via Stripe Checkout, manage subscriptions through the Customer Portal, and see their subscription status on the Storage settings page. Environment-gated configuration ensures non-production deployments use Stripe test/sandbox keys automatically. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b515a2e commit bee8581

16 files changed

Lines changed: 1459 additions & 7 deletions

File tree

Makefile

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
# standalone install method
2-
DOCKER_COMPOSE ?= docker-compose
2+
DOCKER_COMPOSE ?= podman compose
33
CELERY = cd contentcuration/ && celery -A contentcuration worker -l info --concurrency=3 --task-events
44

55
# support new plugin installation for docker-compose
6-
ifeq (, $(shell command -v docker-compose 2>/dev/null))
7-
DOCKER_COMPOSE := docker compose
8-
endif
6+
# ifeq (, $(shell command -v docker-compose 2>/dev/null))
7+
# DOCKER_COMPOSE := docker compose
8+
# endif
99

1010
###############################################################
1111
# PRODUCTION COMMANDS #########################################
@@ -188,6 +188,29 @@ dcshell:
188188
# bash shell inside the (running!) studio-app container
189189
$(DOCKER_COMPOSE) exec studio-app /usr/bin/fish
190190

191+
devserver-stripe:
192+
# Start stripe CLI listener and dev server with webhook secret auto-configured.
193+
# Requires: stripe CLI installed and authenticated (stripe login).
194+
# The listener output is teed to a temp file so we can extract the signing secret.
195+
@STRIPE_LOG=$$(mktemp); \
196+
stripe listen --api-key $$STRIPE_TEST_SECRET_KEY --forward-to localhost:8080/api/stripe/webhook/ > "$$STRIPE_LOG" 2>&1 & \
197+
STRIPE_PID=$$!; \
198+
trap "kill $$STRIPE_PID 2>/dev/null; rm -f $$STRIPE_LOG" EXIT; \
199+
echo "Waiting for Stripe CLI..."; \
200+
for i in 1 2 3 4 5 6 7 8 9 10; do \
201+
WEBHOOK_SECRET=$$(grep -o 'whsec_[a-zA-Z0-9_]*' "$$STRIPE_LOG" | head -1); \
202+
[ -n "$$WEBHOOK_SECRET" ] && break; \
203+
sleep 1; \
204+
done; \
205+
if [ -z "$$WEBHOOK_SECRET" ]; then \
206+
echo "ERROR: Could not extract webhook secret from Stripe CLI"; \
207+
exit 1; \
208+
fi; \
209+
echo "Stripe webhook secret: $$WEBHOOK_SECRET"; \
210+
tail -f "$$STRIPE_LOG" & \
211+
export STRIPE_TEST_WEBHOOK_SECRET=$$WEBHOOK_SECRET; \
212+
pnpm devserver
213+
191214
dcpsql: .docker/pgpass
192215
PGPASSFILE=.docker/pgpass psql --host localhost --port 5432 --username learningequality --dbname "kolibri-studio"
193216

Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
<template>
2+
3+
<div
4+
class="subscription-card"
5+
:style="{ backgroundColor: $themePalette.grey.v_100 }"
6+
>
7+
<div
8+
v-if="loading"
9+
class="loading"
10+
>
11+
<KCircularLoader />
12+
</div>
13+
14+
<div
15+
v-else-if="isActive"
16+
class="active-subscription"
17+
>
18+
<div class="status-header">
19+
<KIcon
20+
icon="check"
21+
:color="cancelAtPeriodEnd ? $themeTokens.annotation : $themeTokens.success"
22+
/>
23+
<span class="status-text">
24+
{{ cancelAtPeriodEnd ? $tr('subscriptionCanceling') : $tr('subscriptionActive') }}
25+
</span>
26+
</div>
27+
<div
28+
v-if="showSuccessMessage"
29+
class="success-banner"
30+
:style="{ backgroundColor: $themePalette.green.v_100, color: $themePalette.green.v_700 }"
31+
>
32+
<KIcon
33+
icon="check"
34+
:color="$themeTokens.success"
35+
/>
36+
<span>{{ $tr('upgradeSuccess', { size: subscriptionGb }) }}</span>
37+
<KIconButton
38+
icon="close"
39+
:ariaLabel="$tr('dismiss')"
40+
:size="'small'"
41+
:color="$themePalette.green.v_400"
42+
class="dismiss-btn"
43+
@click="showSuccessMessage = false"
44+
/>
45+
</div>
46+
<p
47+
class="storage-info"
48+
:style="{ color: $themeTokens.annotation }"
49+
>
50+
{{ $tr('storageIncluded', { size: subscriptionGb }) }}
51+
</p>
52+
<p
53+
v-if="formattedPeriodEnd"
54+
class="period-notice"
55+
:style="{ color: cancelAtPeriodEnd ? $themeTokens.error : $themeTokens.annotation }"
56+
>
57+
{{
58+
cancelAtPeriodEnd
59+
? $tr('cancelNotice', { date: formattedPeriodEnd })
60+
: $tr('renewalNotice', { date: formattedPeriodEnd })
61+
}}
62+
</p>
63+
<KButton
64+
:text="$tr('manageSubscription')"
65+
appearance="basic-link"
66+
@click="handleManageClick"
67+
/>
68+
</div>
69+
70+
<div
71+
v-else
72+
class="upgrade-prompt"
73+
>
74+
<h3>{{ $tr('instantUpgrade') }}</h3>
75+
<p>{{ $tr('upgradeDescription') }}</p>
76+
<div class="storage-selector">
77+
<KTextbox
78+
v-model="selectedGb"
79+
type="number"
80+
:label="$tr('storageAmount')"
81+
:min="1"
82+
:max="50"
83+
:invalid="!isValidGb"
84+
:invalidText="$tr('storageRange')"
85+
:showInvalidText="true"
86+
class="gb-input"
87+
/>
88+
<span class="price-display">
89+
{{ $tr('annualPrice', { price: validGb * PRICE_PER_GB }) }}
90+
</span>
91+
</div>
92+
<KButton
93+
:primary="true"
94+
:disabled="!isValidGb || redirecting"
95+
class="upgrade-btn"
96+
@click="handleUpgradeClick"
97+
>
98+
<span class="upgrade-btn-content">
99+
<KCircularLoader
100+
v-if="redirecting"
101+
:size="24"
102+
:stroke="3"
103+
class="upgrade-btn-loader"
104+
/>
105+
<span :style="{ visibility: redirecting ? 'hidden' : 'visible' }">
106+
{{ $tr('upgradeNow') }}
107+
</span>
108+
</span>
109+
</KButton>
110+
</div>
111+
112+
<Banner
113+
v-if="error"
114+
:value="true"
115+
:text="$tr('genericError')"
116+
error
117+
class="error-banner"
118+
/>
119+
</div>
120+
121+
</template>
122+
123+
124+
<script>
125+
126+
import { ref, watch } from 'vue';
127+
import { useRoute, useRouter } from 'vue-router/composables';
128+
import { useSubscription } from './useSubscription';
129+
import { ONE_GB } from 'shared/constants';
130+
import Banner from 'shared/views/Banner';
131+
132+
const MIN_GB = 1;
133+
const MAX_GB = 50;
134+
const PRICE_PER_GB = 15;
135+
136+
export default {
137+
name: 'SubscriptionCard',
138+
components: {
139+
Banner,
140+
},
141+
setup() {
142+
const {
143+
loading,
144+
redirecting,
145+
error,
146+
isActive,
147+
storageBytes,
148+
cancelAtPeriodEnd,
149+
currentPeriodEnd,
150+
fetchSubscriptionStatus,
151+
createCheckoutSession,
152+
createPortalSession,
153+
} = useSubscription();
154+
155+
const showSuccessMessage = ref(false);
156+
const selectedGb = ref(10);
157+
158+
const route = useRoute();
159+
const router = useRouter();
160+
161+
fetchSubscriptionStatus();
162+
163+
watch(
164+
() => route.query.upgrade,
165+
val => {
166+
if (val === 'success') {
167+
showSuccessMessage.value = true;
168+
router.replace({ query: {} });
169+
}
170+
},
171+
{ immediate: true },
172+
);
173+
174+
const handleUpgradeClick = () => {
175+
createCheckoutSession(Number(selectedGb.value));
176+
};
177+
178+
const handleManageClick = () => {
179+
createPortalSession();
180+
};
181+
182+
return {
183+
loading,
184+
redirecting,
185+
error,
186+
isActive,
187+
storageBytes,
188+
cancelAtPeriodEnd,
189+
currentPeriodEnd,
190+
showSuccessMessage,
191+
selectedGb,
192+
PRICE_PER_GB,
193+
handleUpgradeClick,
194+
handleManageClick,
195+
};
196+
},
197+
computed: {
198+
subscriptionGb() {
199+
if (this.storageBytes) {
200+
return `${Math.round(this.storageBytes / ONE_GB)} GB`;
201+
}
202+
return `${MIN_GB} GB`;
203+
},
204+
formattedPeriodEnd() {
205+
if (!this.currentPeriodEnd) {
206+
return null;
207+
}
208+
return this.$formatDate(this.currentPeriodEnd);
209+
},
210+
validGb() {
211+
const n = Number(this.selectedGb);
212+
if (!Number.isInteger(n) || n < MIN_GB || n > MAX_GB) {
213+
return MIN_GB;
214+
}
215+
return n;
216+
},
217+
isValidGb() {
218+
const n = Number(this.selectedGb);
219+
return Number.isInteger(n) && n >= MIN_GB && n <= MAX_GB;
220+
},
221+
},
222+
$trs: {
223+
instantUpgrade: 'Instant Storage Upgrade',
224+
upgradeDescription: 'Purchase additional storage at $15/GB per year.',
225+
upgradeNow: 'Upgrade Now',
226+
storageAmount: 'Storage (GB)',
227+
storageRange: 'Enter a value between 1 and 50',
228+
annualPrice: '${price}/year',
229+
subscriptionActive: 'Storage Subscription Active',
230+
subscriptionCanceling: 'Subscription Canceling',
231+
cancelNotice: 'Your subscription will expire on {date}. Storage will be removed after that.',
232+
renewalNotice: 'Your subscription will automatically renew on {date}.',
233+
storageIncluded: '{size} included with your subscription',
234+
manageSubscription: 'Manage Subscription',
235+
upgradeSuccess: 'Storage increased to {size}',
236+
dismiss: 'Dismiss',
237+
genericError: 'There was a problem connecting to our payment provider. Please try again.',
238+
},
239+
};
240+
241+
</script>
242+
243+
244+
<style lang="scss" scoped>
245+
246+
.subscription-card {
247+
max-width: 500px;
248+
padding: 24px;
249+
margin-bottom: 24px;
250+
border-radius: 8px;
251+
}
252+
253+
.loading {
254+
display: flex;
255+
justify-content: center;
256+
padding: 16px;
257+
}
258+
259+
.status-header {
260+
display: flex;
261+
align-items: center;
262+
margin-bottom: 8px;
263+
}
264+
265+
.status-text {
266+
margin-left: 8px;
267+
font-weight: bold;
268+
}
269+
270+
.storage-info {
271+
margin-bottom: 16px;
272+
}
273+
274+
.period-notice {
275+
margin-bottom: 16px;
276+
font-size: 0.9em;
277+
}
278+
279+
.upgrade-prompt h3 {
280+
margin-top: 0;
281+
margin-bottom: 8px;
282+
}
283+
284+
.upgrade-prompt p {
285+
margin-bottom: 16px;
286+
}
287+
288+
.storage-selector {
289+
display: flex;
290+
gap: 12px;
291+
align-items: flex-start;
292+
margin-bottom: 16px;
293+
}
294+
295+
.gb-input {
296+
max-width: 120px;
297+
}
298+
299+
.price-display {
300+
padding-top: 24px;
301+
font-weight: bold;
302+
}
303+
304+
.upgrade-btn-content {
305+
display: inline-grid;
306+
align-items: center;
307+
justify-items: center;
308+
}
309+
310+
.upgrade-btn-content > * {
311+
grid-area: 1 / 1;
312+
}
313+
314+
.error-banner {
315+
margin-top: 16px;
316+
}
317+
318+
.success-banner {
319+
display: flex;
320+
gap: 8px;
321+
align-items: center;
322+
padding: 8px 12px;
323+
margin-bottom: 12px;
324+
border-radius: 4px;
325+
}
326+
327+
.dismiss-btn {
328+
margin-left: auto;
329+
}
330+
331+
</style>

0 commit comments

Comments
 (0)