@kernhq/module-billing 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/contract.d.ts +16 -1
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js +20 -3
- package/dist/contract.js.map +1 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +125 -5
- package/dist/server/index.js.map +1 -1
- package/dist/server/router.d.ts +10 -72
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +12 -1
- package/dist/server/router.js.map +1 -1
- package/dist/server/services/catalogue.d.ts +30 -0
- package/dist/server/services/catalogue.d.ts.map +1 -0
- package/dist/server/services/catalogue.js +87 -0
- package/dist/server/services/catalogue.js.map +1 -0
- package/dist/server/services/entitlements.d.ts.map +1 -1
- package/dist/server/services/entitlements.js +28 -8
- package/dist/server/services/entitlements.js.map +1 -1
- package/dist/server/services/stripe.d.ts +34 -0
- package/dist/server/services/stripe.d.ts.map +1 -1
- package/dist/server/services/stripe.js +205 -17
- package/dist/server/services/stripe.js.map +1 -1
- package/package.json +6 -6
- package/src/client/i18n.ts +64 -0
- package/src/client/settings/PlanSettings.svelte +163 -8
- package/src/contract.ts +22 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kernhq/module-billing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Kern billing module: plans, entitlements, subscriptions and Stripe — the mechanism that lets any Kern instance sell seats on itself",
|
|
5
5
|
"homepage": "https://github.com/KernAIO/module-billing#readme",
|
|
6
6
|
"license": "AGPL-3.0-only",
|
|
@@ -46,16 +46,16 @@
|
|
|
46
46
|
"@orpc/contract": "^1.15.0",
|
|
47
47
|
"@orpc/server": "^1.15.0",
|
|
48
48
|
"drizzle-orm": "^0.45.0",
|
|
49
|
-
"stripe": "^22.
|
|
49
|
+
"stripe": "^22.6.0",
|
|
50
50
|
"zod": "^4.1.0"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@biomejs/biome": "^2.0.0",
|
|
54
54
|
"@changesets/cli": "^2.27.0",
|
|
55
55
|
"@kernhq/contracts": "^0.7.0",
|
|
56
|
-
"@kernhq/kernel": "^0.
|
|
56
|
+
"@kernhq/kernel": "^0.9.1",
|
|
57
57
|
"@kernhq/tsconfig": "^0.1.0",
|
|
58
|
-
"@kernhq/ui": "^0.
|
|
58
|
+
"@kernhq/ui": "^0.14.0",
|
|
59
59
|
"@tanstack/svelte-query": "^6.1.0",
|
|
60
60
|
"@types/node": "^24.0.0",
|
|
61
61
|
"@types/pg": "^8.15.0",
|
|
@@ -68,8 +68,8 @@
|
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
70
|
"@kernhq/contracts": "^0.7.0",
|
|
71
|
-
"@kernhq/kernel": "^0.
|
|
72
|
-
"@kernhq/ui": "^0.
|
|
71
|
+
"@kernhq/kernel": "^0.9.1",
|
|
72
|
+
"@kernhq/ui": "^0.14.0",
|
|
73
73
|
"@tanstack/svelte-query": "^6.1.0",
|
|
74
74
|
"svelte": "^5.46.0"
|
|
75
75
|
},
|
package/src/client/i18n.ts
CHANGED
|
@@ -102,9 +102,21 @@ export const en: Record<string, Message> = {
|
|
|
102
102
|
'billing.blocked_seats': 'This plan includes {seats} seats and this workspace has {used} members.',
|
|
103
103
|
'billing.cancels': 'Ends',
|
|
104
104
|
'billing.change_plan': 'Change plan',
|
|
105
|
+
'billing.checkout_cancelled': 'Checkout cancelled',
|
|
106
|
+
'billing.checkout_cancelled_hint': 'Nothing was charged, and the plan has not changed.',
|
|
107
|
+
'billing.checkout_changed': 'Plan changed',
|
|
108
|
+
'billing.checkout_done': 'Payment received',
|
|
109
|
+
'billing.checkout_done_hint': 'The new plan is active.',
|
|
110
|
+
'billing.checkout_pending': 'Payment received',
|
|
111
|
+
'billing.checkout_pending_hint':
|
|
112
|
+
'Stripe has taken the payment and we are waiting for it to confirm. This page updates itself.',
|
|
113
|
+
'billing.checkout_slow': 'Confirmation is taking longer than usual',
|
|
114
|
+
'billing.checkout_slow_hint':
|
|
115
|
+
'Stripe has taken the payment but its confirmation has not arrived. Reload in a few minutes, and contact support if the plan still has not changed.',
|
|
105
116
|
'billing.choose_plan': 'Choose',
|
|
106
117
|
'billing.current': 'Current',
|
|
107
118
|
'billing.current_plan': 'Current plan',
|
|
119
|
+
'billing.dismiss': 'Dismiss',
|
|
108
120
|
'billing.error': 'Billing could not be loaded',
|
|
109
121
|
'billing.free': 'Free',
|
|
110
122
|
'billing.invoice_amount': 'Amount',
|
|
@@ -127,6 +139,8 @@ export const en: Record<string, Message> = {
|
|
|
127
139
|
'billing.per_user_month': 'per user, per month',
|
|
128
140
|
'billing.per_user_year': 'per user, per year',
|
|
129
141
|
'billing.per_year': 'per year',
|
|
142
|
+
'billing.plan_changed':
|
|
143
|
+
'The new plan is active. We have billed the difference for the rest of this period.',
|
|
130
144
|
'billing.plan_section': 'Plan',
|
|
131
145
|
'billing.renews': 'Renews',
|
|
132
146
|
'billing.retry': 'Try again',
|
|
@@ -243,9 +257,20 @@ const ar: Record<string, Message> = {
|
|
|
243
257
|
'billing.blocked_seats': 'تتضمن هذه الخطة {seats} مقعداً، ولدى مساحة العمل {used} عضواً.',
|
|
244
258
|
'billing.cancels': 'تنتهي في',
|
|
245
259
|
'billing.change_plan': 'تغيير الخطة',
|
|
260
|
+
'billing.checkout_cancelled': 'أُلغيت عملية الدفع',
|
|
261
|
+
'billing.checkout_cancelled_hint': 'لم يُخصم أي مبلغ، ولم تتغيّر الخطة.',
|
|
262
|
+
'billing.checkout_changed': 'تغيّرت الخطة',
|
|
263
|
+
'billing.checkout_done': 'وصلت الدفعة',
|
|
264
|
+
'billing.checkout_done_hint': 'الخطة الجديدة سارية الآن.',
|
|
265
|
+
'billing.checkout_pending': 'وصلت الدفعة',
|
|
266
|
+
'billing.checkout_pending_hint': 'استلم Stripe الدفعة وننتظر تأكيدها. تُحدِّث هذه الصفحة نفسها بنفسها.',
|
|
267
|
+
'billing.checkout_slow': 'التأكيد يستغرق وقتًا أطول من المعتاد',
|
|
268
|
+
'billing.checkout_slow_hint':
|
|
269
|
+
'استلم Stripe الدفعة لكن لم يصلنا تأكيدها. أعِد تحميل الصفحة بعد بضع دقائق، وتواصل مع الدعم إن بقيت الخطة كما هي.',
|
|
246
270
|
'billing.choose_plan': 'اختيار',
|
|
247
271
|
'billing.current': 'الحالية',
|
|
248
272
|
'billing.current_plan': 'الخطة الحالية',
|
|
273
|
+
'billing.dismiss': 'إخفاء',
|
|
249
274
|
'billing.error': 'تعذّر تحميل الفوترة',
|
|
250
275
|
'billing.free': 'مجاني',
|
|
251
276
|
'billing.invoice_amount': 'المبلغ',
|
|
@@ -267,6 +292,7 @@ const ar: Record<string, Message> = {
|
|
|
267
292
|
'billing.per_user_month': 'لكل مستخدم شهرياً',
|
|
268
293
|
'billing.per_user_year': 'لكل مستخدم سنوياً',
|
|
269
294
|
'billing.per_year': 'سنوياً',
|
|
295
|
+
'billing.plan_changed': 'الخطة الجديدة سارية الآن، وحُسب فرق القيمة لما تبقّى من هذه الدورة.',
|
|
270
296
|
'billing.plan_section': 'الخطة',
|
|
271
297
|
'billing.renews': 'تتجدد في',
|
|
272
298
|
'billing.retry': 'أعد المحاولة',
|
|
@@ -391,9 +417,21 @@ const de: Record<string, Message> = {
|
|
|
391
417
|
'Dieser Tarif enthält {seats} Plätze, dieser Arbeitsbereich hat {used} Mitglieder.',
|
|
392
418
|
'billing.cancels': 'Endet am',
|
|
393
419
|
'billing.change_plan': 'Tarif wechseln',
|
|
420
|
+
'billing.checkout_cancelled': 'Bezahlung abgebrochen',
|
|
421
|
+
'billing.checkout_cancelled_hint': 'Es wurde nichts berechnet, und der Tarif ist unverändert.',
|
|
422
|
+
'billing.checkout_changed': 'Tarif gewechselt',
|
|
423
|
+
'billing.checkout_done': 'Zahlung eingegangen',
|
|
424
|
+
'billing.checkout_done_hint': 'Der neue Tarif ist aktiv.',
|
|
425
|
+
'billing.checkout_pending': 'Zahlung eingegangen',
|
|
426
|
+
'billing.checkout_pending_hint':
|
|
427
|
+
'Stripe hat die Zahlung angenommen, wir warten auf die Bestätigung. Diese Seite aktualisiert sich von selbst.',
|
|
428
|
+
'billing.checkout_slow': 'Die Bestätigung dauert länger als sonst',
|
|
429
|
+
'billing.checkout_slow_hint':
|
|
430
|
+
'Stripe hat die Zahlung angenommen, aber die Bestätigung ist nicht eingetroffen. Laden Sie die Seite in einigen Minuten neu und wenden Sie sich an den Support, wenn der Tarif sich immer noch nicht geändert hat.',
|
|
394
431
|
'billing.choose_plan': 'Auswählen',
|
|
395
432
|
'billing.current': 'Aktuell',
|
|
396
433
|
'billing.current_plan': 'Aktueller Tarif',
|
|
434
|
+
'billing.dismiss': 'Ausblenden',
|
|
397
435
|
'billing.error': 'Die Abrechnung konnte nicht geladen werden',
|
|
398
436
|
'billing.free': 'Kostenlos',
|
|
399
437
|
'billing.invoice_amount': 'Betrag',
|
|
@@ -416,6 +454,8 @@ const de: Record<string, Message> = {
|
|
|
416
454
|
'billing.per_user_month': 'pro Person und Monat',
|
|
417
455
|
'billing.per_user_year': 'pro Person und Jahr',
|
|
418
456
|
'billing.per_year': 'pro Jahr',
|
|
457
|
+
'billing.plan_changed':
|
|
458
|
+
'Der neue Tarif ist aktiv. Die Differenz für den Rest dieses Zeitraums wurde berechnet.',
|
|
419
459
|
'billing.plan_section': 'Tarif',
|
|
420
460
|
'billing.renews': 'Verlängert am',
|
|
421
461
|
'billing.retry': 'Erneut versuchen',
|
|
@@ -532,9 +572,20 @@ const fa: Record<string, Message> = {
|
|
|
532
572
|
'billing.blocked_seats': 'این طرح {seats} کاربر دارد و این فضای کاری {used} عضو دارد.',
|
|
533
573
|
'billing.cancels': 'پایان',
|
|
534
574
|
'billing.change_plan': 'تغییر طرح',
|
|
575
|
+
'billing.checkout_cancelled': 'پرداخت لغو شد',
|
|
576
|
+
'billing.checkout_cancelled_hint': 'مبلغی کم نشد و طرح هم تغییری نکرد.',
|
|
577
|
+
'billing.checkout_changed': 'طرح تغییر کرد',
|
|
578
|
+
'billing.checkout_done': 'پرداخت رسید',
|
|
579
|
+
'billing.checkout_done_hint': 'طرح تازه فعال است.',
|
|
580
|
+
'billing.checkout_pending': 'پرداخت رسید',
|
|
581
|
+
'billing.checkout_pending_hint': 'Stripe پرداخت را گرفته و منتظر تأیید آن هستیم. این صفحه خودش تازه میشود.',
|
|
582
|
+
'billing.checkout_slow': 'تأیید بیش از حد معمول طول کشیده است',
|
|
583
|
+
'billing.checkout_slow_hint':
|
|
584
|
+
'Stripe پرداخت را گرفته اما تأیید آن نرسیده است. چند دقیقه دیگر صفحه را تازه کنید و اگر طرح همچنان تغییر نکرد با پشتیبانی تماس بگیرید.',
|
|
535
585
|
'billing.choose_plan': 'انتخاب',
|
|
536
586
|
'billing.current': 'کنونی',
|
|
537
587
|
'billing.current_plan': 'طرح کنونی',
|
|
588
|
+
'billing.dismiss': 'بستن',
|
|
538
589
|
'billing.error': 'اطلاعات پرداخت بارگذاری نشد',
|
|
539
590
|
'billing.free': 'رایگان',
|
|
540
591
|
'billing.invoice_amount': 'مبلغ',
|
|
@@ -556,6 +607,7 @@ const fa: Record<string, Message> = {
|
|
|
556
607
|
'billing.per_user_month': 'برای هر کاربر، ماهانه',
|
|
557
608
|
'billing.per_user_year': 'برای هر کاربر، سالانه',
|
|
558
609
|
'billing.per_year': 'سالانه',
|
|
610
|
+
'billing.plan_changed': 'طرح تازه فعال است و تفاوت مبلغ برای باقیماندهی این دوره حساب شد.',
|
|
559
611
|
'billing.plan_section': 'طرح',
|
|
560
612
|
'billing.renews': 'تمدید',
|
|
561
613
|
'billing.retry': 'تلاش دوباره',
|
|
@@ -673,9 +725,20 @@ const tr: Record<string, Message> = {
|
|
|
673
725
|
'billing.blocked_seats': 'Bu tarife {seats} kullanıcı içeriyor ve bu çalışma alanında {used} üye var.',
|
|
674
726
|
'billing.cancels': 'Bitiş',
|
|
675
727
|
'billing.change_plan': 'Tarifeyi değiştir',
|
|
728
|
+
'billing.checkout_cancelled': 'Ödeme iptal edildi',
|
|
729
|
+
'billing.checkout_cancelled_hint': 'Hiçbir ücret alınmadı ve tarife değişmedi.',
|
|
730
|
+
'billing.checkout_changed': 'Tarife değişti',
|
|
731
|
+
'billing.checkout_done': 'Ödeme alındı',
|
|
732
|
+
'billing.checkout_done_hint': 'Yeni tarife etkin.',
|
|
733
|
+
'billing.checkout_pending': 'Ödeme alındı',
|
|
734
|
+
'billing.checkout_pending_hint': 'Stripe ödemeyi aldı, onayını bekliyoruz. Bu sayfa kendini günceller.',
|
|
735
|
+
'billing.checkout_slow': 'Onay her zamankinden uzun sürüyor',
|
|
736
|
+
'billing.checkout_slow_hint':
|
|
737
|
+
'Stripe ödemeyi aldı ama onayı bize ulaşmadı. Birkaç dakika sonra sayfayı yenileyin; tarife hâlâ değişmediyse destek ekibine başvurun.',
|
|
676
738
|
'billing.choose_plan': 'Seç',
|
|
677
739
|
'billing.current': 'Şu anki',
|
|
678
740
|
'billing.current_plan': 'Şu anki tarife',
|
|
741
|
+
'billing.dismiss': 'Kapat',
|
|
679
742
|
'billing.error': 'Ödeme bilgileri yüklenemedi',
|
|
680
743
|
'billing.free': 'Ücretsiz',
|
|
681
744
|
'billing.invoice_amount': 'Tutar',
|
|
@@ -698,6 +761,7 @@ const tr: Record<string, Message> = {
|
|
|
698
761
|
'billing.per_user_month': 'kullanıcı başına, aylık',
|
|
699
762
|
'billing.per_user_year': 'kullanıcı başına, yıllık',
|
|
700
763
|
'billing.per_year': 'yıllık',
|
|
764
|
+
'billing.plan_changed': 'Yeni tarife etkin. Bu dönemin kalanı için aradaki fark faturalandırıldı.',
|
|
701
765
|
'billing.plan_section': 'Tarife',
|
|
702
766
|
'billing.renews': 'Yenilenme',
|
|
703
767
|
'billing.retry': 'Yeniden dene',
|
|
@@ -54,10 +54,47 @@ const { workspaceId, workspaceSlug: slug }: Props = $props()
|
|
|
54
54
|
const canManage = $derived(session.can(BILLING_PERMISSIONS.manage))
|
|
55
55
|
const locale = $derived(messageLocale())
|
|
56
56
|
|
|
57
|
+
/** Where Stripe sends the person back to, and where the portal returns them. */
|
|
58
|
+
const returnPath = $derived(`/${slug}/settings/billing/plan`)
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* What Stripe said on the way back, from `?checkout=`.
|
|
62
|
+
*
|
|
63
|
+
* Read from `window.location` rather than the router: a module page cannot import `$app/state` — it
|
|
64
|
+
* is a SvelteKit alias, and this package is type-checked on its own. The parameter is stripped once
|
|
65
|
+
* read, so a refresh half an hour later does not announce a payment again.
|
|
66
|
+
*
|
|
67
|
+
* Nothing used to read it at all: the person came back from paying to a page that looked exactly
|
|
68
|
+
* as it had before they left, which reads as a payment that did not work.
|
|
69
|
+
*/
|
|
70
|
+
type Outcome = 'done' | 'cancelled' | 'changed'
|
|
71
|
+
let outcome = $state<Outcome | null>(null)
|
|
72
|
+
/** True between "Stripe took the card" and "the webhook told us about it". */
|
|
73
|
+
let awaitingWebhook = $state(false)
|
|
74
|
+
/** How long a webhook may take before the page stops promising it is coming. */
|
|
75
|
+
const WEBHOOK_GRACE_MS = 90_000
|
|
76
|
+
/** When waiting stops being honest. Fixed once, when the wait starts. */
|
|
77
|
+
let waitDeadline = $state<number | null>(null)
|
|
78
|
+
|
|
79
|
+
$effect(() => {
|
|
80
|
+
const url = new URL(window.location.href)
|
|
81
|
+
const value = url.searchParams.get('checkout')
|
|
82
|
+
if (value !== 'done' && value !== 'cancelled' && value !== 'changed') return
|
|
83
|
+
outcome = value
|
|
84
|
+
awaitingWebhook = value === 'done'
|
|
85
|
+
waitDeadline = value === 'done' ? Date.now() + WEBHOOK_GRACE_MS : null
|
|
86
|
+
url.searchParams.delete('checkout')
|
|
87
|
+
window.history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`)
|
|
88
|
+
})
|
|
89
|
+
|
|
57
90
|
const billing = createQuery(() => ({
|
|
58
91
|
queryKey: ['billing', 'subscription', workspaceId],
|
|
59
92
|
queryFn: () => api.subscription.get({ workspaceId }),
|
|
60
93
|
enabled: Boolean(workspaceId),
|
|
94
|
+
// Stripe redirects the browser back before it has delivered the webhook, so the subscription is
|
|
95
|
+
// genuinely not there yet for a second or two. Polling is what turns "nothing happened" into a
|
|
96
|
+
// page that fills itself in.
|
|
97
|
+
refetchInterval: awaitingWebhook ? 3_000 : false,
|
|
61
98
|
}))
|
|
62
99
|
|
|
63
100
|
const plans = createQuery(() => ({
|
|
@@ -76,6 +113,46 @@ const data = $derived(billing.data)
|
|
|
76
113
|
const sub = $derived(data?.subscription ?? null)
|
|
77
114
|
const usage = $derived(data?.usage ?? { seats: 0, storageBytes: 0, updatedAt: '' })
|
|
78
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Stop waiting — either because the webhook landed, or because it is not going to.
|
|
118
|
+
*
|
|
119
|
+
* A poll with no end is the failure mode here: if `STRIPE_WEBHOOK_SECRET` is wrong, every delivery
|
|
120
|
+
* is rejected and the subscription never appears, so a page promising "this updates itself" refetches
|
|
121
|
+
* every three seconds for as long as the tab is open and never stops lying. The deadline is fixed
|
|
122
|
+
* when the wait starts rather than restarted here, because this effect re-runs on every poll — a
|
|
123
|
+
* timer created inside it would be cleared and rebuilt each time and could never fire.
|
|
124
|
+
*/
|
|
125
|
+
let webhookStalled = $state(false)
|
|
126
|
+
|
|
127
|
+
$effect(() => {
|
|
128
|
+
if (!awaitingWebhook) return
|
|
129
|
+
/**
|
|
130
|
+
* `stripeSubscriptionId` is the signal, and it has to be — a status is not one.
|
|
131
|
+
*
|
|
132
|
+
* A workspace that is about to pay for the first time already has a subscription *row*: signup
|
|
133
|
+
* writes one on the default plan, `trialing`, with no Stripe subscription behind it. So "there is
|
|
134
|
+
* a row, and it is not cancelled" is true before the customer ever reaches Stripe, and waiting on
|
|
135
|
+
* it declares the payment confirmed on the first poll — which is the same lie the wait exists to
|
|
136
|
+
* avoid, told faster. `applySubscription` is what fills this column in, and it only ever runs from
|
|
137
|
+
* a webhook that verified.
|
|
138
|
+
*/
|
|
139
|
+
if (sub?.stripeSubscriptionId) {
|
|
140
|
+
awaitingWebhook = false
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
const left = (waitDeadline ?? 0) - Date.now()
|
|
144
|
+
if (left <= 0) {
|
|
145
|
+
awaitingWebhook = false
|
|
146
|
+
webhookStalled = true
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
const timer = setTimeout(() => {
|
|
150
|
+
awaitingWebhook = false
|
|
151
|
+
webhookStalled = true
|
|
152
|
+
}, left)
|
|
153
|
+
return () => clearTimeout(timer)
|
|
154
|
+
})
|
|
155
|
+
|
|
79
156
|
const STATUS_LABEL: Record<string, () => string> = {
|
|
80
157
|
trialing: () => t('status_trialing'),
|
|
81
158
|
active: () => t('status_active'),
|
|
@@ -105,25 +182,56 @@ const nf = $derived(new Intl.NumberFormat(locale))
|
|
|
105
182
|
const dateFmt = $derived(new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }))
|
|
106
183
|
const day = (iso: string | null) => (iso ? dateFmt.format(new Date(iso)) : '—')
|
|
107
184
|
|
|
108
|
-
|
|
185
|
+
const reload = () => {
|
|
186
|
+
void queryClient.invalidateQueries({ queryKey: ['billing'] })
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Buying a plan, or moving to another one.
|
|
191
|
+
*
|
|
192
|
+
* The server decides which of the two it is — a workspace with a subscription is repriced in place,
|
|
193
|
+
* one without goes through Checkout — and says so in `changed`. A repriced plan is already live, so
|
|
194
|
+
* there is nowhere to send the browser.
|
|
195
|
+
*/
|
|
109
196
|
const checkout = createMutation(() => ({
|
|
110
|
-
mutationFn: (planSlug: string) => api.subscription.checkout({ workspaceId, planSlug }),
|
|
111
|
-
onSuccess: ({ url }) => {
|
|
197
|
+
mutationFn: (planSlug: string) => api.subscription.checkout({ workspaceId, planSlug, returnPath }),
|
|
198
|
+
onSuccess: ({ url, changed }) => {
|
|
199
|
+
if (changed) {
|
|
200
|
+
toast.success(t('plan_changed'))
|
|
201
|
+
reload()
|
|
202
|
+
return
|
|
203
|
+
}
|
|
112
204
|
window.location.href = url
|
|
113
205
|
},
|
|
114
206
|
onError: (e: Error) => toast.error(e.message),
|
|
207
|
+
onSettled: () => {
|
|
208
|
+
leaving = false
|
|
209
|
+
},
|
|
115
210
|
}))
|
|
116
211
|
|
|
117
212
|
const portal = createMutation(() => ({
|
|
118
|
-
mutationFn: () => api.subscription.portal({ workspaceId, returnPath
|
|
213
|
+
mutationFn: () => api.subscription.portal({ workspaceId, returnPath }),
|
|
119
214
|
onSuccess: ({ url }) => {
|
|
120
215
|
window.location.href = url
|
|
121
216
|
},
|
|
122
217
|
onError: (e: Error) => toast.error(e.message),
|
|
218
|
+
onSettled: () => {
|
|
219
|
+
leaving = false
|
|
220
|
+
},
|
|
123
221
|
}))
|
|
124
222
|
|
|
125
|
-
|
|
126
|
-
|
|
223
|
+
/**
|
|
224
|
+
* Guards the second click, which `disabled={mutation.isPending}` does not.
|
|
225
|
+
*
|
|
226
|
+
* The attribute reaches the button on the next render and two quick clicks are one render apart —
|
|
227
|
+
* on this screen that opens two Stripe Checkout sessions, or files two plan changes, each of which
|
|
228
|
+
* is a proration the customer sees on their bill.
|
|
229
|
+
*/
|
|
230
|
+
let leaving = $state(false)
|
|
231
|
+
function once(run: () => void) {
|
|
232
|
+
if (leaving) return
|
|
233
|
+
leaving = true
|
|
234
|
+
run()
|
|
127
235
|
}
|
|
128
236
|
</script>
|
|
129
237
|
|
|
@@ -153,6 +261,53 @@ const reload = () => {
|
|
|
153
261
|
<p class="text-[13px] text-[var(--kern-ink-400)]">{t('subtitle')}</p>
|
|
154
262
|
</header>
|
|
155
263
|
|
|
264
|
+
<!-- What happened at Stripe. Coming back from a payment to a page that looks exactly as it did
|
|
265
|
+
before you left reads as a payment that failed, so the page says which of the three it was —
|
|
266
|
+
including the honest middle one, where the money has moved and the webhook has not landed. -->
|
|
267
|
+
{#if outcome}
|
|
268
|
+
{@const pending = outcome === 'done' && awaitingWebhook}
|
|
269
|
+
{@const stalled = outcome === 'done' && webhookStalled}
|
|
270
|
+
{@const tone =
|
|
271
|
+
outcome === 'cancelled'
|
|
272
|
+
? 'var(--kern-slate-tint)'
|
|
273
|
+
: stalled
|
|
274
|
+
? 'var(--kern-warning-tint)'
|
|
275
|
+
: pending
|
|
276
|
+
? 'var(--kern-info-tint)'
|
|
277
|
+
: 'var(--kern-success-tint)'}
|
|
278
|
+
<div
|
|
279
|
+
class="flex items-start justify-between gap-3 rounded-[var(--kern-r-sm)] px-3 py-2.5"
|
|
280
|
+
style="background: {tone}"
|
|
281
|
+
role="status"
|
|
282
|
+
>
|
|
283
|
+
<div class="grid gap-0.5">
|
|
284
|
+
<span class="text-[13px] font-medium text-[var(--kern-ink-900)]">
|
|
285
|
+
{outcome === 'cancelled'
|
|
286
|
+
? t('checkout_cancelled')
|
|
287
|
+
: outcome === 'changed'
|
|
288
|
+
? t('checkout_changed')
|
|
289
|
+
: stalled
|
|
290
|
+
? t('checkout_slow')
|
|
291
|
+
: pending
|
|
292
|
+
? t('checkout_pending')
|
|
293
|
+
: t('checkout_done')}
|
|
294
|
+
</span>
|
|
295
|
+
<span class="text-[13px] text-[var(--kern-ink-700)]">
|
|
296
|
+
{outcome === 'cancelled'
|
|
297
|
+
? t('checkout_cancelled_hint')
|
|
298
|
+
: outcome === 'changed'
|
|
299
|
+
? t('plan_changed')
|
|
300
|
+
: stalled
|
|
301
|
+
? t('checkout_slow_hint')
|
|
302
|
+
: pending
|
|
303
|
+
? t('checkout_pending_hint')
|
|
304
|
+
: t('checkout_done_hint')}
|
|
305
|
+
</span>
|
|
306
|
+
</div>
|
|
307
|
+
<Button variant="ghost" size="sm" onclick={() => (outcome = null)}>{t('dismiss')}</Button>
|
|
308
|
+
</div>
|
|
309
|
+
{/if}
|
|
310
|
+
|
|
156
311
|
{#if billing.isPending}
|
|
157
312
|
<Skeleton class="h-[132px] w-full rounded-[var(--kern-r-md)]" />
|
|
158
313
|
<Skeleton class="h-[180px] w-full rounded-[var(--kern-r-md)]" />
|
|
@@ -202,7 +357,7 @@ const reload = () => {
|
|
|
202
357
|
variant="secondary"
|
|
203
358
|
disabled={!canManage || portal.isPending}
|
|
204
359
|
loading={portal.isPending}
|
|
205
|
-
onclick={() => portal.mutate()}
|
|
360
|
+
onclick={() => once(() => portal.mutate())}
|
|
206
361
|
>
|
|
207
362
|
{t('manage_payment')}
|
|
208
363
|
</Button>
|
|
@@ -295,7 +450,7 @@ const reload = () => {
|
|
|
295
450
|
variant={isCurrent ? 'secondary' : 'primary'}
|
|
296
451
|
disabled={isCurrent || !canManage || blocked !== null || checkout.isPending}
|
|
297
452
|
loading={checkout.isPending && checkout.variables === plan.slug}
|
|
298
|
-
onclick={() => checkout.mutate(plan.slug)}
|
|
453
|
+
onclick={() => once(() => checkout.mutate(plan.slug))}
|
|
299
454
|
>
|
|
300
455
|
{isCurrent ? t('current') : t('choose_plan')}
|
|
301
456
|
</Button>
|
package/src/contract.ts
CHANGED
|
@@ -224,11 +224,30 @@ export const billingContract = {
|
|
|
224
224
|
.route({ method: 'GET', path: '/subscription/invoices', tags: ['billing'] })
|
|
225
225
|
.input(ws.extend(PageInput.shape))
|
|
226
226
|
.output(page(Invoice)),
|
|
227
|
-
/**
|
|
227
|
+
/**
|
|
228
|
+
* Buy a plan, or move to a different one.
|
|
229
|
+
*
|
|
230
|
+
* Two different acts behind one procedure, because the caller does not know which it is: a
|
|
231
|
+
* workspace with no Stripe subscription needs Checkout (a card has to be collected), and one
|
|
232
|
+
* that already has a subscription needs its existing subscription repriced — opening a second
|
|
233
|
+
* Checkout there leaves the first subscription running and bills the customer twice.
|
|
234
|
+
*
|
|
235
|
+
* `changed` says which happened. When it is true the plan is already live and `url` is simply
|
|
236
|
+
* where to send the person back to; when it is false `url` is Stripe's hosted Checkout.
|
|
237
|
+
*
|
|
238
|
+
* `returnPath` is the workspace-relative path to come back to, exactly as `portal` takes it —
|
|
239
|
+
* billing knows workspace ids and not slugs, so the caller supplies the route.
|
|
240
|
+
*/
|
|
228
241
|
checkout: baseContract
|
|
229
242
|
.route({ method: 'POST', path: '/subscription/checkout', tags: ['billing'] })
|
|
230
|
-
.input(
|
|
231
|
-
|
|
243
|
+
.input(
|
|
244
|
+
ws.extend({
|
|
245
|
+
planSlug: z.string(),
|
|
246
|
+
seats: z.number().int().positive().optional(),
|
|
247
|
+
returnPath: z.string().default('/'),
|
|
248
|
+
}),
|
|
249
|
+
)
|
|
250
|
+
.output(z.object({ url: z.string(), changed: z.boolean().default(false) })),
|
|
232
251
|
/** Stripe's own billing portal: payment method, cancellation, invoice download. */
|
|
233
252
|
portal: baseContract
|
|
234
253
|
.route({ method: 'POST', path: '/subscription/portal', tags: ['billing'] })
|