@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.
- package/README.md +3 -3
- package/dist/module.d.mts +2 -2
- package/dist/module.json +1 -1
- package/dist/module.mjs +16 -31
- package/dist/runtime/app/components/SubscriptionPlans.vue +5 -11
- package/dist/runtime/app/components/SubscriptionStatus.d.vue.ts +5 -5
- package/dist/runtime/app/components/SubscriptionStatus.vue +24 -24
- package/dist/runtime/app/components/SubscriptionStatus.vue.d.ts +5 -5
- package/dist/runtime/app/components/Users/Admin/Membership.vue +3 -2
- package/dist/runtime/app/composables/usePlans.js +2 -1
- package/dist/runtime/app/composables/useSubscription.js +4 -3
- package/dist/runtime/app/pages/cancel.vue +18 -6
- package/dist/runtime/app/pages/membership.vue +28 -17
- package/dist/runtime/app/pages/success.vue +18 -6
- package/dist/runtime/app/tabs/Membership.vue +8 -8
- package/dist/runtime/i18n/lang/da.json +13 -11
- package/dist/runtime/i18n/lang/en.json +15 -13
- package/dist/runtime/menus.js +4 -4
- package/dist/runtime/server/routes/admin/{subscription.delete.js → membership.delete.js} +3 -2
- package/dist/runtime/server/routes/admin/{subscription.get.js → membership.get.js} +2 -2
- package/dist/runtime/server/routes/admin/{subscription.post.js → membership.post.js} +9 -14
- package/dist/runtime/server/routes/admin/payment-link.post.js +3 -2
- package/dist/runtime/server/routes/{subscriptions → me}/checkout.post.js +9 -18
- package/dist/runtime/server/routes/{subscriptions → me}/portal.post.js +5 -8
- package/dist/runtime/server/utils/checkout.js +6 -5
- package/dist/runtime/server/utils/subscriptions.d.ts +6 -1
- package/dist/runtime/server/utils/subscriptions.js +15 -12
- package/dist/runtime/utils/api.d.ts +20 -0
- package/dist/runtime/utils/api.js +23 -0
- package/dist/runtime/utils/index.d.ts +2 -1
- package/dist/runtime/utils/index.js +2 -1
- package/package.json +17 -12
- /package/dist/runtime/server/routes/admin/{subscription.delete.d.ts → membership-history.get.d.ts} +0 -0
- /package/dist/runtime/server/routes/admin/{subscriptions.get.js → membership-history.get.js} +0 -0
- /package/dist/runtime/server/routes/admin/{subscription.get.d.ts → membership.delete.d.ts} +0 -0
- /package/dist/runtime/server/routes/admin/{subscription.post.d.ts → membership.get.d.ts} +0 -0
- /package/dist/runtime/server/routes/admin/{subscriptions.get.d.ts → membership.post.d.ts} +0 -0
- /package/dist/runtime/server/routes/{subscriptions → me}/checkout.post.d.ts +0 -0
- /package/dist/runtime/server/routes/{subscriptions → me}/portal.post.d.ts +0 -0
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"confirmUpgrade": "Hvis du opgraderer, flyttes du straks til den højere plan. Resterende kredit fratrækkes, og du opkræves differencen med det samme.",
|
|
20
20
|
"confirmDowngrade": "Hvis du nedgraderer, kører dit nuværende abonnement til næste forfaldsdato, hvorefter det skifter.",
|
|
21
21
|
"subscribeTo": "Du abonnerer nu på:",
|
|
22
|
-
"changeTitle": "Opdatér
|
|
22
|
+
"changeTitle": "Opdatér medlemskab",
|
|
23
23
|
"checkoutTitle": "Bliv medlem",
|
|
24
24
|
"confirmResume": "Dit medlemskab fornyes som hidtil."
|
|
25
25
|
},
|
|
@@ -48,26 +48,28 @@
|
|
|
48
48
|
"canceled": "Annulleret",
|
|
49
49
|
"other": "Inaktiv",
|
|
50
50
|
"currentPlan": "Nuværende plan",
|
|
51
|
-
"cancelling": "
|
|
51
|
+
"cancelling": "Medlemskabet udløber",
|
|
52
52
|
"cancelsAt": "Slutter {date}",
|
|
53
53
|
"renewsAutomaticallyAt": "Fornyes automatisk {date}",
|
|
54
54
|
"cancel": "Opsig medlemskab",
|
|
55
55
|
"changePaymentMethod": "Skift betalingsmetode",
|
|
56
56
|
"free": "Gratis medlemskab",
|
|
57
57
|
"freeUntil": "Gratis medlemskab til {date}",
|
|
58
|
-
"
|
|
59
|
-
"
|
|
58
|
+
"emptyTitle": "Intet medlemskab endnu",
|
|
59
|
+
"emptyDescription": "Opret dit medlemskab for at komme i gang!",
|
|
60
60
|
"loadFailed": "Dit medlemskab kunne ikke indlæses",
|
|
61
61
|
"tryAgain": "Prøv igen"
|
|
62
62
|
},
|
|
63
63
|
"success": {
|
|
64
64
|
"title": "Tak!",
|
|
65
|
-
"
|
|
66
|
-
"
|
|
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
|
-
"
|
|
80
|
-
"label": "
|
|
81
|
-
"description": "Administrer dit
|
|
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": "
|
|
109
|
+
"tab": "Medlemskab",
|
|
108
110
|
"membership": "Medlemskab",
|
|
109
|
-
"currentSubscription": "Nuværende
|
|
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
|
|
22
|
+
"changeTitle": "Update membership",
|
|
23
23
|
"checkoutTitle": "Get a membership",
|
|
24
24
|
"confirmResume": "Your membership will keep renewing as before."
|
|
25
25
|
},
|
|
@@ -35,9 +35,9 @@
|
|
|
35
35
|
"quarter": "quarter"
|
|
36
36
|
},
|
|
37
37
|
"status": {
|
|
38
|
-
"loading": "Loading
|
|
39
|
-
"title": "Your
|
|
40
|
-
"none": "You do not have an active
|
|
38
|
+
"loading": "Loading membership…",
|
|
39
|
+
"title": "Your membership",
|
|
40
|
+
"none": "You do not have an active membership.",
|
|
41
41
|
"renews": "Renews on {date}",
|
|
42
42
|
"cancels": "Ends on {date}",
|
|
43
43
|
"manage": "Manage billing",
|
|
@@ -48,26 +48,28 @@
|
|
|
48
48
|
"canceled": "Canceled",
|
|
49
49
|
"other": "Inactive",
|
|
50
50
|
"currentPlan": "Current plan",
|
|
51
|
-
"cancelling": "
|
|
51
|
+
"cancelling": "Membership is ending",
|
|
52
52
|
"cancelsAt": "Ending at {date}",
|
|
53
53
|
"renewsAutomaticallyAt": "Renews automatically on {date}",
|
|
54
54
|
"cancel": "Cancel membership",
|
|
55
55
|
"changePaymentMethod": "Change payment method",
|
|
56
56
|
"free": "Free membership",
|
|
57
57
|
"freeUntil": "Free membership until {date}",
|
|
58
|
-
"
|
|
59
|
-
"
|
|
58
|
+
"emptyTitle": "No membership yet",
|
|
59
|
+
"emptyDescription": "Set up your membership to get started!",
|
|
60
60
|
"loadFailed": "Your membership could not be loaded",
|
|
61
61
|
"tryAgain": "Try again"
|
|
62
62
|
},
|
|
63
63
|
"success": {
|
|
64
64
|
"title": "Thank you!",
|
|
65
|
-
"
|
|
66
|
-
"
|
|
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
|
-
"
|
|
80
|
-
"label": "
|
|
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": "
|
|
109
|
+
"tab": "Membership",
|
|
108
110
|
"membership": "Membership",
|
|
109
|
-
"currentSubscription": "Current
|
|
111
|
+
"currentSubscription": "Current membership",
|
|
110
112
|
"history": {
|
|
111
113
|
"title": "Subscription history",
|
|
112
114
|
"loadFailed": "The subscription history could not be loaded",
|
package/dist/runtime/menus.js
CHANGED
|
@@ -3,11 +3,11 @@ export default defineMenuSource(() => {
|
|
|
3
3
|
const config = useRuntimeConfig().public.subscriptions;
|
|
4
4
|
if (config?.pages?.membership) {
|
|
5
5
|
extendMenuItem("account", defineMenuItem({
|
|
6
|
-
id: "
|
|
7
|
-
label: "
|
|
8
|
-
labelKey: "subscriptions.menu.
|
|
6
|
+
id: "membership",
|
|
7
|
+
label: "Membership",
|
|
8
|
+
labelKey: "subscriptions.menu.membership.label",
|
|
9
9
|
description: "Manage your plan and billing.",
|
|
10
|
-
descriptionKey: "subscriptions.menu.
|
|
10
|
+
descriptionKey: "subscriptions.menu.membership.description",
|
|
11
11
|
icon: "i-lucide-credit-card",
|
|
12
12
|
to: config.pages.membership,
|
|
13
13
|
order: 15
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { db, schema } from "@nuxthub/db";
|
|
2
2
|
import { and, eq, isNull } from "drizzle-orm";
|
|
3
|
+
import { noContent, notFound } from "@frigear-nu/module-kit/server";
|
|
3
4
|
export default defineEventHandler(async (event) => {
|
|
4
5
|
const { target } = await loadUsersAdminTarget(event, "update");
|
|
5
6
|
const revoked = await db.update(schema.subscriptions).set({ status: "canceled", endedAt: /* @__PURE__ */ new Date() }).where(and(
|
|
@@ -8,7 +9,7 @@ export default defineEventHandler(async (event) => {
|
|
|
8
9
|
isNull(schema.subscriptions.endedAt)
|
|
9
10
|
)).returning({ id: schema.subscriptions.id });
|
|
10
11
|
if (!revoked.length) {
|
|
11
|
-
throw
|
|
12
|
+
throw notFound("The user has no free membership to revoke.");
|
|
12
13
|
}
|
|
13
|
-
return
|
|
14
|
+
return noContent(event);
|
|
14
15
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
+
import { isoDate } from "@frigear-nu/module-kit/server";
|
|
1
2
|
export default defineEventHandler(async (event) => {
|
|
2
3
|
const { target } = await loadUsersAdminTarget(event, "view");
|
|
3
4
|
const current = currentEntitledRow(await listUserSubscriptionRows(target.id));
|
|
4
|
-
const iso = (date) => date ? date.toISOString() : null;
|
|
5
5
|
const plans = useRuntimeConfig().subscriptions?.plans ?? {};
|
|
6
6
|
return {
|
|
7
7
|
plans: Object.entries(plans).map(([key, plan]) => ({ key, label: plan.label ?? key, rank: plan.rank })).sort((a, b) => a.rank - b.rank),
|
|
@@ -10,7 +10,7 @@ export default defineEventHandler(async (event) => {
|
|
|
10
10
|
planKey: current.planKey,
|
|
11
11
|
status: current.status,
|
|
12
12
|
source: current.source === "manual" ? "manual" : "stripe",
|
|
13
|
-
currentPeriodEnd:
|
|
13
|
+
currentPeriodEnd: isoDate(current.currentPeriodEnd),
|
|
14
14
|
cancelAtPeriodEnd: current.cancelAtPeriodEnd
|
|
15
15
|
} : null
|
|
16
16
|
};
|
|
@@ -1,34 +1,29 @@
|
|
|
1
1
|
import { db, schema } from "@nuxthub/db";
|
|
2
2
|
import { and, eq, isNull } from "drizzle-orm";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import { badRequest, conflict, created } from "@frigear-nu/module-kit/server";
|
|
4
5
|
const bodySchema = z.object({
|
|
5
6
|
planKey: z.string().min(1),
|
|
6
|
-
/** Optional end of the free membership (ISO date); the grant is open-ended without it. */
|
|
7
|
-
endsAt: z.
|
|
7
|
+
/** Optional end of the free membership (ISO date, in the future); the grant is open-ended without it. */
|
|
8
|
+
endsAt: z.coerce.date().refine((date) => date.getTime() > Date.now(), "The end date must be in the future.").nullish()
|
|
8
9
|
});
|
|
9
10
|
export default defineEventHandler(async (event) => {
|
|
10
11
|
const { target } = await loadUsersAdminTarget(event, "update");
|
|
11
12
|
const body = await readValidatedBody(event, bodySchema.parse);
|
|
12
13
|
const plans = useRuntimeConfig().subscriptions?.plans ?? {};
|
|
13
14
|
if (Object.keys(plans).length && !plans[body.planKey]) {
|
|
14
|
-
throw
|
|
15
|
-
}
|
|
16
|
-
let endsAt = null;
|
|
17
|
-
if (body.endsAt) {
|
|
18
|
-
endsAt = new Date(body.endsAt);
|
|
19
|
-
if (Number.isNaN(endsAt.getTime()) || endsAt.getTime() <= Date.now()) {
|
|
20
|
-
throw createError({ statusCode: 400, statusMessage: "The end date must be in the future." });
|
|
21
|
-
}
|
|
15
|
+
throw badRequest("Unknown plan.");
|
|
22
16
|
}
|
|
17
|
+
const endsAt = body.endsAt ?? null;
|
|
23
18
|
const rows = await listUserSubscriptionRows(target.id);
|
|
24
19
|
const current = currentEntitledRow(rows);
|
|
25
20
|
if (current && current.source !== "manual") {
|
|
26
|
-
throw
|
|
21
|
+
throw conflict("The user already has a paid subscription.");
|
|
27
22
|
}
|
|
28
23
|
const now = /* @__PURE__ */ new Date();
|
|
29
24
|
await db.update(schema.subscriptions).set({ status: "canceled", endedAt: now }).where(and(eq(schema.subscriptions.userId, target.id), eq(schema.subscriptions.source, "manual"), isNull(schema.subscriptions.endedAt)));
|
|
30
25
|
const id = `manual_${crypto.randomUUID()}`;
|
|
31
|
-
await db.insert(schema.subscriptions).values({
|
|
26
|
+
const [row] = await db.insert(schema.subscriptions).values({
|
|
32
27
|
id,
|
|
33
28
|
userId: target.id,
|
|
34
29
|
planKey: body.planKey,
|
|
@@ -39,6 +34,6 @@ export default defineEventHandler(async (event) => {
|
|
|
39
34
|
currentPeriodEnd: endsAt,
|
|
40
35
|
endedAt: null,
|
|
41
36
|
source: "manual"
|
|
42
|
-
});
|
|
43
|
-
return
|
|
37
|
+
}).returning();
|
|
38
|
+
return created(event, toSubscriptionDto(row));
|
|
44
39
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { apiError, conflict } from "@frigear-nu/module-kit/server";
|
|
2
3
|
const bodySchema = z.object({
|
|
3
4
|
planKey: z.string().min(1).optional(),
|
|
4
5
|
priceId: z.string().min(1).optional(),
|
|
@@ -14,7 +15,7 @@ export default defineEventHandler(async (event) => {
|
|
|
14
15
|
const stripe = useStripe();
|
|
15
16
|
const current = currentEntitledRow(await listUserSubscriptionRows(target.id));
|
|
16
17
|
if (current && current.source !== "manual") {
|
|
17
|
-
throw
|
|
18
|
+
throw conflict("The user already has a paid subscription.");
|
|
18
19
|
}
|
|
19
20
|
const { priceId, planKey } = await resolveCheckoutTarget(stripe, await getResolvedPlans(), body);
|
|
20
21
|
const customer = await ensureStripeCustomer(target.id);
|
|
@@ -27,7 +28,7 @@ export default defineEventHandler(async (event) => {
|
|
|
27
28
|
client_reference_id: String(target.id),
|
|
28
29
|
subscription_data: { metadata: { userId: String(target.id), ...planKey ? { planKey } : {} } }
|
|
29
30
|
});
|
|
30
|
-
if (!session.url) throw
|
|
31
|
+
if (!session.url) throw apiError(502, "bad_gateway", "Stripe did not return a checkout URL.");
|
|
31
32
|
let emailed = false;
|
|
32
33
|
if (body.email) {
|
|
33
34
|
const plan = (useRuntimeConfig().subscriptions?.plans ?? {})[planKey ?? ""];
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { db, schema } from "@nuxthub/db";
|
|
2
2
|
import { desc, eq } from "drizzle-orm";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import { resolveSameOriginUrl } from "@frigear-nu/
|
|
4
|
+
import { apiError, conflict, resolveSameOriginUrl } from "@frigear-nu/module-kit/server";
|
|
5
5
|
import { LIVE_STATUSES, decideChangeKind } from "../../../utils/plans.js";
|
|
6
6
|
const bodySchema = z.object({
|
|
7
7
|
planKey: z.string().min(1).optional(),
|
|
@@ -10,23 +10,14 @@ const bodySchema = z.object({
|
|
|
10
10
|
cancelUrl: z.string().optional()
|
|
11
11
|
}).refine((body) => body.planKey || body.priceId, { message: "Provide `planKey` or `priceId`." });
|
|
12
12
|
export default defineEventHandler(async (event) => {
|
|
13
|
-
const user = await requireUser(event, {
|
|
13
|
+
const user = await requireUser(event, { sessionOnly: true });
|
|
14
14
|
const body = await readValidatedBody(event, bodySchema.parse);
|
|
15
|
-
const origin = getRequestURL(event).origin;
|
|
16
15
|
const config = useRuntimeConfig();
|
|
17
16
|
const pages = config.public.subscriptions?.pages ?? {};
|
|
18
17
|
const configuredPlans = config.subscriptions?.plans ?? {};
|
|
19
18
|
const stripe = useStripe();
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
const safe = resolveSameOriginUrl(input, origin);
|
|
23
|
-
if (!safe) throw createError({ statusCode: 400, statusMessage: `${name} must be a relative path or an URL on the same origin.` });
|
|
24
|
-
return safe;
|
|
25
|
-
}
|
|
26
|
-
return `${origin}${fallbackPath ?? pages.membership ?? "/"}`;
|
|
27
|
-
};
|
|
28
|
-
const successUrl = safeUrl(body.successUrl, pages.success, "successUrl");
|
|
29
|
-
const cancelUrl = safeUrl(body.cancelUrl, pages.cancel, "cancelUrl");
|
|
19
|
+
const successUrl = resolveSameOriginUrl(event, body.successUrl, { fallback: pages.success ?? pages.membership, name: "successUrl" });
|
|
20
|
+
const cancelUrl = resolveSameOriginUrl(event, body.cancelUrl, { fallback: pages.cancel ?? pages.membership, name: "cancelUrl" });
|
|
30
21
|
const { priceId, planKey } = await resolveCheckoutTarget(stripe, await getAvailablePlans(event), body);
|
|
31
22
|
const rows = await db.select().from(schema.subscriptions).where(eq(schema.subscriptions.userId, user.id)).orderBy(desc(schema.subscriptions.createdAt));
|
|
32
23
|
const existing = rows.find((row) => row.source !== "manual" && LIVE_STATUSES.includes(row.status) && !row.endedAt);
|
|
@@ -35,7 +26,7 @@ export default defineEventHandler(async (event) => {
|
|
|
35
26
|
return { updated: true, kind: "resume" };
|
|
36
27
|
}
|
|
37
28
|
if (existing?.priceId === priceId) {
|
|
38
|
-
throw
|
|
29
|
+
throw conflict("You are already subscribed to this price.");
|
|
39
30
|
}
|
|
40
31
|
const kind = existing ? decideChangeKind(existing.planKey ? configuredPlans[existing.planKey]?.rank : null, planKey ? configuredPlans[planKey].rank : Number.POSITIVE_INFINITY) : "checkout";
|
|
41
32
|
const hookContext = {
|
|
@@ -65,7 +56,7 @@ export default defineEventHandler(async (event) => {
|
|
|
65
56
|
}
|
|
66
57
|
const customer = await ensureStripeCustomer(user.id);
|
|
67
58
|
const session = await stripe.checkout.sessions.create({ ...hookContext.params, customer });
|
|
68
|
-
if (!session.url) throw
|
|
59
|
+
if (!session.url) throw apiError(502, "bad_gateway", "Stripe did not return a checkout URL.");
|
|
69
60
|
return { url: session.url };
|
|
70
61
|
});
|
|
71
62
|
async function resumeSubscription(stripe, subscriptionId) {
|
|
@@ -77,7 +68,7 @@ async function resumeSubscription(stripe, subscriptionId) {
|
|
|
77
68
|
async function upgradeSubscription(stripe, subscriptionId, priceId) {
|
|
78
69
|
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
|
79
70
|
const item = subscription.items.data[0];
|
|
80
|
-
if (!item) throw
|
|
71
|
+
if (!item) throw conflict("The subscription has no items to change.");
|
|
81
72
|
const scheduleId = typeof subscription.schedule === "string" ? subscription.schedule : subscription.schedule?.id;
|
|
82
73
|
if (scheduleId) await stripe.subscriptionSchedules.release(scheduleId);
|
|
83
74
|
await stripe.subscriptions.update(subscriptionId, {
|
|
@@ -89,11 +80,11 @@ async function upgradeSubscription(stripe, subscriptionId, priceId) {
|
|
|
89
80
|
async function scheduleDowngrade(stripe, subscriptionId, priceId) {
|
|
90
81
|
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
|
91
82
|
const item = subscription.items.data[0];
|
|
92
|
-
if (!item) throw
|
|
83
|
+
if (!item) throw conflict("The subscription has no items to change.");
|
|
93
84
|
const scheduleId = typeof subscription.schedule === "string" ? subscription.schedule : subscription.schedule?.id;
|
|
94
85
|
const schedule = scheduleId ? await stripe.subscriptionSchedules.retrieve(scheduleId) : await stripe.subscriptionSchedules.create({ from_subscription: subscriptionId });
|
|
95
86
|
const current = schedule.phases[0];
|
|
96
|
-
if (!current) throw
|
|
87
|
+
if (!current) throw conflict("Could not read the current billing phase.");
|
|
97
88
|
await stripe.subscriptionSchedules.update(schedule.id, {
|
|
98
89
|
end_behavior: "release",
|
|
99
90
|
phases: [
|
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { resolveSameOriginUrl } from "@frigear-nu/
|
|
2
|
+
import { resolveSameOriginUrl } from "@frigear-nu/module-kit/server";
|
|
3
3
|
const bodySchema = z.object({ returnUrl: z.string().optional() });
|
|
4
4
|
export default defineEventHandler(async (event) => {
|
|
5
5
|
const user = await requireUser(event, { sessionOnly: true });
|
|
6
6
|
const { returnUrl } = await readValidatedBody(event, bodySchema.parse);
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
if (!safe) throw createError({ statusCode: 400, statusMessage: "returnUrl must be on the same origin." });
|
|
12
|
-
returnTo = safe;
|
|
13
|
-
}
|
|
7
|
+
const returnTo = resolveSameOriginUrl(event, returnUrl, {
|
|
8
|
+
fallback: useRuntimeConfig().public.subscriptions?.pages?.membership ?? "/",
|
|
9
|
+
name: "returnUrl"
|
|
10
|
+
});
|
|
14
11
|
const customer = await ensureStripeCustomer(user.id);
|
|
15
12
|
const session = await useStripe().billingPortal.sessions.create({ customer, return_url: returnTo });
|
|
16
13
|
return { url: session.url };
|
|
@@ -1,22 +1,23 @@
|
|
|
1
|
+
import { badRequest } from "@frigear-nu/module-kit/server";
|
|
1
2
|
import { findPrice } from "../../utils/plans.js";
|
|
2
3
|
export async function resolveCheckoutTarget(stripe, plans, body) {
|
|
3
4
|
const configured = Object.keys(useRuntimeConfig().subscriptions?.plans ?? {}).length > 0;
|
|
4
5
|
if (!configured) {
|
|
5
|
-
if (!body.priceId) throw
|
|
6
|
+
if (!body.priceId) throw badRequest("`priceId` is required when no plans are configured.");
|
|
6
7
|
const price = await stripe.prices.retrieve(body.priceId).catch(() => null);
|
|
7
|
-
if (!price?.recurring || !price.active) throw
|
|
8
|
+
if (!price?.recurring || !price.active) throw badRequest("Unknown or non-recurring price.");
|
|
8
9
|
return { priceId: price.id, planKey: null };
|
|
9
10
|
}
|
|
10
11
|
if (body.planKey) {
|
|
11
12
|
const plan = plans.find((item) => item.key === body.planKey);
|
|
12
|
-
if (!plan) throw
|
|
13
|
+
if (!plan) throw badRequest("Unknown or unavailable plan.");
|
|
13
14
|
const price = body.priceId ? plan.prices.find((item) => item.id === body.priceId) : plan.prices.length === 1 ? plan.prices[0] : void 0;
|
|
14
15
|
if (!price) {
|
|
15
|
-
throw
|
|
16
|
+
throw badRequest(body.priceId ? "That price does not belong to the plan." : "This plan has several prices; provide `priceId`.");
|
|
16
17
|
}
|
|
17
18
|
return { priceId: price.id, planKey: plan.key };
|
|
18
19
|
}
|
|
19
20
|
const found = findPrice(plans, body.priceId);
|
|
20
|
-
if (!found) throw
|
|
21
|
+
if (!found) throw badRequest("Unknown or unavailable price.");
|
|
21
22
|
return { priceId: found.price.id, planKey: found.plan.key };
|
|
22
23
|
}
|
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import
|
|
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(
|
|
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,
|
|
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,
|
|
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.
|
|
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.
|
|
34
|
-
"@frigear-nu/mcp": "0.1.
|
|
35
|
-
"@frigear-nu/module-kit": "1.1.
|
|
36
|
-
"@frigear-nu/notify": "0.1.
|
|
37
|
-
"@frigear-nu/stripe": "0.1.
|
|
38
|
-
"@frigear-nu/ui": "0.1.
|
|
39
|
-
"@frigear-nu/users": "1.1.
|
|
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.
|
|
53
|
-
"@frigear-nu/widget": "0.1.
|
|
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.
|
|
74
|
-
"@frigear-nu/widget": "0.1.
|
|
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",
|
/package/dist/runtime/server/routes/admin/{subscription.delete.d.ts → membership-history.get.d.ts}
RENAMED
|
File without changes
|
/package/dist/runtime/server/routes/admin/{subscriptions.get.js → membership-history.get.js}
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|