Skip to content

Commit 24a93eb

Browse files
authored
Merge pull request #5810 from rtibbles/payment_processing
Add Stripe subscription integration for paid storage upgrades
2 parents bc484fa + 363d10d commit 24a93eb

File tree

16 files changed

+1445
-3
lines changed

16 files changed

+1445
-3
lines changed

Makefile

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