@frigear-nu/subscriptions 0.1.1 → 0.1.3
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/README.md +5 -5
- package/dist/module.d.mts +2 -2
- package/dist/module.json +1 -1
- package/dist/module.mjs +20 -31
- package/dist/runtime/app/components/SubscriptionPlans.vue +9 -15
- package/dist/runtime/app/components/SubscriptionStatus.d.vue.ts +7 -12
- package/dist/runtime/app/components/SubscriptionStatus.vue +26 -26
- package/dist/runtime/app/components/SubscriptionStatus.vue.d.ts +7 -12
- package/dist/runtime/app/components/Users/Admin/Membership.vue +3 -2
- package/dist/runtime/app/composables/usePlans.d.ts +4 -11
- package/dist/runtime/app/composables/usePlans.js +8 -11
- package/dist/runtime/app/composables/useSubscription.d.ts +6 -24
- package/dist/runtime/app/composables/useSubscription.js +12 -17
- package/dist/runtime/app/middleware/subscription.js +2 -6
- package/dist/runtime/app/pages/cancel.vue +18 -6
- package/dist/runtime/app/pages/membership.vue +42 -17
- package/dist/runtime/app/pages/success.vue +19 -7
- package/dist/runtime/app/tabs/Membership.vue +19 -9
- package/dist/runtime/app/widgets/MembershipSummary.vue +2 -2
- package/dist/runtime/i18n/lang/da.json +16 -11
- package/dist/runtime/i18n/lang/en.json +18 -13
- package/dist/runtime/menus.js +4 -4
- package/dist/runtime/server/bootstrap/my-subscriptions.d.ts +7 -0
- package/dist/runtime/server/bootstrap/my-subscriptions.js +4 -0
- package/dist/runtime/server/routes/admin/{subscription.delete.js → membership.delete.js} +3 -2
- package/dist/runtime/server/routes/admin/{subscription.get.js → membership.get.js} +2 -2
- package/dist/runtime/server/routes/admin/{subscription.post.js → membership.post.js} +9 -14
- package/dist/runtime/server/routes/admin/payment-link.post.js +3 -2
- package/dist/runtime/server/routes/{subscriptions → me}/checkout.post.js +9 -18
- package/dist/runtime/server/routes/{subscriptions → me}/portal.post.js +5 -8
- package/dist/runtime/server/routes/subscriptions/me.get.js +2 -4
- package/dist/runtime/server/utils/checkout.js +6 -5
- package/dist/runtime/server/utils/subscriptions.d.ts +6 -1
- package/dist/runtime/server/utils/subscriptions.js +15 -12
- package/dist/runtime/utils/api.d.ts +20 -0
- package/dist/runtime/utils/api.js +23 -0
- package/dist/runtime/utils/index.d.ts +2 -1
- package/dist/runtime/utils/index.js +2 -1
- package/package.json +19 -14
- package/dist/runtime/app/plugins/warm-plans.client.d.ts +0 -6
- package/dist/runtime/app/plugins/warm-plans.client.js +0 -12
- /package/dist/runtime/server/routes/admin/{subscription.delete.d.ts → membership-history.get.d.ts} +0 -0
- /package/dist/runtime/server/routes/admin/{subscriptions.get.js → membership-history.get.js} +0 -0
- /package/dist/runtime/server/routes/admin/{subscription.get.d.ts → membership.delete.d.ts} +0 -0
- /package/dist/runtime/server/routes/admin/{subscription.post.d.ts → membership.get.d.ts} +0 -0
- /package/dist/runtime/server/routes/admin/{subscriptions.get.d.ts → membership.post.d.ts} +0 -0
- /package/dist/runtime/server/routes/{subscriptions → me}/checkout.post.d.ts +0 -0
- /package/dist/runtime/server/routes/{subscriptions → me}/portal.post.d.ts +0 -0
|
@@ -4,30 +4,55 @@ import SubscriptionStatus from "../components/SubscriptionStatus.vue";
|
|
|
4
4
|
import { useSubscription } from "../composables/useSubscription";
|
|
5
5
|
defineOptions({ name: "SubscriptionMembershipPage" });
|
|
6
6
|
const t = useModuleT("subscriptions");
|
|
7
|
-
const
|
|
7
|
+
const tm = useModuleT("subscriptions.menu.membership");
|
|
8
|
+
const { isActive, cancelsAt, loading, loadError, refresh } = await useSubscription();
|
|
8
9
|
const { loggedIn } = useUserSession();
|
|
9
10
|
</script>
|
|
10
11
|
|
|
11
12
|
<template>
|
|
12
|
-
<div class="flex flex-col gap-
|
|
13
|
-
<
|
|
14
|
-
|
|
13
|
+
<div class="flex flex-col gap-4">
|
|
14
|
+
<PageHeading
|
|
15
|
+
:title="tm('label', 'Membership')"
|
|
16
|
+
:description="tm('description', 'Manage your plan and billing.')"
|
|
17
|
+
/>
|
|
18
|
+
|
|
19
|
+
<!-- Until the subscription is known, a placeholder the size of the status card: never the "join" empty state. -->
|
|
20
|
+
<SectionCard
|
|
21
|
+
v-if="loading"
|
|
22
|
+
:title="t('status.title', 'Your membership')"
|
|
23
|
+
icon="i-lucide-credit-card"
|
|
24
|
+
loading
|
|
25
|
+
/>
|
|
26
|
+
<SubscriptionStatus v-else-if="isActive" />
|
|
27
|
+
<UEmpty
|
|
28
|
+
v-else-if="loadError"
|
|
29
|
+
icon="i-lucide-circle-alert"
|
|
30
|
+
:title="t('status.loadFailed', 'Your membership could not be loaded')"
|
|
31
|
+
:description="errorMessage(loadError, '')"
|
|
32
|
+
:actions="[{ label: t('status.tryAgain', 'Try again'), icon: 'i-lucide-rotate-ccw', color: 'neutral', variant: 'outline', onClick: () => refresh() }]"
|
|
33
|
+
/>
|
|
34
|
+
<SectionCard
|
|
15
35
|
v-else-if="loggedIn"
|
|
16
|
-
|
|
36
|
+
:title="t('status.title', 'Your membership')"
|
|
37
|
+
icon="i-lucide-credit-card"
|
|
17
38
|
>
|
|
18
|
-
<
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
</
|
|
25
|
-
|
|
39
|
+
<UEmpty
|
|
40
|
+
variant="naked"
|
|
41
|
+
icon="i-lucide-badge-check"
|
|
42
|
+
:title="t('status.emptyTitle', 'No membership yet')"
|
|
43
|
+
:description="t('status.emptyDescription', 'Set up your membership to get started!')"
|
|
44
|
+
/>
|
|
45
|
+
</SectionCard>
|
|
46
|
+
|
|
47
|
+
<SectionCard
|
|
26
48
|
v-if="cancelsAt"
|
|
27
|
-
|
|
49
|
+
:title="t('plans.resubscribe', 'Resubscribe')"
|
|
28
50
|
>
|
|
29
|
-
|
|
30
|
-
</
|
|
31
|
-
<SubscriptionPlans
|
|
51
|
+
<SubscriptionPlans confirm-change />
|
|
52
|
+
</SectionCard>
|
|
53
|
+
<SubscriptionPlans
|
|
54
|
+
v-else
|
|
55
|
+
confirm-change
|
|
56
|
+
/>
|
|
32
57
|
</div>
|
|
33
58
|
</template>
|
|
@@ -2,9 +2,14 @@
|
|
|
2
2
|
import { useSubscription } from "../composables/useSubscription";
|
|
3
3
|
defineOptions({ name: "SubscriptionSuccessPage" });
|
|
4
4
|
const t = useModuleT("subscriptions.success");
|
|
5
|
-
const { refresh, isActive } = useSubscription();
|
|
5
|
+
const { refresh, isActive } = await useSubscription();
|
|
6
6
|
const { localize } = useLocaleLink();
|
|
7
|
+
const tm = useModuleT("subscriptions.menu.membership");
|
|
7
8
|
const membership = useRuntimeConfig().public.subscriptions?.pages?.membership;
|
|
9
|
+
const breadcrumb = computed(() => [
|
|
10
|
+
...membership ? [{ label: tm("label", "Membership"), to: localize(membership) }] : [],
|
|
11
|
+
{ label: t("crumb", "Checkout complete") }
|
|
12
|
+
]);
|
|
8
13
|
const MAX_ATTEMPTS = 40;
|
|
9
14
|
const INTERVAL_MS = 3e3;
|
|
10
15
|
let stopped = false;
|
|
@@ -20,10 +25,17 @@ onMounted(async () => {
|
|
|
20
25
|
</script>
|
|
21
26
|
|
|
22
27
|
<template>
|
|
23
|
-
<
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
<div class="flex flex-col gap-4">
|
|
29
|
+
<PageHeading
|
|
30
|
+
:title="tm('label', 'Membership')"
|
|
31
|
+
:description="tm('description', 'Manage your plan and billing.')"
|
|
32
|
+
:breadcrumb="breadcrumb"
|
|
33
|
+
/>
|
|
34
|
+
<UEmpty
|
|
35
|
+
:icon="isActive ? 'i-lucide-circle-check' : 'i-lucide-loader-circle'"
|
|
36
|
+
:title="t('title', 'Thank you!')"
|
|
37
|
+
:description="isActive ? t('activeBody', 'Your membership is now active.') : t('body', 'Your membership is being activated. This can take a few seconds.')"
|
|
38
|
+
:actions="membership ? [{ label: t('back', 'Back to membership'), to: localize(membership) }] : []"
|
|
39
|
+
/>
|
|
40
|
+
</div>
|
|
29
41
|
</template>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
+
import { subscriptionsApi } from "../../utils/api";
|
|
2
3
|
defineOptions({ name: "UsersAdminMembershipTab" });
|
|
3
4
|
const props = defineProps({
|
|
4
5
|
user: { type: Object, required: true }
|
|
@@ -6,15 +7,16 @@ const props = defineProps({
|
|
|
6
7
|
const emit = defineEmits(["refresh"]);
|
|
7
8
|
const t = useModuleT("subscriptions.admin");
|
|
8
9
|
const format = useFormat();
|
|
9
|
-
const
|
|
10
|
-
const { data: history, refresh: refreshHistory, status } = useLazyFetch(
|
|
11
|
-
() =>
|
|
10
|
+
const StatusBadge = resolveComponent("StatusBadge");
|
|
11
|
+
const { data: history, refresh: refreshHistory, status, error } = useLazyFetch(
|
|
12
|
+
() => subscriptionsApi.admin.history(props.user.id),
|
|
12
13
|
{ key: () => `admin-subscriptions-${props.user.id}` }
|
|
13
14
|
);
|
|
14
|
-
const
|
|
15
|
+
const loading = computed(() => status.value === "pending" && !history.value);
|
|
16
|
+
const date = (value) => format.date(value) || emptyCell();
|
|
15
17
|
const columns = computed(() => [
|
|
16
18
|
{ accessorKey: "planKey", header: t("history.plan", "Plan"), cell: ({ row }) => row.original.planKey ?? row.original.priceId },
|
|
17
|
-
{ accessorKey: "status", header: t("history.status", "Status"), cell: ({ row }) => h(
|
|
19
|
+
{ accessorKey: "status", header: t("history.status", "Status"), cell: ({ row }) => h(StatusBadge, { status: row.original.status }) },
|
|
18
20
|
{ accessorKey: "source", header: t("history.source", "Source"), cell: ({ row }) => row.original.source === "manual" ? t("free", "Free of charge") : t("paid", "Paid (Stripe)") },
|
|
19
21
|
{ accessorKey: "currentPeriodStart", header: t("history.started", "Started"), cell: ({ row }) => date(row.original.currentPeriodStart) },
|
|
20
22
|
{ accessorKey: "currentPeriodEnd", header: t("history.periodEnd", "Period end"), cell: ({ row }) => date(row.original.currentPeriodEnd) },
|
|
@@ -35,24 +37,32 @@ async function changed() {
|
|
|
35
37
|
/>
|
|
36
38
|
</SectionCard>
|
|
37
39
|
|
|
38
|
-
<
|
|
40
|
+
<TableCard
|
|
39
41
|
:title="t('history.title', 'Subscription history')"
|
|
40
|
-
|
|
42
|
+
:loading="loading"
|
|
43
|
+
:refreshing="status === 'pending' && !loading"
|
|
41
44
|
>
|
|
42
45
|
<UTable
|
|
43
46
|
:data="history?.subscriptions ?? []"
|
|
44
47
|
:columns="columns"
|
|
45
|
-
:loading="status === 'pending'"
|
|
46
48
|
:ui="tableUi({ tr: '' })"
|
|
47
49
|
>
|
|
48
50
|
<template #empty>
|
|
49
51
|
<UEmpty
|
|
52
|
+
v-if="error"
|
|
53
|
+
variant="naked"
|
|
54
|
+
icon="i-lucide-circle-alert"
|
|
55
|
+
:title="t('history.loadFailed', 'The subscription history could not be loaded')"
|
|
56
|
+
:description="errorMessage(error, '')"
|
|
57
|
+
/>
|
|
58
|
+
<UEmpty
|
|
59
|
+
v-else
|
|
50
60
|
variant="naked"
|
|
51
61
|
icon="i-lucide-badge-check"
|
|
52
62
|
:title="t('history.empty', 'No subscriptions yet.')"
|
|
53
63
|
/>
|
|
54
64
|
</template>
|
|
55
65
|
</UTable>
|
|
56
|
-
</
|
|
66
|
+
</TableCard>
|
|
57
67
|
</div>
|
|
58
68
|
</template>
|
|
@@ -8,7 +8,7 @@ const tp = useModuleT("subscriptions.catalog");
|
|
|
8
8
|
const format = useFormat();
|
|
9
9
|
const { localize } = useLocaleLink();
|
|
10
10
|
const { plans } = usePlans();
|
|
11
|
-
const { subscription, plan,
|
|
11
|
+
const { subscription, plan, isActive, cancelsAt, loading } = useSubscription();
|
|
12
12
|
const membership = useRuntimeConfig().public.subscriptions?.pages?.membership;
|
|
13
13
|
const planLabel = computed(() => {
|
|
14
14
|
if (!plan.value) return "";
|
|
@@ -28,7 +28,7 @@ const hint = computed(() => {
|
|
|
28
28
|
:label="t('label', 'Membership')"
|
|
29
29
|
icon="i-lucide-credit-card"
|
|
30
30
|
:to="membership ? localize(membership) : void 0"
|
|
31
|
-
:loading="
|
|
31
|
+
:loading="loading"
|
|
32
32
|
:value="isActive ? planLabel : void 0"
|
|
33
33
|
:hint="isActive ? hint : void 0"
|
|
34
34
|
:empty="t('none', 'No active membership')"
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"confirmUpgrade": "Hvis du opgraderer, flyttes du straks til den højere plan. Resterende kredit fratrækkes, og du opkræves differencen med det samme.",
|
|
20
20
|
"confirmDowngrade": "Hvis du nedgraderer, kører dit nuværende abonnement til næste forfaldsdato, hvorefter det skifter.",
|
|
21
21
|
"subscribeTo": "Du abonnerer nu på:",
|
|
22
|
-
"changeTitle": "Opdatér
|
|
22
|
+
"changeTitle": "Opdatér medlemskab",
|
|
23
23
|
"checkoutTitle": "Bliv medlem",
|
|
24
24
|
"confirmResume": "Dit medlemskab fornyes som hidtil."
|
|
25
25
|
},
|
|
@@ -48,24 +48,28 @@
|
|
|
48
48
|
"canceled": "Annulleret",
|
|
49
49
|
"other": "Inaktiv",
|
|
50
50
|
"currentPlan": "Nuværende plan",
|
|
51
|
-
"cancelling": "
|
|
51
|
+
"cancelling": "Medlemskabet udløber",
|
|
52
52
|
"cancelsAt": "Slutter {date}",
|
|
53
53
|
"renewsAutomaticallyAt": "Fornyes automatisk {date}",
|
|
54
54
|
"cancel": "Opsig medlemskab",
|
|
55
55
|
"changePaymentMethod": "Skift betalingsmetode",
|
|
56
56
|
"free": "Gratis medlemskab",
|
|
57
57
|
"freeUntil": "Gratis medlemskab til {date}",
|
|
58
|
-
"
|
|
59
|
-
"
|
|
58
|
+
"emptyTitle": "Intet medlemskab endnu",
|
|
59
|
+
"emptyDescription": "Opret dit medlemskab for at komme i gang!",
|
|
60
|
+
"loadFailed": "Dit medlemskab kunne ikke indlæses",
|
|
61
|
+
"tryAgain": "Prøv igen"
|
|
60
62
|
},
|
|
61
63
|
"success": {
|
|
62
64
|
"title": "Tak!",
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
+
"crumb": "Betaling gennemført",
|
|
66
|
+
"body": "Dit medlemskab bliver aktiveret. Det kan tage et par sekunder.",
|
|
67
|
+
"activeBody": "Dit medlemskab er aktiveret.",
|
|
65
68
|
"back": "Tilbage til medlemskab"
|
|
66
69
|
},
|
|
67
70
|
"cancel": {
|
|
68
71
|
"title": "Betaling annulleret",
|
|
72
|
+
"crumb": "Betaling annulleret",
|
|
69
73
|
"body": "Der blev ikke foretaget ændringer, og du er ikke blevet opkrævet.",
|
|
70
74
|
"back": "Tilbage til medlemskab"
|
|
71
75
|
},
|
|
@@ -74,9 +78,9 @@
|
|
|
74
78
|
"portal": "Kunne ikke åbne betalingsportalen."
|
|
75
79
|
},
|
|
76
80
|
"menu": {
|
|
77
|
-
"
|
|
78
|
-
"label": "
|
|
79
|
-
"description": "Administrer dit
|
|
81
|
+
"membership": {
|
|
82
|
+
"label": "Medlemskab",
|
|
83
|
+
"description": "Administrer dit medlemskab og din betaling."
|
|
80
84
|
}
|
|
81
85
|
},
|
|
82
86
|
"admin": {
|
|
@@ -102,11 +106,12 @@
|
|
|
102
106
|
"linkHint": "Linket er gyldigt i 24 timer.",
|
|
103
107
|
"paidHint": "Betalte abonnementer administreres i Stripe.",
|
|
104
108
|
"error": "Noget gik galt.",
|
|
105
|
-
"tab": "
|
|
109
|
+
"tab": "Medlemskab",
|
|
106
110
|
"membership": "Medlemskab",
|
|
107
|
-
"currentSubscription": "Nuværende
|
|
111
|
+
"currentSubscription": "Nuværende medlemskab",
|
|
108
112
|
"history": {
|
|
109
113
|
"title": "Abonnementshistorik",
|
|
114
|
+
"loadFailed": "Abonnementshistorikken kunne ikke hentes",
|
|
110
115
|
"empty": "Ingen abonnementer endnu.",
|
|
111
116
|
"plan": "Plan",
|
|
112
117
|
"status": "Status",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"confirmUpgrade": "If you upgrade, you are moved to the higher plan immediately. Any remaining credit is deducted and you are charged the difference right away.",
|
|
20
20
|
"confirmDowngrade": "If you downgrade, your current subscription runs until the next due date, then switches.",
|
|
21
21
|
"subscribeTo": "You will now subscribe to:",
|
|
22
|
-
"changeTitle": "Update
|
|
22
|
+
"changeTitle": "Update membership",
|
|
23
23
|
"checkoutTitle": "Get a membership",
|
|
24
24
|
"confirmResume": "Your membership will keep renewing as before."
|
|
25
25
|
},
|
|
@@ -35,9 +35,9 @@
|
|
|
35
35
|
"quarter": "quarter"
|
|
36
36
|
},
|
|
37
37
|
"status": {
|
|
38
|
-
"loading": "Loading
|
|
39
|
-
"title": "Your
|
|
40
|
-
"none": "You do not have an active
|
|
38
|
+
"loading": "Loading membership…",
|
|
39
|
+
"title": "Your membership",
|
|
40
|
+
"none": "You do not have an active membership.",
|
|
41
41
|
"renews": "Renews on {date}",
|
|
42
42
|
"cancels": "Ends on {date}",
|
|
43
43
|
"manage": "Manage billing",
|
|
@@ -48,24 +48,28 @@
|
|
|
48
48
|
"canceled": "Canceled",
|
|
49
49
|
"other": "Inactive",
|
|
50
50
|
"currentPlan": "Current plan",
|
|
51
|
-
"cancelling": "
|
|
51
|
+
"cancelling": "Membership is ending",
|
|
52
52
|
"cancelsAt": "Ending at {date}",
|
|
53
53
|
"renewsAutomaticallyAt": "Renews automatically on {date}",
|
|
54
54
|
"cancel": "Cancel membership",
|
|
55
55
|
"changePaymentMethod": "Change payment method",
|
|
56
56
|
"free": "Free membership",
|
|
57
57
|
"freeUntil": "Free membership until {date}",
|
|
58
|
-
"
|
|
59
|
-
"
|
|
58
|
+
"emptyTitle": "No membership yet",
|
|
59
|
+
"emptyDescription": "Set up your membership to get started!",
|
|
60
|
+
"loadFailed": "Your membership could not be loaded",
|
|
61
|
+
"tryAgain": "Try again"
|
|
60
62
|
},
|
|
61
63
|
"success": {
|
|
62
64
|
"title": "Thank you!",
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
+
"crumb": "Checkout complete",
|
|
66
|
+
"body": "Your membership is being activated. This can take a few seconds.",
|
|
67
|
+
"activeBody": "Your membership is now active.",
|
|
65
68
|
"back": "Back to membership"
|
|
66
69
|
},
|
|
67
70
|
"cancel": {
|
|
68
71
|
"title": "Checkout canceled",
|
|
72
|
+
"crumb": "Checkout canceled",
|
|
69
73
|
"body": "No changes were made and you have not been charged.",
|
|
70
74
|
"back": "Back to membership"
|
|
71
75
|
},
|
|
@@ -74,8 +78,8 @@
|
|
|
74
78
|
"portal": "Failed to open the billing portal."
|
|
75
79
|
},
|
|
76
80
|
"menu": {
|
|
77
|
-
"
|
|
78
|
-
"label": "
|
|
81
|
+
"membership": {
|
|
82
|
+
"label": "Membership",
|
|
79
83
|
"description": "Manage your plan and billing."
|
|
80
84
|
}
|
|
81
85
|
},
|
|
@@ -102,11 +106,12 @@
|
|
|
102
106
|
"linkHint": "The link is valid for 24 hours.",
|
|
103
107
|
"paidHint": "Paid subscriptions are managed in Stripe.",
|
|
104
108
|
"error": "Something went wrong.",
|
|
105
|
-
"tab": "
|
|
109
|
+
"tab": "Membership",
|
|
106
110
|
"membership": "Membership",
|
|
107
|
-
"currentSubscription": "Current
|
|
111
|
+
"currentSubscription": "Current membership",
|
|
108
112
|
"history": {
|
|
109
113
|
"title": "Subscription history",
|
|
114
|
+
"loadFailed": "The subscription history could not be loaded",
|
|
110
115
|
"empty": "No subscriptions yet.",
|
|
111
116
|
"plan": "Plan",
|
|
112
117
|
"status": "Status",
|
package/dist/runtime/menus.js
CHANGED
|
@@ -3,11 +3,11 @@ export default defineMenuSource(() => {
|
|
|
3
3
|
const config = useRuntimeConfig().public.subscriptions;
|
|
4
4
|
if (config?.pages?.membership) {
|
|
5
5
|
extendMenuItem("account", defineMenuItem({
|
|
6
|
-
id: "
|
|
7
|
-
label: "
|
|
8
|
-
labelKey: "subscriptions.menu.
|
|
6
|
+
id: "membership",
|
|
7
|
+
label: "Membership",
|
|
8
|
+
labelKey: "subscriptions.menu.membership.label",
|
|
9
9
|
description: "Manage your plan and billing.",
|
|
10
|
-
descriptionKey: "subscriptions.menu.
|
|
10
|
+
descriptionKey: "subscriptions.menu.membership.description",
|
|
11
11
|
icon: "i-lucide-credit-card",
|
|
12
12
|
to: config.pages.membership,
|
|
13
13
|
order: 15
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { H3Event } from 'h3';
|
|
2
|
+
import type { MeResponse } from '../../../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* The caller's subscriptions: the body of `GET /api/subscriptions/me` and the account bootstrap's `subscriptions:me`
|
|
5
|
+
* (the key `useSubscription()` reads), so both always return the same thing.
|
|
6
|
+
*/
|
|
7
|
+
export default function mySubscriptions(event: H3Event): Promise<MeResponse>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { db, schema } from "@nuxthub/db";
|
|
2
2
|
import { and, eq, isNull } from "drizzle-orm";
|
|
3
|
+
import { noContent, notFound } from "@frigear-nu/module-kit/server";
|
|
3
4
|
export default defineEventHandler(async (event) => {
|
|
4
5
|
const { target } = await loadUsersAdminTarget(event, "update");
|
|
5
6
|
const revoked = await db.update(schema.subscriptions).set({ status: "canceled", endedAt: /* @__PURE__ */ new Date() }).where(and(
|
|
@@ -8,7 +9,7 @@ export default defineEventHandler(async (event) => {
|
|
|
8
9
|
isNull(schema.subscriptions.endedAt)
|
|
9
10
|
)).returning({ id: schema.subscriptions.id });
|
|
10
11
|
if (!revoked.length) {
|
|
11
|
-
throw
|
|
12
|
+
throw notFound("The user has no free membership to revoke.");
|
|
12
13
|
}
|
|
13
|
-
return
|
|
14
|
+
return noContent(event);
|
|
14
15
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
+
import { isoDate } from "@frigear-nu/module-kit/server";
|
|
1
2
|
export default defineEventHandler(async (event) => {
|
|
2
3
|
const { target } = await loadUsersAdminTarget(event, "view");
|
|
3
4
|
const current = currentEntitledRow(await listUserSubscriptionRows(target.id));
|
|
4
|
-
const iso = (date) => date ? date.toISOString() : null;
|
|
5
5
|
const plans = useRuntimeConfig().subscriptions?.plans ?? {};
|
|
6
6
|
return {
|
|
7
7
|
plans: Object.entries(plans).map(([key, plan]) => ({ key, label: plan.label ?? key, rank: plan.rank })).sort((a, b) => a.rank - b.rank),
|
|
@@ -10,7 +10,7 @@ export default defineEventHandler(async (event) => {
|
|
|
10
10
|
planKey: current.planKey,
|
|
11
11
|
status: current.status,
|
|
12
12
|
source: current.source === "manual" ? "manual" : "stripe",
|
|
13
|
-
currentPeriodEnd:
|
|
13
|
+
currentPeriodEnd: isoDate(current.currentPeriodEnd),
|
|
14
14
|
cancelAtPeriodEnd: current.cancelAtPeriodEnd
|
|
15
15
|
} : null
|
|
16
16
|
};
|
|
@@ -1,34 +1,29 @@
|
|
|
1
1
|
import { db, schema } from "@nuxthub/db";
|
|
2
2
|
import { and, eq, isNull } from "drizzle-orm";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import { badRequest, conflict, created } from "@frigear-nu/module-kit/server";
|
|
4
5
|
const bodySchema = z.object({
|
|
5
6
|
planKey: z.string().min(1),
|
|
6
|
-
/** Optional end of the free membership (ISO date); the grant is open-ended without it. */
|
|
7
|
-
endsAt: z.
|
|
7
|
+
/** Optional end of the free membership (ISO date, in the future); the grant is open-ended without it. */
|
|
8
|
+
endsAt: z.coerce.date().refine((date) => date.getTime() > Date.now(), "The end date must be in the future.").nullish()
|
|
8
9
|
});
|
|
9
10
|
export default defineEventHandler(async (event) => {
|
|
10
11
|
const { target } = await loadUsersAdminTarget(event, "update");
|
|
11
12
|
const body = await readValidatedBody(event, bodySchema.parse);
|
|
12
13
|
const plans = useRuntimeConfig().subscriptions?.plans ?? {};
|
|
13
14
|
if (Object.keys(plans).length && !plans[body.planKey]) {
|
|
14
|
-
throw
|
|
15
|
-
}
|
|
16
|
-
let endsAt = null;
|
|
17
|
-
if (body.endsAt) {
|
|
18
|
-
endsAt = new Date(body.endsAt);
|
|
19
|
-
if (Number.isNaN(endsAt.getTime()) || endsAt.getTime() <= Date.now()) {
|
|
20
|
-
throw createError({ statusCode: 400, statusMessage: "The end date must be in the future." });
|
|
21
|
-
}
|
|
15
|
+
throw badRequest("Unknown plan.");
|
|
22
16
|
}
|
|
17
|
+
const endsAt = body.endsAt ?? null;
|
|
23
18
|
const rows = await listUserSubscriptionRows(target.id);
|
|
24
19
|
const current = currentEntitledRow(rows);
|
|
25
20
|
if (current && current.source !== "manual") {
|
|
26
|
-
throw
|
|
21
|
+
throw conflict("The user already has a paid subscription.");
|
|
27
22
|
}
|
|
28
23
|
const now = /* @__PURE__ */ new Date();
|
|
29
24
|
await db.update(schema.subscriptions).set({ status: "canceled", endedAt: now }).where(and(eq(schema.subscriptions.userId, target.id), eq(schema.subscriptions.source, "manual"), isNull(schema.subscriptions.endedAt)));
|
|
30
25
|
const id = `manual_${crypto.randomUUID()}`;
|
|
31
|
-
await db.insert(schema.subscriptions).values({
|
|
26
|
+
const [row] = await db.insert(schema.subscriptions).values({
|
|
32
27
|
id,
|
|
33
28
|
userId: target.id,
|
|
34
29
|
planKey: body.planKey,
|
|
@@ -39,6 +34,6 @@ export default defineEventHandler(async (event) => {
|
|
|
39
34
|
currentPeriodEnd: endsAt,
|
|
40
35
|
endedAt: null,
|
|
41
36
|
source: "manual"
|
|
42
|
-
});
|
|
43
|
-
return
|
|
37
|
+
}).returning();
|
|
38
|
+
return created(event, toSubscriptionDto(row));
|
|
44
39
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { apiError, conflict } from "@frigear-nu/module-kit/server";
|
|
2
3
|
const bodySchema = z.object({
|
|
3
4
|
planKey: z.string().min(1).optional(),
|
|
4
5
|
priceId: z.string().min(1).optional(),
|
|
@@ -14,7 +15,7 @@ export default defineEventHandler(async (event) => {
|
|
|
14
15
|
const stripe = useStripe();
|
|
15
16
|
const current = currentEntitledRow(await listUserSubscriptionRows(target.id));
|
|
16
17
|
if (current && current.source !== "manual") {
|
|
17
|
-
throw
|
|
18
|
+
throw conflict("The user already has a paid subscription.");
|
|
18
19
|
}
|
|
19
20
|
const { priceId, planKey } = await resolveCheckoutTarget(stripe, await getResolvedPlans(), body);
|
|
20
21
|
const customer = await ensureStripeCustomer(target.id);
|
|
@@ -27,7 +28,7 @@ export default defineEventHandler(async (event) => {
|
|
|
27
28
|
client_reference_id: String(target.id),
|
|
28
29
|
subscription_data: { metadata: { userId: String(target.id), ...planKey ? { planKey } : {} } }
|
|
29
30
|
});
|
|
30
|
-
if (!session.url) throw
|
|
31
|
+
if (!session.url) throw apiError(502, "bad_gateway", "Stripe did not return a checkout URL.");
|
|
31
32
|
let emailed = false;
|
|
32
33
|
if (body.email) {
|
|
33
34
|
const plan = (useRuntimeConfig().subscriptions?.plans ?? {})[planKey ?? ""];
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { db, schema } from "@nuxthub/db";
|
|
2
2
|
import { desc, eq } from "drizzle-orm";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import { resolveSameOriginUrl } from "@frigear-nu/
|
|
4
|
+
import { apiError, conflict, resolveSameOriginUrl } from "@frigear-nu/module-kit/server";
|
|
5
5
|
import { LIVE_STATUSES, decideChangeKind } from "../../../utils/plans.js";
|
|
6
6
|
const bodySchema = z.object({
|
|
7
7
|
planKey: z.string().min(1).optional(),
|
|
@@ -10,23 +10,14 @@ const bodySchema = z.object({
|
|
|
10
10
|
cancelUrl: z.string().optional()
|
|
11
11
|
}).refine((body) => body.planKey || body.priceId, { message: "Provide `planKey` or `priceId`." });
|
|
12
12
|
export default defineEventHandler(async (event) => {
|
|
13
|
-
const user = await requireUser(event, {
|
|
13
|
+
const user = await requireUser(event, { sessionOnly: true });
|
|
14
14
|
const body = await readValidatedBody(event, bodySchema.parse);
|
|
15
|
-
const origin = getRequestURL(event).origin;
|
|
16
15
|
const config = useRuntimeConfig();
|
|
17
16
|
const pages = config.public.subscriptions?.pages ?? {};
|
|
18
17
|
const configuredPlans = config.subscriptions?.plans ?? {};
|
|
19
18
|
const stripe = useStripe();
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
const safe = resolveSameOriginUrl(input, origin);
|
|
23
|
-
if (!safe) throw createError({ statusCode: 400, statusMessage: `${name} must be a relative path or an URL on the same origin.` });
|
|
24
|
-
return safe;
|
|
25
|
-
}
|
|
26
|
-
return `${origin}${fallbackPath ?? pages.membership ?? "/"}`;
|
|
27
|
-
};
|
|
28
|
-
const successUrl = safeUrl(body.successUrl, pages.success, "successUrl");
|
|
29
|
-
const cancelUrl = safeUrl(body.cancelUrl, pages.cancel, "cancelUrl");
|
|
19
|
+
const successUrl = resolveSameOriginUrl(event, body.successUrl, { fallback: pages.success ?? pages.membership, name: "successUrl" });
|
|
20
|
+
const cancelUrl = resolveSameOriginUrl(event, body.cancelUrl, { fallback: pages.cancel ?? pages.membership, name: "cancelUrl" });
|
|
30
21
|
const { priceId, planKey } = await resolveCheckoutTarget(stripe, await getAvailablePlans(event), body);
|
|
31
22
|
const rows = await db.select().from(schema.subscriptions).where(eq(schema.subscriptions.userId, user.id)).orderBy(desc(schema.subscriptions.createdAt));
|
|
32
23
|
const existing = rows.find((row) => row.source !== "manual" && LIVE_STATUSES.includes(row.status) && !row.endedAt);
|
|
@@ -35,7 +26,7 @@ export default defineEventHandler(async (event) => {
|
|
|
35
26
|
return { updated: true, kind: "resume" };
|
|
36
27
|
}
|
|
37
28
|
if (existing?.priceId === priceId) {
|
|
38
|
-
throw
|
|
29
|
+
throw conflict("You are already subscribed to this price.");
|
|
39
30
|
}
|
|
40
31
|
const kind = existing ? decideChangeKind(existing.planKey ? configuredPlans[existing.planKey]?.rank : null, planKey ? configuredPlans[planKey].rank : Number.POSITIVE_INFINITY) : "checkout";
|
|
41
32
|
const hookContext = {
|
|
@@ -65,7 +56,7 @@ export default defineEventHandler(async (event) => {
|
|
|
65
56
|
}
|
|
66
57
|
const customer = await ensureStripeCustomer(user.id);
|
|
67
58
|
const session = await stripe.checkout.sessions.create({ ...hookContext.params, customer });
|
|
68
|
-
if (!session.url) throw
|
|
59
|
+
if (!session.url) throw apiError(502, "bad_gateway", "Stripe did not return a checkout URL.");
|
|
69
60
|
return { url: session.url };
|
|
70
61
|
});
|
|
71
62
|
async function resumeSubscription(stripe, subscriptionId) {
|
|
@@ -77,7 +68,7 @@ async function resumeSubscription(stripe, subscriptionId) {
|
|
|
77
68
|
async function upgradeSubscription(stripe, subscriptionId, priceId) {
|
|
78
69
|
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
|
79
70
|
const item = subscription.items.data[0];
|
|
80
|
-
if (!item) throw
|
|
71
|
+
if (!item) throw conflict("The subscription has no items to change.");
|
|
81
72
|
const scheduleId = typeof subscription.schedule === "string" ? subscription.schedule : subscription.schedule?.id;
|
|
82
73
|
if (scheduleId) await stripe.subscriptionSchedules.release(scheduleId);
|
|
83
74
|
await stripe.subscriptions.update(subscriptionId, {
|
|
@@ -89,11 +80,11 @@ async function upgradeSubscription(stripe, subscriptionId, priceId) {
|
|
|
89
80
|
async function scheduleDowngrade(stripe, subscriptionId, priceId) {
|
|
90
81
|
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
|
91
82
|
const item = subscription.items.data[0];
|
|
92
|
-
if (!item) throw
|
|
83
|
+
if (!item) throw conflict("The subscription has no items to change.");
|
|
93
84
|
const scheduleId = typeof subscription.schedule === "string" ? subscription.schedule : subscription.schedule?.id;
|
|
94
85
|
const schedule = scheduleId ? await stripe.subscriptionSchedules.retrieve(scheduleId) : await stripe.subscriptionSchedules.create({ from_subscription: subscriptionId });
|
|
95
86
|
const current = schedule.phases[0];
|
|
96
|
-
if (!current) throw
|
|
87
|
+
if (!current) throw conflict("Could not read the current billing phase.");
|
|
97
88
|
await stripe.subscriptionSchedules.update(schedule.id, {
|
|
98
89
|
end_behavior: "release",
|
|
99
90
|
phases: [
|
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { resolveSameOriginUrl } from "@frigear-nu/
|
|
2
|
+
import { resolveSameOriginUrl } from "@frigear-nu/module-kit/server";
|
|
3
3
|
const bodySchema = z.object({ returnUrl: z.string().optional() });
|
|
4
4
|
export default defineEventHandler(async (event) => {
|
|
5
5
|
const user = await requireUser(event, { sessionOnly: true });
|
|
6
6
|
const { returnUrl } = await readValidatedBody(event, bodySchema.parse);
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
if (!safe) throw createError({ statusCode: 400, statusMessage: "returnUrl must be on the same origin." });
|
|
12
|
-
returnTo = safe;
|
|
13
|
-
}
|
|
7
|
+
const returnTo = resolveSameOriginUrl(event, returnUrl, {
|
|
8
|
+
fallback: useRuntimeConfig().public.subscriptions?.pages?.membership ?? "/",
|
|
9
|
+
name: "returnUrl"
|
|
10
|
+
});
|
|
14
11
|
const customer = await ensureStripeCustomer(user.id);
|
|
15
12
|
const session = await useStripe().billingPortal.sessions.create({ customer, return_url: returnTo });
|
|
16
13
|
return { url: session.url };
|
|
@@ -1,4 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
return listUserSubscriptions(user.id);
|
|
4
|
-
});
|
|
1
|
+
import mySubscriptions from "../../bootstrap/my-subscriptions.js";
|
|
2
|
+
export default defineEventHandler(mySubscriptions);
|
|
@@ -1,22 +1,23 @@
|
|
|
1
|
+
import { badRequest } from "@frigear-nu/module-kit/server";
|
|
1
2
|
import { findPrice } from "../../utils/plans.js";
|
|
2
3
|
export async function resolveCheckoutTarget(stripe, plans, body) {
|
|
3
4
|
const configured = Object.keys(useRuntimeConfig().subscriptions?.plans ?? {}).length > 0;
|
|
4
5
|
if (!configured) {
|
|
5
|
-
if (!body.priceId) throw
|
|
6
|
+
if (!body.priceId) throw badRequest("`priceId` is required when no plans are configured.");
|
|
6
7
|
const price = await stripe.prices.retrieve(body.priceId).catch(() => null);
|
|
7
|
-
if (!price?.recurring || !price.active) throw
|
|
8
|
+
if (!price?.recurring || !price.active) throw badRequest("Unknown or non-recurring price.");
|
|
8
9
|
return { priceId: price.id, planKey: null };
|
|
9
10
|
}
|
|
10
11
|
if (body.planKey) {
|
|
11
12
|
const plan = plans.find((item) => item.key === body.planKey);
|
|
12
|
-
if (!plan) throw
|
|
13
|
+
if (!plan) throw badRequest("Unknown or unavailable plan.");
|
|
13
14
|
const price = body.priceId ? plan.prices.find((item) => item.id === body.priceId) : plan.prices.length === 1 ? plan.prices[0] : void 0;
|
|
14
15
|
if (!price) {
|
|
15
|
-
throw
|
|
16
|
+
throw badRequest(body.priceId ? "That price does not belong to the plan." : "This plan has several prices; provide `priceId`.");
|
|
16
17
|
}
|
|
17
18
|
return { priceId: price.id, planKey: plan.key };
|
|
18
19
|
}
|
|
19
20
|
const found = findPrice(plans, body.priceId);
|
|
20
|
-
if (!found) throw
|
|
21
|
+
if (!found) throw badRequest("Unknown or unavailable price.");
|
|
21
22
|
return { priceId: found.price.id, planKey: found.plan.key };
|
|
22
23
|
}
|