@forklaunch/implementation-billing-stripe 0.5.12 → 0.5.13

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.
@@ -102,6 +102,34 @@ export class StripeWebhookService<
102
102
  this.subscriptionService = subscriptionService;
103
103
  }
104
104
 
105
+ /**
106
+ * Extract features from Stripe product metadata.
107
+ * Features can be stored as:
108
+ * - metadata.features: comma-separated string (e.g., "feature1,feature2,feature3")
109
+ * - metadata.features: JSON array string (e.g., '["feature1","feature2"]')
110
+ */
111
+ private extractFeaturesFromProduct(product: Stripe.Product): string[] {
112
+ const featuresStr = product.metadata?.features;
113
+ if (!featuresStr) {
114
+ return [];
115
+ }
116
+
117
+ // Try parsing as JSON array first
118
+ try {
119
+ const parsed = JSON.parse(featuresStr);
120
+ if (Array.isArray(parsed)) {
121
+ return parsed.filter((f): f is string => typeof f === 'string');
122
+ }
123
+ } catch {
124
+ // Not JSON, treat as comma-separated
125
+ }
126
+
127
+ return featuresStr
128
+ .split(',')
129
+ .map((f) => f.trim())
130
+ .filter((f) => f.length > 0);
131
+ }
132
+
105
133
  async handleWebhookEvent(event: Stripe.Event): Promise<void> {
106
134
  if (this.openTelemetryCollector) {
107
135
  this.openTelemetryCollector.info('Handling webhook event', event);
@@ -185,22 +213,26 @@ export class StripeWebhookService<
185
213
 
186
214
  case 'plan.created': {
187
215
  if (
188
- typeof event.data.object.product === 'object' &&
189
216
  event.data.object.product != null &&
190
217
  event.data.object.amount != null
191
218
  ) {
219
+ const productId =
220
+ typeof event.data.object.product === 'string'
221
+ ? event.data.object.product
222
+ : event.data.object.product.id;
223
+ const product = await this.stripeClient.products.retrieve(productId);
224
+ const features = this.extractFeaturesFromProduct(product);
225
+
192
226
  await this.planService.basePlanService.createPlan({
193
227
  id: event.data.object.id,
194
228
  billingProvider: BillingProviderEnum.STRIPE,
195
229
  cadence: event.data.object.interval as PlanCadenceEnum,
196
230
  currency: event.data.object.currency as CurrencyEnum,
197
- active: true,
198
- name:
199
- typeof event.data.object.product === 'string'
200
- ? event.data.object.product
201
- : event.data.object.product?.id,
231
+ active: product.active,
232
+ name: product.name,
202
233
  price: event.data.object.amount,
203
- externalId: event.data.object.id
234
+ externalId: event.data.object.id,
235
+ features
204
236
  });
205
237
  } else {
206
238
  throw new Error('Invalid plan');
@@ -210,22 +242,26 @@ export class StripeWebhookService<
210
242
 
211
243
  case 'plan.updated': {
212
244
  if (
213
- typeof event.data.object.product === 'object' &&
214
245
  event.data.object.product != null &&
215
246
  event.data.object.amount != null
216
247
  ) {
248
+ const productId =
249
+ typeof event.data.object.product === 'string'
250
+ ? event.data.object.product
251
+ : event.data.object.product.id;
252
+ const product = await this.stripeClient.products.retrieve(productId);
253
+ const features = this.extractFeaturesFromProduct(product);
254
+
217
255
  await this.planService.basePlanService.updatePlan({
218
256
  id: event.data.object.id,
219
257
  billingProvider: BillingProviderEnum.STRIPE,
220
258
  cadence: event.data.object.interval as PlanCadenceEnum,
221
259
  currency: event.data.object.currency as CurrencyEnum,
222
- active: true,
223
- name:
224
- typeof event.data.object.product === 'string'
225
- ? event.data.object.product
226
- : event.data.object.product?.id,
260
+ active: product.active,
261
+ name: product.name,
227
262
  price: event.data.object.amount,
228
- externalId: event.data.object.id
263
+ externalId: event.data.object.id,
264
+ features
229
265
  });
230
266
  } else {
231
267
  throw new Error('Invalid plan');
@@ -240,6 +276,89 @@ export class StripeWebhookService<
240
276
  break;
241
277
  }
242
278
 
279
+ case 'product.created':
280
+ case 'product.updated': {
281
+ // When a product is created/updated, sync features to all associated plans
282
+ const product = event.data.object;
283
+ const features = this.extractFeaturesFromProduct(product);
284
+
285
+ // Update all legacy plans (iterates through all pages)
286
+ await this.stripeClient.plans
287
+ .list({ product: product.id })
288
+ .autoPagingEach(async (plan) => {
289
+ try {
290
+ await this.planService.basePlanService.updatePlan({
291
+ id: plan.id,
292
+ features,
293
+ active: product.active,
294
+ name: product.name
295
+ });
296
+ } catch (error) {
297
+ this.openTelemetryCollector.warn(
298
+ `Failed to update plan ${plan.id} with product features`,
299
+ error
300
+ );
301
+ }
302
+ });
303
+
304
+ // Update all price-based plans (iterates through all pages)
305
+ await this.stripeClient.prices
306
+ .list({ product: product.id })
307
+ .autoPagingEach(async (price) => {
308
+ try {
309
+ await this.planService.basePlanService.updatePlan({
310
+ id: price.id,
311
+ features,
312
+ active: price.active && product.active,
313
+ name: product.name
314
+ });
315
+ } catch (error) {
316
+ this.openTelemetryCollector.warn(
317
+ `Failed to update price-based plan ${price.id} with product features`,
318
+ error
319
+ );
320
+ }
321
+ });
322
+ break;
323
+ }
324
+
325
+ // Handle Stripe Prices API (newer alternative to Plans)
326
+ case 'price.created':
327
+ case 'price.updated': {
328
+ const price = event.data.object;
329
+ if (
330
+ price.product != null &&
331
+ price.unit_amount != null &&
332
+ price.recurring
333
+ ) {
334
+ const productId =
335
+ typeof price.product === 'string'
336
+ ? price.product
337
+ : price.product.id;
338
+ const product = await this.stripeClient.products.retrieve(productId);
339
+ const features = this.extractFeaturesFromProduct(product);
340
+
341
+ const planData = {
342
+ id: price.id,
343
+ billingProvider: BillingProviderEnum.STRIPE,
344
+ cadence: price.recurring.interval as PlanCadenceEnum,
345
+ currency: price.currency as CurrencyEnum,
346
+ active: price.active && product.active,
347
+ name: product.name,
348
+ price: price.unit_amount,
349
+ externalId: price.id,
350
+ features
351
+ };
352
+
353
+ if (event.type === 'price.created') {
354
+ await this.planService.basePlanService.createPlan(planData);
355
+ } else {
356
+ await this.planService.basePlanService.updatePlan(planData);
357
+ }
358
+ }
359
+ break;
360
+ }
361
+
243
362
  case 'customer.subscription.created': {
244
363
  if (
245
364
  !event.data.object.items?.data ||
@@ -168,6 +168,13 @@ declare class StripeWebhookService<SchemaValidator extends AnySchemaValidator, S
168
168
  protected readonly planService: StripePlanService<SchemaValidator, PlanEntities>;
169
169
  protected readonly subscriptionService: StripeSubscriptionService<SchemaValidator, PartyEnum, SubscriptionEntities>;
170
170
  constructor(stripeClient: stripe__default, em: EntityManager, schemaValidator: SchemaValidator, openTelemetryCollector: OpenTelemetryCollector<MetricsDefinition>, billingPortalService: StripeBillingPortalService<SchemaValidator, BillingPortalEntities>, checkoutSessionService: StripeCheckoutSessionService<SchemaValidator, StatusEnum, CheckoutSessionEntities>, paymentLinkService: StripePaymentLinkService<SchemaValidator, StatusEnum, PaymentLinkEntities>, planService: StripePlanService<SchemaValidator, PlanEntities>, subscriptionService: StripeSubscriptionService<SchemaValidator, PartyEnum, SubscriptionEntities>);
171
+ /**
172
+ * Extract features from Stripe product metadata.
173
+ * Features can be stored as:
174
+ * - metadata.features: comma-separated string (e.g., "feature1,feature2,feature3")
175
+ * - metadata.features: JSON array string (e.g., '["feature1","feature2"]')
176
+ */
177
+ private extractFeaturesFromProduct;
171
178
  handleWebhookEvent(event: stripe__default.Event): Promise<void>;
172
179
  }
173
180
 
@@ -168,6 +168,13 @@ declare class StripeWebhookService<SchemaValidator extends AnySchemaValidator, S
168
168
  protected readonly planService: StripePlanService<SchemaValidator, PlanEntities>;
169
169
  protected readonly subscriptionService: StripeSubscriptionService<SchemaValidator, PartyEnum, SubscriptionEntities>;
170
170
  constructor(stripeClient: stripe__default, em: EntityManager, schemaValidator: SchemaValidator, openTelemetryCollector: OpenTelemetryCollector<MetricsDefinition>, billingPortalService: StripeBillingPortalService<SchemaValidator, BillingPortalEntities>, checkoutSessionService: StripeCheckoutSessionService<SchemaValidator, StatusEnum, CheckoutSessionEntities>, paymentLinkService: StripePaymentLinkService<SchemaValidator, StatusEnum, PaymentLinkEntities>, planService: StripePlanService<SchemaValidator, PlanEntities>, subscriptionService: StripeSubscriptionService<SchemaValidator, PartyEnum, SubscriptionEntities>);
171
+ /**
172
+ * Extract features from Stripe product metadata.
173
+ * Features can be stored as:
174
+ * - metadata.features: comma-separated string (e.g., "feature1,feature2,feature3")
175
+ * - metadata.features: JSON array string (e.g., '["feature1","feature2"]')
176
+ */
177
+ private extractFeaturesFromProduct;
171
178
  handleWebhookEvent(event: stripe__default.Event): Promise<void>;
172
179
  }
173
180
 
@@ -646,6 +646,26 @@ var StripeWebhookService = class {
646
646
  this.planService = planService;
647
647
  this.subscriptionService = subscriptionService;
648
648
  }
649
+ /**
650
+ * Extract features from Stripe product metadata.
651
+ * Features can be stored as:
652
+ * - metadata.features: comma-separated string (e.g., "feature1,feature2,feature3")
653
+ * - metadata.features: JSON array string (e.g., '["feature1","feature2"]')
654
+ */
655
+ extractFeaturesFromProduct(product) {
656
+ const featuresStr = product.metadata?.features;
657
+ if (!featuresStr) {
658
+ return [];
659
+ }
660
+ try {
661
+ const parsed = JSON.parse(featuresStr);
662
+ if (Array.isArray(parsed)) {
663
+ return parsed.filter((f) => typeof f === "string");
664
+ }
665
+ } catch {
666
+ }
667
+ return featuresStr.split(",").map((f) => f.trim()).filter((f) => f.length > 0);
668
+ }
649
669
  async handleWebhookEvent(event) {
650
670
  if (this.openTelemetryCollector) {
651
671
  this.openTelemetryCollector.info("Handling webhook event", event);
@@ -714,16 +734,20 @@ var StripeWebhookService = class {
714
734
  break;
715
735
  }
716
736
  case "plan.created": {
717
- if (typeof event.data.object.product === "object" && event.data.object.product != null && event.data.object.amount != null) {
737
+ if (event.data.object.product != null && event.data.object.amount != null) {
738
+ const productId = typeof event.data.object.product === "string" ? event.data.object.product : event.data.object.product.id;
739
+ const product = await this.stripeClient.products.retrieve(productId);
740
+ const features = this.extractFeaturesFromProduct(product);
718
741
  await this.planService.basePlanService.createPlan({
719
742
  id: event.data.object.id,
720
743
  billingProvider: BillingProviderEnum.STRIPE,
721
744
  cadence: event.data.object.interval,
722
745
  currency: event.data.object.currency,
723
- active: true,
724
- name: typeof event.data.object.product === "string" ? event.data.object.product : event.data.object.product?.id,
746
+ active: product.active,
747
+ name: product.name,
725
748
  price: event.data.object.amount,
726
- externalId: event.data.object.id
749
+ externalId: event.data.object.id,
750
+ features
727
751
  });
728
752
  } else {
729
753
  throw new Error("Invalid plan");
@@ -731,16 +755,20 @@ var StripeWebhookService = class {
731
755
  break;
732
756
  }
733
757
  case "plan.updated": {
734
- if (typeof event.data.object.product === "object" && event.data.object.product != null && event.data.object.amount != null) {
758
+ if (event.data.object.product != null && event.data.object.amount != null) {
759
+ const productId = typeof event.data.object.product === "string" ? event.data.object.product : event.data.object.product.id;
760
+ const product = await this.stripeClient.products.retrieve(productId);
761
+ const features = this.extractFeaturesFromProduct(product);
735
762
  await this.planService.basePlanService.updatePlan({
736
763
  id: event.data.object.id,
737
764
  billingProvider: BillingProviderEnum.STRIPE,
738
765
  cadence: event.data.object.interval,
739
766
  currency: event.data.object.currency,
740
- active: true,
741
- name: typeof event.data.object.product === "string" ? event.data.object.product : event.data.object.product?.id,
767
+ active: product.active,
768
+ name: product.name,
742
769
  price: event.data.object.amount,
743
- externalId: event.data.object.id
770
+ externalId: event.data.object.id,
771
+ features
744
772
  });
745
773
  } else {
746
774
  throw new Error("Invalid plan");
@@ -753,6 +781,69 @@ var StripeWebhookService = class {
753
781
  });
754
782
  break;
755
783
  }
784
+ case "product.created":
785
+ case "product.updated": {
786
+ const product = event.data.object;
787
+ const features = this.extractFeaturesFromProduct(product);
788
+ await this.stripeClient.plans.list({ product: product.id }).autoPagingEach(async (plan) => {
789
+ try {
790
+ await this.planService.basePlanService.updatePlan({
791
+ id: plan.id,
792
+ features,
793
+ active: product.active,
794
+ name: product.name
795
+ });
796
+ } catch (error) {
797
+ this.openTelemetryCollector.warn(
798
+ `Failed to update plan ${plan.id} with product features`,
799
+ error
800
+ );
801
+ }
802
+ });
803
+ await this.stripeClient.prices.list({ product: product.id }).autoPagingEach(async (price) => {
804
+ try {
805
+ await this.planService.basePlanService.updatePlan({
806
+ id: price.id,
807
+ features,
808
+ active: price.active && product.active,
809
+ name: product.name
810
+ });
811
+ } catch (error) {
812
+ this.openTelemetryCollector.warn(
813
+ `Failed to update price-based plan ${price.id} with product features`,
814
+ error
815
+ );
816
+ }
817
+ });
818
+ break;
819
+ }
820
+ // Handle Stripe Prices API (newer alternative to Plans)
821
+ case "price.created":
822
+ case "price.updated": {
823
+ const price = event.data.object;
824
+ if (price.product != null && price.unit_amount != null && price.recurring) {
825
+ const productId = typeof price.product === "string" ? price.product : price.product.id;
826
+ const product = await this.stripeClient.products.retrieve(productId);
827
+ const features = this.extractFeaturesFromProduct(product);
828
+ const planData = {
829
+ id: price.id,
830
+ billingProvider: BillingProviderEnum.STRIPE,
831
+ cadence: price.recurring.interval,
832
+ currency: price.currency,
833
+ active: price.active && product.active,
834
+ name: product.name,
835
+ price: price.unit_amount,
836
+ externalId: price.id,
837
+ features
838
+ };
839
+ if (event.type === "price.created") {
840
+ await this.planService.basePlanService.createPlan(planData);
841
+ } else {
842
+ await this.planService.basePlanService.updatePlan(planData);
843
+ }
844
+ }
845
+ break;
846
+ }
756
847
  case "customer.subscription.created": {
757
848
  if (!event.data.object.items?.data || event.data.object.items.data.length === 0 || !event.data.object.items.data[0]?.plan?.id) {
758
849
  throw new Error(
@@ -614,6 +614,26 @@ var StripeWebhookService = class {
614
614
  this.planService = planService;
615
615
  this.subscriptionService = subscriptionService;
616
616
  }
617
+ /**
618
+ * Extract features from Stripe product metadata.
619
+ * Features can be stored as:
620
+ * - metadata.features: comma-separated string (e.g., "feature1,feature2,feature3")
621
+ * - metadata.features: JSON array string (e.g., '["feature1","feature2"]')
622
+ */
623
+ extractFeaturesFromProduct(product) {
624
+ const featuresStr = product.metadata?.features;
625
+ if (!featuresStr) {
626
+ return [];
627
+ }
628
+ try {
629
+ const parsed = JSON.parse(featuresStr);
630
+ if (Array.isArray(parsed)) {
631
+ return parsed.filter((f) => typeof f === "string");
632
+ }
633
+ } catch {
634
+ }
635
+ return featuresStr.split(",").map((f) => f.trim()).filter((f) => f.length > 0);
636
+ }
617
637
  async handleWebhookEvent(event) {
618
638
  if (this.openTelemetryCollector) {
619
639
  this.openTelemetryCollector.info("Handling webhook event", event);
@@ -682,16 +702,20 @@ var StripeWebhookService = class {
682
702
  break;
683
703
  }
684
704
  case "plan.created": {
685
- if (typeof event.data.object.product === "object" && event.data.object.product != null && event.data.object.amount != null) {
705
+ if (event.data.object.product != null && event.data.object.amount != null) {
706
+ const productId = typeof event.data.object.product === "string" ? event.data.object.product : event.data.object.product.id;
707
+ const product = await this.stripeClient.products.retrieve(productId);
708
+ const features = this.extractFeaturesFromProduct(product);
686
709
  await this.planService.basePlanService.createPlan({
687
710
  id: event.data.object.id,
688
711
  billingProvider: BillingProviderEnum.STRIPE,
689
712
  cadence: event.data.object.interval,
690
713
  currency: event.data.object.currency,
691
- active: true,
692
- name: typeof event.data.object.product === "string" ? event.data.object.product : event.data.object.product?.id,
714
+ active: product.active,
715
+ name: product.name,
693
716
  price: event.data.object.amount,
694
- externalId: event.data.object.id
717
+ externalId: event.data.object.id,
718
+ features
695
719
  });
696
720
  } else {
697
721
  throw new Error("Invalid plan");
@@ -699,16 +723,20 @@ var StripeWebhookService = class {
699
723
  break;
700
724
  }
701
725
  case "plan.updated": {
702
- if (typeof event.data.object.product === "object" && event.data.object.product != null && event.data.object.amount != null) {
726
+ if (event.data.object.product != null && event.data.object.amount != null) {
727
+ const productId = typeof event.data.object.product === "string" ? event.data.object.product : event.data.object.product.id;
728
+ const product = await this.stripeClient.products.retrieve(productId);
729
+ const features = this.extractFeaturesFromProduct(product);
703
730
  await this.planService.basePlanService.updatePlan({
704
731
  id: event.data.object.id,
705
732
  billingProvider: BillingProviderEnum.STRIPE,
706
733
  cadence: event.data.object.interval,
707
734
  currency: event.data.object.currency,
708
- active: true,
709
- name: typeof event.data.object.product === "string" ? event.data.object.product : event.data.object.product?.id,
735
+ active: product.active,
736
+ name: product.name,
710
737
  price: event.data.object.amount,
711
- externalId: event.data.object.id
738
+ externalId: event.data.object.id,
739
+ features
712
740
  });
713
741
  } else {
714
742
  throw new Error("Invalid plan");
@@ -721,6 +749,69 @@ var StripeWebhookService = class {
721
749
  });
722
750
  break;
723
751
  }
752
+ case "product.created":
753
+ case "product.updated": {
754
+ const product = event.data.object;
755
+ const features = this.extractFeaturesFromProduct(product);
756
+ await this.stripeClient.plans.list({ product: product.id }).autoPagingEach(async (plan) => {
757
+ try {
758
+ await this.planService.basePlanService.updatePlan({
759
+ id: plan.id,
760
+ features,
761
+ active: product.active,
762
+ name: product.name
763
+ });
764
+ } catch (error) {
765
+ this.openTelemetryCollector.warn(
766
+ `Failed to update plan ${plan.id} with product features`,
767
+ error
768
+ );
769
+ }
770
+ });
771
+ await this.stripeClient.prices.list({ product: product.id }).autoPagingEach(async (price) => {
772
+ try {
773
+ await this.planService.basePlanService.updatePlan({
774
+ id: price.id,
775
+ features,
776
+ active: price.active && product.active,
777
+ name: product.name
778
+ });
779
+ } catch (error) {
780
+ this.openTelemetryCollector.warn(
781
+ `Failed to update price-based plan ${price.id} with product features`,
782
+ error
783
+ );
784
+ }
785
+ });
786
+ break;
787
+ }
788
+ // Handle Stripe Prices API (newer alternative to Plans)
789
+ case "price.created":
790
+ case "price.updated": {
791
+ const price = event.data.object;
792
+ if (price.product != null && price.unit_amount != null && price.recurring) {
793
+ const productId = typeof price.product === "string" ? price.product : price.product.id;
794
+ const product = await this.stripeClient.products.retrieve(productId);
795
+ const features = this.extractFeaturesFromProduct(product);
796
+ const planData = {
797
+ id: price.id,
798
+ billingProvider: BillingProviderEnum.STRIPE,
799
+ cadence: price.recurring.interval,
800
+ currency: price.currency,
801
+ active: price.active && product.active,
802
+ name: product.name,
803
+ price: price.unit_amount,
804
+ externalId: price.id,
805
+ features
806
+ };
807
+ if (event.type === "price.created") {
808
+ await this.planService.basePlanService.createPlan(planData);
809
+ } else {
810
+ await this.planService.basePlanService.updatePlan(planData);
811
+ }
812
+ }
813
+ break;
814
+ }
724
815
  case "customer.subscription.created": {
725
816
  if (!event.data.object.items?.data || event.data.object.items.data.length === 0 || !event.data.object.items.data[0]?.plan?.id) {
726
817
  throw new Error(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forklaunch/implementation-billing-stripe",
3
- "version": "0.5.12",
3
+ "version": "0.5.13",
4
4
  "description": "Stripe implementation for forklaunch billing",
5
5
  "homepage": "https://github.com/forklaunch/forklaunch-js#readme",
6
6
  "bugs": {
@@ -42,23 +42,23 @@
42
42
  "lib/**"
43
43
  ],
44
44
  "dependencies": {
45
- "@forklaunch/common": "^0.6.28",
46
- "@forklaunch/core": "^0.18.1",
47
- "@forklaunch/internal": "^0.3.28",
48
- "@forklaunch/validator": "^0.10.28",
49
- "@mikro-orm/core": "^6.6.6",
45
+ "@forklaunch/common": "^0.6.29",
46
+ "@forklaunch/core": "^0.18.2",
47
+ "@forklaunch/internal": "^0.3.29",
48
+ "@forklaunch/validator": "^0.10.29",
49
+ "@mikro-orm/core": "^6.6.7",
50
50
  "@sinclair/typebox": "^0.34.48",
51
- "ajv": "^8.17.1",
52
- "stripe": "^20.3.0",
51
+ "ajv": "^8.18.0",
52
+ "stripe": "^20.3.1",
53
53
  "zod": "^4.3.6",
54
- "@forklaunch/implementation-billing-base": "0.8.12",
55
- "@forklaunch/interfaces-billing": "0.8.12"
54
+ "@forklaunch/implementation-billing-base": "0.8.13",
55
+ "@forklaunch/interfaces-billing": "0.8.13"
56
56
  },
57
57
  "devDependencies": {
58
- "@typescript/native-preview": "7.0.0-dev.20260204.1",
58
+ "@typescript/native-preview": "7.0.0-dev.20260223.1",
59
59
  "depcheck": "^1.4.7",
60
60
  "prettier": "^3.8.1",
61
- "typedoc": "^0.28.16"
61
+ "typedoc": "^0.28.17"
62
62
  },
63
63
  "scripts": {
64
64
  "build": "tsgo --noEmit && tsup domain/schemas/index.ts services/index.ts domain/enum/index.ts domain/types/index.ts --format cjs,esm --no-splitting --dts --tsconfig tsconfig.json --out-dir lib --clean && if [ -f eject-package.bash ]; then pnpm package:eject; fi",