@strav/stripe 0.1.0

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/src/types.ts ADDED
@@ -0,0 +1,97 @@
1
+ import type Stripe from 'stripe'
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Config
5
+ // ---------------------------------------------------------------------------
6
+
7
+ export interface StripeConfig {
8
+ /** Stripe secret key. */
9
+ secret: string
10
+ /** Stripe publishable key (passed to frontend). */
11
+ key: string
12
+ /** Stripe webhook signing secret. */
13
+ webhookSecret: string
14
+ /** Default currency code (lowercase). */
15
+ currency: string
16
+ /** The user model's primary key property name (e.g. 'id', 'uid'). */
17
+ userKey: string
18
+ /** URL prefix for Checkout success/cancel. */
19
+ urls: {
20
+ success: string
21
+ cancel: string
22
+ }
23
+ }
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Data Records
27
+ // ---------------------------------------------------------------------------
28
+
29
+ export interface CustomerData {
30
+ id: number
31
+ userId: string | number
32
+ stripeId: string
33
+ pmType: string | null
34
+ pmLastFour: string | null
35
+ trialEndsAt: Date | null
36
+ createdAt: Date
37
+ updatedAt: Date
38
+ }
39
+
40
+ export interface SubscriptionData {
41
+ id: number
42
+ userId: string | number
43
+ name: string
44
+ stripeId: string
45
+ stripeStatus: string
46
+ stripePriceId: string | null
47
+ quantity: number | null
48
+ trialEndsAt: Date | null
49
+ endsAt: Date | null
50
+ createdAt: Date
51
+ updatedAt: Date
52
+ }
53
+
54
+ export interface SubscriptionItemData {
55
+ id: number
56
+ subscriptionId: number
57
+ stripeId: string
58
+ stripeProductId: string
59
+ stripePriceId: string
60
+ quantity: number | null
61
+ createdAt: Date
62
+ updatedAt: Date
63
+ }
64
+
65
+ export interface ReceiptData {
66
+ id: number
67
+ userId: string | number
68
+ stripeId: string
69
+ amount: number
70
+ currency: string
71
+ description: string | null
72
+ receiptUrl: string | null
73
+ createdAt: Date
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Subscription Status
78
+ // ---------------------------------------------------------------------------
79
+
80
+ export const SubscriptionStatus = {
81
+ Active: 'active',
82
+ Canceled: 'canceled',
83
+ Incomplete: 'incomplete',
84
+ IncompleteExpired: 'incomplete_expired',
85
+ PastDue: 'past_due',
86
+ Paused: 'paused',
87
+ Trialing: 'trialing',
88
+ Unpaid: 'unpaid',
89
+ } as const
90
+
91
+ export type SubscriptionStatusValue = (typeof SubscriptionStatus)[keyof typeof SubscriptionStatus]
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Webhook
95
+ // ---------------------------------------------------------------------------
96
+
97
+ export type WebhookEventHandler = (event: Stripe.Event) => void | Promise<void>
package/src/webhook.ts ADDED
@@ -0,0 +1,171 @@
1
+ import type Stripe from 'stripe'
2
+ import type { Context, Handler } from '@stravigor/http'
3
+ import StripeManager from './stripe_manager.ts'
4
+ import Customer from './customer.ts'
5
+ import Subscription from './subscription.ts'
6
+ import SubscriptionItem from './subscription_item.ts'
7
+ import { WebhookSignatureError } from './errors.ts'
8
+ import type { WebhookEventHandler } from './types.ts'
9
+
10
+ /** Registry of custom webhook event handlers. */
11
+ const customHandlers = new Map<string, WebhookEventHandler[]>()
12
+
13
+ /**
14
+ * Register a custom handler for a Stripe webhook event type.
15
+ *
16
+ * @example
17
+ * import { onWebhookEvent } from '@stravigor/stripe/webhook'
18
+ *
19
+ * onWebhookEvent('invoice.payment_failed', async (event) => {
20
+ * const invoice = event.data.object as Stripe.Invoice
21
+ * // Send notification to user...
22
+ * })
23
+ */
24
+ export function onWebhookEvent(eventType: string, handler: WebhookEventHandler): void {
25
+ const handlers = customHandlers.get(eventType) ?? []
26
+ handlers.push(handler)
27
+ customHandlers.set(eventType, handlers)
28
+ }
29
+
30
+ /**
31
+ * Create a route handler for Stripe webhooks.
32
+ *
33
+ * Verifies the Stripe signature, dispatches built-in handlers to keep
34
+ * local DB in sync, then calls any custom handlers registered via
35
+ * `onWebhookEvent()`.
36
+ *
37
+ * @example
38
+ * import { stripeWebhook } from '@stravigor/stripe/webhook'
39
+ * router.post('/stripe/webhook', stripeWebhook())
40
+ */
41
+ export function stripeWebhook(): Handler {
42
+ return async (ctx: Context): Promise<Response> => {
43
+ const signature = ctx.header('stripe-signature')
44
+ if (!signature) {
45
+ return ctx.json({ error: 'Missing stripe-signature header' }, 400)
46
+ }
47
+
48
+ const rawBody = await ctx.request.text()
49
+ const webhookSecret = StripeManager.config.webhookSecret
50
+
51
+ let event: Stripe.Event
52
+ try {
53
+ event = await StripeManager.stripe.webhooks.constructEventAsync(
54
+ rawBody,
55
+ signature,
56
+ webhookSecret
57
+ )
58
+ } catch {
59
+ throw new WebhookSignatureError()
60
+ }
61
+
62
+ // Dispatch built-in handlers
63
+ await handleBuiltinEvent(event)
64
+
65
+ // Dispatch custom handlers
66
+ const handlers = customHandlers.get(event.type) ?? []
67
+ for (const handler of handlers) {
68
+ await handler(event)
69
+ }
70
+
71
+ return ctx.json({ received: true }, 200)
72
+ }
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Built-in event handling: keeps local DB in sync with Stripe
77
+ // ---------------------------------------------------------------------------
78
+
79
+ async function handleBuiltinEvent(event: Stripe.Event): Promise<void> {
80
+ switch (event.type) {
81
+ // ---- Customer Events ----
82
+
83
+ case 'customer.updated': {
84
+ const stripeCustomer = event.data.object as Stripe.Customer
85
+ const defaultPm = stripeCustomer.invoice_settings?.default_payment_method
86
+ if (defaultPm && typeof defaultPm !== 'string') {
87
+ await Customer.updateDefaultPaymentMethod(stripeCustomer.id, defaultPm)
88
+ }
89
+ break
90
+ }
91
+
92
+ case 'customer.deleted': {
93
+ const stripeCustomer = event.data.object as Stripe.Customer
94
+ const customer = await Customer.findByStripeId(stripeCustomer.id)
95
+ if (customer) {
96
+ // Clean up all local subscription records
97
+ const subs = await Subscription.findByUser(customer.userId)
98
+ for (const sub of subs) {
99
+ await Subscription.delete(sub.id)
100
+ }
101
+ await Customer.deleteByStripeId(stripeCustomer.id)
102
+ }
103
+ break
104
+ }
105
+
106
+ // ---- Subscription Events ----
107
+
108
+ case 'customer.subscription.created': {
109
+ const stripeSub = event.data.object as Stripe.Subscription
110
+ const customerId =
111
+ typeof stripeSub.customer === 'string' ? stripeSub.customer : stripeSub.customer.id
112
+ const customer = await Customer.findByStripeId(customerId)
113
+
114
+ if (customer) {
115
+ const existing = await Subscription.findByStripeId(stripeSub.id)
116
+ if (!existing) {
117
+ const name = stripeSub.metadata?.strav_name ?? 'default'
118
+ const localSub = await Subscription.create({
119
+ user: customer.userId,
120
+ name,
121
+ stripeId: stripeSub.id,
122
+ stripeStatus: stripeSub.status,
123
+ stripePriceId: stripeSub.items.data[0]?.price.id ?? null,
124
+ quantity: stripeSub.items.data[0]?.quantity ?? null,
125
+ trialEndsAt: stripeSub.trial_end ? new Date(stripeSub.trial_end * 1000) : null,
126
+ })
127
+ await SubscriptionItem.syncFromStripe(localSub, localSub.id)
128
+ }
129
+ }
130
+ break
131
+ }
132
+
133
+ case 'customer.subscription.updated': {
134
+ const stripeSub = event.data.object as Stripe.Subscription
135
+
136
+ const endsAt = stripeSub.cancel_at
137
+ ? new Date(stripeSub.cancel_at * 1000)
138
+ : stripeSub.canceled_at
139
+ ? new Date(stripeSub.current_period_end * 1000)
140
+ : null
141
+
142
+ await Subscription.syncStripeStatus(stripeSub.id, stripeSub.status, endsAt)
143
+
144
+ // Sync items and metadata
145
+ const localSub = await Subscription.findByStripeId(stripeSub.id)
146
+ if (localSub) {
147
+ await SubscriptionItem.syncFromStripe(localSub, localSub.id)
148
+
149
+ // Update price_id, quantity, and trial from first item
150
+ const firstItem = stripeSub.items.data[0]
151
+ if (firstItem) {
152
+ await StripeManager.db.sql`
153
+ UPDATE "subscription"
154
+ SET "stripe_price_id" = ${firstItem.price.id},
155
+ "quantity" = ${firstItem.quantity ?? null},
156
+ "trial_ends_at" = ${stripeSub.trial_end ? new Date(stripeSub.trial_end * 1000) : null},
157
+ "updated_at" = NOW()
158
+ WHERE "stripe_id" = ${stripeSub.id}
159
+ `
160
+ }
161
+ }
162
+ break
163
+ }
164
+
165
+ case 'customer.subscription.deleted': {
166
+ const stripeSub = event.data.object as Stripe.Subscription
167
+ await Subscription.syncStripeStatus(stripeSub.id, 'canceled', new Date())
168
+ break
169
+ }
170
+ }
171
+ }
@@ -0,0 +1,27 @@
1
+ import { env } from '@stravigor/kernel/helpers'
2
+
3
+ export default {
4
+ /** Stripe secret key. */
5
+ secret: env('STRIPE_SECRET', ''),
6
+
7
+ /** Stripe publishable key. */
8
+ key: env('STRIPE_KEY', ''),
9
+
10
+ /** Stripe webhook signing secret. */
11
+ webhookSecret: env('STRIPE_WEBHOOK_SECRET', ''),
12
+
13
+ /** Default currency for charges. */
14
+ currency: 'usd',
15
+
16
+ /**
17
+ * The user model's primary key property (determines FK column name).
18
+ * 'id' → user_id, 'uid' → user_uid, etc.
19
+ */
20
+ userKey: 'id',
21
+
22
+ /** URLs for Stripe Checkout success/cancel redirects. */
23
+ urls: {
24
+ success: env('APP_URL', 'http://localhost:3000') + '/billing/success',
25
+ cancel: env('APP_URL', 'http://localhost:3000') + '/billing/cancel',
26
+ },
27
+ }
@@ -0,0 +1,12 @@
1
+ import { defineSchema, t, Archetype } from '@stravigor/database/schema'
2
+
3
+ export default defineSchema('customer', {
4
+ archetype: Archetype.Component,
5
+ parents: ['user'],
6
+ fields: {
7
+ stripeId: t.varchar(255).required().unique().index(),
8
+ pmType: t.varchar(50).nullable(),
9
+ pmLastFour: t.varchar(4).nullable(),
10
+ trialEndsAt: t.timestamptz().nullable(),
11
+ },
12
+ })
@@ -0,0 +1,13 @@
1
+ import { defineSchema, t, Archetype } from '@stravigor/database/schema'
2
+
3
+ export default defineSchema('receipt', {
4
+ archetype: Archetype.Component,
5
+ parents: ['user'],
6
+ fields: {
7
+ stripeId: t.varchar(255).required().unique().index(),
8
+ amount: t.integer().required(),
9
+ currency: t.varchar(3).required(),
10
+ description: t.text().nullable(),
11
+ receiptUrl: t.text().nullable(),
12
+ },
13
+ })
@@ -0,0 +1,15 @@
1
+ import { defineSchema, t, Archetype } from '@stravigor/database/schema'
2
+
3
+ export default defineSchema('subscription', {
4
+ archetype: Archetype.Component,
5
+ parents: ['user'],
6
+ fields: {
7
+ name: t.varchar(255).required().index(),
8
+ stripeId: t.varchar(255).required().unique().index(),
9
+ stripeStatus: t.varchar(50).required(),
10
+ stripePriceId: t.varchar(255).nullable(),
11
+ quantity: t.integer().nullable(),
12
+ trialEndsAt: t.timestamptz().nullable(),
13
+ endsAt: t.timestamptz().nullable(),
14
+ },
15
+ })
@@ -0,0 +1,12 @@
1
+ import { defineSchema, t, Archetype } from '@stravigor/database/schema'
2
+
3
+ export default defineSchema('subscription_item', {
4
+ archetype: Archetype.Component,
5
+ parents: ['subscription'],
6
+ fields: {
7
+ stripeId: t.varchar(255).required().unique().index(),
8
+ stripeProductId: t.varchar(255).required().index(),
9
+ stripePriceId: t.varchar(255).required().index(),
10
+ quantity: t.integer().nullable(),
11
+ },
12
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "include": ["src/**/*.ts"],
4
+ "exclude": ["node_modules", "tests"]
5
+ }