@frigear-nu/payments 0.0.6 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,7 +33,7 @@ const { payment, url } = await createPayment({
33
33
  nitroApp.hooks.hook('payments:paid', ({ payment }) => { /* ... */ })
34
34
  ```
35
35
 
36
- Full documentation: `apps/docs/content/4.modules/24.payments.md`.
36
+ Full documentation: `apps/docs/content/4.modules/13.payments.md`.
37
37
 
38
38
  ## Development
39
39
 
package/dist/module.d.mts CHANGED
@@ -6,6 +6,7 @@ type ModuleOptions = {
6
6
  settings?: {
7
7
  /** Minutes a Checkout session stays open before it expires (Stripe allows 30 - 1440). Defaults to `30`. */
8
8
  checkoutExpiresInMinutes?: number;
9
+ mcp?: boolean;
9
10
  };
10
11
  };
11
12
  type PaymentsRuntimeConfig = {
@@ -54,6 +55,23 @@ type CreatePaymentResult = {
54
55
  payment: PaymentDto;
55
56
  url: string;
56
57
  };
58
+ /** An invoice to store as a payment, see `recordInvoicePayment`. */
59
+ type InvoicePaymentInput = {
60
+ userId: number;
61
+ invoiceId: string;
62
+ status: 'paid' | 'failed' | 'expired';
63
+ /** Total in the currency's minor unit. */
64
+ amount: number;
65
+ currency: string;
66
+ description: string | null;
67
+ /** Defaults to `subscription`. */
68
+ referenceType?: string;
69
+ /** What the invoice is for, e.g. the Stripe subscription id. */
70
+ referenceId: string | null;
71
+ paymentIntentId: string | null;
72
+ paidAt: Date | null;
73
+ createdAt?: Date;
74
+ };
57
75
  type PaymentHookContext = {
58
76
  payment: PaymentDto;
59
77
  };
@@ -93,4 +111,4 @@ declare module 'nitropack' {
93
111
  declare const _default: _nuxt_schema.NuxtModule<ModuleOptions, ModuleOptions, false>;
94
112
 
95
113
  export { _default as default };
96
- export type { CheckoutBeforeContext, CreatePaymentInput, CreatePaymentResult, ModuleOptions, PaymentDto, PaymentHookContext, PaymentItem, PaymentReference, PaymentStatus, PaymentsRuntimeConfig, ResumeResponse };
114
+ export type { CheckoutBeforeContext, CreatePaymentInput, CreatePaymentResult, InvoicePaymentInput, ModuleOptions, PaymentDto, PaymentHookContext, PaymentItem, PaymentReference, PaymentStatus, PaymentsRuntimeConfig, ResumeResponse };
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@frigear-nu/payments",
3
3
  "configKey": "payments",
4
- "version": "0.0.6",
4
+ "version": "0.0.8",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.3",
7
7
  "unbuild": "unknown"
package/dist/module.mjs CHANGED
@@ -1,12 +1,15 @@
1
- import { defineNuxtModule, createResolver, addServerHandler, addServerImportsDir, addImports } from '@nuxt/kit';
2
- import { registerModule, registerLocales, registerDatabaseTable, moduleApiUrl } from '@frigear-nu/module-kit/utils';
1
+ import { defineNuxtModule, createResolver, addServerHandler, addServerImportsDir, addServerPlugin, addImports } from '@nuxt/kit';
2
+ import { registerModule, registerTailwindSource, registerLocales, registerDatabaseTable, moduleApiUrl } from '@frigear-nu/module-kit/utils';
3
+ import { addAuthScope } from '@frigear-nu/auth/utils';
4
+ import { extendUsersAdmin } from '@frigear-nu/users/utils';
5
+ import { addMcpDefinitions } from '@frigear-nu/mcp/utils';
3
6
  import { registerStripeEventHandler } from '@frigear-nu/stripe/utils';
4
7
 
5
8
  const MODULE_NAME = "@frigear-nu/payments";
6
9
  const module$1 = defineNuxtModule({
7
10
  meta: { name: MODULE_NAME, configKey: "payments" },
8
11
  defaults: {
9
- settings: { checkoutExpiresInMinutes: 30 }
12
+ settings: { checkoutExpiresInMinutes: 30, mcp: false }
10
13
  },
11
14
  moduleDependencies: {
12
15
  "@nuxthub/core": {
@@ -14,12 +17,23 @@ const module$1 = defineNuxtModule({
14
17
  defaults: { kv: true }
15
18
  },
16
19
  "@frigear-nu/module-kit": { version: ">=0.0.1" },
20
+ "@frigear-nu/auth": { version: ">=0.0.1" },
17
21
  "@frigear-nu/users": { version: ">=0.0.1" },
18
- "@frigear-nu/stripe": { version: ">=0.0.1" }
22
+ "@frigear-nu/stripe": { version: ">=0.0.1" },
23
+ "@frigear-nu/mcp": { version: ">=0.0.1" },
24
+ "@frigear-nu/widget": { version: ">=0.0.0", optional: true },
25
+ // The admin user tab and card are built from its components, so they are only added with it.
26
+ "@frigear-nu/admin": { version: ">=0.0.0", optional: true }
19
27
  },
20
28
  async setup(options, nuxt) {
21
29
  registerModule(MODULE_NAME, nuxt);
30
+ addAuthScope(
31
+ nuxt,
32
+ { name: "payments:read", description: "Read your payments" },
33
+ { name: "payments:write", description: "Resume your payments" }
34
+ );
22
35
  const resolver = createResolver(import.meta.url);
36
+ registerTailwindSource(nuxt, resolver.resolve("./runtime/app"));
23
37
  registerLocales(nuxt, resolver.resolve("./runtime/i18n/lang"));
24
38
  nuxt.options.runtimeConfig.payments = {
25
39
  checkoutExpiresInMinutes: options.settings?.checkoutExpiresInMinutes ?? 30
@@ -49,7 +63,26 @@ const module$1 = defineNuxtModule({
49
63
  method: "post",
50
64
  handler: resolver.resolve("./runtime/server/routes/payments/resume.post")
51
65
  });
66
+ await extendUsersAdmin(nuxt, {
67
+ routes: [{ method: "get", path: "payments", handler: resolver.resolve("./runtime/server/routes/admin/user-payments.get") }],
68
+ tab: {
69
+ id: "payments",
70
+ component: resolver.resolve("./runtime/app/tabs/Payments.vue"),
71
+ order: 20,
72
+ label: "Payments",
73
+ labelKey: "payments.admin.tab",
74
+ icon: "i-lucide-credit-card"
75
+ },
76
+ widget: {
77
+ id: "payments-last",
78
+ component: resolver.resolve("./runtime/app/widgets/AdminLastPayment.vue"),
79
+ order: 20,
80
+ span: 2
81
+ }
82
+ });
52
83
  addServerImportsDir(resolver.resolve("./runtime/server/utils"));
84
+ addServerPlugin(resolver.resolve("./runtime/server/plugins/invoice-payments"));
85
+ if (options.settings?.mcp === true) addMcpDefinitions(nuxt, resolver.resolve("./runtime/server/mcp"));
53
86
  addImports([
54
87
  { name: "usePayments", from: resolver.resolve("./runtime/app/composables/usePayments") }
55
88
  ]);
@@ -0,0 +1,8 @@
1
+ type __VLS_Props = {
2
+ user: {
3
+ id: number;
4
+ };
5
+ };
6
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
7
+ declare const _default: typeof __VLS_export;
8
+ export default _default;
@@ -0,0 +1,74 @@
1
+ <script setup>
2
+ import { formatMoney as formatMinorAmount } from "@frigear-nu/ui/money";
3
+ defineOptions({ name: "UsersAdminPayments" });
4
+ const props = defineProps({
5
+ user: { type: Object, required: true }
6
+ });
7
+ const t = useModuleT("payments.admin");
8
+ const status = useModuleT("payments.status");
9
+ const { data: payments, status: fetchStatus } = useLazyFetch(
10
+ () => `/api/admin/users/${props.user.id}/payments`,
11
+ { key: () => `admin-payments-${props.user.id}` }
12
+ );
13
+ const statusColors = {
14
+ paid: "success",
15
+ pending: "warning",
16
+ failed: "error",
17
+ expired: "neutral",
18
+ refunded: "neutral",
19
+ partially_refunded: "warning"
20
+ };
21
+ const statusLabel = (value) => status(value, value);
22
+ const columns = computed(() => [
23
+ { accessorKey: "createdAt", header: t("date", "Date") },
24
+ { accessorKey: "description", header: t("description", "Description") },
25
+ { accessorKey: "amount", header: t("amount", "Amount") },
26
+ { accessorKey: "status", header: t("statusColumn", "Status") },
27
+ { accessorKey: "refundedAmount", header: t("refunded", "Refunded") },
28
+ { accessorKey: "referenceType", header: t("reference", "Reference") }
29
+ ]);
30
+ const date = (value) => value ? new Date(value).toLocaleString() : "\u2013";
31
+ </script>
32
+
33
+ <template>
34
+ <AdminSection
35
+ :title="t('title', 'Payments')"
36
+ >
37
+ <template #flush>
38
+ <UTable
39
+ :data="payments ?? []"
40
+ :columns="columns"
41
+ :loading="fetchStatus === 'pending'"
42
+ >
43
+ <template #createdAt-cell="{ row }">
44
+ <span class="whitespace-nowrap">{{ date(row.original.paidAt ?? row.original.createdAt) }}</span>
45
+ </template>
46
+ <template #description-cell="{ row }">
47
+ {{ row.original.description ?? t("defaultDescription", "Payment") }}
48
+ </template>
49
+ <template #amount-cell="{ row }">
50
+ <span class="whitespace-nowrap">{{ formatMinorAmount(row.original.amount, row.original.currency) }}</span>
51
+ </template>
52
+ <template #status-cell="{ row }">
53
+ <UBadge
54
+ variant="subtle"
55
+ :color="statusColors[row.original.status] ?? 'neutral'"
56
+ :label="statusLabel(row.original.status)"
57
+ />
58
+ </template>
59
+ <template #refundedAmount-cell="{ row }">
60
+ <span class="whitespace-nowrap">{{ row.original.refundedAmount ? formatMinorAmount(row.original.refundedAmount, row.original.currency) : "\u2013" }}</span>
61
+ </template>
62
+ <template #referenceType-cell="{ row }">
63
+ <span class="text-muted">{{ row.original.referenceType ? `${row.original.referenceType} ${row.original.referenceId ?? ""}`.trim() : "\u2013" }}</span>
64
+ </template>
65
+ <template #empty>
66
+ <AdminEmpty
67
+ icon="i-lucide-credit-card"
68
+ :title="t('empty', 'No payments yet.')"
69
+ />
70
+ </template>
71
+ </UTable>
72
+ </template>
73
+ </AdminSection>
74
+ </template>
@@ -0,0 +1,8 @@
1
+ type __VLS_Props = {
2
+ user: {
3
+ id: number;
4
+ };
5
+ };
6
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
7
+ declare const _default: typeof __VLS_export;
8
+ export default _default;
@@ -0,0 +1,8 @@
1
+ type __VLS_Props = {
2
+ user: {
3
+ id: number;
4
+ };
5
+ };
6
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
7
+ declare const _default: typeof __VLS_export;
8
+ export default _default;
@@ -0,0 +1,33 @@
1
+ <script setup>
2
+ import { formatMoney as formatMinorAmount } from "@frigear-nu/ui/money";
3
+ defineOptions({ name: "UsersAdminLastPayment" });
4
+ const props = defineProps({
5
+ user: { type: Object, required: true }
6
+ });
7
+ const t = useModuleT("payments.admin");
8
+ const status = useModuleT("payments.status");
9
+ const { data: payments, status: fetchStatus } = useLazyFetch(
10
+ () => `/api/admin/users/${props.user.id}/payments`,
11
+ { key: () => `admin-last-payment-${props.user.id}`, query: { limit: 1 } }
12
+ );
13
+ const last = computed(() => payments.value?.[0]);
14
+ const date = (value) => value ? new Date(value).toLocaleDateString() : "\u2013";
15
+ </script>
16
+
17
+ <template>
18
+ <AdminStatCard
19
+ :label="t('lastPayment', 'Last payment')"
20
+ :empty="t('empty', 'No payments yet.')"
21
+ :loading="fetchStatus === 'pending' && !payments"
22
+ >
23
+ <template v-if="last">
24
+ <span class="text-highlighted font-medium">{{ formatMinorAmount(last.amount, last.currency) }}</span>
25
+ <UBadge
26
+ variant="subtle"
27
+ :color="last.status === 'paid' ? 'success' : 'neutral'"
28
+ :label="status(last.status, last.status)"
29
+ />
30
+ <span class="text-muted">{{ date(last.paidAt ?? last.createdAt) }}</span>
31
+ </template>
32
+ </AdminStatCard>
33
+ </template>
@@ -0,0 +1,8 @@
1
+ type __VLS_Props = {
2
+ user: {
3
+ id: number;
4
+ };
5
+ };
6
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
7
+ declare const _default: typeof __VLS_export;
8
+ export default _default;
@@ -8,6 +8,19 @@
8
8
  "refunded": "Refunderet",
9
9
  "partially_refunded": "Delvist refunderet"
10
10
  },
11
- "defaultDescription": "Betaling"
11
+ "defaultDescription": "Betaling",
12
+ "admin": {
13
+ "tab": "Betalinger",
14
+ "title": "Betalinger",
15
+ "lastPayment": "Seneste betaling",
16
+ "empty": "Ingen betalinger endnu.",
17
+ "date": "Dato",
18
+ "description": "Beskrivelse",
19
+ "amount": "Beløb",
20
+ "statusColumn": "Status",
21
+ "refunded": "Refunderet",
22
+ "reference": "Reference",
23
+ "defaultDescription": "Betaling"
24
+ }
12
25
  }
13
26
  }
@@ -8,6 +8,19 @@
8
8
  "refunded": "Refunded",
9
9
  "partially_refunded": "Partially refunded"
10
10
  },
11
- "defaultDescription": "Payment"
11
+ "defaultDescription": "Payment",
12
+ "admin": {
13
+ "tab": "Payments",
14
+ "title": "Payments",
15
+ "lastPayment": "Last payment",
16
+ "empty": "No payments yet.",
17
+ "date": "Date",
18
+ "description": "Description",
19
+ "amount": "Amount",
20
+ "statusColumn": "Status",
21
+ "refunded": "Refunded",
22
+ "reference": "Reference",
23
+ "defaultDescription": "Payment"
24
+ }
12
25
  }
13
26
  }
@@ -1,3 +1,4 @@
1
+ import type { PaymentItem } from '../../../types.js';
1
2
  export declare const payments: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
2
3
  name: "payments";
3
4
  schema: undefined;
@@ -157,7 +158,26 @@ export declare const payments: import("drizzle-orm/sqlite-core").SQLiteTableWith
157
158
  columnType: "SQLiteText";
158
159
  data: string;
159
160
  driverParam: string;
160
- notNull: true;
161
+ notNull: false;
162
+ hasDefault: false;
163
+ isPrimaryKey: false;
164
+ isAutoincrement: false;
165
+ hasRuntimeDefault: false;
166
+ enumValues: [string, ...string[]];
167
+ baseColumn: never;
168
+ identity: undefined;
169
+ generated: undefined;
170
+ }, {}, {
171
+ length: undefined;
172
+ }>;
173
+ invoiceId: import("drizzle-orm/sqlite-core").SQLiteColumn<{
174
+ name: "invoiceId";
175
+ tableName: "payments";
176
+ dataType: "string";
177
+ columnType: "SQLiteText";
178
+ data: string;
179
+ driverParam: string;
180
+ notNull: false;
161
181
  hasDefault: false;
162
182
  isPrimaryKey: false;
163
183
  isAutoincrement: false;
@@ -224,6 +244,25 @@ export declare const payments: import("drizzle-orm/sqlite-core").SQLiteTableWith
224
244
  }, {}, {
225
245
  $type: Record<string, string>;
226
246
  }>;
247
+ items: import("drizzle-orm/sqlite-core").SQLiteColumn<{
248
+ name: "items";
249
+ tableName: "payments";
250
+ dataType: "json";
251
+ columnType: "SQLiteTextJson";
252
+ data: PaymentItem[];
253
+ driverParam: string;
254
+ notNull: false;
255
+ hasDefault: false;
256
+ isPrimaryKey: false;
257
+ isAutoincrement: false;
258
+ hasRuntimeDefault: false;
259
+ enumValues: undefined;
260
+ baseColumn: never;
261
+ identity: undefined;
262
+ generated: undefined;
263
+ }, {}, {
264
+ $type: PaymentItem[];
265
+ }>;
227
266
  paidAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
228
267
  name: "paidAt";
229
268
  tableName: "payments";
@@ -12,10 +12,15 @@ export const payments = sqliteTable("payments", {
12
12
  /** What the payment is for (e.g. `event_ticket`) and the id of that thing. */
13
13
  referenceType: text(),
14
14
  referenceId: text(),
15
- checkoutSessionId: text().notNull().unique(),
15
+ /** `null` for payments recorded from a Stripe invoice (e.g. a membership renewal), which have no Checkout session. */
16
+ checkoutSessionId: text().unique(),
17
+ /** Stripe invoice id for payments recorded from an invoice (see `recordInvoicePayment`). */
18
+ invoiceId: text().unique(),
16
19
  paymentIntentId: text(),
17
20
  refundedAmount: integer().notNull().default(0),
18
21
  metadata: text({ mode: "json" }).$type(),
22
+ /** The line items the session was created from, so an expired session can be rebuilt. `null` for rows created before this column existed. */
23
+ items: text({ mode: "json" }).$type(),
19
24
  paidAt: integer({ mode: "timestamp" }),
20
25
  createdAt: integer({ mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date()),
21
26
  updatedAt: integer({ mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date()).$onUpdateFn(() => /* @__PURE__ */ new Date())
@@ -0,0 +1,2 @@
1
+ declare const _default: any;
2
+ export default _default;
@@ -0,0 +1,8 @@
1
+ import { z } from "zod";
2
+ export default defineScopedMcpTool({
3
+ description: "List your own payments, newest first: tickets, memberships and other charges with amount, status, refunds and what each was for.",
4
+ scopes: ["payments:read"],
5
+ annotations: { readOnlyHint: true },
6
+ inputSchema: { limit: z.number().int().min(1).max(100).optional().describe("How many payments to return (default 50).") },
7
+ handler: async ({ limit }, { user }) => ({ payments: await listUserPayments(user.id, { limit: limit ?? 50 }) })
8
+ });
@@ -0,0 +1,2 @@
1
+ declare const _default: import("nitropack/types").NitroAppPlugin;
2
+ export default _default;
@@ -0,0 +1,7 @@
1
+ import { defineNitroPlugin } from "nitropack/runtime";
2
+ export default defineNitroPlugin((nitroApp) => {
3
+ const hooks = nitroApp.hooks;
4
+ hooks.hook("subscriptions:invoice", async ({ invoice }) => {
5
+ await recordInvoicePayment(invoice);
6
+ });
7
+ });
@@ -0,0 +1,2 @@
1
+ declare const _default: any;
2
+ export default _default;
@@ -0,0 +1,5 @@
1
+ export default defineEventHandler(async (event) => {
2
+ const { target } = await loadUsersAdminTarget(event, "view");
3
+ const limit = Number(getQuery(event).limit);
4
+ return listUserPayments(target.id, { limit: Number.isInteger(limit) && limit > 0 ? Math.min(limit, 500) : 100 });
5
+ });
@@ -1,4 +1,4 @@
1
1
  export default defineEventHandler(async (event) => {
2
- const { user } = await requireUserSession(event);
2
+ const user = await requireUser(event, { scopes: ["payments:read"] });
3
3
  return listUserPayments(user.id);
4
4
  });
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  const bodySchema = z.object({ paymentId: z.string().min(1) });
3
3
  export default defineEventHandler(async (event) => {
4
- const { user } = await requireUserSession(event);
4
+ const user = await requireUser(event, { scopes: ["payments:write"] });
5
5
  const { paymentId } = await readValidatedBody(event, bodySchema.parse);
6
6
  return resumePayment(paymentId, user.id, event);
7
7
  });
@@ -1,6 +1,6 @@
1
1
  import { schema } from '@nuxthub/db';
2
2
  import type { H3Event } from 'h3';
3
- import type { CreatePaymentInput, CreatePaymentResult, PaymentDto } from '../../../types.js';
3
+ import type { CreatePaymentInput, InvoicePaymentInput, CreatePaymentResult, PaymentDto } from '../../../types.js';
4
4
  type PaymentRow = typeof schema.payments.$inferSelect;
5
5
  export declare function toPaymentDto(row: PaymentRow): PaymentDto;
6
6
  /**
@@ -12,10 +12,14 @@ export declare function toPaymentDto(row: PaymentRow): PaymentDto;
12
12
  export declare function createPayment(input: CreatePaymentInput, event?: H3Event): Promise<CreatePaymentResult>;
13
13
  export declare function getPayment(id: string): Promise<PaymentDto | null>;
14
14
  export declare function getPaymentByReference(type: string, id: string): Promise<PaymentDto | null>;
15
- export declare function listUserPayments(userId: number): Promise<PaymentDto[]>;
15
+ /** A user's payments, newest first; `limit` caps how many are returned. */
16
+ export declare function listUserPayments(userId: number, options?: {
17
+ limit?: number;
18
+ }): Promise<PaymentDto[]>;
16
19
  /**
17
20
  * 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
21
+ * a fresh session built from the stored items (the payment row follows the new session). The
22
+ * `payments:checkout:before` hook and return URL validation run again, as in `createPayment`. Throws for payments
19
23
  * that are already paid/refunded.
20
24
  */
21
25
  export declare function resumePayment(paymentId: string, userId: number, event?: H3Event): Promise<{
@@ -28,4 +32,12 @@ export declare function resumePayment(paymentId: string, userId: number, event?:
28
32
  export declare function refundPayment(paymentId: string, options?: {
29
33
  amount?: number;
30
34
  }): Promise<void>;
35
+ /**
36
+ * Stores (or updates) the payment for a Stripe invoice, keyed by the invoice id, so recurring charges such as
37
+ * membership renewals show up next to one-time payments. Idempotent: a redelivered event changes nothing and a
38
+ * late `failed`/`expired` never overwrites a payment that is already paid or refunded. Fires the matching
39
+ * `payments:*` hook only when the status actually changed. Refunds arrive through the `charge.refunded` handler,
40
+ * which finds the row by `paymentIntentId`.
41
+ */
42
+ export declare function recordInvoicePayment(input: InvoicePaymentInput): Promise<PaymentDto>;
31
43
  export {};
@@ -1,6 +1,7 @@
1
1
  import { db, schema } from "@nuxthub/db";
2
- import { and, desc, eq } from "drizzle-orm";
3
- import { resolveSameOriginUrl, sumItems } from "../../utils/index.js";
2
+ import { and, desc, eq, sql } from "drizzle-orm";
3
+ import { resolveSameOriginUrl } from "@frigear-nu/stripe/utils";
4
+ import { sumItems } from "../../utils/index.js";
4
5
  export function toPaymentDto(row) {
5
6
  return {
6
7
  id: row.id,
@@ -18,11 +19,46 @@ export function toPaymentDto(row) {
18
19
  }
19
20
  const newPaymentId = () => `pay_${crypto.randomUUID().replaceAll("-", "")}`;
20
21
  function safeReturnUrl(input, event, name) {
21
- if (!event) return input;
22
+ if (!event) {
23
+ const absolute = URL.canParse(input) && ["http:", "https:"].includes(new URL(input).protocol);
24
+ if (!absolute) throw createError({ statusCode: 400, statusMessage: `${name} must be an absolute http(s) URL.` });
25
+ return input;
26
+ }
22
27
  const resolved = resolveSameOriginUrl(input, getRequestURL(event).origin);
23
28
  if (!resolved) throw createError({ statusCode: 400, statusMessage: `${name} must be a relative path or an URL on the same origin.` });
24
29
  return resolved;
25
30
  }
31
+ function toLineItems(items, currency) {
32
+ return items.map((item) => ({
33
+ quantity: item.quantity ?? 1,
34
+ price_data: {
35
+ currency,
36
+ unit_amount: item.amount,
37
+ product_data: { name: item.name, ...item.description ? { description: item.description } : {} }
38
+ }
39
+ }));
40
+ }
41
+ async function buildCheckoutParams(source) {
42
+ const metadata = { ...source.metadata, paymentId: source.paymentId, userId: String(source.userId) };
43
+ const context = {
44
+ event: source.event,
45
+ userId: source.userId,
46
+ reference: source.reference,
47
+ params: {
48
+ mode: "payment",
49
+ customer: source.customer,
50
+ client_reference_id: String(source.userId),
51
+ line_items: toLineItems(source.items, source.currency),
52
+ success_url: safeReturnUrl(source.successUrl, source.event, "successUrl"),
53
+ cancel_url: safeReturnUrl(source.cancelUrl, source.event, "cancelUrl"),
54
+ expires_at: expiresAt(source.expiresInMinutes),
55
+ metadata,
56
+ payment_intent_data: { metadata }
57
+ }
58
+ };
59
+ await useNitroApp().hooks.callHook("payments:checkout:before", context);
60
+ return context.params;
61
+ }
26
62
  function expiresAt(minutes) {
27
63
  const clamped = Math.min(Math.max(Math.round(minutes), 30), 1440);
28
64
  return Math.floor(Date.now() / 1e3) + clamped * 60;
@@ -39,32 +75,20 @@ export async function createPayment(input, event) {
39
75
  const stripe = useStripe();
40
76
  const customer = await ensureStripeCustomer(input.userId);
41
77
  const id = newPaymentId();
42
- const metadata = { ...input.metadata, paymentId: id, userId: String(input.userId) };
43
- const context = {
44
- event,
78
+ const params = await buildCheckoutParams({
79
+ paymentId: id,
45
80
  userId: input.userId,
81
+ customer,
82
+ currency,
83
+ items: input.items,
46
84
  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);
85
+ successUrl: input.successUrl,
86
+ cancelUrl: input.cancelUrl,
87
+ expiresInMinutes: input.expiresInMinutes ?? config.checkoutExpiresInMinutes,
88
+ metadata: input.metadata,
89
+ event
90
+ });
91
+ const session = await stripe.checkout.sessions.create(params);
68
92
  if (!session.url) throw createError({ statusCode: 502, statusMessage: "Stripe did not return a checkout URL." });
69
93
  try {
70
94
  const [row] = await db.insert(schema.payments).values({
@@ -76,7 +100,8 @@ export async function createPayment(input, event) {
76
100
  referenceType: input.reference?.type ?? null,
77
101
  referenceId: input.reference?.id ?? null,
78
102
  checkoutSessionId: session.id,
79
- metadata: input.metadata ?? null
103
+ metadata: input.metadata ?? null,
104
+ items: input.items
80
105
  }).returning();
81
106
  return { payment: toPaymentDto(row), url: session.url };
82
107
  } catch (error) {
@@ -92,8 +117,9 @@ export async function getPaymentByReference(type, id) {
92
117
  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
118
  return row ? toPaymentDto(row) : null;
94
119
  }
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));
120
+ export async function listUserPayments(userId, options = {}) {
121
+ const query = db.select().from(schema.payments).where(eq(schema.payments.userId, userId)).orderBy(desc(sql`coalesce(${schema.payments.paidAt}, ${schema.payments.createdAt})`));
122
+ const rows = await (options.limit ? query.limit(options.limit) : query);
97
123
  return rows.map(toPaymentDto);
98
124
  }
99
125
  export async function resumePayment(paymentId, userId, event) {
@@ -103,33 +129,30 @@ export async function resumePayment(paymentId, userId, event) {
103
129
  throw createError({ statusCode: 409, statusMessage: "This payment is already paid." });
104
130
  }
105
131
  const stripe = useStripe();
132
+ if (!row.checkoutSessionId) throw createError({ statusCode: 409, statusMessage: "This payment cannot be resumed." });
106
133
  const session = await stripe.checkout.sessions.retrieve(row.checkoutSessionId);
107
134
  if (session.status === "open" && session.url) return { url: session.url };
108
135
  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
136
  const origin = event ? getRequestURL(event).origin : void 0;
112
137
  const successUrl = session.success_url ?? origin;
113
138
  const cancelUrl = session.cancel_url ?? origin;
114
139
  if (!successUrl || !cancelUrl) throw createError({ statusCode: 409, statusMessage: "Cannot resume this payment." });
115
- const next = await stripe.checkout.sessions.create({
116
- mode: "payment",
140
+ const items = row.items?.length ? row.items : [{ name: row.description ?? "Payment", amount: row.amount, currency: row.currency }];
141
+ const customer = await ensureStripeCustomer(userId);
142
+ const params = await buildCheckoutParams({
143
+ paymentId: row.id,
144
+ userId,
117
145
  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 }
146
+ currency: row.currency,
147
+ items,
148
+ reference: row.referenceType && row.referenceId ? { type: row.referenceType, id: row.referenceId } : void 0,
149
+ successUrl,
150
+ cancelUrl,
151
+ expiresInMinutes: useRuntimeConfig().payments.checkoutExpiresInMinutes,
152
+ metadata: row.metadata,
153
+ event
132
154
  });
155
+ const next = await stripe.checkout.sessions.create(params);
133
156
  if (!next.url) throw createError({ statusCode: 502, statusMessage: "Stripe did not return a checkout URL." });
134
157
  await db.update(schema.payments).set({ checkoutSessionId: next.id, status: "pending" }).where(eq(schema.payments.id, row.id));
135
158
  return { url: next.url };
@@ -148,3 +171,33 @@ export async function refundPayment(paymentId, options = {}) {
148
171
  ...options.amount !== void 0 ? { amount: options.amount } : {}
149
172
  });
150
173
  }
174
+ const hookForInvoice = { paid: "payments:paid", failed: "payments:failed", expired: "payments:expired" };
175
+ export async function recordInvoicePayment(input) {
176
+ const nitro = useNitroApp();
177
+ const [existing] = await db.select().from(schema.payments).where(eq(schema.payments.invoiceId, input.invoiceId)).limit(1);
178
+ if (existing) {
179
+ const settled = ["paid", "refunded", "partially_refunded"].includes(existing.status);
180
+ if (settled || existing.status === input.status) return toPaymentDto(existing);
181
+ }
182
+ const values = {
183
+ userId: input.userId,
184
+ status: input.status,
185
+ amount: input.amount,
186
+ currency: input.currency,
187
+ description: input.description,
188
+ referenceType: input.referenceType ?? "subscription",
189
+ referenceId: input.referenceId,
190
+ invoiceId: input.invoiceId,
191
+ paymentIntentId: input.paymentIntentId,
192
+ paidAt: input.status === "paid" ? input.paidAt ?? /* @__PURE__ */ new Date() : null
193
+ };
194
+ const [row] = existing ? await db.update(schema.payments).set(values).where(eq(schema.payments.id, existing.id)).returning() : await db.insert(schema.payments).values({ id: newPaymentId(), ...values, ...input.createdAt ? { createdAt: input.createdAt } : {} }).onConflictDoNothing({ target: schema.payments.invoiceId }).returning();
195
+ if (!row) {
196
+ const [current] = await db.select().from(schema.payments).where(eq(schema.payments.invoiceId, input.invoiceId)).limit(1);
197
+ if (!current) throw createError({ statusCode: 500, statusMessage: "Could not record the invoice payment." });
198
+ return toPaymentDto(current);
199
+ }
200
+ const dto = toPaymentDto(row);
201
+ if (input.status in hookForInvoice) await nitro.hooks.callHook(hookForInvoice[input.status], { payment: dto });
202
+ return dto;
203
+ }
@@ -2,5 +2,3 @@ import type { PaymentItem } from '../../types.js';
2
2
  export type { CheckoutBeforeContext, CreatePaymentInput, CreatePaymentResult, ModuleOptions, PaymentDto, PaymentHookContext, PaymentItem, PaymentReference, PaymentStatus, } from '../../types.js';
3
3
  /** Total of a list of items in minor units. */
4
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;
@@ -1,14 +1,3 @@
1
1
  export function sumItems(items) {
2
2
  return items.reduce((total, item) => total + item.amount * (item.quantity ?? 1), 0);
3
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
- }
package/dist/types.d.mts CHANGED
@@ -1,3 +1,3 @@
1
1
  export { default } from './module.mjs'
2
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'
3
+ export { type CheckoutBeforeContext, type CreatePaymentInput, type CreatePaymentResult, type InvoicePaymentInput, type ModuleOptions, type PaymentDto, type PaymentHookContext, type PaymentItem, type PaymentReference, type PaymentStatus, type PaymentsRuntimeConfig, type ResumeResponse } from './module.mjs'
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@frigear-nu/payments",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "description": "One-time payments (Stripe Checkout, refunds and payment lifecycle hooks) for the frigear ecosystem, built on @frigear-nu/stripe.",
5
+ "homepage": "https://docs.frigear.dev/modules/payments",
5
6
  "repository": {
6
7
  "type": "git",
7
8
  "url": "git+https://github.com/frigear-nu/frigear-nu.git",
@@ -29,9 +30,12 @@
29
30
  "playground"
30
31
  ],
31
32
  "dependencies": {
32
- "@frigear-nu/module-kit": "0.1.5",
33
- "@frigear-nu/stripe": "0.0.6",
34
- "@frigear-nu/users": "1.0.7",
33
+ "@frigear-nu/auth": "0.0.3",
34
+ "@frigear-nu/mcp": "0.0.3",
35
+ "@frigear-nu/module-kit": "0.1.7",
36
+ "@frigear-nu/stripe": "0.0.8",
37
+ "@frigear-nu/ui": "0.0.7",
38
+ "@frigear-nu/users": "1.0.9",
35
39
  "@libsql/client": "^0.18.0",
36
40
  "@nuxt/kit": "^4.6.0",
37
41
  "drizzle-kit": "^0.31.11",
@@ -42,15 +46,20 @@
42
46
  "zod": "^4.6.5"
43
47
  },
44
48
  "peerDependencies": {
49
+ "@frigear-nu/widget": "0.0.2",
45
50
  "@nuxt/ui": "^4.10.0",
46
51
  "@nuxthub/core": "^0.10.8"
47
52
  },
48
53
  "peerDependenciesMeta": {
54
+ "@frigear-nu/widget": {
55
+ "optional": true
56
+ },
49
57
  "@nuxt/ui": {
50
58
  "optional": true
51
59
  }
52
60
  },
53
61
  "devDependencies": {
62
+ "@frigear-nu/widget": "0.0.2",
54
63
  "@nuxt/devtools": "^3.4.2",
55
64
  "@nuxt/eslint-config": "^1.17.0",
56
65
  "@nuxt/module-builder": "^1.0.3",