@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.
Files changed (48) hide show
  1. package/README.md +5 -5
  2. package/dist/module.d.mts +2 -2
  3. package/dist/module.json +1 -1
  4. package/dist/module.mjs +20 -31
  5. package/dist/runtime/app/components/SubscriptionPlans.vue +9 -15
  6. package/dist/runtime/app/components/SubscriptionStatus.d.vue.ts +7 -12
  7. package/dist/runtime/app/components/SubscriptionStatus.vue +26 -26
  8. package/dist/runtime/app/components/SubscriptionStatus.vue.d.ts +7 -12
  9. package/dist/runtime/app/components/Users/Admin/Membership.vue +3 -2
  10. package/dist/runtime/app/composables/usePlans.d.ts +4 -11
  11. package/dist/runtime/app/composables/usePlans.js +8 -11
  12. package/dist/runtime/app/composables/useSubscription.d.ts +6 -24
  13. package/dist/runtime/app/composables/useSubscription.js +12 -17
  14. package/dist/runtime/app/middleware/subscription.js +2 -6
  15. package/dist/runtime/app/pages/cancel.vue +18 -6
  16. package/dist/runtime/app/pages/membership.vue +42 -17
  17. package/dist/runtime/app/pages/success.vue +19 -7
  18. package/dist/runtime/app/tabs/Membership.vue +19 -9
  19. package/dist/runtime/app/widgets/MembershipSummary.vue +2 -2
  20. package/dist/runtime/i18n/lang/da.json +16 -11
  21. package/dist/runtime/i18n/lang/en.json +18 -13
  22. package/dist/runtime/menus.js +4 -4
  23. package/dist/runtime/server/bootstrap/my-subscriptions.d.ts +7 -0
  24. package/dist/runtime/server/bootstrap/my-subscriptions.js +4 -0
  25. package/dist/runtime/server/routes/admin/{subscription.delete.js → membership.delete.js} +3 -2
  26. package/dist/runtime/server/routes/admin/{subscription.get.js → membership.get.js} +2 -2
  27. package/dist/runtime/server/routes/admin/{subscription.post.js → membership.post.js} +9 -14
  28. package/dist/runtime/server/routes/admin/payment-link.post.js +3 -2
  29. package/dist/runtime/server/routes/{subscriptions → me}/checkout.post.js +9 -18
  30. package/dist/runtime/server/routes/{subscriptions → me}/portal.post.js +5 -8
  31. package/dist/runtime/server/routes/subscriptions/me.get.js +2 -4
  32. package/dist/runtime/server/utils/checkout.js +6 -5
  33. package/dist/runtime/server/utils/subscriptions.d.ts +6 -1
  34. package/dist/runtime/server/utils/subscriptions.js +15 -12
  35. package/dist/runtime/utils/api.d.ts +20 -0
  36. package/dist/runtime/utils/api.js +23 -0
  37. package/dist/runtime/utils/index.d.ts +2 -1
  38. package/dist/runtime/utils/index.js +2 -1
  39. package/package.json +19 -14
  40. package/dist/runtime/app/plugins/warm-plans.client.d.ts +0 -6
  41. package/dist/runtime/app/plugins/warm-plans.client.js +0 -12
  42. /package/dist/runtime/server/routes/admin/{subscription.delete.d.ts → membership-history.get.d.ts} +0 -0
  43. /package/dist/runtime/server/routes/admin/{subscriptions.get.js → membership-history.get.js} +0 -0
  44. /package/dist/runtime/server/routes/admin/{subscription.get.d.ts → membership.delete.d.ts} +0 -0
  45. /package/dist/runtime/server/routes/admin/{subscription.post.d.ts → membership.get.d.ts} +0 -0
  46. /package/dist/runtime/server/routes/admin/{subscriptions.get.d.ts → membership.post.d.ts} +0 -0
  47. /package/dist/runtime/server/routes/{subscriptions → me}/checkout.post.d.ts +0 -0
  48. /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 { isActive, cancelsAt } = useSubscription();
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-6">
13
- <SubscriptionStatus v-if="isActive" />
14
- <div
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
- class="flex flex-col gap-1"
36
+ :title="t('status.title', 'Your membership')"
37
+ icon="i-lucide-credit-card"
17
38
  >
18
- <h2 class="text-highlighted text-lg font-semibold">
19
- {{ t("status.heroTitle", "Join the team today") }}
20
- </h2>
21
- <p class="text-muted text-sm">
22
- {{ t("status.heroDescription", "Set up your membership to get started!") }}
23
- </p>
24
- </div>
25
- <h2
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
- class="text-highlighted text-lg font-semibold"
49
+ :title="t('plans.resubscribe', 'Resubscribe')"
28
50
  >
29
- {{ t("plans.resubscribe", "Resubscribe") }}
30
- </h2>
31
- <SubscriptionPlans confirm-change />
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
- <UEmpty
24
- :icon="isActive ? 'i-lucide-circle-check' : 'i-lucide-loader-circle'"
25
- :title="t('title', 'Thank you!')"
26
- :description="isActive ? t('activeBody', 'Your subscription is now active.') : t('body', 'Your subscription is being activated. This can take a few seconds.')"
27
- :actions="membership ? [{ label: t('back', 'Back to membership'), to: localize(membership) }] : []"
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 UBadge = resolveComponent("UBadge");
10
- const { data: history, refresh: refreshHistory, status } = useLazyFetch(
11
- () => `/api/admin/users/${props.user.id}/subscriptions`,
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 date = (value) => format.date(value) || "\u2013";
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(UBadge, { label: row.original.status, variant: "subtle", color: statusColor(row.original.status) }) },
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
- <SectionCard
40
+ <TableCard
39
41
  :title="t('history.title', 'Subscription history')"
40
- flush
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
- </SectionCard>
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, status, isActive, cancelsAt } = useSubscription();
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="status === 'pending' && !subscription"
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 abonnement",
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": "Abonnementet udløber",
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
- "heroTitle": "Bliv en del af holdet i dag",
59
- "heroDescription": "Opret dit medlemskab for at komme i gang!"
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
- "body": "Dit abonnement bliver aktiveret. Det kan tage et par sekunder.",
64
- "activeBody": "Dit abonnement er aktiveret.",
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
- "subscription": {
78
- "label": "Abonnement",
79
- "description": "Administrer dit abonnement og din betaling."
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": "Abonnement",
109
+ "tab": "Medlemskab",
106
110
  "membership": "Medlemskab",
107
- "currentSubscription": "Nuværende abonnement",
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 subscription",
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 subscription…",
39
- "title": "Your subscription",
40
- "none": "You do not have an active subscription.",
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": "Subscription is ending",
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
- "heroTitle": "Join the team today",
59
- "heroDescription": "Set up your membership to get started!"
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
- "body": "Your subscription is being activated. This can take a few seconds.",
64
- "activeBody": "Your subscription is now active.",
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
- "subscription": {
78
- "label": "Subscription",
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": "Subscription",
109
+ "tab": "Membership",
106
110
  "membership": "Membership",
107
- "currentSubscription": "Current subscription",
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",
@@ -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: "subscription",
7
- label: "Subscription",
8
- labelKey: "subscriptions.menu.subscription.label",
6
+ id: "membership",
7
+ label: "Membership",
8
+ labelKey: "subscriptions.menu.membership.label",
9
9
  description: "Manage your plan and billing.",
10
- descriptionKey: "subscriptions.menu.subscription.description",
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>;
@@ -0,0 +1,4 @@
1
+ export default async function mySubscriptions(event) {
2
+ const user = await requireUser(event, { scopes: ["subscriptions:read"] });
3
+ return listUserSubscriptions(user.id);
4
+ }
@@ -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 createError({ statusCode: 404, statusMessage: "The user has no free membership to revoke." });
12
+ throw notFound("The user has no free membership to revoke.");
12
13
  }
13
- return { revoked: revoked.length };
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: iso(current.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.string().nullish()
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 createError({ statusCode: 400, statusMessage: "Unknown plan." });
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 createError({ statusCode: 409, statusMessage: "The user already has a paid subscription." });
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 { id, planKey: body.planKey, endsAt: endsAt?.toISOString() ?? null };
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 createError({ statusCode: 409, statusMessage: "The user already has a paid subscription." });
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 createError({ statusCode: 502, statusMessage: "Stripe did not return a checkout URL." });
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/stripe/utils";
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, { scopes: ["subscriptions:write"] });
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 safeUrl = (input, fallbackPath, name) => {
21
- if (input) {
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 createError({ statusCode: 409, statusMessage: "You are already subscribed to this price." });
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 createError({ statusCode: 502, statusMessage: "Stripe did not return a checkout URL." });
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 createError({ statusCode: 409, statusMessage: "The subscription has no items to change." });
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 createError({ statusCode: 409, statusMessage: "The subscription has no items to change." });
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 createError({ statusCode: 409, statusMessage: "Could not read the current billing phase." });
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/stripe/utils";
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 origin = getRequestURL(event).origin;
8
- let returnTo = `${origin}${useRuntimeConfig().public.subscriptions?.pages?.membership ?? "/"}`;
9
- if (returnUrl) {
10
- const safe = resolveSameOriginUrl(returnUrl, origin);
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
- export default defineEventHandler(async (event) => {
2
- const user = await requireUser(event, { scopes: ["subscriptions:read"] });
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 createError({ statusCode: 400, statusMessage: "`priceId` is required when no plans are configured." });
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 createError({ statusCode: 400, statusMessage: "Unknown or non-recurring price." });
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 createError({ statusCode: 400, statusMessage: "Unknown or unavailable plan." });
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 createError({ statusCode: 400, statusMessage: body.priceId ? "That price does not belong to the plan." : "This plan has several prices; provide `priceId`." });
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 createError({ statusCode: 400, statusMessage: "Unknown or unavailable price." });
21
+ if (!found) throw badRequest("Unknown or unavailable price.");
21
22
  return { priceId: found.price.id, planKey: found.plan.key };
22
23
  }