Skip to content

Commit d8ea559

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 d8ea559

16 files changed

Lines changed: 1407 additions & 3 deletions

File tree

Makefile

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,28 @@ 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+
export STRIPE_TEST_WEBHOOK_SECRET=$$WEBHOOK_SECRET; \
211+
pnpm devserver
212+
191213
dcpsql: .docker/pgpass
192214
PGPASSFILE=.docker/pgpass psql --host localhost --port 5432 --username learningequality --dbname "kolibri-studio"
193215

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

contentcuration/contentcuration/frontend/settings/pages/Storage/index.vue

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@
5454
</KFixedGrid>
5555

5656
<div class="storage-request">
57+
<SubscriptionCard />
58+
5759
<h2 ref="requestheader">
5860
{{ $tr('requestMoreSpaceHeading') }}
5961
</h2>
@@ -112,6 +114,7 @@
112114
import { mapGetters } from 'vuex';
113115
import useKShow from 'kolibri-design-system/lib/composables/useKShow';
114116
import RequestForm from './RequestForm';
117+
import SubscriptionCard from './SubscriptionCard';
115118
import { fileSizeMixin, constantsTranslationMixin } from 'shared/mixins';
116119
import { ContentKindsList, ContentKindsNames } from 'shared/leUtils/ContentKinds';
117120
import theme from 'shared/vuetify/theme';
@@ -122,6 +125,7 @@
122125
components: {
123126
RequestForm,
124127
StudioLargeLoader,
128+
SubscriptionCard,
125129
},
126130
mixins: [fileSizeMixin, constantsTranslationMixin],
127131
setup() {

0 commit comments

Comments
 (0)