@frigear-nu/payments 0.0.0-stage → 0.0.2

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 CHANGED
@@ -1,3 +1,43 @@
1
- # Temporary Holding Version
1
+ # @frigear-nu/payments
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ One-time Stripe payments for the frigear ecosystem: Checkout sessions with inline prices, a `payments` table, refunds and
4
+ Nitro lifecycle hooks (`payments:paid`, `payments:failed`, `payments:expired`, `payments:refunded`). Built on
5
+ [`@frigear-nu/stripe`](../stripe); it has no webhook of its own and only reacts to the verified Stripe events that module
6
+ dispatches.
7
+
8
+ ## Quick Setup
9
+
10
+ Requires `@nuxthub/core` with `hub.kv: true`, `@frigear-nu/users` and `@frigear-nu/stripe`.
11
+
12
+ ```ts
13
+ export default defineNuxtConfig({
14
+ modules: ['@frigear-nu/users', '@frigear-nu/stripe', '@frigear-nu/payments'],
15
+ })
16
+ ```
17
+
18
+ Generate and commit a migration for the `payments` table in your app (see the repository `AGENTS.md`).
19
+
20
+ ## Usage
21
+
22
+ ```ts
23
+ // In a route handler
24
+ const { payment, url } = await createPayment({
25
+ userId: user.id,
26
+ items: [{ name: 'Ticket', amount: 15000, currency: 'DKK' }],
27
+ reference: { type: 'event_ticket', id: ticketId },
28
+ successUrl: '/thanks',
29
+ cancelUrl: '/cancelled',
30
+ }, event)
31
+
32
+ // Anywhere in Nitro
33
+ nitroApp.hooks.hook('payments:paid', ({ payment }) => { /* ... */ })
34
+ ```
35
+
36
+ Full documentation: `apps/docs/content/4.modules/24.payments.md`.
37
+
38
+ ## Development
39
+
40
+ ```bash
41
+ pnpm --filter @frigear-nu/payments test
42
+ pnpm --filter @frigear-nu/payments lint
43
+ ```
@@ -0,0 +1,96 @@
1
+ import * as _nuxt_schema from '@nuxt/schema';
2
+ import Stripe from 'stripe';
3
+ import { H3Event } from 'h3';
4
+
5
+ type ModuleOptions = {
6
+ settings?: {
7
+ /** Minutes a Checkout session stays open before it expires (Stripe allows 30 - 1440). Defaults to `30`. */
8
+ checkoutExpiresInMinutes?: number;
9
+ };
10
+ };
11
+ type PaymentsRuntimeConfig = {
12
+ checkoutExpiresInMinutes: number;
13
+ };
14
+ type PaymentStatus = 'pending' | 'paid' | 'failed' | 'expired' | 'refunded' | 'partially_refunded';
15
+ type PaymentItem = {
16
+ name: string;
17
+ description?: string;
18
+ /** Unit amount in the currency's minor unit (e.g. cents / øre). */
19
+ amount: number;
20
+ /** Three letter ISO currency code. */
21
+ currency: string;
22
+ quantity?: number;
23
+ };
24
+ type PaymentReference = {
25
+ /** What the payment is for, e.g. `event_ticket`. Used by consumers to filter lifecycle hooks. */
26
+ type: string;
27
+ id: string;
28
+ };
29
+ type PaymentDto = {
30
+ id: string;
31
+ userId: number;
32
+ status: PaymentStatus;
33
+ amount: number;
34
+ currency: string;
35
+ description: string | null;
36
+ referenceType: string | null;
37
+ referenceId: string | null;
38
+ refundedAmount: number;
39
+ paidAt: string | null;
40
+ createdAt: string;
41
+ };
42
+ type CreatePaymentInput = {
43
+ userId: number;
44
+ items: PaymentItem[];
45
+ reference?: PaymentReference;
46
+ /** Absolute URL, or a path on the current origin. */
47
+ successUrl: string;
48
+ cancelUrl: string;
49
+ description?: string;
50
+ expiresInMinutes?: number;
51
+ metadata?: Record<string, string>;
52
+ };
53
+ type CreatePaymentResult = {
54
+ payment: PaymentDto;
55
+ url: string;
56
+ };
57
+ type PaymentHookContext = {
58
+ payment: PaymentDto;
59
+ };
60
+ type CheckoutBeforeContext = {
61
+ event?: H3Event;
62
+ userId: number;
63
+ reference?: PaymentReference;
64
+ /** Mutable: adjust the Checkout session params before they are sent to Stripe. */
65
+ params: Stripe.Checkout.SessionCreateParams;
66
+ };
67
+ type ResumeResponse = {
68
+ url: string;
69
+ };
70
+
71
+ declare module '@nuxt/schema' {
72
+ interface NuxtConfig {
73
+ payments?: ModuleOptions;
74
+ }
75
+ interface RuntimeConfig {
76
+ payments: PaymentsRuntimeConfig;
77
+ }
78
+ }
79
+ declare module 'nitropack' {
80
+ interface NitroRuntimeHooks {
81
+ /** Policy hook: throw to deny a checkout, or adjust `ctx.params` before it reaches Stripe. */
82
+ 'payments:checkout:before': (ctx: CheckoutBeforeContext) => void | Promise<void>;
83
+ /** A payment became `paid` (fired once, from the verified Stripe event). */
84
+ 'payments:paid': (ctx: PaymentHookContext) => void | Promise<void>;
85
+ /** An asynchronous payment method failed. */
86
+ 'payments:failed': (ctx: PaymentHookContext) => void | Promise<void>;
87
+ /** The Checkout session expired without payment. */
88
+ 'payments:expired': (ctx: PaymentHookContext) => void | Promise<void>;
89
+ /** The payment was (partially) refunded; inspect `payment.status` / `payment.refundedAmount`. */
90
+ 'payments:refunded': (ctx: PaymentHookContext) => void | Promise<void>;
91
+ }
92
+ }
93
+ declare const _default: _nuxt_schema.NuxtModule<ModuleOptions, ModuleOptions, false>;
94
+
95
+ export { _default as default };
96
+ export type { CheckoutBeforeContext, CreatePaymentInput, CreatePaymentResult, ModuleOptions, PaymentDto, PaymentHookContext, PaymentItem, PaymentReference, PaymentStatus, PaymentsRuntimeConfig, ResumeResponse };
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "@frigear-nu/payments",
3
+ "configKey": "payments",
4
+ "version": "0.0.2",
5
+ "builder": {
6
+ "@nuxt/module-builder": "1.0.3",
7
+ "unbuild": "unknown"
8
+ }
9
+ }
@@ -0,0 +1,59 @@
1
+ import { defineNuxtModule, createResolver, addServerHandler, addServerImportsDir, addImports } from '@nuxt/kit';
2
+ import { registerModule, registerLocales, registerDatabaseTable, moduleApiUrl } from '@frigear-nu/module-kit/utils';
3
+ import { registerStripeEventHandler } from '@frigear-nu/stripe/utils';
4
+
5
+ const MODULE_NAME = "@frigear-nu/payments";
6
+ const module$1 = defineNuxtModule({
7
+ meta: { name: MODULE_NAME, configKey: "payments" },
8
+ defaults: {
9
+ settings: { checkoutExpiresInMinutes: 30 }
10
+ },
11
+ moduleDependencies: {
12
+ "@nuxthub/core": {
13
+ version: ">=0.10",
14
+ defaults: { kv: true }
15
+ },
16
+ "@frigear-nu/module-kit": { version: ">=0.0.1" },
17
+ "@frigear-nu/users": { version: ">=0.0.1" },
18
+ "@frigear-nu/stripe": { version: ">=0.0.1" }
19
+ },
20
+ async setup(options, nuxt) {
21
+ registerModule(MODULE_NAME, nuxt);
22
+ const resolver = createResolver(import.meta.url);
23
+ registerLocales(nuxt, resolver.resolve("./runtime/i18n/lang"));
24
+ nuxt.options.runtimeConfig.payments = {
25
+ checkoutExpiresInMinutes: options.settings?.checkoutExpiresInMinutes ?? 30
26
+ };
27
+ await registerDatabaseTable(nuxt, {
28
+ name: "payments",
29
+ resolver,
30
+ schema: (dialect) => `./runtime/server/db/schema.${dialect}`
31
+ });
32
+ registerStripeEventHandler(nuxt, {
33
+ events: [
34
+ "checkout.session.completed",
35
+ "checkout.session.async_payment_succeeded",
36
+ "checkout.session.async_payment_failed",
37
+ "checkout.session.expired",
38
+ "charge.refunded"
39
+ ],
40
+ handler: resolver.resolve("./runtime/server/stripe/payment-events")
41
+ });
42
+ addServerHandler({
43
+ route: moduleApiUrl("payments"),
44
+ method: "get",
45
+ handler: resolver.resolve("./runtime/server/routes/payments/index.get")
46
+ });
47
+ addServerHandler({
48
+ route: moduleApiUrl("payments", "resume"),
49
+ method: "post",
50
+ handler: resolver.resolve("./runtime/server/routes/payments/resume.post")
51
+ });
52
+ addServerImportsDir(resolver.resolve("./runtime/server/utils"));
53
+ addImports([
54
+ { name: "usePayments", from: resolver.resolve("./runtime/app/composables/usePayments") }
55
+ ]);
56
+ }
57
+ });
58
+
59
+ export { module$1 as default };
@@ -0,0 +1 @@
1
+ export { usePayments } from './usePayments.js';
@@ -0,0 +1 @@
1
+ export { usePayments } from "./usePayments.js";
@@ -0,0 +1,7 @@
1
+ /** The signed-in user's payments, plus a helper to resume an unpaid one. */
2
+ export declare function usePayments(): {
3
+ payments: any;
4
+ refresh: any;
5
+ status: any;
6
+ resume: (paymentId: string) => Promise<void>;
7
+ };
@@ -0,0 +1,14 @@
1
+ export function usePayments() {
2
+ const { loggedIn } = useUserSession();
3
+ const { data, refresh, status } = useFetch("/api/payments", {
4
+ key: "payments:mine",
5
+ immediate: loggedIn.value,
6
+ watch: false,
7
+ default: () => []
8
+ });
9
+ async function resume(paymentId) {
10
+ const { url } = await $fetch("/api/payments/resume", { method: "POST", body: { paymentId } });
11
+ await navigateTo(url, { external: true });
12
+ }
13
+ return { payments: data, refresh, status, resume };
14
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "payments": {
3
+ "status": {
4
+ "pending": "Afventer",
5
+ "paid": "Betalt",
6
+ "failed": "Fejlet",
7
+ "expired": "Udløbet",
8
+ "refunded": "Refunderet",
9
+ "partially_refunded": "Delvist refunderet"
10
+ },
11
+ "defaultDescription": "Betaling"
12
+ }
13
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "payments": {
3
+ "status": {
4
+ "pending": "Pending",
5
+ "paid": "Paid",
6
+ "failed": "Failed",
7
+ "expired": "Expired",
8
+ "refunded": "Refunded",
9
+ "partially_refunded": "Partially refunded"
10
+ },
11
+ "defaultDescription": "Payment"
12
+ }
13
+ }
@@ -0,0 +1,280 @@
1
+ export declare const payments: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
2
+ name: "payments";
3
+ schema: undefined;
4
+ columns: {
5
+ id: import("drizzle-orm/sqlite-core").SQLiteColumn<{
6
+ name: "id";
7
+ tableName: "payments";
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: "payments";
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
+ status: import("drizzle-orm/sqlite-core").SQLiteColumn<{
42
+ name: "status";
43
+ tableName: "payments";
44
+ dataType: "string";
45
+ columnType: "SQLiteText";
46
+ data: string;
47
+ driverParam: string;
48
+ notNull: true;
49
+ hasDefault: true;
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
+ amount: import("drizzle-orm/sqlite-core").SQLiteColumn<{
61
+ name: "amount";
62
+ tableName: "payments";
63
+ dataType: "number";
64
+ columnType: "SQLiteInteger";
65
+ data: number;
66
+ driverParam: number;
67
+ notNull: true;
68
+ hasDefault: false;
69
+ isPrimaryKey: false;
70
+ isAutoincrement: false;
71
+ hasRuntimeDefault: false;
72
+ enumValues: undefined;
73
+ baseColumn: never;
74
+ identity: undefined;
75
+ generated: undefined;
76
+ }, {}, {}>;
77
+ currency: import("drizzle-orm/sqlite-core").SQLiteColumn<{
78
+ name: "currency";
79
+ tableName: "payments";
80
+ dataType: "string";
81
+ columnType: "SQLiteText";
82
+ data: string;
83
+ driverParam: string;
84
+ notNull: true;
85
+ hasDefault: false;
86
+ isPrimaryKey: false;
87
+ isAutoincrement: false;
88
+ hasRuntimeDefault: false;
89
+ enumValues: [string, ...string[]];
90
+ baseColumn: never;
91
+ identity: undefined;
92
+ generated: undefined;
93
+ }, {}, {
94
+ length: undefined;
95
+ }>;
96
+ description: import("drizzle-orm/sqlite-core").SQLiteColumn<{
97
+ name: "description";
98
+ tableName: "payments";
99
+ dataType: "string";
100
+ columnType: "SQLiteText";
101
+ data: string;
102
+ driverParam: string;
103
+ notNull: false;
104
+ hasDefault: false;
105
+ isPrimaryKey: false;
106
+ isAutoincrement: false;
107
+ hasRuntimeDefault: false;
108
+ enumValues: [string, ...string[]];
109
+ baseColumn: never;
110
+ identity: undefined;
111
+ generated: undefined;
112
+ }, {}, {
113
+ length: undefined;
114
+ }>;
115
+ referenceType: import("drizzle-orm/sqlite-core").SQLiteColumn<{
116
+ name: "referenceType";
117
+ tableName: "payments";
118
+ dataType: "string";
119
+ columnType: "SQLiteText";
120
+ data: string;
121
+ driverParam: string;
122
+ notNull: false;
123
+ hasDefault: false;
124
+ isPrimaryKey: false;
125
+ isAutoincrement: false;
126
+ hasRuntimeDefault: false;
127
+ enumValues: [string, ...string[]];
128
+ baseColumn: never;
129
+ identity: undefined;
130
+ generated: undefined;
131
+ }, {}, {
132
+ length: undefined;
133
+ }>;
134
+ referenceId: import("drizzle-orm/sqlite-core").SQLiteColumn<{
135
+ name: "referenceId";
136
+ tableName: "payments";
137
+ dataType: "string";
138
+ columnType: "SQLiteText";
139
+ data: string;
140
+ driverParam: string;
141
+ notNull: false;
142
+ hasDefault: false;
143
+ isPrimaryKey: false;
144
+ isAutoincrement: false;
145
+ hasRuntimeDefault: false;
146
+ enumValues: [string, ...string[]];
147
+ baseColumn: never;
148
+ identity: undefined;
149
+ generated: undefined;
150
+ }, {}, {
151
+ length: undefined;
152
+ }>;
153
+ checkoutSessionId: import("drizzle-orm/sqlite-core").SQLiteColumn<{
154
+ name: "checkoutSessionId";
155
+ tableName: "payments";
156
+ dataType: "string";
157
+ columnType: "SQLiteText";
158
+ data: string;
159
+ driverParam: string;
160
+ notNull: true;
161
+ hasDefault: false;
162
+ isPrimaryKey: false;
163
+ isAutoincrement: false;
164
+ hasRuntimeDefault: false;
165
+ enumValues: [string, ...string[]];
166
+ baseColumn: never;
167
+ identity: undefined;
168
+ generated: undefined;
169
+ }, {}, {
170
+ length: undefined;
171
+ }>;
172
+ paymentIntentId: import("drizzle-orm/sqlite-core").SQLiteColumn<{
173
+ name: "paymentIntentId";
174
+ tableName: "payments";
175
+ dataType: "string";
176
+ columnType: "SQLiteText";
177
+ data: string;
178
+ driverParam: string;
179
+ notNull: false;
180
+ hasDefault: false;
181
+ isPrimaryKey: false;
182
+ isAutoincrement: false;
183
+ hasRuntimeDefault: false;
184
+ enumValues: [string, ...string[]];
185
+ baseColumn: never;
186
+ identity: undefined;
187
+ generated: undefined;
188
+ }, {}, {
189
+ length: undefined;
190
+ }>;
191
+ refundedAmount: import("drizzle-orm/sqlite-core").SQLiteColumn<{
192
+ name: "refundedAmount";
193
+ tableName: "payments";
194
+ dataType: "number";
195
+ columnType: "SQLiteInteger";
196
+ data: number;
197
+ driverParam: number;
198
+ notNull: true;
199
+ hasDefault: true;
200
+ isPrimaryKey: false;
201
+ isAutoincrement: false;
202
+ hasRuntimeDefault: false;
203
+ enumValues: undefined;
204
+ baseColumn: never;
205
+ identity: undefined;
206
+ generated: undefined;
207
+ }, {}, {}>;
208
+ metadata: import("drizzle-orm/sqlite-core").SQLiteColumn<{
209
+ name: "metadata";
210
+ tableName: "payments";
211
+ dataType: "json";
212
+ columnType: "SQLiteTextJson";
213
+ data: Record<string, string>;
214
+ driverParam: string;
215
+ notNull: false;
216
+ hasDefault: false;
217
+ isPrimaryKey: false;
218
+ isAutoincrement: false;
219
+ hasRuntimeDefault: false;
220
+ enumValues: undefined;
221
+ baseColumn: never;
222
+ identity: undefined;
223
+ generated: undefined;
224
+ }, {}, {
225
+ $type: Record<string, string>;
226
+ }>;
227
+ paidAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
228
+ name: "paidAt";
229
+ tableName: "payments";
230
+ dataType: "date";
231
+ columnType: "SQLiteTimestamp";
232
+ data: Date;
233
+ driverParam: number;
234
+ notNull: false;
235
+ hasDefault: false;
236
+ isPrimaryKey: false;
237
+ isAutoincrement: false;
238
+ hasRuntimeDefault: false;
239
+ enumValues: undefined;
240
+ baseColumn: never;
241
+ identity: undefined;
242
+ generated: undefined;
243
+ }, {}, {}>;
244
+ createdAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
245
+ name: "createdAt";
246
+ tableName: "payments";
247
+ dataType: "date";
248
+ columnType: "SQLiteTimestamp";
249
+ data: Date;
250
+ driverParam: number;
251
+ notNull: true;
252
+ hasDefault: true;
253
+ isPrimaryKey: false;
254
+ isAutoincrement: false;
255
+ hasRuntimeDefault: true;
256
+ enumValues: undefined;
257
+ baseColumn: never;
258
+ identity: undefined;
259
+ generated: undefined;
260
+ }, {}, {}>;
261
+ updatedAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
262
+ name: "updatedAt";
263
+ tableName: "payments";
264
+ dataType: "date";
265
+ columnType: "SQLiteTimestamp";
266
+ data: Date;
267
+ driverParam: number;
268
+ notNull: true;
269
+ hasDefault: true;
270
+ isPrimaryKey: false;
271
+ isAutoincrement: false;
272
+ hasRuntimeDefault: true;
273
+ enumValues: undefined;
274
+ baseColumn: never;
275
+ identity: undefined;
276
+ generated: undefined;
277
+ }, {}, {}>;
278
+ };
279
+ dialect: "sqlite";
280
+ }>;
@@ -0,0 +1,26 @@
1
+ import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
2
+ export const payments = sqliteTable("payments", {
3
+ /** `pay_` + random id. */
4
+ id: text().primaryKey(),
5
+ userId: integer().notNull(),
6
+ /** `pending | paid | failed | expired | refunded | partially_refunded`. */
7
+ status: text().notNull().default("pending"),
8
+ /** Total in the currency's minor unit. */
9
+ amount: integer().notNull(),
10
+ currency: text().notNull(),
11
+ description: text(),
12
+ /** What the payment is for (e.g. `event_ticket`) and the id of that thing. */
13
+ referenceType: text(),
14
+ referenceId: text(),
15
+ checkoutSessionId: text().notNull().unique(),
16
+ paymentIntentId: text(),
17
+ refundedAmount: integer().notNull().default(0),
18
+ metadata: text({ mode: "json" }).$type(),
19
+ paidAt: integer({ mode: "timestamp" }),
20
+ createdAt: integer({ mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date()),
21
+ updatedAt: integer({ mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date()).$onUpdateFn(() => /* @__PURE__ */ new Date())
22
+ }, (t) => [
23
+ index("payment_user_idx").on(t.userId),
24
+ index("payment_reference_idx").on(t.referenceType, t.referenceId),
25
+ index("payment_intent_idx").on(t.paymentIntentId)
26
+ ]);
@@ -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 { user } = await requireUserSession(event);
3
+ return listUserPayments(user.id);
4
+ });
@@ -0,0 +1,2 @@
1
+ declare const _default: any;
2
+ export default _default;
@@ -0,0 +1,7 @@
1
+ import { z } from "zod";
2
+ const bodySchema = z.object({ paymentId: z.string().min(1) });
3
+ export default defineEventHandler(async (event) => {
4
+ const { user } = await requireUserSession(event);
5
+ const { paymentId } = await readValidatedBody(event, bodySchema.parse);
6
+ return resumePayment(paymentId, user.id, event);
7
+ });
@@ -0,0 +1,2 @@
1
+ declare const _default: import("@frigear-nu/stripe/types").StripeEventHandler;
2
+ export default _default;
@@ -0,0 +1,59 @@
1
+ import { db, schema } from "@nuxthub/db";
2
+ import { eq } from "drizzle-orm";
3
+ import { defineStripeEventHandler } from "@frigear-nu/stripe/utils";
4
+ import { toPaymentDto } from "../utils/payments.js";
5
+ const hookFor = {
6
+ paid: "payments:paid",
7
+ failed: "payments:failed",
8
+ expired: "payments:expired",
9
+ refunded: "payments:refunded"
10
+ };
11
+ export default defineStripeEventHandler(async (event) => {
12
+ const nitro = useNitroApp();
13
+ async function transition(where, next, extra, allowedFrom, hook) {
14
+ const [row] = await db.select().from(schema.payments).where(where).limit(1);
15
+ if (!row) return;
16
+ if (row.status === next && hook !== "refunded") return;
17
+ if (!allowedFrom.includes(row.status)) return;
18
+ const [updated] = await db.update(schema.payments).set({ status: next, ...extra }).where(eq(schema.payments.id, row.id)).returning();
19
+ if (updated) await nitro.hooks.callHook(hookFor[hook], { payment: toPaymentDto(updated) });
20
+ }
21
+ switch (event.type) {
22
+ case "checkout.session.completed":
23
+ case "checkout.session.async_payment_succeeded": {
24
+ const session = event.data.object;
25
+ if (session.payment_status !== "paid") return;
26
+ await transition(
27
+ eq(schema.payments.checkoutSessionId, session.id),
28
+ "paid",
29
+ { paidAt: /* @__PURE__ */ new Date(), paymentIntentId: typeof session.payment_intent === "string" ? session.payment_intent : session.payment_intent?.id ?? null },
30
+ ["pending", "failed", "expired"],
31
+ "paid"
32
+ );
33
+ return;
34
+ }
35
+ case "checkout.session.async_payment_failed": {
36
+ await transition(eq(schema.payments.checkoutSessionId, event.data.object.id), "failed", {}, ["pending"], "failed");
37
+ return;
38
+ }
39
+ case "checkout.session.expired": {
40
+ await transition(eq(schema.payments.checkoutSessionId, event.data.object.id), "expired", {}, ["pending"], "expired");
41
+ return;
42
+ }
43
+ case "charge.refunded": {
44
+ const charge = event.data.object;
45
+ const intent = typeof charge.payment_intent === "string" ? charge.payment_intent : charge.payment_intent?.id;
46
+ if (!intent) return;
47
+ const full = charge.amount_refunded >= charge.amount;
48
+ const [row] = await db.select().from(schema.payments).where(eq(schema.payments.paymentIntentId, intent)).limit(1);
49
+ if (!row || row.refundedAmount === charge.amount_refunded) return;
50
+ await transition(
51
+ eq(schema.payments.paymentIntentId, intent),
52
+ full ? "refunded" : "partially_refunded",
53
+ { refundedAmount: charge.amount_refunded },
54
+ ["paid", "partially_refunded"],
55
+ "refunded"
56
+ );
57
+ }
58
+ }
59
+ });
@@ -0,0 +1,31 @@
1
+ import { schema } from '@nuxthub/db';
2
+ import type { H3Event } from 'h3';
3
+ import type { CreatePaymentInput, CreatePaymentResult, PaymentDto } from '../../../types.js';
4
+ type PaymentRow = typeof schema.payments.$inferSelect;
5
+ export declare function toPaymentDto(row: PaymentRow): PaymentDto;
6
+ /**
7
+ * Creates a Stripe Checkout session (`mode: 'payment'`, inline `price_data`) and a `pending` payment
8
+ * row. The row only turns `paid` when Stripe tells us so (see `payments:paid`).
9
+ *
10
+ * Pass the request `event` to have the return URLs restricted to the current origin.
11
+ */
12
+ export declare function createPayment(input: CreatePaymentInput, event?: H3Event): Promise<CreatePaymentResult>;
13
+ export declare function getPayment(id: string): Promise<PaymentDto | null>;
14
+ export declare function getPaymentByReference(type: string, id: string): Promise<PaymentDto | null>;
15
+ export declare function listUserPayments(userId: number): Promise<PaymentDto[]>;
16
+ /**
17
+ * Returns a checkout URL for a still-unpaid payment: the original session when it is open, otherwise
18
+ * a fresh session for the same amount (the payment row follows the new session). Throws for payments
19
+ * that are already paid/refunded.
20
+ */
21
+ export declare function resumePayment(paymentId: string, userId: number, event?: H3Event): Promise<{
22
+ url: string;
23
+ }>;
24
+ /**
25
+ * Refunds a paid payment (fully, or `amount` minor units). The status change is applied by the
26
+ * `charge.refunded` Stripe event, so the `payments:refunded` hook fires once from one place.
27
+ */
28
+ export declare function refundPayment(paymentId: string, options?: {
29
+ amount?: number;
30
+ }): Promise<void>;
31
+ export {};
@@ -0,0 +1,150 @@
1
+ import { db, schema } from "@nuxthub/db";
2
+ import { and, desc, eq } from "drizzle-orm";
3
+ import { resolveSameOriginUrl, sumItems } from "../../utils/index.js";
4
+ export function toPaymentDto(row) {
5
+ return {
6
+ id: row.id,
7
+ userId: row.userId,
8
+ status: row.status,
9
+ amount: row.amount,
10
+ currency: row.currency,
11
+ description: row.description,
12
+ referenceType: row.referenceType,
13
+ referenceId: row.referenceId,
14
+ refundedAmount: row.refundedAmount,
15
+ paidAt: row.paidAt?.toISOString() ?? null,
16
+ createdAt: row.createdAt.toISOString()
17
+ };
18
+ }
19
+ const newPaymentId = () => `pay_${crypto.randomUUID().replaceAll("-", "")}`;
20
+ function safeReturnUrl(input, event, name) {
21
+ if (!event) return input;
22
+ const resolved = resolveSameOriginUrl(input, getRequestURL(event).origin);
23
+ if (!resolved) throw createError({ statusCode: 400, statusMessage: `${name} must be a relative path or an URL on the same origin.` });
24
+ return resolved;
25
+ }
26
+ function expiresAt(minutes) {
27
+ const clamped = Math.min(Math.max(Math.round(minutes), 30), 1440);
28
+ return Math.floor(Date.now() / 1e3) + clamped * 60;
29
+ }
30
+ export async function createPayment(input, event) {
31
+ if (input.items.length === 0) throw createError({ statusCode: 400, statusMessage: "A payment needs at least one item." });
32
+ const currency = input.items[0].currency.toLowerCase();
33
+ if (input.items.some((item) => item.currency.toLowerCase() !== currency)) {
34
+ throw createError({ statusCode: 400, statusMessage: "All items in a payment must share one currency." });
35
+ }
36
+ const amount = sumItems(input.items);
37
+ if (amount <= 0) throw createError({ statusCode: 400, statusMessage: "The payment amount must be greater than zero." });
38
+ const config = useRuntimeConfig().payments;
39
+ const stripe = useStripe();
40
+ const customer = await ensureStripeCustomer(input.userId);
41
+ const id = newPaymentId();
42
+ const metadata = { ...input.metadata, paymentId: id, userId: String(input.userId) };
43
+ const context = {
44
+ event,
45
+ userId: input.userId,
46
+ reference: input.reference,
47
+ params: {
48
+ mode: "payment",
49
+ customer,
50
+ client_reference_id: String(input.userId),
51
+ line_items: input.items.map((item) => ({
52
+ quantity: item.quantity ?? 1,
53
+ price_data: {
54
+ currency,
55
+ unit_amount: item.amount,
56
+ product_data: { name: item.name, ...item.description ? { description: item.description } : {} }
57
+ }
58
+ })),
59
+ success_url: safeReturnUrl(input.successUrl, event, "successUrl"),
60
+ cancel_url: safeReturnUrl(input.cancelUrl, event, "cancelUrl"),
61
+ expires_at: expiresAt(input.expiresInMinutes ?? config.checkoutExpiresInMinutes),
62
+ metadata,
63
+ payment_intent_data: { metadata }
64
+ }
65
+ };
66
+ await useNitroApp().hooks.callHook("payments:checkout:before", context);
67
+ const session = await stripe.checkout.sessions.create(context.params);
68
+ if (!session.url) throw createError({ statusCode: 502, statusMessage: "Stripe did not return a checkout URL." });
69
+ try {
70
+ const [row] = await db.insert(schema.payments).values({
71
+ id,
72
+ userId: input.userId,
73
+ amount,
74
+ currency,
75
+ description: input.description ?? null,
76
+ referenceType: input.reference?.type ?? null,
77
+ referenceId: input.reference?.id ?? null,
78
+ checkoutSessionId: session.id,
79
+ metadata: input.metadata ?? null
80
+ }).returning();
81
+ return { payment: toPaymentDto(row), url: session.url };
82
+ } catch (error) {
83
+ await stripe.checkout.sessions.expire(session.id).catch(() => void 0);
84
+ throw error;
85
+ }
86
+ }
87
+ export async function getPayment(id) {
88
+ const [row] = await db.select().from(schema.payments).where(eq(schema.payments.id, id)).limit(1);
89
+ return row ? toPaymentDto(row) : null;
90
+ }
91
+ export async function getPaymentByReference(type, id) {
92
+ const [row] = await db.select().from(schema.payments).where(and(eq(schema.payments.referenceType, type), eq(schema.payments.referenceId, id))).orderBy(desc(schema.payments.createdAt)).limit(1);
93
+ return row ? toPaymentDto(row) : null;
94
+ }
95
+ export async function listUserPayments(userId) {
96
+ const rows = await db.select().from(schema.payments).where(eq(schema.payments.userId, userId)).orderBy(desc(schema.payments.createdAt));
97
+ return rows.map(toPaymentDto);
98
+ }
99
+ export async function resumePayment(paymentId, userId, event) {
100
+ const [row] = await db.select().from(schema.payments).where(and(eq(schema.payments.id, paymentId), eq(schema.payments.userId, userId))).limit(1);
101
+ if (!row) throw createError({ statusCode: 404, statusMessage: "Payment not found." });
102
+ if (row.status === "paid" || row.status === "refunded" || row.status === "partially_refunded") {
103
+ throw createError({ statusCode: 409, statusMessage: "This payment is already paid." });
104
+ }
105
+ const stripe = useStripe();
106
+ const session = await stripe.checkout.sessions.retrieve(row.checkoutSessionId);
107
+ if (session.status === "open" && session.url) return { url: session.url };
108
+ if (session.status === "complete") throw createError({ statusCode: 409, statusMessage: "This payment is already paid." });
109
+ const customer = await ensureStripeCustomer(userId);
110
+ const metadata = { ...row.metadata, paymentId: row.id, userId: String(userId) };
111
+ const origin = event ? getRequestURL(event).origin : void 0;
112
+ const successUrl = session.success_url ?? origin;
113
+ const cancelUrl = session.cancel_url ?? origin;
114
+ if (!successUrl || !cancelUrl) throw createError({ statusCode: 409, statusMessage: "Cannot resume this payment." });
115
+ const next = await stripe.checkout.sessions.create({
116
+ mode: "payment",
117
+ customer,
118
+ client_reference_id: String(userId),
119
+ line_items: [{
120
+ quantity: 1,
121
+ price_data: {
122
+ currency: row.currency,
123
+ unit_amount: row.amount,
124
+ product_data: { name: row.description ?? "Payment" }
125
+ }
126
+ }],
127
+ success_url: successUrl,
128
+ cancel_url: cancelUrl,
129
+ expires_at: expiresAt(useRuntimeConfig().payments.checkoutExpiresInMinutes),
130
+ metadata,
131
+ payment_intent_data: { metadata }
132
+ });
133
+ if (!next.url) throw createError({ statusCode: 502, statusMessage: "Stripe did not return a checkout URL." });
134
+ await db.update(schema.payments).set({ checkoutSessionId: next.id, status: "pending" }).where(eq(schema.payments.id, row.id));
135
+ return { url: next.url };
136
+ }
137
+ export async function refundPayment(paymentId, options = {}) {
138
+ const [row] = await db.select().from(schema.payments).where(eq(schema.payments.id, paymentId)).limit(1);
139
+ if (!row) throw createError({ statusCode: 404, statusMessage: "Payment not found." });
140
+ if (!row.paymentIntentId || row.status !== "paid" && row.status !== "partially_refunded") {
141
+ throw createError({ statusCode: 409, statusMessage: "Only paid payments can be refunded." });
142
+ }
143
+ if (options.amount !== void 0 && (options.amount <= 0 || options.amount > row.amount - row.refundedAmount)) {
144
+ throw createError({ statusCode: 400, statusMessage: "Invalid refund amount." });
145
+ }
146
+ await useStripe().refunds.create({
147
+ payment_intent: row.paymentIntentId,
148
+ ...options.amount !== void 0 ? { amount: options.amount } : {}
149
+ });
150
+ }
@@ -0,0 +1,6 @@
1
+ import type { PaymentItem } from '../../types.js';
2
+ export type { CheckoutBeforeContext, CreatePaymentInput, CreatePaymentResult, ModuleOptions, PaymentDto, PaymentHookContext, PaymentItem, PaymentReference, PaymentStatus, } from '../../types.js';
3
+ /** Total of a list of items in minor units. */
4
+ export declare function sumItems(items: PaymentItem[]): number;
5
+ /** Resolves a relative path against `origin`, and only accepts absolute URLs on the same origin. */
6
+ export declare function resolveSameOriginUrl(input: string, origin: string): string | null;
@@ -0,0 +1,14 @@
1
+ export function sumItems(items) {
2
+ return items.reduce((total, item) => total + item.amount * (item.quantity ?? 1), 0);
3
+ }
4
+ export function resolveSameOriginUrl(input, origin) {
5
+ if (input.startsWith("/")) {
6
+ if (input.startsWith("//") || input.startsWith("/\\")) return null;
7
+ return `${origin}${input}`;
8
+ }
9
+ try {
10
+ return new URL(input).origin === new URL(origin).origin ? input : null;
11
+ } catch {
12
+ return null;
13
+ }
14
+ }
@@ -0,0 +1,3 @@
1
+ export { default } from './module.mjs'
2
+
3
+ export { type CheckoutBeforeContext, type CreatePaymentInput, type CreatePaymentResult, type ModuleOptions, type PaymentDto, type PaymentHookContext, type PaymentItem, type PaymentReference, type PaymentStatus, type PaymentsRuntimeConfig, type ResumeResponse } from './module.mjs'
package/package.json CHANGED
@@ -1,6 +1,89 @@
1
1
  {
2
2
  "name": "@frigear-nu/payments",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.0.2",
4
+ "description": "One-time payments (Stripe Checkout, refunds and payment lifecycle hooks) for the frigear ecosystem, built on @frigear-nu/stripe.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/frigear-nu/frigear-nu.git",
8
+ "directory": "packages/payments"
9
+ },
10
+ "license": "MIT",
11
+ "type": "module",
12
+ "main": "./dist/module.mjs",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/types.d.mts",
16
+ "import": "./dist/module.mjs",
17
+ "default": "./dist/module.mjs"
18
+ },
19
+ "./utils": {
20
+ "types": "./dist/runtime/utils/index.d.ts",
21
+ "import": "./dist/runtime/utils/index.js",
22
+ "default": "./dist/runtime/utils/index.js"
23
+ }
24
+ },
25
+ "files": [
26
+ "dist"
27
+ ],
28
+ "workspaces": [
29
+ "playground"
30
+ ],
31
+ "dependencies": {
32
+ "@frigear-nu/module-kit": "0.1.0",
33
+ "@frigear-nu/stripe": "0.0.2",
34
+ "@frigear-nu/users": "1.0.2",
35
+ "@libsql/client": "^0.18.0",
36
+ "@nuxt/kit": "^4.6.0",
37
+ "drizzle-kit": "^0.31.11",
38
+ "drizzle-orm": "^0.45.3",
39
+ "h3": "^1.15.11",
40
+ "stripe": "^23.0.0",
41
+ "unstorage": "^1.17.5",
42
+ "zod": "^4.6.5"
43
+ },
44
+ "peerDependencies": {
45
+ "@nuxt/ui": "^4.10.0",
46
+ "@nuxthub/core": "^0.10.8"
47
+ },
48
+ "peerDependenciesMeta": {
49
+ "@nuxt/ui": {
50
+ "optional": true
51
+ }
52
+ },
53
+ "devDependencies": {
54
+ "@nuxt/devtools": "^3.4.2",
55
+ "@nuxt/eslint-config": "^1.17.0",
56
+ "@nuxt/module-builder": "^1.0.3",
57
+ "@nuxt/schema": "^4.6.0",
58
+ "@nuxt/test-utils": "^4.3.2",
59
+ "@nuxt/ui": "^4.11.0",
60
+ "@nuxthub/core": "^0.10.8",
61
+ "@types/node": "latest",
62
+ "changelogen": "^0.6.2",
63
+ "eslint": "^10.10.0",
64
+ "nuxt": "^4.6.0",
65
+ "typescript": "^6.0.3",
66
+ "vitest": "^5.0.1",
67
+ "vue-tsc": "^3.3.11"
68
+ },
69
+ "scripts": {
70
+ "postinstall": "nuxt-module-build build --stub && nuxt prepare",
71
+ "build": "nuxt-module-build build",
72
+ "dev": "npm run dev:prepare && nuxt dev playground",
73
+ "dev:build": "nuxt build playground",
74
+ "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxt prepare playground",
75
+ "release": "npm run lint && npm run test && npm run prepack && changelogen --release && npm publish && git push --follow-tags",
76
+ "lint": "eslint .",
77
+ "lint:fix": "eslint . --fix",
78
+ "test": "vitest run",
79
+ "test:watch": "vitest watch",
80
+ "test:types": "vue-tsc --noEmit && cd playground && vue-tsc --noEmit"
81
+ },
82
+ "typesVersions": {
83
+ "*": {
84
+ ".": [
85
+ "./dist/types.d.mts"
86
+ ]
87
+ }
88
+ }
6
89
  }