@frigear-nu/subscriptions 0.0.6 → 0.0.8

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 (67) hide show
  1. package/dist/module.d.mts +65 -2
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +60 -29
  4. package/dist/runtime/app/components/SubscriptionPlans.d.vue.ts +31 -27
  5. package/dist/runtime/app/components/SubscriptionPlans.vue +203 -105
  6. package/dist/runtime/app/components/SubscriptionPlans.vue.d.ts +31 -27
  7. package/dist/runtime/app/components/SubscriptionStatus.d.vue.ts +1 -1
  8. package/dist/runtime/app/components/SubscriptionStatus.vue +45 -24
  9. package/dist/runtime/app/components/SubscriptionStatus.vue.d.ts +1 -1
  10. package/dist/runtime/app/components/Users/Admin/Membership.d.vue.ts +14 -0
  11. package/dist/runtime/app/components/Users/Admin/Membership.vue +192 -0
  12. package/dist/runtime/app/components/Users/Admin/Membership.vue.d.ts +14 -0
  13. package/dist/runtime/app/composables/usePlans.d.ts +11 -0
  14. package/dist/runtime/app/composables/usePlans.js +9 -1
  15. package/dist/runtime/app/composables/useSubscription.d.ts +1 -0
  16. package/dist/runtime/app/composables/useSubscription.js +17 -3
  17. package/dist/runtime/app/pages/cancel.d.vue.ts +1 -1
  18. package/dist/runtime/app/pages/cancel.vue.d.ts +1 -1
  19. package/dist/runtime/app/pages/membership.d.vue.ts +1 -1
  20. package/dist/runtime/app/pages/membership.vue +18 -1
  21. package/dist/runtime/app/pages/membership.vue.d.ts +1 -1
  22. package/dist/runtime/app/pages/success.d.vue.ts +1 -1
  23. package/dist/runtime/app/pages/success.vue +9 -3
  24. package/dist/runtime/app/pages/success.vue.d.ts +1 -1
  25. package/dist/runtime/app/plugins/warm-plans.client.d.ts +6 -0
  26. package/dist/runtime/app/plugins/warm-plans.client.js +12 -0
  27. package/dist/runtime/app/tabs/Membership.d.vue.ts +14 -0
  28. package/dist/runtime/app/tabs/Membership.vue +53 -0
  29. package/dist/runtime/app/tabs/Membership.vue.d.ts +14 -0
  30. package/dist/runtime/app/widgets/AdminCurrentSubscription.d.vue.ts +11 -0
  31. package/dist/runtime/app/widgets/AdminCurrentSubscription.vue +23 -0
  32. package/dist/runtime/app/widgets/AdminCurrentSubscription.vue.d.ts +11 -0
  33. package/dist/runtime/i18n/lang/da.json +67 -3
  34. package/dist/runtime/i18n/lang/en.json +67 -3
  35. package/dist/runtime/menus.js +2 -0
  36. package/dist/runtime/server/db/schema.sqlite.d.ts +19 -0
  37. package/dist/runtime/server/db/schema.sqlite.js +2 -0
  38. package/dist/runtime/server/mcp/tools/my-subscriptions.d.ts +2 -0
  39. package/dist/runtime/server/mcp/tools/my-subscriptions.js +9 -0
  40. package/dist/runtime/server/routes/admin/payment-link.post.d.ts +2 -0
  41. package/dist/runtime/server/routes/admin/payment-link.post.js +46 -0
  42. package/dist/runtime/server/routes/admin/subscription.delete.d.ts +2 -0
  43. package/dist/runtime/server/routes/admin/subscription.delete.js +14 -0
  44. package/dist/runtime/server/routes/admin/subscription.get.d.ts +2 -0
  45. package/dist/runtime/server/routes/admin/subscription.get.js +17 -0
  46. package/dist/runtime/server/routes/admin/subscription.post.d.ts +2 -0
  47. package/dist/runtime/server/routes/admin/subscription.post.js +44 -0
  48. package/dist/runtime/server/routes/admin/subscriptions.get.d.ts +2 -0
  49. package/dist/runtime/server/routes/admin/subscriptions.get.js +4 -0
  50. package/dist/runtime/server/routes/subscriptions/checkout.post.js +16 -30
  51. package/dist/runtime/server/routes/subscriptions/me.get.js +2 -20
  52. package/dist/runtime/server/routes/subscriptions/portal.post.js +2 -2
  53. package/dist/runtime/server/stripe/invoice-events.d.ts +2 -0
  54. package/dist/runtime/server/stripe/invoice-events.js +42 -0
  55. package/dist/runtime/server/utils/admin-membership.d.ts +5 -0
  56. package/dist/runtime/server/utils/admin-membership.js +7 -0
  57. package/dist/runtime/server/utils/checkout.d.ts +15 -0
  58. package/dist/runtime/server/utils/checkout.js +22 -0
  59. package/dist/runtime/server/utils/plans.js +20 -1
  60. package/dist/runtime/server/utils/subscriptions.d.ts +3 -0
  61. package/dist/runtime/server/utils/subscriptions.js +22 -3
  62. package/dist/runtime/utils/index.d.ts +1 -1
  63. package/dist/runtime/utils/index.js +1 -1
  64. package/dist/runtime/utils/plans.d.ts +15 -6
  65. package/dist/runtime/utils/plans.js +16 -11
  66. package/dist/types.d.mts +1 -1
  67. package/package.json +18 -5
@@ -11,7 +11,16 @@
11
11
  "confirmTitle": "Skift plan",
12
12
  "confirmBody": "Skift til {plan}? Dit abonnement bliver opdateret.",
13
13
  "confirm": "Bekræft",
14
- "cancel": "Annullér"
14
+ "cancel": "Annullér",
15
+ "feeIncluded": "*Betalingsgebyrer er inkluderet i beløbet",
16
+ "select": "Vælg",
17
+ "resubscribe": "Abonnér igen",
18
+ "confirmCheckout": "Du sendes nu videre til Stripe for at bekræfte.",
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
+ "confirmDowngrade": "Hvis du nedgraderer, kører dit nuværende abonnement til næste forfaldsdato, hvorefter det skifter.",
21
+ "subscribeTo": "Du abonnerer nu på:",
22
+ "changeTitle": "Opdatér abonnement",
23
+ "checkoutTitle": "Bliv medlem"
15
24
  },
16
25
  "interval": {
17
26
  "day": "dag",
@@ -21,7 +30,8 @@
21
30
  "days": "dage",
22
31
  "weeks": "uger",
23
32
  "months": "måneder",
24
- "years": "år"
33
+ "years": "år",
34
+ "quarter": "kvartal"
25
35
  },
26
36
  "status": {
27
37
  "loading": "Henter medlemskab…",
@@ -35,11 +45,22 @@
35
45
  "trialing": "Prøveperiode",
36
46
  "pastDue": "Forfalden",
37
47
  "canceled": "Annulleret",
38
- "other": "Inaktiv"
48
+ "other": "Inaktiv",
49
+ "currentPlan": "Nuværende plan",
50
+ "cancelling": "Abonnementet udløber",
51
+ "cancelsAt": "Slutter {date}",
52
+ "renewsAutomaticallyAt": "Fornyes automatisk {date}",
53
+ "cancel": "Opsig medlemskab",
54
+ "changePaymentMethod": "Skift betalingsmetode",
55
+ "free": "Gratis medlemskab",
56
+ "freeUntil": "Gratis medlemskab til {date}",
57
+ "heroTitle": "Bliv en del af holdet i dag",
58
+ "heroDescription": "Opret dit medlemskab for at komme i gang!"
39
59
  },
40
60
  "success": {
41
61
  "title": "Tak!",
42
62
  "body": "Dit abonnement bliver aktiveret. Det kan tage et par sekunder.",
63
+ "activeBody": "Dit abonnement er aktiveret.",
43
64
  "continue": "Fortsæt"
44
65
  },
45
66
  "cancel": {
@@ -50,6 +71,49 @@
50
71
  "errors": {
51
72
  "checkout": "Kunne ikke starte betalingen.",
52
73
  "portal": "Kunne ikke åbne betalingsportalen."
74
+ },
75
+ "menu": {
76
+ "subscription": {
77
+ "label": "Abonnement",
78
+ "description": "Administrer dit abonnement og din betaling."
79
+ }
80
+ },
81
+ "admin": {
82
+ "title": "Medlemskab",
83
+ "none": "Intet aktivt medlemskab.",
84
+ "free": "Gratis",
85
+ "paid": "Betalt (Stripe)",
86
+ "until": "til {date}",
87
+ "openEnded": "Ingen slutdato",
88
+ "plan": "Plan",
89
+ "planPlaceholder": "Vælg en plan",
90
+ "endsAt": "Slutter den (valgfri)",
91
+ "grant": "Giv gratis",
92
+ "granted": "Medlemskab givet.",
93
+ "revoke": "Fjern gratis medlemskab",
94
+ "revoked": "Medlemskab fjernet.",
95
+ "sendLink": "Send betalingslink",
96
+ "emailUser": "Send linket til brugeren på e-mail",
97
+ "linkCreated": "Betalingslink oprettet.",
98
+ "linkEmailed": "Betalingslink oprettet og sendt på e-mail.",
99
+ "copy": "Kopiér link",
100
+ "copied": "Link kopieret.",
101
+ "linkHint": "Linket er gyldigt i 24 timer.",
102
+ "paidHint": "Betalte abonnementer administreres i Stripe.",
103
+ "error": "Noget gik galt.",
104
+ "tab": "Abonnement",
105
+ "membership": "Medlemskab",
106
+ "currentSubscription": "Nuværende abonnement",
107
+ "history": {
108
+ "title": "Abonnementshistorik",
109
+ "empty": "Ingen abonnementer endnu.",
110
+ "plan": "Plan",
111
+ "status": "Status",
112
+ "source": "Kilde",
113
+ "started": "Startet",
114
+ "periodEnd": "Periodeslut",
115
+ "ended": "Afsluttet"
116
+ }
53
117
  }
54
118
  }
55
119
  }
@@ -11,7 +11,16 @@
11
11
  "confirmTitle": "Change plan",
12
12
  "confirmBody": "Switch to {plan}? Your subscription will be updated.",
13
13
  "confirm": "Confirm",
14
- "cancel": "Cancel"
14
+ "cancel": "Cancel",
15
+ "feeIncluded": "*Payment fees are included in the amount",
16
+ "select": "Select",
17
+ "resubscribe": "Resubscribe",
18
+ "confirmCheckout": "You will now be taken to Stripe to confirm.",
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
+ "confirmDowngrade": "If you downgrade, your current subscription runs until the next due date, then switches.",
21
+ "subscribeTo": "You will now subscribe to:",
22
+ "changeTitle": "Update subscription",
23
+ "checkoutTitle": "Get a membership"
15
24
  },
16
25
  "interval": {
17
26
  "day": "day",
@@ -21,7 +30,8 @@
21
30
  "days": "days",
22
31
  "weeks": "weeks",
23
32
  "months": "months",
24
- "years": "years"
33
+ "years": "years",
34
+ "quarter": "quarter"
25
35
  },
26
36
  "status": {
27
37
  "loading": "Loading subscription…",
@@ -35,11 +45,22 @@
35
45
  "trialing": "Trial",
36
46
  "pastDue": "Past due",
37
47
  "canceled": "Canceled",
38
- "other": "Inactive"
48
+ "other": "Inactive",
49
+ "currentPlan": "Current plan",
50
+ "cancelling": "Subscription is ending",
51
+ "cancelsAt": "Ending at {date}",
52
+ "renewsAutomaticallyAt": "Renews automatically on {date}",
53
+ "cancel": "Cancel membership",
54
+ "changePaymentMethod": "Change payment method",
55
+ "free": "Free membership",
56
+ "freeUntil": "Free membership until {date}",
57
+ "heroTitle": "Join the team today",
58
+ "heroDescription": "Set up your membership to get started!"
39
59
  },
40
60
  "success": {
41
61
  "title": "Thank you!",
42
62
  "body": "Your subscription is being activated. This can take a few seconds.",
63
+ "activeBody": "Your subscription is now active.",
43
64
  "continue": "Continue"
44
65
  },
45
66
  "cancel": {
@@ -50,6 +71,49 @@
50
71
  "errors": {
51
72
  "checkout": "Failed to start checkout.",
52
73
  "portal": "Failed to open the billing portal."
74
+ },
75
+ "menu": {
76
+ "subscription": {
77
+ "label": "Subscription",
78
+ "description": "Manage your plan and billing."
79
+ }
80
+ },
81
+ "admin": {
82
+ "title": "Membership",
83
+ "none": "No active membership.",
84
+ "free": "Free of charge",
85
+ "paid": "Paid (Stripe)",
86
+ "until": "until {date}",
87
+ "openEnded": "No end date",
88
+ "plan": "Plan",
89
+ "planPlaceholder": "Choose a plan",
90
+ "endsAt": "Ends on (optional)",
91
+ "grant": "Grant free",
92
+ "granted": "Membership granted.",
93
+ "revoke": "Revoke free membership",
94
+ "revoked": "Membership revoked.",
95
+ "sendLink": "Send payment link",
96
+ "emailUser": "Email the link to the user",
97
+ "linkCreated": "Payment link created.",
98
+ "linkEmailed": "Payment link created and emailed.",
99
+ "copy": "Copy link",
100
+ "copied": "Link copied.",
101
+ "linkHint": "The link is valid for 24 hours.",
102
+ "paidHint": "Paid subscriptions are managed in Stripe.",
103
+ "error": "Something went wrong.",
104
+ "tab": "Subscription",
105
+ "membership": "Membership",
106
+ "currentSubscription": "Current subscription",
107
+ "history": {
108
+ "title": "Subscription history",
109
+ "empty": "No subscriptions yet.",
110
+ "plan": "Plan",
111
+ "status": "Status",
112
+ "source": "Source",
113
+ "started": "Started",
114
+ "periodEnd": "Period end",
115
+ "ended": "Ended"
116
+ }
53
117
  }
54
118
  }
55
119
  }
@@ -5,7 +5,9 @@ export default defineMenuSource(() => {
5
5
  const item = defineMenuItem({
6
6
  id: "subscription",
7
7
  label: "Subscription",
8
+ labelKey: "subscriptions.menu.subscription.label",
8
9
  description: "Manage your plan and billing.",
10
+ descriptionKey: "subscriptions.menu.subscription.description",
9
11
  icon: "i-lucide-credit-card",
10
12
  to: config.pages.membership,
11
13
  order: 30
@@ -163,6 +163,25 @@ export declare const subscriptions: import("drizzle-orm/sqlite-core").SQLiteTabl
163
163
  identity: undefined;
164
164
  generated: undefined;
165
165
  }, {}, {}>;
166
+ source: import("drizzle-orm/sqlite-core").SQLiteColumn<{
167
+ name: "source";
168
+ tableName: "subscriptions";
169
+ dataType: "string";
170
+ columnType: "SQLiteText";
171
+ data: string;
172
+ driverParam: string;
173
+ notNull: true;
174
+ hasDefault: true;
175
+ isPrimaryKey: false;
176
+ isAutoincrement: false;
177
+ hasRuntimeDefault: false;
178
+ enumValues: [string, ...string[]];
179
+ baseColumn: never;
180
+ identity: undefined;
181
+ generated: undefined;
182
+ }, {}, {
183
+ length: undefined;
184
+ }>;
166
185
  rawItems: import("drizzle-orm/sqlite-core").SQLiteColumn<{
167
186
  name: "rawItems";
168
187
  tableName: "subscriptions";
@@ -12,6 +12,8 @@ export const subscriptions = sqliteTable("subscriptions", {
12
12
  currentPeriodStart: integer({ mode: "timestamp" }),
13
13
  currentPeriodEnd: integer({ mode: "timestamp" }),
14
14
  endedAt: integer({ mode: "timestamp" }),
15
+ /** `stripe` for webhook-synced rows, `manual` for free grants made by staff (no Stripe subscription behind it). */
16
+ source: text().notNull().default("stripe"),
15
17
  rawItems: text({ mode: "json" }).$type(),
16
18
  createdAt: integer({ mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date()),
17
19
  updatedAt: integer({ mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date()).$onUpdateFn(() => /* @__PURE__ */ new Date())
@@ -0,0 +1,2 @@
1
+ declare const _default: any;
2
+ export default _default;
@@ -0,0 +1,9 @@
1
+ export default defineScopedMcpTool({
2
+ description: "List your own subscriptions (plan, status, billing period and whether it cancels at period end) and your active plan.",
3
+ scopes: ["subscriptions:read"],
4
+ annotations: { readOnlyHint: true },
5
+ handler: async (_args, { user }) => {
6
+ const { subscriptions, activePlan } = await listUserSubscriptions(user.id);
7
+ return { activePlan, subscriptions: subscriptions.map(({ priceId: _priceId, ...rest }) => rest) };
8
+ }
9
+ });
@@ -0,0 +1,2 @@
1
+ declare const _default: any;
2
+ export default _default;
@@ -0,0 +1,46 @@
1
+ import { z } from "zod";
2
+ const bodySchema = z.object({
3
+ planKey: z.string().min(1).optional(),
4
+ priceId: z.string().min(1).optional(),
5
+ /** Also email the link to the user. */
6
+ email: z.boolean().optional()
7
+ }).refine((body) => body.planKey || body.priceId, { message: "Provide `planKey` or `priceId`." });
8
+ const escapeHtml = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
9
+ export default defineEventHandler(async (event) => {
10
+ const { target } = await loadUsersAdminTarget(event, "update");
11
+ const body = await readValidatedBody(event, bodySchema.parse);
12
+ const origin = getRequestURL(event).origin;
13
+ const pages = useRuntimeConfig().public.subscriptions?.pages ?? {};
14
+ const stripe = useStripe();
15
+ const current = currentEntitledRow(await listUserSubscriptionRows(target.id));
16
+ if (current && current.source !== "manual") {
17
+ throw createError({ statusCode: 409, statusMessage: "The user already has a paid subscription." });
18
+ }
19
+ const { priceId, planKey } = await resolveCheckoutTarget(stripe, await getResolvedPlans(), body);
20
+ const customer = await ensureStripeCustomer(target.id);
21
+ const session = await stripe.checkout.sessions.create({
22
+ mode: "subscription",
23
+ customer,
24
+ line_items: [{ price: priceId, quantity: 1 }],
25
+ success_url: `${origin}${pages.success ?? pages.membership ?? "/"}`,
26
+ cancel_url: `${origin}${pages.cancel ?? pages.membership ?? "/"}`,
27
+ client_reference_id: String(target.id),
28
+ subscription_data: { metadata: { userId: String(target.id), ...planKey ? { planKey } : {} } }
29
+ });
30
+ if (!session.url) throw createError({ statusCode: 502, statusMessage: "Stripe did not return a checkout URL." });
31
+ let emailed = false;
32
+ if (body.email) {
33
+ const plan = (useRuntimeConfig().subscriptions?.plans ?? {})[planKey ?? ""];
34
+ const label = plan?.label ?? planKey ?? "membership";
35
+ await dispatchEmailNotification({
36
+ to: target.email,
37
+ subject: `Complete your ${label} membership`,
38
+ body: `<p>Hi ${escapeHtml(target.name)},</p><p>Use the link below to complete your ${escapeHtml(label)} membership. The link is valid for 24 hours.</p><p><a href="${escapeHtml(session.url)}">${escapeHtml(session.url)}</a></p>`,
39
+ text: `Hi ${target.name},
40
+
41
+ Complete your ${label} membership (valid for 24 hours): ${session.url}`
42
+ });
43
+ emailed = true;
44
+ }
45
+ return { url: session.url, emailed };
46
+ });
@@ -0,0 +1,2 @@
1
+ declare const _default: any;
2
+ export default _default;
@@ -0,0 +1,14 @@
1
+ import { db, schema } from "@nuxthub/db";
2
+ import { and, eq, isNull } from "drizzle-orm";
3
+ export default defineEventHandler(async (event) => {
4
+ const { target } = await loadUsersAdminTarget(event, "update");
5
+ const revoked = await db.update(schema.subscriptions).set({ status: "canceled", endedAt: /* @__PURE__ */ new Date() }).where(and(
6
+ eq(schema.subscriptions.userId, target.id),
7
+ eq(schema.subscriptions.source, "manual"),
8
+ isNull(schema.subscriptions.endedAt)
9
+ )).returning({ id: schema.subscriptions.id });
10
+ if (!revoked.length) {
11
+ throw createError({ statusCode: 404, statusMessage: "The user has no free membership to revoke." });
12
+ }
13
+ return { revoked: revoked.length };
14
+ });
@@ -0,0 +1,2 @@
1
+ declare const _default: any;
2
+ export default _default;
@@ -0,0 +1,17 @@
1
+ export default defineEventHandler(async (event) => {
2
+ const { target } = await loadUsersAdminTarget(event, "view");
3
+ const current = currentEntitledRow(await listUserSubscriptionRows(target.id));
4
+ const iso = (date) => date ? date.toISOString() : null;
5
+ const plans = useRuntimeConfig().subscriptions?.plans ?? {};
6
+ return {
7
+ plans: Object.entries(plans).map(([key, plan]) => ({ key, label: plan.label ?? key, rank: plan.rank })).sort((a, b) => a.rank - b.rank),
8
+ current: current ? {
9
+ id: current.id,
10
+ planKey: current.planKey,
11
+ status: current.status,
12
+ source: current.source === "manual" ? "manual" : "stripe",
13
+ currentPeriodEnd: iso(current.currentPeriodEnd),
14
+ cancelAtPeriodEnd: current.cancelAtPeriodEnd
15
+ } : null
16
+ };
17
+ });
@@ -0,0 +1,2 @@
1
+ declare const _default: any;
2
+ export default _default;
@@ -0,0 +1,44 @@
1
+ import { db, schema } from "@nuxthub/db";
2
+ import { and, eq, isNull } from "drizzle-orm";
3
+ import { z } from "zod";
4
+ const bodySchema = z.object({
5
+ 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()
8
+ });
9
+ export default defineEventHandler(async (event) => {
10
+ const { target } = await loadUsersAdminTarget(event, "update");
11
+ const body = await readValidatedBody(event, bodySchema.parse);
12
+ const plans = useRuntimeConfig().subscriptions?.plans ?? {};
13
+ 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
+ }
22
+ }
23
+ const rows = await listUserSubscriptionRows(target.id);
24
+ const current = currentEntitledRow(rows);
25
+ if (current && current.source !== "manual") {
26
+ throw createError({ statusCode: 409, statusMessage: "The user already has a paid subscription." });
27
+ }
28
+ const now = /* @__PURE__ */ new Date();
29
+ 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
+ const id = `manual_${crypto.randomUUID()}`;
31
+ await db.insert(schema.subscriptions).values({
32
+ id,
33
+ userId: target.id,
34
+ planKey: body.planKey,
35
+ priceId: "",
36
+ status: "active",
37
+ cancelAtPeriodEnd: false,
38
+ currentPeriodStart: now,
39
+ currentPeriodEnd: endsAt,
40
+ endedAt: null,
41
+ source: "manual"
42
+ });
43
+ return { id, planKey: body.planKey, endsAt: endsAt?.toISOString() ?? null };
44
+ });
@@ -0,0 +1,2 @@
1
+ declare const _default: any;
2
+ export default _default;
@@ -0,0 +1,4 @@
1
+ export default defineEventHandler(async (event) => {
2
+ const { target } = await loadUsersAdminTarget(event, "view");
3
+ return listUserSubscriptions(target.id);
4
+ });
@@ -1,7 +1,8 @@
1
1
  import { db, schema } from "@nuxthub/db";
2
2
  import { desc, eq } from "drizzle-orm";
3
3
  import { z } from "zod";
4
- import { LIVE_STATUSES, decideChangeKind, findPrice, resolveSameOriginUrl } from "../../../utils/plans.js";
4
+ import { resolveSameOriginUrl } from "@frigear-nu/stripe/utils";
5
+ import { LIVE_STATUSES, decideChangeKind } from "../../../utils/plans.js";
5
6
  const bodySchema = z.object({
6
7
  planKey: z.string().min(1).optional(),
7
8
  priceId: z.string().min(1).optional(),
@@ -9,7 +10,7 @@ const bodySchema = z.object({
9
10
  cancelUrl: z.string().optional()
10
11
  }).refine((body) => body.planKey || body.priceId, { message: "Provide `planKey` or `priceId`." });
11
12
  export default defineEventHandler(async (event) => {
12
- const { user } = await requireUserSession(event);
13
+ const user = await requireUser(event, { scopes: ["subscriptions:write"] });
13
14
  const body = await readValidatedBody(event, bodySchema.parse);
14
15
  const origin = getRequestURL(event).origin;
15
16
  const config = useRuntimeConfig();
@@ -26,34 +27,13 @@ export default defineEventHandler(async (event) => {
26
27
  };
27
28
  const successUrl = safeUrl(body.successUrl, pages.success, "successUrl");
28
29
  const cancelUrl = safeUrl(body.cancelUrl, pages.cancel, "cancelUrl");
29
- let priceId;
30
- let planKey;
31
- if (Object.keys(configuredPlans).length === 0) {
32
- if (!body.priceId) throw createError({ statusCode: 400, statusMessage: "`priceId` is required when no plans are configured." });
33
- const price = await stripe.prices.retrieve(body.priceId).catch(() => null);
34
- if (!price?.recurring || !price.active) throw createError({ statusCode: 400, statusMessage: "Unknown or non-recurring price." });
35
- priceId = price.id;
36
- planKey = null;
37
- } else {
38
- const plans = await getAvailablePlans(event);
39
- if (body.planKey) {
40
- const plan = plans.find((item) => item.key === body.planKey);
41
- if (!plan) throw createError({ statusCode: 400, statusMessage: "Unknown or unavailable plan." });
42
- const price = body.priceId ? plan.prices.find((item) => item.id === body.priceId) : plan.prices.length === 1 ? plan.prices[0] : void 0;
43
- if (!price) {
44
- throw createError({ statusCode: 400, statusMessage: body.priceId ? "That price does not belong to the plan." : "This plan has several prices; provide `priceId`." });
45
- }
46
- priceId = price.id;
47
- planKey = plan.key;
48
- } else {
49
- const found = findPrice(plans, body.priceId);
50
- if (!found) throw createError({ statusCode: 400, statusMessage: "Unknown or unavailable price." });
51
- priceId = found.price.id;
52
- planKey = found.plan.key;
53
- }
54
- }
30
+ const { priceId, planKey } = await resolveCheckoutTarget(stripe, await getAvailablePlans(event), body);
55
31
  const rows = await db.select().from(schema.subscriptions).where(eq(schema.subscriptions.userId, user.id)).orderBy(desc(schema.subscriptions.createdAt));
56
- const existing = rows.find((row) => LIVE_STATUSES.includes(row.status) && !row.endedAt);
32
+ const existing = rows.find((row) => row.source !== "manual" && LIVE_STATUSES.includes(row.status) && !row.endedAt);
33
+ if (existing?.priceId === priceId && existing.cancelAtPeriodEnd) {
34
+ await resumeSubscription(stripe, existing.id);
35
+ return { updated: true, kind: "resume" };
36
+ }
57
37
  if (existing?.priceId === priceId) {
58
38
  throw createError({ statusCode: 409, statusMessage: "You are already subscribed to this price." });
59
39
  }
@@ -76,7 +56,7 @@ export default defineEventHandler(async (event) => {
76
56
  await useNitroApp().hooks.callHook("subscriptions:checkout:before", hookContext);
77
57
  if (existing) {
78
58
  const change = hookContext.kind;
79
- if (change === "upgrade") {
59
+ if (change === "upgrade" || existing.cancelAtPeriodEnd) {
80
60
  await upgradeSubscription(stripe, existing.id, priceId);
81
61
  } else {
82
62
  await scheduleDowngrade(stripe, existing.id, priceId);
@@ -88,6 +68,12 @@ export default defineEventHandler(async (event) => {
88
68
  if (!session.url) throw createError({ statusCode: 502, statusMessage: "Stripe did not return a checkout URL." });
89
69
  return { url: session.url };
90
70
  });
71
+ async function resumeSubscription(stripe, subscriptionId) {
72
+ const subscription = await stripe.subscriptions.retrieve(subscriptionId);
73
+ const scheduleId = typeof subscription.schedule === "string" ? subscription.schedule : subscription.schedule?.id;
74
+ if (scheduleId) await stripe.subscriptionSchedules.release(scheduleId);
75
+ await stripe.subscriptions.update(subscriptionId, { cancel_at_period_end: false });
76
+ }
91
77
  async function upgradeSubscription(stripe, subscriptionId, priceId) {
92
78
  const subscription = await stripe.subscriptions.retrieve(subscriptionId);
93
79
  const item = subscription.items.data[0];
@@ -1,22 +1,4 @@
1
- import { db, schema } from "@nuxthub/db";
2
- import { desc, eq } from "drizzle-orm";
3
- import { DEFAULT_ENTITLED_STATUSES } from "../../../utils/plans.js";
4
1
  export default defineEventHandler(async (event) => {
5
- const { user } = await requireUserSession(event);
6
- const rows = await db.select().from(schema.subscriptions).where(eq(schema.subscriptions.userId, user.id)).orderBy(desc(schema.subscriptions.createdAt));
7
- const iso = (date) => date ? date.toISOString() : null;
8
- const active = rows.filter((row) => DEFAULT_ENTITLED_STATUSES.includes(row.status) && !row.endedAt).sort((a, b) => (b.currentPeriodEnd?.getTime() ?? 0) - (a.currentPeriodEnd?.getTime() ?? 0))[0];
9
- return {
10
- subscriptions: rows.map((row) => ({
11
- id: row.id,
12
- planKey: row.planKey,
13
- priceId: row.priceId,
14
- status: row.status,
15
- cancelAtPeriodEnd: row.cancelAtPeriodEnd,
16
- currentPeriodStart: iso(row.currentPeriodStart),
17
- currentPeriodEnd: iso(row.currentPeriodEnd),
18
- endedAt: iso(row.endedAt)
19
- })),
20
- activePlan: active?.planKey ?? null
21
- };
2
+ const user = await requireUser(event, { scopes: ["subscriptions:read"] });
3
+ return listUserSubscriptions(user.id);
22
4
  });
@@ -1,8 +1,8 @@
1
1
  import { z } from "zod";
2
- import { resolveSameOriginUrl } from "../../../utils/plans.js";
2
+ import { resolveSameOriginUrl } from "@frigear-nu/stripe/utils";
3
3
  const bodySchema = z.object({ returnUrl: z.string().optional() });
4
4
  export default defineEventHandler(async (event) => {
5
- const { user } = await requireUserSession(event);
5
+ const user = await requireUser(event, { sessionOnly: true });
6
6
  const { returnUrl } = await readValidatedBody(event, bodySchema.parse);
7
7
  const origin = getRequestURL(event).origin;
8
8
  let returnTo = `${origin}${useRuntimeConfig().public.subscriptions?.pages?.membership ?? "/"}`;
@@ -0,0 +1,2 @@
1
+ declare const _default: import("@frigear-nu/stripe/types").StripeEventHandler;
2
+ export default _default;
@@ -0,0 +1,42 @@
1
+ import { defineStripeEventHandler } from "@frigear-nu/stripe/utils";
2
+ const statusFor = {
3
+ "invoice.paid": "paid",
4
+ "invoice.payment_failed": "failed",
5
+ "invoice.marked_uncollectible": "failed",
6
+ "invoice.voided": "expired"
7
+ };
8
+ const idOf = (value) => typeof value === "string" ? value : value?.id ?? null;
9
+ export default defineStripeEventHandler(async (event, ctx) => {
10
+ const invoice = event.data.object;
11
+ if (ctx.userId === null || !invoice.id) return;
12
+ const subscriptionId = idOf(invoice.parent?.subscription_details?.subscription);
13
+ if (!subscriptionId) return;
14
+ if (invoice.total === 0 && invoice.amount_paid === 0) return;
15
+ const status = statusFor[event.type];
16
+ const paymentIntentId = await findPaymentIntent(invoice);
17
+ const line = invoice.lines?.data?.[0];
18
+ const payload = {
19
+ userId: ctx.userId,
20
+ invoiceId: invoice.id,
21
+ status,
22
+ amount: status === "paid" ? invoice.amount_paid : invoice.amount_due,
23
+ currency: invoice.currency,
24
+ description: line?.description ?? "Membership",
25
+ referenceType: "subscription",
26
+ referenceId: subscriptionId,
27
+ paymentIntentId,
28
+ paidAt: invoice.status_transitions?.paid_at ? new Date(invoice.status_transitions.paid_at * 1e3) : null,
29
+ createdAt: new Date(invoice.created * 1e3)
30
+ };
31
+ await useNitroApp().hooks.callHook("subscriptions:invoice", { invoice: payload });
32
+ });
33
+ async function findPaymentIntent(invoice) {
34
+ if (invoice.status !== "paid") return null;
35
+ try {
36
+ const { data } = await useStripe().invoicePayments.list({ invoice: invoice.id, limit: 1 });
37
+ return idOf(data[0]?.payment?.payment_intent);
38
+ } catch (error) {
39
+ console.warn(`[@frigear-nu/subscriptions] Could not resolve the payment intent of invoice ${invoice.id}:`, error);
40
+ return null;
41
+ }
42
+ }
@@ -0,0 +1,5 @@
1
+ /** The user's subscription rows, newest first. */
2
+ export declare const listUserSubscriptionRows: (userId: number) => any;
3
+ export type AdminMembershipRow = Awaited<ReturnType<typeof listUserSubscriptionRows>>[number];
4
+ /** The row currently entitling the user (manual grant or Stripe subscription), preferring the latest period end. */
5
+ export declare function currentEntitledRow(rows: AdminMembershipRow[]): any;
@@ -0,0 +1,7 @@
1
+ import { db, schema } from "@nuxthub/db";
2
+ import { desc, eq } from "drizzle-orm";
3
+ import { isEntitledRow } from "../../utils/plans.js";
4
+ export const listUserSubscriptionRows = (userId) => db.select().from(schema.subscriptions).where(eq(schema.subscriptions.userId, userId)).orderBy(desc(schema.subscriptions.createdAt));
5
+ export function currentEntitledRow(rows) {
6
+ return rows.filter((row) => isEntitledRow(row)).sort((a, b) => (b.currentPeriodEnd?.getTime() ?? Number.POSITIVE_INFINITY) - (a.currentPeriodEnd?.getTime() ?? Number.POSITIVE_INFINITY))[0];
7
+ }
@@ -0,0 +1,15 @@
1
+ import type { ResolvedPlan } from '../../../types.js';
2
+ type StripeClient = ReturnType<typeof useStripe>;
3
+ /**
4
+ * Resolves the Stripe price (and configured plan key) a checkout targets from a `planKey` and/or `priceId`,
5
+ * against the given plans. Without configured plans any active recurring price may be used ("any subscription").
6
+ * Throws a 400 for unknown or ambiguous targets.
7
+ */
8
+ export declare function resolveCheckoutTarget(stripe: StripeClient, plans: ResolvedPlan[], body: {
9
+ planKey?: string;
10
+ priceId?: string;
11
+ }): Promise<{
12
+ priceId: string;
13
+ planKey: string | null;
14
+ }>;
15
+ export {};
@@ -0,0 +1,22 @@
1
+ import { findPrice } from "../../utils/plans.js";
2
+ export async function resolveCheckoutTarget(stripe, plans, body) {
3
+ const configured = Object.keys(useRuntimeConfig().subscriptions?.plans ?? {}).length > 0;
4
+ if (!configured) {
5
+ if (!body.priceId) throw createError({ statusCode: 400, statusMessage: "`priceId` is required when no plans are configured." });
6
+ 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
+ return { priceId: price.id, planKey: null };
9
+ }
10
+ if (body.planKey) {
11
+ const plan = plans.find((item) => item.key === body.planKey);
12
+ if (!plan) throw createError({ statusCode: 400, statusMessage: "Unknown or unavailable plan." });
13
+ const price = body.priceId ? plan.prices.find((item) => item.id === body.priceId) : plan.prices.length === 1 ? plan.prices[0] : void 0;
14
+ 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
+ }
17
+ return { priceId: price.id, planKey: plan.key };
18
+ }
19
+ const found = findPrice(plans, body.priceId);
20
+ if (!found) throw createError({ statusCode: 400, statusMessage: "Unknown or unavailable price." });
21
+ return { priceId: found.price.id, planKey: found.plan.key };
22
+ }