@frigear-nu/stripe 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.
- package/README.md +75 -2
- package/dist/module.d.mts +74 -0
- package/dist/module.json +9 -0
- package/dist/module.mjs +80 -0
- package/dist/runtime/server/db/schema.sqlite.d.ts +26 -0
- package/dist/runtime/server/db/schema.sqlite.js +4 -0
- package/dist/runtime/server/routes/webhook.post.d.ts +2 -0
- package/dist/runtime/server/routes/webhook.post.js +82 -0
- package/dist/runtime/server/utils/stripe.d.ts +14 -0
- package/dist/runtime/server/utils/stripe.js +47 -0
- package/dist/runtime/utils/handlers.d.ts +32 -0
- package/dist/runtime/utils/handlers.js +15 -0
- package/dist/runtime/utils/index.d.ts +4 -0
- package/dist/runtime/utils/index.js +2 -0
- package/dist/runtime/utils/registry.d.ts +18 -0
- package/dist/runtime/utils/registry.js +10 -0
- package/dist/types.d.mts +3 -0
- package/package.json +78 -3
package/README.md
CHANGED
|
@@ -1,3 +1,76 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @frigear-nu/stripe
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Core Stripe plumbing for the frigear ecosystem. It owns the SDK, the user <-> Stripe customer link and a single
|
|
4
|
+
verified, idempotent webhook that other modules (subscriptions, payments, ...) subscribe to. It contains no product
|
|
5
|
+
logic itself.
|
|
6
|
+
|
|
7
|
+
## Setup
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
export default defineNuxtConfig({
|
|
11
|
+
modules: ['@nuxthub/core', '@frigear-nu/users', '@frigear-nu/stripe'],
|
|
12
|
+
hub: { db: { dialect: 'sqlite', driver: 'libsql' }, kv: true }, // kv is required (webhook idempotency)
|
|
13
|
+
stripe: { settings: { webhookPath: '/api/stripe/webhook' } }, // optional, this is the default
|
|
14
|
+
})
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`unstorage` must be resolvable by the host app (NuxtHub KV needs it).
|
|
18
|
+
|
|
19
|
+
Environment variables:
|
|
20
|
+
|
|
21
|
+
| Variable | Purpose |
|
|
22
|
+
| ------------------------------------ | ----------------------------------------------- |
|
|
23
|
+
| `NUXT_STRIPE_SECRET_KEY` | Stripe secret key (`runtimeConfig.stripe.secretKey`) |
|
|
24
|
+
| `NUXT_STRIPE_WEBHOOK_SECRET` | Webhook signing secret (`whsec_...`) |
|
|
25
|
+
| `NUXT_STRIPE_API_VERSION` | Optional override, defaults to the pinned `2026-09-30.endive` |
|
|
26
|
+
| `NUXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | Publishable key (`runtimeConfig.public.stripe.publishableKey`) |
|
|
27
|
+
|
|
28
|
+
## Database
|
|
29
|
+
|
|
30
|
+
Extends the `users` table with a nullable `stripe_customer_id` column. Generate and commit migrations in the host app.
|
|
31
|
+
|
|
32
|
+
## Server utils (auto-imported in Nitro)
|
|
33
|
+
|
|
34
|
+
- `useStripe()` - the Stripe SDK, using the fetch HTTP client (Cloudflare Workers compatible).
|
|
35
|
+
- `ensureStripeCustomer(userId)` - returns the user's Stripe customer id: stored id, else a Stripe search by email, else a new customer (persisted).
|
|
36
|
+
- `getStripeCustomerUser(customerId)` - the local user row for a Stripe customer id, or `null`.
|
|
37
|
+
|
|
38
|
+
## Webhook
|
|
39
|
+
|
|
40
|
+
`POST /api/stripe/webhook` reads the raw body, verifies the signature (`constructEventAsync` + SubtleCrypto), and
|
|
41
|
+
dispatches to handlers. Processed event ids are stored in NuxtHub KV for 7 days, so duplicates are ignored. If any
|
|
42
|
+
handler throws, the others still run, the response is 500 (Stripe retries) and the event is not marked processed -
|
|
43
|
+
**handlers must be idempotent**. The Nitro hook `stripe:event` is fired for every dispatched event.
|
|
44
|
+
|
|
45
|
+
## Subscribing to events
|
|
46
|
+
|
|
47
|
+
From a module (build time, bundled into the server - no runtime scanning):
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { registerStripeEventHandler } from '@frigear-nu/stripe/utils'
|
|
51
|
+
|
|
52
|
+
registerStripeEventHandler(nuxt, {
|
|
53
|
+
events: ['customer.subscription.updated', 'checkout.session.completed'], // or '*'
|
|
54
|
+
handler: resolver.resolve('./runtime/server/stripe/subscription-events'),
|
|
55
|
+
order: 100, // optional, lower runs first
|
|
56
|
+
})
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
// runtime/server/stripe/subscription-events.ts
|
|
61
|
+
import { defineStripeEventHandler } from '@frigear-nu/stripe/utils'
|
|
62
|
+
|
|
63
|
+
export default defineStripeEventHandler<'customer.subscription.updated' | 'checkout.session.completed'>(
|
|
64
|
+
async (event, { stripe, userId, event: h3Event }) => {
|
|
65
|
+
// `event` is narrowed by `event.type`; `userId` is null for unknown customers
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
From a host app's Nitro plugin:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
export default defineNitroPlugin(() => {
|
|
74
|
+
onStripeEvent('invoice.payment_failed', (event, ctx) => { /* ... */ })
|
|
75
|
+
})
|
|
76
|
+
```
|
|
@@ -0,0 +1,74 @@
|
|
|
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
|
+
/** Path the Stripe webhook is served on. Defaults to `/api/stripe/webhook`. */
|
|
8
|
+
webhookPath?: string;
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
/** Private runtime config (`useRuntimeConfig().stripe`), set through `NUXT_STRIPE_*` env vars. */
|
|
12
|
+
type StripeRuntimeConfig = {
|
|
13
|
+
secretKey: string;
|
|
14
|
+
webhookSecret: string;
|
|
15
|
+
apiVersion: string;
|
|
16
|
+
};
|
|
17
|
+
/** Public runtime config (`useRuntimeConfig().public.stripe`). */
|
|
18
|
+
type StripePublicRuntimeConfig = {
|
|
19
|
+
publishableKey: string;
|
|
20
|
+
};
|
|
21
|
+
/** Every Stripe event type string, e.g. `'customer.subscription.updated'`. */
|
|
22
|
+
type StripeEventType = Stripe.Event['type'];
|
|
23
|
+
/** The `Stripe.Event` for a given event type (or union of event types). */
|
|
24
|
+
type StripeEventOf<T extends StripeEventType = StripeEventType> = Extract<Stripe.Event, {
|
|
25
|
+
type: T;
|
|
26
|
+
}>;
|
|
27
|
+
type StripeEventHandlerContext = {
|
|
28
|
+
/** A ready-to-use Stripe SDK instance (see `useStripe`). */
|
|
29
|
+
stripe: Stripe;
|
|
30
|
+
/** The local user owning the event's Stripe customer, or `null` if there is none / it is unknown. */
|
|
31
|
+
userId: number | null;
|
|
32
|
+
/** The Nitro request event of the webhook call. */
|
|
33
|
+
event: H3Event;
|
|
34
|
+
};
|
|
35
|
+
type StripeEventHandler<T extends StripeEventType = StripeEventType> = (event: StripeEventOf<T>, ctx: StripeEventHandlerContext) => void | Promise<void>;
|
|
36
|
+
/** A handler registered at build time through `registerStripeEventHandler`. */
|
|
37
|
+
type RegisteredStripeEventHandler = {
|
|
38
|
+
/** Event types to subscribe to, or `'*'` for every event. */
|
|
39
|
+
events: string[] | '*';
|
|
40
|
+
/** Absolute path to a file whose default export is a `defineStripeEventHandler(...)` result. */
|
|
41
|
+
handler: string;
|
|
42
|
+
/** Lower runs first. Defaults to `100`. */
|
|
43
|
+
order?: number;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
declare module '@nuxt/schema' {
|
|
47
|
+
interface NuxtConfig {
|
|
48
|
+
stripe?: ModuleOptions;
|
|
49
|
+
}
|
|
50
|
+
interface NuxtOptions {
|
|
51
|
+
stripe?: ModuleOptions & {
|
|
52
|
+
_handlers?: RegisteredStripeEventHandler[];
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
interface RuntimeConfig {
|
|
56
|
+
stripe: StripeRuntimeConfig;
|
|
57
|
+
}
|
|
58
|
+
interface PublicRuntimeConfig {
|
|
59
|
+
stripe: StripePublicRuntimeConfig;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
declare module 'nitropack' {
|
|
63
|
+
interface NitroRuntimeHooks {
|
|
64
|
+
/** Fired for every verified, not-yet-processed Stripe webhook event, once handlers have been dispatched. */
|
|
65
|
+
'stripe:event': (event: Stripe.Event, ctx: StripeEventHandlerContext) => void | Promise<void>;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** The Stripe API version this package is written against (matches the pinned `stripe` SDK). */
|
|
69
|
+
declare const STRIPE_API_VERSION = "2026-09-30.endive";
|
|
70
|
+
declare const DEFAULT_WEBHOOK_PATH = "/api/stripe/webhook";
|
|
71
|
+
declare const _default: _nuxt_schema.NuxtModule<ModuleOptions, ModuleOptions, false>;
|
|
72
|
+
|
|
73
|
+
export { DEFAULT_WEBHOOK_PATH, STRIPE_API_VERSION, _default as default };
|
|
74
|
+
export type { ModuleOptions, RegisteredStripeEventHandler, StripeEventHandler, StripeEventHandlerContext, StripeEventOf, StripeEventType, StripePublicRuntimeConfig, StripeRuntimeConfig };
|
package/dist/module.json
ADDED
package/dist/module.mjs
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { defineNuxtModule, createResolver, addServerHandler, addServerImportsDir, addServerImports, addTypeTemplate, addServerTemplate } from '@nuxt/kit';
|
|
2
|
+
import { registerModule, extendDatabaseTable } from '@frigear-nu/module-kit/utils';
|
|
3
|
+
|
|
4
|
+
const STRIPE_API_VERSION = "2026-09-30.endive";
|
|
5
|
+
const DEFAULT_WEBHOOK_PATH = "/api/stripe/webhook";
|
|
6
|
+
const DEFAULT_HANDLER_ORDER = 100;
|
|
7
|
+
const module$1 = defineNuxtModule({
|
|
8
|
+
meta: { name: "@frigear-nu/stripe", configKey: "stripe" },
|
|
9
|
+
defaults: {
|
|
10
|
+
settings: { webhookPath: DEFAULT_WEBHOOK_PATH }
|
|
11
|
+
},
|
|
12
|
+
moduleDependencies: {
|
|
13
|
+
"@nuxthub/core": {
|
|
14
|
+
version: ">=0.10",
|
|
15
|
+
// The webhook stores processed event ids in NuxtHub KV.
|
|
16
|
+
defaults: { kv: true }
|
|
17
|
+
},
|
|
18
|
+
"@frigear-nu/module-kit": {
|
|
19
|
+
version: ">=0.0.1"
|
|
20
|
+
},
|
|
21
|
+
"@frigear-nu/users": {
|
|
22
|
+
version: ">=0.0.1"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
async setup(options, nuxt) {
|
|
26
|
+
registerModule("@frigear-nu/stripe", nuxt);
|
|
27
|
+
const resolver = createResolver(import.meta.url);
|
|
28
|
+
const webhookPath = options.settings?.webhookPath || DEFAULT_WEBHOOK_PATH;
|
|
29
|
+
if (!nuxt.options.hub?.kv) {
|
|
30
|
+
throw new Error("[@frigear-nu/stripe] NuxtHub KV is required for webhook idempotency. Set `hub: { kv: true }` in your Nuxt config.");
|
|
31
|
+
}
|
|
32
|
+
const runtimeConfig = nuxt.options.runtimeConfig;
|
|
33
|
+
runtimeConfig.stripe = {
|
|
34
|
+
secretKey: "",
|
|
35
|
+
webhookSecret: "",
|
|
36
|
+
apiVersion: STRIPE_API_VERSION,
|
|
37
|
+
...runtimeConfig.stripe
|
|
38
|
+
};
|
|
39
|
+
runtimeConfig.public.stripe = {
|
|
40
|
+
publishableKey: "",
|
|
41
|
+
...runtimeConfig.public.stripe
|
|
42
|
+
};
|
|
43
|
+
addServerHandler({
|
|
44
|
+
route: webhookPath,
|
|
45
|
+
method: "post",
|
|
46
|
+
handler: resolver.resolve("./runtime/server/routes/webhook.post")
|
|
47
|
+
});
|
|
48
|
+
addServerImportsDir(resolver.resolve("./runtime/server/utils"));
|
|
49
|
+
addServerImports([
|
|
50
|
+
{ name: "onStripeEvent", from: resolver.resolve("./runtime/utils/registry") },
|
|
51
|
+
{ name: "defineStripeEventHandler", from: resolver.resolve("./runtime/utils/handlers") }
|
|
52
|
+
]);
|
|
53
|
+
addTypeTemplate({
|
|
54
|
+
filename: "types/stripe-handlers.d.ts",
|
|
55
|
+
getContents: () => `declare module '#stripe/handlers' {
|
|
56
|
+
import type { StripeEventHandler } from ${JSON.stringify(resolver.resolve("./types"))}
|
|
57
|
+
export const handlers: Array<{ events: string[] | '*', order?: number, handler: StripeEventHandler }>
|
|
58
|
+
}
|
|
59
|
+
`
|
|
60
|
+
});
|
|
61
|
+
nuxt.hook("modules:done", () => {
|
|
62
|
+
const registered = Array.isArray(nuxt.options.stripe?._handlers) ? nuxt.options.stripe._handlers : [];
|
|
63
|
+
addServerTemplate({
|
|
64
|
+
filename: "#stripe/handlers",
|
|
65
|
+
getContents: () => `
|
|
66
|
+
${registered.map((entry, index) => `import handler${index} from ${JSON.stringify(entry.handler)}`).join("\n")}
|
|
67
|
+
export const handlers = [${registered.map(
|
|
68
|
+
(entry, index) => `{ events: ${JSON.stringify(entry.events)}, order: ${entry.order ?? DEFAULT_HANDLER_ORDER}, handler: handler${index} }`
|
|
69
|
+
).join(", ")}]
|
|
70
|
+
`
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
extendDatabaseTable(nuxt, {
|
|
74
|
+
resolver,
|
|
75
|
+
file: (dialect) => `./runtime/server/db/schema.${dialect}`
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
export { DEFAULT_WEBHOOK_PATH, STRIPE_API_VERSION, module$1 as default };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export declare const users: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
|
|
2
|
+
name: "users";
|
|
3
|
+
schema: undefined;
|
|
4
|
+
columns: {
|
|
5
|
+
stripeCustomerId: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
6
|
+
name: "stripe_customer_id";
|
|
7
|
+
tableName: "users";
|
|
8
|
+
dataType: "string";
|
|
9
|
+
columnType: "SQLiteText";
|
|
10
|
+
data: string;
|
|
11
|
+
driverParam: string;
|
|
12
|
+
notNull: false;
|
|
13
|
+
hasDefault: false;
|
|
14
|
+
isPrimaryKey: false;
|
|
15
|
+
isAutoincrement: false;
|
|
16
|
+
hasRuntimeDefault: false;
|
|
17
|
+
enumValues: [string, ...string[]];
|
|
18
|
+
baseColumn: never;
|
|
19
|
+
identity: undefined;
|
|
20
|
+
generated: undefined;
|
|
21
|
+
}, {}, {
|
|
22
|
+
length: number | undefined;
|
|
23
|
+
}>;
|
|
24
|
+
};
|
|
25
|
+
dialect: "sqlite";
|
|
26
|
+
}>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import Stripe from "stripe";
|
|
2
|
+
import { kv } from "@nuxthub/kv";
|
|
3
|
+
import { handlers as registeredHandlers } from "#stripe/handlers";
|
|
4
|
+
import { getRuntimeStripeEventHandlers } from "../../utils/registry.js";
|
|
5
|
+
const PROCESSED_TTL_SECONDS = 60 * 60 * 24 * 7;
|
|
6
|
+
const DEFAULT_ORDER = 100;
|
|
7
|
+
const matches = (events, type) => events === "*" || events.includes(type);
|
|
8
|
+
function collectHandlers(type) {
|
|
9
|
+
const all = [
|
|
10
|
+
...registeredHandlers.map((entry) => ({ events: entry.events, order: entry.order ?? DEFAULT_ORDER, handler: entry.handler })),
|
|
11
|
+
...getRuntimeStripeEventHandlers().map((entry) => ({ events: entry.events, order: DEFAULT_ORDER, handler: entry.handler }))
|
|
12
|
+
];
|
|
13
|
+
return all.filter((entry) => matches(entry.events, type)).sort((a, b) => a.order - b.order).map((entry) => entry.handler);
|
|
14
|
+
}
|
|
15
|
+
function getEventCustomerId(stripeEvent) {
|
|
16
|
+
const object = stripeEvent.data.object;
|
|
17
|
+
if (!object) return null;
|
|
18
|
+
if (object.object === "customer" && typeof object.id === "string") return object.id;
|
|
19
|
+
const customer = object.customer;
|
|
20
|
+
if (typeof customer === "string") return customer;
|
|
21
|
+
if (customer && typeof customer === "object" && typeof customer.id === "string") {
|
|
22
|
+
return customer.id;
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
export default defineEventHandler(async (event) => {
|
|
27
|
+
const { webhookSecret } = useRuntimeConfig(event).stripe;
|
|
28
|
+
if (!webhookSecret) {
|
|
29
|
+
throw createError({ statusCode: 500, statusMessage: "Stripe webhook is not configured (missing NUXT_STRIPE_WEBHOOK_SECRET)." });
|
|
30
|
+
}
|
|
31
|
+
const signature = getHeader(event, "stripe-signature");
|
|
32
|
+
if (!signature) {
|
|
33
|
+
throw createError({ statusCode: 400, statusMessage: "Missing stripe-signature header." });
|
|
34
|
+
}
|
|
35
|
+
const rawBody = await readRawBody(event, "utf8");
|
|
36
|
+
if (!rawBody) {
|
|
37
|
+
throw createError({ statusCode: 400, statusMessage: "Missing request body." });
|
|
38
|
+
}
|
|
39
|
+
let stripeEvent;
|
|
40
|
+
try {
|
|
41
|
+
stripeEvent = await Stripe.webhooks.constructEventAsync(
|
|
42
|
+
rawBody,
|
|
43
|
+
signature,
|
|
44
|
+
webhookSecret,
|
|
45
|
+
void 0,
|
|
46
|
+
Stripe.createSubtleCryptoProvider()
|
|
47
|
+
);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
console.warn("[@frigear-nu/stripe] Rejected webhook with an invalid signature:", error instanceof Error ? error.message : error);
|
|
50
|
+
throw createError({ statusCode: 400, statusMessage: "Invalid Stripe signature." });
|
|
51
|
+
}
|
|
52
|
+
const processedKey = `stripe:event:${stripeEvent.id}`;
|
|
53
|
+
if (await kv.has(processedKey)) {
|
|
54
|
+
return { received: true, duplicate: true };
|
|
55
|
+
}
|
|
56
|
+
const customerId = getEventCustomerId(stripeEvent);
|
|
57
|
+
const user = customerId ? await getStripeCustomerUser(customerId) : null;
|
|
58
|
+
const context = {
|
|
59
|
+
stripe: useStripe(),
|
|
60
|
+
userId: user?.id ?? null,
|
|
61
|
+
event
|
|
62
|
+
};
|
|
63
|
+
let failures = 0;
|
|
64
|
+
for (const handler of collectHandlers(stripeEvent.type)) {
|
|
65
|
+
try {
|
|
66
|
+
await handler(stripeEvent, context);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
failures++;
|
|
69
|
+
console.error(`[@frigear-nu/stripe] Handler failed for "${stripeEvent.type}" (${stripeEvent.id}):`, error);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
await useNitroApp().hooks.callHook("stripe:event", stripeEvent, context);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
console.error(`[@frigear-nu/stripe] "stripe:event" hook failed for ${stripeEvent.id}:`, error);
|
|
76
|
+
}
|
|
77
|
+
if (failures > 0) {
|
|
78
|
+
throw createError({ statusCode: 500, statusMessage: `${failures} Stripe event handler(s) failed.` });
|
|
79
|
+
}
|
|
80
|
+
await kv.set(processedKey, Date.now(), { ttl: PROCESSED_TTL_SECONDS });
|
|
81
|
+
return { received: true };
|
|
82
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import Stripe from 'stripe';
|
|
2
|
+
/**
|
|
3
|
+
* The shared Stripe SDK instance, configured from `runtimeConfig.stripe`. It uses the fetch HTTP
|
|
4
|
+
* client, so it works on Cloudflare Workers as well as Node.
|
|
5
|
+
*/
|
|
6
|
+
export declare function useStripe(): Stripe;
|
|
7
|
+
/**
|
|
8
|
+
* Returns the Stripe customer id for a user, creating (and persisting) one when needed. Lookup
|
|
9
|
+
* order: `users.stripeCustomerId`, then a Stripe customer search by email, then a new customer.
|
|
10
|
+
* Safe to call lazily from consumers - there is no login coupling.
|
|
11
|
+
*/
|
|
12
|
+
export declare function ensureStripeCustomer(userId: number): Promise<string>;
|
|
13
|
+
/** Resolves the local user owning a Stripe customer id, or `null` if none is linked. */
|
|
14
|
+
export declare function getStripeCustomerUser(customerId: string): Promise<any>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import Stripe from "stripe";
|
|
2
|
+
import { db, schema } from "@nuxthub/db";
|
|
3
|
+
import { and, eq, isNull } from "drizzle-orm";
|
|
4
|
+
let cached;
|
|
5
|
+
export function useStripe() {
|
|
6
|
+
const { secretKey, apiVersion } = useRuntimeConfig().stripe;
|
|
7
|
+
if (!secretKey) {
|
|
8
|
+
throw createError({ statusCode: 500, statusMessage: "Stripe is not configured (missing `runtimeConfig.stripe.secretKey` / NUXT_STRIPE_SECRET_KEY)." });
|
|
9
|
+
}
|
|
10
|
+
if (!cached || cached.key !== secretKey || cached.apiVersion !== apiVersion) {
|
|
11
|
+
cached = {
|
|
12
|
+
key: secretKey,
|
|
13
|
+
apiVersion,
|
|
14
|
+
client: new Stripe(secretKey, {
|
|
15
|
+
apiVersion,
|
|
16
|
+
httpClient: Stripe.createFetchHttpClient()
|
|
17
|
+
})
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
return cached.client;
|
|
21
|
+
}
|
|
22
|
+
const escapeSearchValue = (value) => value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
23
|
+
export async function ensureStripeCustomer(userId) {
|
|
24
|
+
const [user] = await db.select().from(schema.users).where(eq(schema.users.id, userId)).limit(1);
|
|
25
|
+
if (!user) {
|
|
26
|
+
throw createError({ statusCode: 404, statusMessage: "User not found." });
|
|
27
|
+
}
|
|
28
|
+
if (user.stripeCustomerId) return user.stripeCustomerId;
|
|
29
|
+
const stripe = useStripe();
|
|
30
|
+
const existing = await stripe.customers.search({
|
|
31
|
+
query: `email:'${escapeSearchValue(user.email)}'`,
|
|
32
|
+
limit: 1
|
|
33
|
+
});
|
|
34
|
+
const customerId = existing.data[0]?.id ?? (await stripe.customers.create({
|
|
35
|
+
email: user.email,
|
|
36
|
+
name: user.name,
|
|
37
|
+
metadata: { userId: String(user.id) }
|
|
38
|
+
})).id;
|
|
39
|
+
const [updated] = await db.update(schema.users).set({ stripeCustomerId: customerId }).where(and(eq(schema.users.id, userId), isNull(schema.users.stripeCustomerId))).returning({ stripeCustomerId: schema.users.stripeCustomerId });
|
|
40
|
+
if (updated?.stripeCustomerId) return updated.stripeCustomerId;
|
|
41
|
+
const [winner] = await db.select({ stripeCustomerId: schema.users.stripeCustomerId }).from(schema.users).where(eq(schema.users.id, userId)).limit(1);
|
|
42
|
+
return winner?.stripeCustomerId ?? customerId;
|
|
43
|
+
}
|
|
44
|
+
export async function getStripeCustomerUser(customerId) {
|
|
45
|
+
const [user] = await db.select().from(schema.users).where(eq(schema.users.stripeCustomerId, customerId)).limit(1);
|
|
46
|
+
return user ?? null;
|
|
47
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Nuxt } from '@nuxt/schema';
|
|
2
|
+
import type { RegisteredStripeEventHandler, StripeEventHandler, StripeEventType } from '../../types.js';
|
|
3
|
+
export type RegisterStripeEventHandlerOptions = RegisteredStripeEventHandler;
|
|
4
|
+
/**
|
|
5
|
+
* A typed identity helper for authoring a Stripe event handler file. The event is narrowed to the
|
|
6
|
+
* event type(s) given as the generic; the default export of the file is the handler.
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* export default defineStripeEventHandler<'customer.subscription.updated' | 'customer.subscription.deleted'>(
|
|
10
|
+
* async (event, { stripe, userId }) => {
|
|
11
|
+
* const subscription = event.data.object
|
|
12
|
+
* },
|
|
13
|
+
* )
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* Handlers must be idempotent: if any handler for an event throws, the webhook answers 5xx and
|
|
17
|
+
* Stripe retries the whole event.
|
|
18
|
+
*/
|
|
19
|
+
export declare function defineStripeEventHandler<T extends StripeEventType = StripeEventType>(handler: StripeEventHandler<T>): StripeEventHandler;
|
|
20
|
+
/**
|
|
21
|
+
* Build-time API for any module (or host app module) to subscribe to Stripe webhook events. Call it
|
|
22
|
+
* during `setup()`; registration order between modules doesn't matter, since handlers are only
|
|
23
|
+
* bundled once every module has finished setting up. `handler` must be an absolute path.
|
|
24
|
+
*
|
|
25
|
+
* ```ts
|
|
26
|
+
* registerStripeEventHandler(nuxt, {
|
|
27
|
+
* events: ['customer.subscription.updated', 'checkout.session.completed'],
|
|
28
|
+
* handler: resolver.resolve('./runtime/server/stripe/subscription-events'),
|
|
29
|
+
* })
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export declare function registerStripeEventHandler(nuxt: Nuxt, options: RegisterStripeEventHandlerOptions): void;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function defineStripeEventHandler(handler) {
|
|
2
|
+
return handler;
|
|
3
|
+
}
|
|
4
|
+
export function registerStripeEventHandler(nuxt, options) {
|
|
5
|
+
nuxt.options.stripe ||= {};
|
|
6
|
+
const handlers = Array.isArray(nuxt.options.stripe._handlers) ? nuxt.options.stripe._handlers : [];
|
|
7
|
+
nuxt.options.stripe._handlers = handlers;
|
|
8
|
+
const events = options.events === "*" ? "*" : [...options.events];
|
|
9
|
+
const exists = handlers.some(
|
|
10
|
+
(existing) => existing.handler === options.handler && JSON.stringify(existing.events) === JSON.stringify(events)
|
|
11
|
+
);
|
|
12
|
+
if (!exists) {
|
|
13
|
+
handlers.push({ events, handler: options.handler, order: options.order });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { registerStripeEventHandler, defineStripeEventHandler } from './handlers.js';
|
|
2
|
+
export type { RegisterStripeEventHandlerOptions } from './handlers.js';
|
|
3
|
+
export { onStripeEvent } from './registry.js';
|
|
4
|
+
export type { ModuleOptions, StripeEventType, StripeEventOf, StripeEventHandler, StripeEventHandlerContext, RegisteredStripeEventHandler, StripeRuntimeConfig, StripePublicRuntimeConfig, } from '../../types.js';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { StripeEventHandler, StripeEventType } from '../../types.js';
|
|
2
|
+
export type RuntimeStripeEventHandler = {
|
|
3
|
+
events: string[] | '*';
|
|
4
|
+
handler: StripeEventHandler;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Runtime counterpart of `registerStripeEventHandler`, for Nitro plugins in host apps:
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* // server/plugins/stripe.ts
|
|
11
|
+
* export default defineNitroPlugin(() => {
|
|
12
|
+
* onStripeEvent('invoice.payment_failed', (event, ctx) => { ... })
|
|
13
|
+
* })
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export declare function onStripeEvent<T extends StripeEventType>(type: T | T[] | '*', handler: StripeEventHandler<T>): void;
|
|
17
|
+
/** Handlers registered through `onStripeEvent`, in registration order. Internal: used by the webhook route. */
|
|
18
|
+
export declare function getRuntimeStripeEventHandlers(): readonly RuntimeStripeEventHandler[];
|
package/dist/types.d.mts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { type DEFAULT_WEBHOOK_PATH, type STRIPE_API_VERSION, default } from './module.mjs'
|
|
2
|
+
|
|
3
|
+
export { type ModuleOptions, type RegisteredStripeEventHandler, type StripeEventHandler, type StripeEventHandlerContext, type StripeEventOf, type StripeEventType, type StripePublicRuntimeConfig, type StripeRuntimeConfig } from './module.mjs'
|
package/package.json
CHANGED
|
@@ -1,6 +1,81 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frigear-nu/stripe",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.0.3",
|
|
4
|
+
"description": "Core Stripe plumbing for the frigear ecosystem: SDK access, customer linking, and a verified, idempotent webhook that other modules can subscribe to.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/frigear-nu/frigear-nu.git",
|
|
8
|
+
"directory": "packages/stripe"
|
|
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.2",
|
|
33
|
+
"@frigear-nu/users": "1.0.3",
|
|
34
|
+
"@libsql/client": "^0.18.0",
|
|
35
|
+
"@nuxt/kit": "^4.6.0",
|
|
36
|
+
"h3": "^1.15.11",
|
|
37
|
+
"drizzle-kit": "^0.31.11",
|
|
38
|
+
"drizzle-orm": "^0.45.3",
|
|
39
|
+
"stripe": "^23.0.0",
|
|
40
|
+
"unstorage": "^1.17.5",
|
|
41
|
+
"zod": "^4.6.5"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@nuxthub/core": "^0.10.8"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@nuxt/devtools": "^3.4.2",
|
|
48
|
+
"@nuxt/eslint-config": "^1.17.0",
|
|
49
|
+
"@nuxt/module-builder": "^1.0.3",
|
|
50
|
+
"@nuxt/schema": "^4.6.0",
|
|
51
|
+
"@nuxt/test-utils": "^4.3.2",
|
|
52
|
+
"@nuxthub/core": "^0.10.8",
|
|
53
|
+
"@types/node": "latest",
|
|
54
|
+
"changelogen": "^0.6.2",
|
|
55
|
+
"eslint": "^10.10.0",
|
|
56
|
+
"nuxt": "^4.6.0",
|
|
57
|
+
"typescript": "^6.0.3",
|
|
58
|
+
"vitest": "^5.0.1",
|
|
59
|
+
"vue-tsc": "^3.3.11"
|
|
60
|
+
},
|
|
61
|
+
"scripts": {
|
|
62
|
+
"postinstall": "nuxt-module-build build --stub && nuxt prepare",
|
|
63
|
+
"build": "nuxt-module-build build",
|
|
64
|
+
"dev": "npm run dev:prepare && nuxt dev playground",
|
|
65
|
+
"dev:build": "nuxt build playground",
|
|
66
|
+
"dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxt prepare playground",
|
|
67
|
+
"release": "npm run lint && npm run test && npm run prepack && changelogen --release && npm publish && git push --follow-tags",
|
|
68
|
+
"lint": "eslint .",
|
|
69
|
+
"lint:fix": "eslint . --fix",
|
|
70
|
+
"test": "vitest run",
|
|
71
|
+
"test:watch": "vitest watch",
|
|
72
|
+
"test:types": "vue-tsc --noEmit && cd playground && vue-tsc --noEmit"
|
|
73
|
+
},
|
|
74
|
+
"typesVersions": {
|
|
75
|
+
"*": {
|
|
76
|
+
".": [
|
|
77
|
+
"./dist/types.d.mts"
|
|
78
|
+
]
|
|
79
|
+
}
|
|
80
|
+
}
|
|
6
81
|
}
|