@frigear-nu/subscriptions 0.0.0-stage → 0.0.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 (53) hide show
  1. package/README.md +127 -2
  2. package/dist/module.d.mts +155 -0
  3. package/dist/module.json +9 -0
  4. package/dist/module.mjs +120 -0
  5. package/dist/runtime/app/components/SubscriptionPlans.d.vue.ts +71 -0
  6. package/dist/runtime/app/components/SubscriptionPlans.vue +215 -0
  7. package/dist/runtime/app/components/SubscriptionPlans.vue.d.ts +71 -0
  8. package/dist/runtime/app/components/SubscriptionStatus.d.vue.ts +44 -0
  9. package/dist/runtime/app/components/SubscriptionStatus.vue +138 -0
  10. package/dist/runtime/app/components/SubscriptionStatus.vue.d.ts +44 -0
  11. package/dist/runtime/app/composables/index.d.ts +4 -0
  12. package/dist/runtime/app/composables/index.js +2 -0
  13. package/dist/runtime/app/composables/usePlans.d.ts +23 -0
  14. package/dist/runtime/app/composables/usePlans.js +10 -0
  15. package/dist/runtime/app/composables/useSubscription.d.ts +27 -0
  16. package/dist/runtime/app/composables/useSubscription.js +73 -0
  17. package/dist/runtime/app/middleware/subscription.d.ts +2 -0
  18. package/dist/runtime/app/middleware/subscription.js +14 -0
  19. package/dist/runtime/app/pages/cancel.d.vue.ts +3 -0
  20. package/dist/runtime/app/pages/cancel.vue +23 -0
  21. package/dist/runtime/app/pages/cancel.vue.d.ts +3 -0
  22. package/dist/runtime/app/pages/membership.d.vue.ts +3 -0
  23. package/dist/runtime/app/pages/membership.vue +18 -0
  24. package/dist/runtime/app/pages/membership.vue.d.ts +3 -0
  25. package/dist/runtime/app/pages/success.d.vue.ts +3 -0
  26. package/dist/runtime/app/pages/success.vue +33 -0
  27. package/dist/runtime/app/pages/success.vue.d.ts +3 -0
  28. package/dist/runtime/i18n/lang/da.json +55 -0
  29. package/dist/runtime/i18n/lang/en.json +55 -0
  30. package/dist/runtime/menus.d.ts +2 -0
  31. package/dist/runtime/menus.js +16 -0
  32. package/dist/runtime/server/db/schema.sqlite.d.ts +231 -0
  33. package/dist/runtime/server/db/schema.sqlite.js +20 -0
  34. package/dist/runtime/server/routes/subscriptions/checkout.post.d.ts +2 -0
  35. package/dist/runtime/server/routes/subscriptions/checkout.post.js +125 -0
  36. package/dist/runtime/server/routes/subscriptions/me.get.d.ts +2 -0
  37. package/dist/runtime/server/routes/subscriptions/me.get.js +22 -0
  38. package/dist/runtime/server/routes/subscriptions/plans.get.d.ts +2 -0
  39. package/dist/runtime/server/routes/subscriptions/plans.get.js +3 -0
  40. package/dist/runtime/server/routes/subscriptions/portal.post.d.ts +2 -0
  41. package/dist/runtime/server/routes/subscriptions/portal.post.js +17 -0
  42. package/dist/runtime/server/stripe/subscription-events.d.ts +2 -0
  43. package/dist/runtime/server/stripe/subscription-events.js +36 -0
  44. package/dist/runtime/server/utils/plans.d.ts +6 -0
  45. package/dist/runtime/server/utils/plans.js +64 -0
  46. package/dist/runtime/server/utils/subscriptions.d.ts +13 -0
  47. package/dist/runtime/server/utils/subscriptions.js +16 -0
  48. package/dist/runtime/utils/index.d.ts +4 -0
  49. package/dist/runtime/utils/index.js +2 -0
  50. package/dist/runtime/utils/plans.d.ts +32 -0
  51. package/dist/runtime/utils/plans.js +33 -0
  52. package/dist/types.d.mts +3 -0
  53. package/package.json +88 -3
@@ -0,0 +1,23 @@
1
+ <script setup>
2
+ defineOptions({ name: "SubscriptionCancelPage" });
3
+ const t = useModuleT("subscriptions.cancel");
4
+ const membership = useRuntimeConfig().public.subscriptions?.pages?.membership;
5
+ </script>
6
+
7
+ <template>
8
+ <UContainer>
9
+ <UPage>
10
+ <UPageBody class="space-y-6">
11
+ <UPageHeader
12
+ :title="t('title', 'Checkout canceled')"
13
+ :description="t('body', 'No changes were made and you have not been charged.')"
14
+ />
15
+ <UButton
16
+ v-if="membership"
17
+ :to="membership"
18
+ :label="t('back', 'Back to plans')"
19
+ />
20
+ </UPageBody>
21
+ </UPage>
22
+ </UContainer>
23
+ </template>
@@ -0,0 +1,3 @@
1
+ declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
2
+ declare const _default: typeof __VLS_export;
3
+ export default _default;
@@ -0,0 +1,3 @@
1
+ declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
2
+ declare const _default: typeof __VLS_export;
3
+ export default _default;
@@ -0,0 +1,18 @@
1
+ <script setup>
2
+ import SubscriptionPlans from "../components/SubscriptionPlans.vue";
3
+ import SubscriptionStatus from "../components/SubscriptionStatus.vue";
4
+ import { useSubscription } from "../composables/useSubscription";
5
+ defineOptions({ name: "SubscriptionMembershipPage" });
6
+ const { isActive } = useSubscription();
7
+ </script>
8
+
9
+ <template>
10
+ <UContainer>
11
+ <UPage>
12
+ <UPageBody class="space-y-8">
13
+ <SubscriptionStatus v-if="isActive" />
14
+ <SubscriptionPlans confirm-change />
15
+ </UPageBody>
16
+ </UPage>
17
+ </UContainer>
18
+ </template>
@@ -0,0 +1,3 @@
1
+ declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
2
+ declare const _default: typeof __VLS_export;
3
+ export default _default;
@@ -0,0 +1,3 @@
1
+ declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
2
+ declare const _default: typeof __VLS_export;
3
+ export default _default;
@@ -0,0 +1,33 @@
1
+ <script setup>
2
+ import SubscriptionStatus from "../components/SubscriptionStatus.vue";
3
+ import { useSubscription } from "../composables/useSubscription";
4
+ defineOptions({ name: "SubscriptionSuccessPage" });
5
+ const t = useModuleT("subscriptions.success");
6
+ const { refresh, isActive } = useSubscription();
7
+ const membership = useRuntimeConfig().public.subscriptions?.pages?.membership;
8
+ onMounted(async () => {
9
+ for (let attempt = 0; attempt < 5 && !isActive.value; attempt++) {
10
+ await refresh();
11
+ if (!isActive.value) await new Promise((resolve) => setTimeout(resolve, 2e3));
12
+ }
13
+ });
14
+ </script>
15
+
16
+ <template>
17
+ <UContainer>
18
+ <UPage>
19
+ <UPageBody class="space-y-6">
20
+ <UPageHeader
21
+ :title="t('title', 'Thank you!')"
22
+ :description="t('body', 'Your subscription is being activated. This can take a few seconds.')"
23
+ />
24
+ <SubscriptionStatus />
25
+ <UButton
26
+ v-if="membership"
27
+ :to="membership"
28
+ :label="t('continue', 'Continue')"
29
+ />
30
+ </UPageBody>
31
+ </UPage>
32
+ </UContainer>
33
+ </template>
@@ -0,0 +1,3 @@
1
+ declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
2
+ declare const _default: typeof __VLS_export;
3
+ export default _default;
@@ -0,0 +1,55 @@
1
+ {
2
+ "subscriptions": {
3
+ "plans": {
4
+ "loading": "Henter planer…",
5
+ "empty": "Der er ingen planer tilgængelige lige nu.",
6
+ "current": "Nuværende plan",
7
+ "subscribe": "Abonnér",
8
+ "upgrade": "Opgradér",
9
+ "downgrade": "Nedgradér",
10
+ "trial": "{days} dages gratis prøveperiode",
11
+ "confirmTitle": "Skift plan",
12
+ "confirmBody": "Skift til {plan}? Dit abonnement bliver opdateret.",
13
+ "confirm": "Bekræft",
14
+ "cancel": "Annullér"
15
+ },
16
+ "interval": {
17
+ "day": "dag",
18
+ "week": "uge",
19
+ "month": "måned",
20
+ "year": "år",
21
+ "days": "dage",
22
+ "weeks": "uger",
23
+ "months": "måneder",
24
+ "years": "år"
25
+ },
26
+ "status": {
27
+ "loading": "Henter medlemskab…",
28
+ "title": "Dit medlemskab",
29
+ "none": "Du har ikke et aktivt medlemskab.",
30
+ "renews": "Fornyes {date}",
31
+ "cancels": "Afsluttes {date}",
32
+ "manage": "Administrér betaling",
33
+ "resubscribe": "Abonnér igen",
34
+ "active": "Aktiv",
35
+ "trialing": "Prøveperiode",
36
+ "pastDue": "Forfalden",
37
+ "canceled": "Annulleret",
38
+ "other": "Inaktiv"
39
+ },
40
+ "success": {
41
+ "title": "Tak!",
42
+ "body": "Dit abonnement bliver aktiveret. Det kan tage et par sekunder.",
43
+ "continue": "Fortsæt"
44
+ },
45
+ "cancel": {
46
+ "title": "Betaling annulleret",
47
+ "body": "Der blev ikke foretaget ændringer, og du er ikke blevet opkrævet.",
48
+ "back": "Tilbage til planer"
49
+ },
50
+ "errors": {
51
+ "checkout": "Kunne ikke starte betalingen.",
52
+ "portal": "Kunne ikke åbne betalingsportalen."
53
+ }
54
+ }
55
+ }
@@ -0,0 +1,55 @@
1
+ {
2
+ "subscriptions": {
3
+ "plans": {
4
+ "loading": "Loading plans…",
5
+ "empty": "No plans are available right now.",
6
+ "current": "Current plan",
7
+ "subscribe": "Subscribe",
8
+ "upgrade": "Upgrade",
9
+ "downgrade": "Downgrade",
10
+ "trial": "{days}-day free trial",
11
+ "confirmTitle": "Change plan",
12
+ "confirmBody": "Switch to {plan}? Your subscription will be updated.",
13
+ "confirm": "Confirm",
14
+ "cancel": "Cancel"
15
+ },
16
+ "interval": {
17
+ "day": "day",
18
+ "week": "week",
19
+ "month": "month",
20
+ "year": "year",
21
+ "days": "days",
22
+ "weeks": "weeks",
23
+ "months": "months",
24
+ "years": "years"
25
+ },
26
+ "status": {
27
+ "loading": "Loading subscription…",
28
+ "title": "Your subscription",
29
+ "none": "You do not have an active subscription.",
30
+ "renews": "Renews on {date}",
31
+ "cancels": "Ends on {date}",
32
+ "manage": "Manage billing",
33
+ "resubscribe": "Resubscribe",
34
+ "active": "Active",
35
+ "trialing": "Trial",
36
+ "pastDue": "Past due",
37
+ "canceled": "Canceled",
38
+ "other": "Inactive"
39
+ },
40
+ "success": {
41
+ "title": "Thank you!",
42
+ "body": "Your subscription is being activated. This can take a few seconds.",
43
+ "continue": "Continue"
44
+ },
45
+ "cancel": {
46
+ "title": "Checkout canceled",
47
+ "body": "No changes were made and you have not been charged.",
48
+ "back": "Back to plans"
49
+ },
50
+ "errors": {
51
+ "checkout": "Failed to start checkout.",
52
+ "portal": "Failed to open the billing portal."
53
+ }
54
+ }
55
+ }
@@ -0,0 +1,2 @@
1
+ declare const _default: import("@frigear-nu/menu/types").MenuSource;
2
+ export default _default;
@@ -0,0 +1,16 @@
1
+ import { defineMenuItem, defineMenuSource, extendMenuItem } from "@frigear-nu/menu/utils";
2
+ export default defineMenuSource(() => {
3
+ const config = useRuntimeConfig().public.subscriptions;
4
+ if (config?.pages?.membership) {
5
+ const item = defineMenuItem({
6
+ id: "subscription",
7
+ label: "Subscription",
8
+ description: "Manage your plan and billing.",
9
+ icon: "i-lucide-credit-card",
10
+ to: config.pages.membership,
11
+ order: 30
12
+ });
13
+ extendMenuItem("account", item);
14
+ extendMenuItem("dashboard.account", item);
15
+ }
16
+ });
@@ -0,0 +1,231 @@
1
+ export declare const subscriptions: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
2
+ name: "subscriptions";
3
+ schema: undefined;
4
+ columns: {
5
+ id: import("drizzle-orm/sqlite-core").SQLiteColumn<{
6
+ name: "id";
7
+ tableName: "subscriptions";
8
+ dataType: "string";
9
+ columnType: "SQLiteText";
10
+ data: string;
11
+ driverParam: string;
12
+ notNull: true;
13
+ hasDefault: false;
14
+ isPrimaryKey: true;
15
+ isAutoincrement: false;
16
+ hasRuntimeDefault: false;
17
+ enumValues: [string, ...string[]];
18
+ baseColumn: never;
19
+ identity: undefined;
20
+ generated: undefined;
21
+ }, {}, {
22
+ length: undefined;
23
+ }>;
24
+ userId: import("drizzle-orm/sqlite-core").SQLiteColumn<{
25
+ name: "userId";
26
+ tableName: "subscriptions";
27
+ dataType: "number";
28
+ columnType: "SQLiteInteger";
29
+ data: number;
30
+ driverParam: number;
31
+ notNull: true;
32
+ hasDefault: false;
33
+ isPrimaryKey: false;
34
+ isAutoincrement: false;
35
+ hasRuntimeDefault: false;
36
+ enumValues: undefined;
37
+ baseColumn: never;
38
+ identity: undefined;
39
+ generated: undefined;
40
+ }, {}, {}>;
41
+ planKey: import("drizzle-orm/sqlite-core").SQLiteColumn<{
42
+ name: "planKey";
43
+ tableName: "subscriptions";
44
+ dataType: "string";
45
+ columnType: "SQLiteText";
46
+ data: string;
47
+ driverParam: string;
48
+ notNull: false;
49
+ hasDefault: false;
50
+ isPrimaryKey: false;
51
+ isAutoincrement: false;
52
+ hasRuntimeDefault: false;
53
+ enumValues: [string, ...string[]];
54
+ baseColumn: never;
55
+ identity: undefined;
56
+ generated: undefined;
57
+ }, {}, {
58
+ length: undefined;
59
+ }>;
60
+ priceId: import("drizzle-orm/sqlite-core").SQLiteColumn<{
61
+ name: "priceId";
62
+ tableName: "subscriptions";
63
+ dataType: "string";
64
+ columnType: "SQLiteText";
65
+ data: string;
66
+ driverParam: string;
67
+ notNull: true;
68
+ hasDefault: false;
69
+ isPrimaryKey: false;
70
+ isAutoincrement: false;
71
+ hasRuntimeDefault: false;
72
+ enumValues: [string, ...string[]];
73
+ baseColumn: never;
74
+ identity: undefined;
75
+ generated: undefined;
76
+ }, {}, {
77
+ length: undefined;
78
+ }>;
79
+ status: import("drizzle-orm/sqlite-core").SQLiteColumn<{
80
+ name: "status";
81
+ tableName: "subscriptions";
82
+ dataType: "string";
83
+ columnType: "SQLiteText";
84
+ data: string;
85
+ driverParam: string;
86
+ notNull: true;
87
+ hasDefault: false;
88
+ isPrimaryKey: false;
89
+ isAutoincrement: false;
90
+ hasRuntimeDefault: false;
91
+ enumValues: [string, ...string[]];
92
+ baseColumn: never;
93
+ identity: undefined;
94
+ generated: undefined;
95
+ }, {}, {
96
+ length: undefined;
97
+ }>;
98
+ cancelAtPeriodEnd: import("drizzle-orm/sqlite-core").SQLiteColumn<{
99
+ name: "cancelAtPeriodEnd";
100
+ tableName: "subscriptions";
101
+ dataType: "boolean";
102
+ columnType: "SQLiteBoolean";
103
+ data: boolean;
104
+ driverParam: number;
105
+ notNull: true;
106
+ hasDefault: true;
107
+ isPrimaryKey: false;
108
+ isAutoincrement: false;
109
+ hasRuntimeDefault: false;
110
+ enumValues: undefined;
111
+ baseColumn: never;
112
+ identity: undefined;
113
+ generated: undefined;
114
+ }, {}, {}>;
115
+ currentPeriodStart: import("drizzle-orm/sqlite-core").SQLiteColumn<{
116
+ name: "currentPeriodStart";
117
+ tableName: "subscriptions";
118
+ dataType: "date";
119
+ columnType: "SQLiteTimestamp";
120
+ data: Date;
121
+ driverParam: number;
122
+ notNull: false;
123
+ hasDefault: false;
124
+ isPrimaryKey: false;
125
+ isAutoincrement: false;
126
+ hasRuntimeDefault: false;
127
+ enumValues: undefined;
128
+ baseColumn: never;
129
+ identity: undefined;
130
+ generated: undefined;
131
+ }, {}, {}>;
132
+ currentPeriodEnd: import("drizzle-orm/sqlite-core").SQLiteColumn<{
133
+ name: "currentPeriodEnd";
134
+ tableName: "subscriptions";
135
+ dataType: "date";
136
+ columnType: "SQLiteTimestamp";
137
+ data: Date;
138
+ driverParam: number;
139
+ notNull: false;
140
+ hasDefault: false;
141
+ isPrimaryKey: false;
142
+ isAutoincrement: false;
143
+ hasRuntimeDefault: false;
144
+ enumValues: undefined;
145
+ baseColumn: never;
146
+ identity: undefined;
147
+ generated: undefined;
148
+ }, {}, {}>;
149
+ endedAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
150
+ name: "endedAt";
151
+ tableName: "subscriptions";
152
+ dataType: "date";
153
+ columnType: "SQLiteTimestamp";
154
+ data: Date;
155
+ driverParam: number;
156
+ notNull: false;
157
+ hasDefault: false;
158
+ isPrimaryKey: false;
159
+ isAutoincrement: false;
160
+ hasRuntimeDefault: false;
161
+ enumValues: undefined;
162
+ baseColumn: never;
163
+ identity: undefined;
164
+ generated: undefined;
165
+ }, {}, {}>;
166
+ rawItems: import("drizzle-orm/sqlite-core").SQLiteColumn<{
167
+ name: "rawItems";
168
+ tableName: "subscriptions";
169
+ dataType: "json";
170
+ columnType: "SQLiteTextJson";
171
+ data: {
172
+ id: string;
173
+ priceId: string;
174
+ lookupKey: string | null;
175
+ quantity: number | null;
176
+ }[];
177
+ driverParam: string;
178
+ notNull: false;
179
+ hasDefault: false;
180
+ isPrimaryKey: false;
181
+ isAutoincrement: false;
182
+ hasRuntimeDefault: false;
183
+ enumValues: undefined;
184
+ baseColumn: never;
185
+ identity: undefined;
186
+ generated: undefined;
187
+ }, {}, {
188
+ $type: {
189
+ id: string;
190
+ priceId: string;
191
+ lookupKey: string | null;
192
+ quantity: number | null;
193
+ }[];
194
+ }>;
195
+ createdAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
196
+ name: "createdAt";
197
+ tableName: "subscriptions";
198
+ dataType: "date";
199
+ columnType: "SQLiteTimestamp";
200
+ data: Date;
201
+ driverParam: number;
202
+ notNull: true;
203
+ hasDefault: true;
204
+ isPrimaryKey: false;
205
+ isAutoincrement: false;
206
+ hasRuntimeDefault: true;
207
+ enumValues: undefined;
208
+ baseColumn: never;
209
+ identity: undefined;
210
+ generated: undefined;
211
+ }, {}, {}>;
212
+ updatedAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
213
+ name: "updatedAt";
214
+ tableName: "subscriptions";
215
+ dataType: "date";
216
+ columnType: "SQLiteTimestamp";
217
+ data: Date;
218
+ driverParam: number;
219
+ notNull: true;
220
+ hasDefault: true;
221
+ isPrimaryKey: false;
222
+ isAutoincrement: false;
223
+ hasRuntimeDefault: true;
224
+ enumValues: undefined;
225
+ baseColumn: never;
226
+ identity: undefined;
227
+ generated: undefined;
228
+ }, {}, {}>;
229
+ };
230
+ dialect: "sqlite";
231
+ }>;
@@ -0,0 +1,20 @@
1
+ import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
2
+ export const subscriptions = sqliteTable("subscriptions", {
3
+ /** The Stripe subscription id (`sub_...`). */
4
+ id: text().primaryKey(),
5
+ userId: integer().notNull(),
6
+ /** Configured plan key, or null when the price isn't part of any configured plan. */
7
+ planKey: text(),
8
+ priceId: text().notNull(),
9
+ /** The raw Stripe status (`active`, `trialing`, `past_due`, `canceled`, ...). */
10
+ status: text().notNull(),
11
+ cancelAtPeriodEnd: integer({ mode: "boolean" }).notNull().default(false),
12
+ currentPeriodStart: integer({ mode: "timestamp" }),
13
+ currentPeriodEnd: integer({ mode: "timestamp" }),
14
+ endedAt: integer({ mode: "timestamp" }),
15
+ rawItems: text({ mode: "json" }).$type(),
16
+ createdAt: integer({ mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date()),
17
+ updatedAt: integer({ mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date()).$onUpdateFn(() => /* @__PURE__ */ new Date())
18
+ }, (t) => [
19
+ index("subscription_user_idx").on(t.userId)
20
+ ]);
@@ -0,0 +1,2 @@
1
+ declare const _default: any;
2
+ export default _default;
@@ -0,0 +1,125 @@
1
+ import { db, schema } from "@nuxthub/db";
2
+ import { desc, eq } from "drizzle-orm";
3
+ import { z } from "zod";
4
+ import { LIVE_STATUSES, decideChangeKind, findPrice, resolveSameOriginUrl } from "../../../utils/plans.js";
5
+ const bodySchema = z.object({
6
+ planKey: z.string().min(1).optional(),
7
+ priceId: z.string().min(1).optional(),
8
+ successUrl: z.string().optional(),
9
+ cancelUrl: z.string().optional()
10
+ }).refine((body) => body.planKey || body.priceId, { message: "Provide `planKey` or `priceId`." });
11
+ export default defineEventHandler(async (event) => {
12
+ const { user } = await requireUserSession(event);
13
+ const body = await readValidatedBody(event, bodySchema.parse);
14
+ const origin = getRequestURL(event).origin;
15
+ const config = useRuntimeConfig();
16
+ const pages = config.public.subscriptions?.pages ?? {};
17
+ const configuredPlans = config.subscriptions?.plans ?? {};
18
+ const stripe = useStripe();
19
+ const safeUrl = (input, fallbackPath, name) => {
20
+ if (input) {
21
+ const safe = resolveSameOriginUrl(input, origin);
22
+ if (!safe) throw createError({ statusCode: 400, statusMessage: `${name} must be a relative path or an URL on the same origin.` });
23
+ return safe;
24
+ }
25
+ return `${origin}${fallbackPath ?? pages.membership ?? "/"}`;
26
+ };
27
+ const successUrl = safeUrl(body.successUrl, pages.success, "successUrl");
28
+ 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
+ }
55
+ 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);
57
+ if (existing?.priceId === priceId) {
58
+ throw createError({ statusCode: 409, statusMessage: "You are already subscribed to this price." });
59
+ }
60
+ const kind = existing ? decideChangeKind(existing.planKey ? configuredPlans[existing.planKey]?.rank : null, planKey ? configuredPlans[planKey].rank : Number.POSITIVE_INFINITY) : "checkout";
61
+ const hookContext = {
62
+ event,
63
+ userId: user.id,
64
+ planKey,
65
+ priceId,
66
+ kind,
67
+ params: {
68
+ mode: "subscription",
69
+ line_items: [{ price: priceId, quantity: 1 }],
70
+ success_url: successUrl,
71
+ cancel_url: cancelUrl,
72
+ client_reference_id: String(user.id),
73
+ subscription_data: { metadata: { userId: String(user.id), ...planKey ? { planKey } : {} } }
74
+ }
75
+ };
76
+ await useNitroApp().hooks.callHook("subscriptions:checkout:before", hookContext);
77
+ if (existing) {
78
+ const change = hookContext.kind;
79
+ if (change === "upgrade") {
80
+ await upgradeSubscription(stripe, existing.id, priceId);
81
+ } else {
82
+ await scheduleDowngrade(stripe, existing.id, priceId);
83
+ }
84
+ return { updated: true, kind: change };
85
+ }
86
+ const customer = await ensureStripeCustomer(user.id);
87
+ const session = await stripe.checkout.sessions.create({ ...hookContext.params, customer });
88
+ if (!session.url) throw createError({ statusCode: 502, statusMessage: "Stripe did not return a checkout URL." });
89
+ return { url: session.url };
90
+ });
91
+ async function upgradeSubscription(stripe, subscriptionId, priceId) {
92
+ const subscription = await stripe.subscriptions.retrieve(subscriptionId);
93
+ const item = subscription.items.data[0];
94
+ if (!item) throw createError({ statusCode: 409, statusMessage: "The subscription has no items to change." });
95
+ const scheduleId = typeof subscription.schedule === "string" ? subscription.schedule : subscription.schedule?.id;
96
+ if (scheduleId) await stripe.subscriptionSchedules.release(scheduleId);
97
+ await stripe.subscriptions.update(subscriptionId, {
98
+ items: [{ id: item.id, price: priceId }],
99
+ proration_behavior: "always_invoice",
100
+ cancel_at_period_end: false
101
+ });
102
+ }
103
+ async function scheduleDowngrade(stripe, subscriptionId, priceId) {
104
+ const subscription = await stripe.subscriptions.retrieve(subscriptionId);
105
+ const item = subscription.items.data[0];
106
+ if (!item) throw createError({ statusCode: 409, statusMessage: "The subscription has no items to change." });
107
+ const scheduleId = typeof subscription.schedule === "string" ? subscription.schedule : subscription.schedule?.id;
108
+ const schedule = scheduleId ? await stripe.subscriptionSchedules.retrieve(scheduleId) : await stripe.subscriptionSchedules.create({ from_subscription: subscriptionId });
109
+ const current = schedule.phases[0];
110
+ if (!current) throw createError({ statusCode: 409, statusMessage: "Could not read the current billing phase." });
111
+ await stripe.subscriptionSchedules.update(schedule.id, {
112
+ end_behavior: "release",
113
+ phases: [
114
+ {
115
+ items: [{ price: item.price.id, quantity: item.quantity ?? 1 }],
116
+ start_date: current.start_date,
117
+ end_date: current.end_date
118
+ },
119
+ {
120
+ items: [{ price: priceId, quantity: item.quantity ?? 1 }],
121
+ iterations: 1
122
+ }
123
+ ]
124
+ });
125
+ }
@@ -0,0 +1,2 @@
1
+ declare const _default: any;
2
+ export default _default;
@@ -0,0 +1,22 @@
1
+ import { db, schema } from "@nuxthub/db";
2
+ import { desc, eq } from "drizzle-orm";
3
+ import { DEFAULT_ENTITLED_STATUSES } from "../../../utils/plans.js";
4
+ 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
+ };
22
+ });
@@ -0,0 +1,2 @@
1
+ declare const _default: any;
2
+ export default _default;
@@ -0,0 +1,3 @@
1
+ export default defineEventHandler(async (event) => {
2
+ return { plans: await getAvailablePlans(event) };
3
+ });
@@ -0,0 +1,2 @@
1
+ declare const _default: any;
2
+ export default _default;