@frigear-nu/subscriptions 0.1.2 → 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 (39) hide show
  1. package/README.md +3 -3
  2. package/dist/module.d.mts +2 -2
  3. package/dist/module.json +1 -1
  4. package/dist/module.mjs +16 -31
  5. package/dist/runtime/app/components/SubscriptionPlans.vue +5 -11
  6. package/dist/runtime/app/components/SubscriptionStatus.d.vue.ts +5 -5
  7. package/dist/runtime/app/components/SubscriptionStatus.vue +24 -24
  8. package/dist/runtime/app/components/SubscriptionStatus.vue.d.ts +5 -5
  9. package/dist/runtime/app/components/Users/Admin/Membership.vue +3 -2
  10. package/dist/runtime/app/composables/usePlans.js +2 -1
  11. package/dist/runtime/app/composables/useSubscription.js +4 -3
  12. package/dist/runtime/app/pages/cancel.vue +18 -6
  13. package/dist/runtime/app/pages/membership.vue +28 -17
  14. package/dist/runtime/app/pages/success.vue +18 -6
  15. package/dist/runtime/app/tabs/Membership.vue +8 -8
  16. package/dist/runtime/i18n/lang/da.json +13 -11
  17. package/dist/runtime/i18n/lang/en.json +15 -13
  18. package/dist/runtime/menus.js +4 -4
  19. package/dist/runtime/server/routes/admin/{subscription.delete.js → membership.delete.js} +3 -2
  20. package/dist/runtime/server/routes/admin/{subscription.get.js → membership.get.js} +2 -2
  21. package/dist/runtime/server/routes/admin/{subscription.post.js → membership.post.js} +9 -14
  22. package/dist/runtime/server/routes/admin/payment-link.post.js +3 -2
  23. package/dist/runtime/server/routes/{subscriptions → me}/checkout.post.js +9 -18
  24. package/dist/runtime/server/routes/{subscriptions → me}/portal.post.js +5 -8
  25. package/dist/runtime/server/utils/checkout.js +6 -5
  26. package/dist/runtime/server/utils/subscriptions.d.ts +6 -1
  27. package/dist/runtime/server/utils/subscriptions.js +15 -12
  28. package/dist/runtime/utils/api.d.ts +20 -0
  29. package/dist/runtime/utils/api.js +23 -0
  30. package/dist/runtime/utils/index.d.ts +2 -1
  31. package/dist/runtime/utils/index.js +2 -1
  32. package/package.json +17 -12
  33. /package/dist/runtime/server/routes/admin/{subscription.delete.d.ts → membership-history.get.d.ts} +0 -0
  34. /package/dist/runtime/server/routes/admin/{subscriptions.get.js → membership-history.get.js} +0 -0
  35. /package/dist/runtime/server/routes/admin/{subscription.get.d.ts → membership.delete.d.ts} +0 -0
  36. /package/dist/runtime/server/routes/admin/{subscription.post.d.ts → membership.get.d.ts} +0 -0
  37. /package/dist/runtime/server/routes/admin/{subscriptions.get.d.ts → membership.post.d.ts} +0 -0
  38. /package/dist/runtime/server/routes/{subscriptions → me}/checkout.post.d.ts +0 -0
  39. /package/dist/runtime/server/routes/{subscriptions → me}/portal.post.d.ts +0 -0
@@ -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,26 +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
60
  "loadFailed": "Dit medlemskab kunne ikke indlæses",
61
61
  "tryAgain": "Prøv igen"
62
62
  },
63
63
  "success": {
64
64
  "title": "Tak!",
65
- "body": "Dit abonnement bliver aktiveret. Det kan tage et par sekunder.",
66
- "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.",
67
68
  "back": "Tilbage til medlemskab"
68
69
  },
69
70
  "cancel": {
70
71
  "title": "Betaling annulleret",
72
+ "crumb": "Betaling annulleret",
71
73
  "body": "Der blev ikke foretaget ændringer, og du er ikke blevet opkrævet.",
72
74
  "back": "Tilbage til medlemskab"
73
75
  },
@@ -76,9 +78,9 @@
76
78
  "portal": "Kunne ikke åbne betalingsportalen."
77
79
  },
78
80
  "menu": {
79
- "subscription": {
80
- "label": "Abonnement",
81
- "description": "Administrer dit abonnement og din betaling."
81
+ "membership": {
82
+ "label": "Medlemskab",
83
+ "description": "Administrer dit medlemskab og din betaling."
82
84
  }
83
85
  },
84
86
  "admin": {
@@ -104,9 +106,9 @@
104
106
  "linkHint": "Linket er gyldigt i 24 timer.",
105
107
  "paidHint": "Betalte abonnementer administreres i Stripe.",
106
108
  "error": "Noget gik galt.",
107
- "tab": "Abonnement",
109
+ "tab": "Medlemskab",
108
110
  "membership": "Medlemskab",
109
- "currentSubscription": "Nuværende abonnement",
111
+ "currentSubscription": "Nuværende medlemskab",
110
112
  "history": {
111
113
  "title": "Abonnementshistorik",
112
114
  "loadFailed": "Abonnementshistorikken kunne ikke hentes",
@@ -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,26 +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
60
  "loadFailed": "Your membership could not be loaded",
61
61
  "tryAgain": "Try again"
62
62
  },
63
63
  "success": {
64
64
  "title": "Thank you!",
65
- "body": "Your subscription is being activated. This can take a few seconds.",
66
- "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.",
67
68
  "back": "Back to membership"
68
69
  },
69
70
  "cancel": {
70
71
  "title": "Checkout canceled",
72
+ "crumb": "Checkout canceled",
71
73
  "body": "No changes were made and you have not been charged.",
72
74
  "back": "Back to membership"
73
75
  },
@@ -76,8 +78,8 @@
76
78
  "portal": "Failed to open the billing portal."
77
79
  },
78
80
  "menu": {
79
- "subscription": {
80
- "label": "Subscription",
81
+ "membership": {
82
+ "label": "Membership",
81
83
  "description": "Manage your plan and billing."
82
84
  }
83
85
  },
@@ -104,9 +106,9 @@
104
106
  "linkHint": "The link is valid for 24 hours.",
105
107
  "paidHint": "Paid subscriptions are managed in Stripe.",
106
108
  "error": "Something went wrong.",
107
- "tab": "Subscription",
109
+ "tab": "Membership",
108
110
  "membership": "Membership",
109
- "currentSubscription": "Current subscription",
111
+ "currentSubscription": "Current membership",
110
112
  "history": {
111
113
  "title": "Subscription history",
112
114
  "loadFailed": "The subscription history could not be loaded",
@@ -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
@@ -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,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
  }
@@ -1,10 +1,14 @@
1
- import type { MeResponse } from '../../../types.js';
1
+ import { schema } from '@nuxthub/db';
2
+ import type { MeResponse, SubscriptionDto } from '../../../types.js';
2
3
  export type HasActiveSubscriptionOptions = {
3
4
  /** Only count subscriptions on one of these plan keys. Omit for "any plan". */
4
5
  plans?: string[];
5
6
  /** Statuses that count as entitled. Defaults to `active` and `trialing`. */
6
7
  statuses?: string[];
7
8
  };
9
+ type SubscriptionRow = typeof schema.subscriptions.$inferSelect;
10
+ /** The allowlisted DTO of a subscription row: no customer ids, no internal columns. */
11
+ export declare function toSubscriptionDto(row: SubscriptionRow): SubscriptionDto;
8
12
  /** Every subscription row of the user, newest first, in the shape the API and MCP expose. */
9
13
  export declare function listUserSubscriptions(userId: number): Promise<MeResponse>;
10
14
  /**
@@ -14,3 +18,4 @@ export declare function listUserSubscriptions(userId: number): Promise<MeRespons
14
18
  export declare function getUserSubscription(userId: number, options?: Pick<HasActiveSubscriptionOptions, 'statuses'>): Promise<any>;
15
19
  /** Whether the user holds an entitled subscription, optionally restricted to specific plans. */
16
20
  export declare function hasActiveSubscription(userId: number, options?: HasActiveSubscriptionOptions): Promise<boolean>;
21
+ export {};
@@ -1,22 +1,25 @@
1
1
  import { db, schema } from "@nuxthub/db";
2
2
  import { desc, eq } from "drizzle-orm";
3
+ import { isoDate } from "@frigear-nu/module-kit/server";
3
4
  import { DEFAULT_ENTITLED_STATUSES, isEntitledRow } from "../../utils/plans.js";
5
+ export function toSubscriptionDto(row) {
6
+ return {
7
+ id: row.id,
8
+ planKey: row.planKey,
9
+ priceId: row.priceId,
10
+ status: row.status,
11
+ cancelAtPeriodEnd: row.cancelAtPeriodEnd,
12
+ currentPeriodStart: isoDate(row.currentPeriodStart),
13
+ currentPeriodEnd: isoDate(row.currentPeriodEnd),
14
+ endedAt: isoDate(row.endedAt),
15
+ source: row.source === "manual" ? "manual" : "stripe"
16
+ };
17
+ }
4
18
  export async function listUserSubscriptions(userId) {
5
19
  const rows = await db.select().from(schema.subscriptions).where(eq(schema.subscriptions.userId, userId)).orderBy(desc(schema.subscriptions.createdAt));
6
- const iso = (date) => date ? date.toISOString() : null;
7
20
  const active = rows.filter((row) => isEntitledRow(row)).sort((a, b) => (b.currentPeriodEnd?.getTime() ?? 0) - (a.currentPeriodEnd?.getTime() ?? 0))[0];
8
21
  return {
9
- subscriptions: rows.map((row) => ({
10
- id: row.id,
11
- planKey: row.planKey,
12
- priceId: row.priceId,
13
- status: row.status,
14
- cancelAtPeriodEnd: row.cancelAtPeriodEnd,
15
- currentPeriodStart: iso(row.currentPeriodStart),
16
- currentPeriodEnd: iso(row.currentPeriodEnd),
17
- endedAt: iso(row.endedAt),
18
- source: row.source === "manual" ? "manual" : "stripe"
19
- })),
22
+ subscriptions: rows.map(toSubscriptionDto),
20
23
  activePlan: active?.planKey ?? null
21
24
  };
22
25
  }
@@ -0,0 +1,20 @@
1
+ export declare const subscriptionsApi: {
2
+ /** `GET` the configured plans with live prices (public). */
3
+ readonly plans: "/api/subscriptions/plans";
4
+ readonly me: {
5
+ /** `GET` the caller's subscriptions and active plan (also the account bootstrap's `subscriptions:me`). */
6
+ readonly subscriptions: "/api/subscriptions/me";
7
+ /** `POST` to start a checkout or change plan (sessions only). */
8
+ readonly checkout: "/api/subscriptions/me/checkout";
9
+ /** `POST` to open the Stripe billing portal (sessions only). */
10
+ readonly portal: "/api/subscriptions/me/portal";
11
+ };
12
+ readonly admin: {
13
+ /** `GET` the user's current membership and grantable plans / `POST` a free grant / `DELETE` the free grant (staff). */
14
+ readonly membership: (userId: number | string) => string;
15
+ /** `GET` the user's whole subscription history (staff). */
16
+ readonly history: (userId: number | string) => string;
17
+ /** `POST` a Checkout link for the user, optionally emailed (staff). */
18
+ readonly paymentLink: (userId: number | string) => string;
19
+ };
20
+ };
@@ -0,0 +1,23 @@
1
+ const BASE = "/api/subscriptions";
2
+ const enc = encodeURIComponent;
3
+ const userBase = (userId) => `/api/admin/users/${enc(String(userId))}/membership`;
4
+ export const subscriptionsApi = {
5
+ /** `GET` the configured plans with live prices (public). */
6
+ plans: `${BASE}/plans`,
7
+ me: {
8
+ /** `GET` the caller's subscriptions and active plan (also the account bootstrap's `subscriptions:me`). */
9
+ subscriptions: `${BASE}/me`,
10
+ /** `POST` to start a checkout or change plan (sessions only). */
11
+ checkout: `${BASE}/me/checkout`,
12
+ /** `POST` to open the Stripe billing portal (sessions only). */
13
+ portal: `${BASE}/me/portal`
14
+ },
15
+ admin: {
16
+ /** `GET` the user's current membership and grantable plans / `POST` a free grant / `DELETE` the free grant (staff). */
17
+ membership: userBase,
18
+ /** `GET` the user's whole subscription history (staff). */
19
+ history: (userId) => `${userBase(userId)}/history`,
20
+ /** `POST` a Checkout link for the user, optionally emailed (staff). */
21
+ paymentLink: (userId) => `${userBase(userId)}/payment-link`
22
+ }
23
+ };
@@ -1,4 +1,5 @@
1
1
  export { getUserSubscription, hasActiveSubscription } from '../server/utils/subscriptions.js';
2
2
  export type { HasActiveSubscriptionOptions } from '../server/utils/subscriptions.js';
3
- export { decideChangeKind, resolvePlanKey, resolveSameOriginUrl, isWithinDisabledRanges, DEFAULT_ENTITLED_STATUSES } from './plans.js';
3
+ export { decideChangeKind, resolvePlanKey, isWithinDisabledRanges, DEFAULT_ENTITLED_STATUSES } from './plans.js';
4
4
  export type { CheckoutBeforeContext, PlansFilterContext, ResolvedPlan, PlanPrice, SubscriptionDto, ModuleOptions, } from '../../types.js';
5
+ export * from './api.js';
@@ -1,2 +1,3 @@
1
1
  export { getUserSubscription, hasActiveSubscription } from "../server/utils/subscriptions.js";
2
- export { decideChangeKind, resolvePlanKey, resolveSameOriginUrl, isWithinDisabledRanges, DEFAULT_ENTITLED_STATUSES } from "./plans.js";
2
+ export { decideChangeKind, resolvePlanKey, isWithinDisabledRanges, DEFAULT_ENTITLED_STATUSES } from "./plans.js";
3
+ export * from "./api.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frigear-nu/subscriptions",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Recurring subscriptions (plans, checkout, upgrade/downgrade, billing portal and entitlements) for the frigear ecosystem, built on @frigear-nu/stripe.",
5
5
  "homepage": "https://docs.frigear.dev/modules/subscriptions",
6
6
  "repository": {
@@ -21,6 +21,11 @@
21
21
  "types": "./dist/runtime/utils/index.d.ts",
22
22
  "import": "./dist/runtime/utils/index.js",
23
23
  "default": "./dist/runtime/utils/index.js"
24
+ },
25
+ "./api": {
26
+ "types": "./dist/runtime/utils/api.d.ts",
27
+ "import": "./dist/runtime/utils/api.js",
28
+ "default": "./dist/runtime/utils/api.js"
24
29
  }
25
30
  },
26
31
  "files": [
@@ -30,13 +35,13 @@
30
35
  "playground"
31
36
  ],
32
37
  "dependencies": {
33
- "@frigear-nu/auth": "0.1.3",
34
- "@frigear-nu/mcp": "0.1.2",
35
- "@frigear-nu/module-kit": "1.1.2",
36
- "@frigear-nu/notify": "0.1.2",
37
- "@frigear-nu/stripe": "0.1.2",
38
- "@frigear-nu/ui": "0.1.2",
39
- "@frigear-nu/users": "1.1.3",
38
+ "@frigear-nu/auth": "0.1.4",
39
+ "@frigear-nu/mcp": "0.1.3",
40
+ "@frigear-nu/module-kit": "1.1.3",
41
+ "@frigear-nu/notify": "0.1.3",
42
+ "@frigear-nu/stripe": "0.1.3",
43
+ "@frigear-nu/ui": "0.1.3",
44
+ "@frigear-nu/users": "1.1.4",
40
45
  "@libsql/client": "^0.18.0",
41
46
  "@nuxt/kit": "^4.6.1",
42
47
  "@vueuse/core": "^15.0.0",
@@ -49,8 +54,8 @@
49
54
  },
50
55
  "peerDependencies": {
51
56
  "@frigear-nu/email-templates": "0.1.0",
52
- "@frigear-nu/menu": "0.1.2",
53
- "@frigear-nu/widget": "0.1.2",
57
+ "@frigear-nu/menu": "0.1.3",
58
+ "@frigear-nu/widget": "0.1.3",
54
59
  "@nuxt/ui": "^4.12.0",
55
60
  "@nuxthub/core": "^0.10.8"
56
61
  },
@@ -70,8 +75,8 @@
70
75
  },
71
76
  "devDependencies": {
72
77
  "@frigear-nu/email-templates": "0.1.0",
73
- "@frigear-nu/menu": "0.1.2",
74
- "@frigear-nu/widget": "0.1.2",
78
+ "@frigear-nu/menu": "0.1.3",
79
+ "@frigear-nu/widget": "0.1.3",
75
80
  "@nuxt/devtools": "^3.4.2",
76
81
  "@nuxt/eslint-config": "^1.17.0",
77
82
  "@nuxt/module-builder": "^1.0.3",