@forklaunch/implementation-billing-stripe 1.2.12 → 1.3.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.
Files changed (60) hide show
  1. package/lib/domain/schemas/billingPortal.schema.d.ts +2 -2
  2. package/lib/domain/schemas/index.js +27 -14
  3. package/lib/domain/schemas/index.mjs +33 -14
  4. package/lib/domain/schemas/plan.schema.d.ts +2 -2
  5. package/lib/domain/schemas/typebox/billingPortal.schema.d.ts +2 -2
  6. package/lib/domain/schemas/typebox/checkoutSession.schema.d.ts +3 -3
  7. package/lib/domain/schemas/typebox/checkoutSession.schema.d.ts.map +1 -1
  8. package/lib/domain/schemas/typebox/paymentLink.schema.d.ts +2 -1
  9. package/lib/domain/schemas/typebox/paymentLink.schema.d.ts.map +1 -1
  10. package/lib/domain/schemas/typebox/plan.schema.d.ts +2 -2
  11. package/lib/domain/schemas/typebox/subscription.schema.d.ts +1 -1
  12. package/lib/domain/schemas/zod/billingPortal.schema.d.ts +2 -2
  13. package/lib/domain/schemas/zod/checkoutSession.schema.d.ts +3 -3
  14. package/lib/domain/schemas/zod/checkoutSession.schema.d.ts.map +1 -1
  15. package/lib/domain/schemas/zod/paymentLink.schema.d.ts.map +1 -1
  16. package/lib/domain/schemas/zod/plan.schema.d.ts +2 -2
  17. package/lib/domain/schemas/zod/subscription.schema.d.ts +1 -1
  18. package/lib/domain/schemas/zod/subscription.schema.d.ts.map +1 -1
  19. package/lib/domain/types/billingPortal.mapper.types.d.ts +8 -0
  20. package/lib/domain/types/billingPortal.mapper.types.d.ts.map +1 -1
  21. package/lib/domain/types/checkoutSession.mapper.types.d.ts +6 -0
  22. package/lib/domain/types/checkoutSession.mapper.types.d.ts.map +1 -1
  23. package/lib/domain/types/paymentLink.mapper.types.d.ts +20 -0
  24. package/lib/domain/types/paymentLink.mapper.types.d.ts.map +1 -1
  25. package/lib/domain/types/plan.mapper.types.d.ts +10 -0
  26. package/lib/domain/types/plan.mapper.types.d.ts.map +1 -1
  27. package/lib/domain/types/stripe.dto.types.d.ts +6 -8
  28. package/lib/domain/types/stripe.dto.types.d.ts.map +1 -1
  29. package/lib/domain/types/stripe.entity.types.d.ts.map +1 -1
  30. package/lib/domain/types/subscription.mapper.types.d.ts +15 -0
  31. package/lib/domain/types/subscription.mapper.types.d.ts.map +1 -1
  32. package/lib/eject/domain/schemas/billingPortal.schema.ts +1 -1
  33. package/lib/eject/domain/schemas/checkoutSession.schema.ts +5 -3
  34. package/lib/eject/domain/schemas/paymentLink.schema.ts +12 -1
  35. package/lib/eject/domain/schemas/plan.schema.ts +1 -1
  36. package/lib/eject/domain/schemas/subscription.schema.ts +2 -1
  37. package/lib/eject/domain/types/billingPortal.mapper.types.ts +17 -0
  38. package/lib/eject/domain/types/checkoutSession.mapper.types.ts +9 -0
  39. package/lib/eject/domain/types/paymentLink.mapper.types.ts +36 -0
  40. package/lib/eject/domain/types/plan.mapper.types.ts +16 -0
  41. package/lib/eject/domain/types/stripe.dto.types.ts +6 -8
  42. package/lib/eject/domain/types/stripe.entity.types.ts +0 -6
  43. package/lib/eject/domain/types/subscription.mapper.types.ts +21 -0
  44. package/lib/eject/services/billingPortal.service.ts +24 -0
  45. package/lib/eject/services/checkoutSession.service.ts +54 -9
  46. package/lib/eject/services/paymentLink.service.ts +149 -25
  47. package/lib/eject/services/plan.service.ts +34 -4
  48. package/lib/eject/services/subscription.service.ts +42 -1
  49. package/lib/eject/services/webhook.service.ts +10 -1
  50. package/lib/services/billingPortal.service.d.ts.map +1 -1
  51. package/lib/services/checkoutSession.service.d.ts +8 -0
  52. package/lib/services/checkoutSession.service.d.ts.map +1 -1
  53. package/lib/services/index.js +238 -25
  54. package/lib/services/index.mjs +238 -25
  55. package/lib/services/paymentLink.service.d.ts +12 -0
  56. package/lib/services/paymentLink.service.d.ts.map +1 -1
  57. package/lib/services/plan.service.d.ts.map +1 -1
  58. package/lib/services/subscription.service.d.ts.map +1 -1
  59. package/lib/services/webhook.service.d.ts.map +1 -1
  60. package/package.json +8 -8
@@ -9,12 +9,26 @@ export type StripePaymentLinkMappers<
9
9
  Dto extends StripePaymentLinkDtos<StatusEnum>
10
10
  > = {
11
11
  PaymentLinkMapper: {
12
+ /** Validate and restore declared DTO fields after a JSON cache read. */
13
+ normalizeCachedDto?: (
14
+ value: Dto['PaymentLinkMapper']
15
+ ) => Dto['PaymentLinkMapper'];
12
16
  entity: Entities['PaymentLinkMapper'];
13
17
  toDto: (
14
18
  entity: InferEntity<Entities['PaymentLinkMapper']>
15
19
  ) => Promise<Dto['PaymentLinkMapper']>;
20
+ validateCachedRead?: (existing: Dto['PaymentLinkMapper']) => void;
21
+ /** Opt-in cached reference lookup; does not prove provider account ownership. */
22
+ readCachedDto?: (
23
+ existing: Dto['PaymentLinkMapper'],
24
+ retrieve: (providerId: string) => Promise<Stripe.PaymentLink>
25
+ ) => Promise<Dto['PaymentLinkMapper']>;
16
26
  };
17
27
  CreatePaymentLinkMapper: {
28
+ /** Provider effects already occurred; validate shape before local persistence. */
29
+ validateProviderCreate?: (provider: Stripe.PaymentLink) => Promise<void>;
30
+ /** Pure configured validation before any provider creation effects. */
31
+ validateCreate?: (dto: Dto['CreatePaymentLinkMapper']) => Promise<void>;
18
32
  entity: Entities['CreatePaymentLinkMapper'];
19
33
  toEntity: (
20
34
  dto: Dto['CreatePaymentLinkMapper'],
@@ -24,6 +38,28 @@ export type StripePaymentLinkMappers<
24
38
  ) => Promise<InferEntity<Entities['CreatePaymentLinkMapper']>>;
25
39
  };
26
40
  UpdatePaymentLinkMapper: {
41
+ /** Validate the already-applied provider result and prepare one local update.
42
+ * `provider.id` is the stored Stripe id, which the service has already
43
+ * matched; `dto.id` stays the public id. Does not resolve aliases or
44
+ * establish provider account ownership.
45
+ */
46
+ toLocalUpdate?: (
47
+ dto: Dto['UpdatePaymentLinkMapper'],
48
+ provider: Stripe.PaymentLink
49
+ ) => Promise<Dto['UpdatePaymentLinkMapper']>;
50
+ toCachedDto?: (
51
+ dto: Dto['UpdatePaymentLinkMapper'],
52
+ existing: Dto['PaymentLinkMapper'],
53
+ em: EntityManager,
54
+ ...args: unknown[]
55
+ ) => Promise<Dto['PaymentLinkMapper']>;
56
+ /** Validate a patch against the normalized cached DTO before provider I/O.
57
+ * This hook does not resolve provider IDs or authorize provider mutations.
58
+ */
59
+ validateCachedUpdate?: (
60
+ dto: Dto['UpdatePaymentLinkMapper'],
61
+ existing: Dto['PaymentLinkMapper']
62
+ ) => Promise<void>;
27
63
  entity: Entities['UpdatePaymentLinkMapper'];
28
64
  toEntity: (
29
65
  dto: Dto['UpdatePaymentLinkMapper'],
@@ -8,12 +8,21 @@ export type StripePlanMappers<
8
8
  Dto extends StripePlanDtos
9
9
  > = {
10
10
  PlanMapper: {
11
+ /** Read-result identity check; does not establish provider account ownership. */
12
+ validateProviderRead?: (
13
+ expectedExternalId: string,
14
+ provider: Stripe.Plan
15
+ ) => Promise<void>;
11
16
  entity: Entities['PlanMapper'];
12
17
  toDto: (
13
18
  entity: InferEntity<Entities['PlanMapper']>
14
19
  ) => Promise<Dto['PlanMapper']>;
15
20
  };
16
21
  CreatePlanMapper: {
22
+ /** Provider effects already occurred; validate shape before local persistence. */
23
+ validateProviderCreate?: (provider: Stripe.Plan) => Promise<void>;
24
+ /** Pure configured validation before any provider creation effects. */
25
+ validateCreate?: (dto: Dto['CreatePlanMapper']) => Promise<void>;
17
26
  entity: Entities['CreatePlanMapper'];
18
27
  toEntity: (
19
28
  dto: Dto['CreatePlanMapper'],
@@ -23,6 +32,13 @@ export type StripePlanMappers<
23
32
  ) => Promise<InferEntity<Entities['CreatePlanMapper']>>;
24
33
  };
25
34
  UpdatePlanMapper: {
35
+ /** Validate consumed provider references before a subsequent mutation. */
36
+ validateProviderUpdate?: (
37
+ dto: Dto['UpdatePlanMapper'],
38
+ provider: Stripe.Plan
39
+ ) => Promise<void>;
40
+ /** Pure configured validation before any provider replacement effects. */
41
+ validateUpdate?: (dto: Dto['UpdatePlanMapper']) => Promise<void>;
26
42
  entity: Entities['UpdatePlanMapper'];
27
43
  toEntity: (
28
44
  dto: Dto['UpdatePlanMapper'],
@@ -84,7 +84,7 @@ export type StripeCreateBillingPortalDto = Omit<
84
84
  CreateBillingPortalDto,
85
85
  'providerFields'
86
86
  > & {
87
- stripeFields: Omit<
87
+ stripeFields?: Omit<
88
88
  StripeBillingPortalSessionCreateParams,
89
89
  BillingPortalOmissions
90
90
  >;
@@ -126,10 +126,9 @@ export type StripeCreateCheckoutSessionDto<StatusEnum> = Omit<
126
126
  typeof CurrencyEnum,
127
127
  StatusEnum
128
128
  >,
129
- 'providerFields' | 'uri'
129
+ 'providerFields'
130
130
  > & {
131
- uri: string;
132
- stripeFields: Omit<
131
+ stripeFields?: Omit<
133
132
  StripeCheckoutSessionCreateParams,
134
133
  CheckoutSessionOmissions
135
134
  >;
@@ -151,9 +150,8 @@ export type StripeUpdateCheckoutSessionDto<StatusEnum> = Omit<
151
150
 
152
151
  export type StripeCheckoutSessionDto<StatusEnum> = Omit<
153
152
  CheckoutSessionDto<typeof PaymentMethodEnum, typeof CurrencyEnum, StatusEnum>,
154
- 'providerFields' | 'uri'
153
+ 'providerFields'
155
154
  > & {
156
- uri: string;
157
155
  stripeFields: StripeCheckoutSession;
158
156
  };
159
157
 
@@ -210,7 +208,7 @@ export type StripeCreatePlanDto = Omit<
210
208
  >,
211
209
  'providerFields'
212
210
  > & {
213
- stripeFields: Omit<StripePlanCreateParams, PlanOmissions>;
211
+ stripeFields?: Omit<StripePlanCreateParams, PlanOmissions>;
214
212
  };
215
213
 
216
214
  export type StripeUpdatePlanDto = Omit<
@@ -248,7 +246,7 @@ export type StripeCreateSubscriptionDto<PartyType> = Omit<
248
246
  CreateSubscriptionDto<PartyType, typeof BillingProviderEnum>,
249
247
  'providerFields'
250
248
  > & {
251
- stripeFields: Omit<StripeSubscriptionCreateParams, SubscriptionOmissions>;
249
+ stripeFields?: Omit<StripeSubscriptionCreateParams, SubscriptionOmissions>;
252
250
  };
253
251
 
254
252
  export type StripeUpdateSubscriptionDto<PartyType> = Omit<
@@ -6,12 +6,6 @@ import {
6
6
  Plan,
7
7
  Subscription
8
8
  } from '../../persistence/entities';
9
- import {
10
- BillingProviderEnum,
11
- CurrencyEnum,
12
- PaymentMethodEnum,
13
- PlanCadenceEnum
14
- } from '../enum';
15
9
 
16
10
  // Billing Portal Types
17
11
  export type StripeBillingPortalEntities = {
@@ -9,12 +9,23 @@ export type StripeSubscriptionMappers<
9
9
  Dto extends StripeSubscriptionDtos<PartyTypeEnum>
10
10
  > = {
11
11
  SubscriptionMapper: {
12
+ /** Read-result identity check; does not establish provider account ownership. */
13
+ validateProviderRead?: (
14
+ expectedExternalId: string,
15
+ provider: Stripe.Subscription
16
+ ) => Promise<void>;
17
+ /** Trusted persisted party discriminators for local subscription lookups. */
18
+ partyTypes?: Readonly<{ user: string; organization: string }>;
12
19
  entity: Entities['SubscriptionMapper'];
13
20
  toDto: (
14
21
  entity: InferEntity<Entities['SubscriptionMapper']>
15
22
  ) => Promise<Dto['SubscriptionMapper']>;
16
23
  };
17
24
  CreateSubscriptionMapper: {
25
+ /** Provider effects already occurred; validate shape before local persistence. */
26
+ validateProviderCreate?: (provider: Stripe.Subscription) => Promise<void>;
27
+ /** Pure configured validation before any provider creation effects. */
28
+ validateCreate?: (dto: Dto['CreateSubscriptionMapper']) => Promise<void>;
18
29
  entity: Entities['CreateSubscriptionMapper'];
19
30
  toEntity: (
20
31
  dto: Dto['CreateSubscriptionMapper'],
@@ -24,6 +35,16 @@ export type StripeSubscriptionMappers<
24
35
  ) => Promise<InferEntity<Entities['CreateSubscriptionMapper']>>;
25
36
  };
26
37
  UpdateSubscriptionMapper: {
38
+ /** Validate consumed provider references before a subsequent mutation. */
39
+ validateProviderUpdate?: (
40
+ dto: Dto['UpdateSubscriptionMapper'],
41
+ provider: Stripe.Subscription
42
+ ) => Promise<void>;
43
+ /** Configured preflight; changing customer ownership needs a separate workflow. */
44
+ validateUpdate?: (
45
+ dto: Dto['UpdateSubscriptionMapper'],
46
+ existing: Dto['SubscriptionMapper']
47
+ ) => Promise<void>;
27
48
  entity: Entities['UpdateSubscriptionMapper'];
28
49
  toEntity: (
29
50
  dto: Dto['UpdateSubscriptionMapper'],
@@ -78,11 +78,15 @@ export class StripeBillingPortalService<
78
78
  billingPortalDto: StripeCreateBillingPortalDto,
79
79
  ...args: unknown[]
80
80
  ): Promise<Dto['BillingPortalMapper']> {
81
+ await this.mappers.CreateBillingPortalMapper.validateCreate?.(billingPortalDto);
82
+
81
83
  const session = await this.stripeClient.billingPortal.sessions.create({
82
84
  ...billingPortalDto.stripeFields,
83
85
  customer: billingPortalDto.customerId
84
86
  });
85
87
 
88
+ await this.mappers.CreateBillingPortalMapper.validateProviderCreate?.(session);
89
+
86
90
  return await this.baseBillingPortalService.createBillingPortalSession(
87
91
  {
88
92
  ...billingPortalDto,
@@ -116,11 +120,31 @@ export class StripeBillingPortalService<
116
120
  await this.baseBillingPortalService.getBillingPortalSession({
117
121
  id: billingPortalDto.id
118
122
  });
123
+ if (
124
+ !this.options?.enableDatabaseBackup &&
125
+ this.mappers.UpdateBillingPortalMapper.toCachedDto &&
126
+ billingPortalDto.customerId !== undefined &&
127
+ billingPortalDto.customerId !== existingSession.customerId
128
+ ) {
129
+ throw new Error('Cached session identity cannot change');
130
+ }
131
+ if (
132
+ !this.options?.enableDatabaseBackup &&
133
+ this.mappers.UpdateBillingPortalMapper.toCachedDto &&
134
+ this.mappers.UpdateBillingPortalMapper.validateCachedUpdate
135
+ ) {
136
+ await this.mappers.UpdateBillingPortalMapper.validateCachedUpdate(
137
+ billingPortalDto,
138
+ existingSession
139
+ );
140
+ }
119
141
  const session = await this.stripeClient.billingPortal.sessions.create({
120
142
  ...billingPortalDto.stripeFields,
121
143
  customer: billingPortalDto.customerId || existingSession.customerId
122
144
  });
123
145
 
146
+ await this.mappers.CreateBillingPortalMapper.validateProviderCreate?.(session);
147
+
124
148
  return await this.baseBillingPortalService.updateBillingPortalSession(
125
149
  {
126
150
  ...billingPortalDto,
@@ -93,6 +93,8 @@ export class StripeCheckoutSessionService<
93
93
  checkoutSessionDto: StripeCreateCheckoutSessionDto<StatusEnum>,
94
94
  ...args: unknown[]
95
95
  ): Promise<Dto['CheckoutSessionMapper']> {
96
+ await this.mappers.CreateCheckoutSessionMapper.validateCreate?.(checkoutSessionDto);
97
+
96
98
  const session = await this.stripeClient.checkout.sessions.create({
97
99
  ...checkoutSessionDto.stripeFields,
98
100
  mode: 'subscription',
@@ -102,6 +104,8 @@ export class StripeCheckoutSessionService<
102
104
  cancel_url: checkoutSessionDto.cancelRedirectUri
103
105
  });
104
106
 
107
+ await this.mappers.CreateCheckoutSessionMapper.validateProviderCreate?.(session);
108
+
105
109
  return await this.baseCheckoutSessionService.createCheckoutSession(
106
110
  {
107
111
  ...checkoutSessionDto,
@@ -119,6 +123,17 @@ export class StripeCheckoutSessionService<
119
123
  async getCheckoutSession(
120
124
  idDto: IdDto
121
125
  ): Promise<Dto['CheckoutSessionMapper']> {
126
+ const readCachedDto = this.mappers.CheckoutSessionMapper.readCachedDto;
127
+ if (readCachedDto) {
128
+ const existing =
129
+ await this.baseCheckoutSessionService.getCheckoutSession(idDto);
130
+ if (existing.id !== idDto.id) {
131
+ throw new Error('Cached checkout identity does not match');
132
+ }
133
+ return readCachedDto(existing, (providerId) =>
134
+ this.stripeClient.checkout.sessions.retrieve(providerId)
135
+ );
136
+ }
122
137
  const session = await this.stripeClient.checkout.sessions.retrieve(
123
138
  idDto.id
124
139
  );
@@ -130,26 +145,56 @@ export class StripeCheckoutSessionService<
130
145
  return databaseCheckoutSession;
131
146
  }
132
147
 
148
+ /**
149
+ * The Stripe id (`cs_...`) stored with a checkout session when it was
150
+ * created. Public ids are local; Stripe only knows its own id. The stored
151
+ * provider object's id wins; a record without one is still usable when its
152
+ * own id is a Stripe id. Anything else fails here rather than sending Stripe
153
+ * a local id.
154
+ */
155
+ protected async stripeCheckoutSessionId(id: string): Promise<string> {
156
+ const checkoutSession =
157
+ await this.baseCheckoutSessionService.getCheckoutSession({ id });
158
+ const stored = (
159
+ checkoutSession?.stripeFields as { id?: unknown } | undefined
160
+ )?.id;
161
+ const stripeId = stored === undefined ? checkoutSession?.id : stored;
162
+ if (typeof stripeId !== 'string' || !/^cs_[A-Za-z0-9_]+$/.test(stripeId)) {
163
+ throw new Error(
164
+ `Checkout session ${id} has no stored Stripe checkout session id`
165
+ );
166
+ }
167
+ return stripeId;
168
+ }
169
+
133
170
  async expireCheckoutSession({ id }: IdDto): Promise<void> {
134
- await this.stripeClient.checkout.sessions.expire(id);
171
+ await this.stripeClient.checkout.sessions.expire(
172
+ await this.stripeCheckoutSessionId(id)
173
+ );
135
174
  await this.baseCheckoutSessionService.expireCheckoutSession({ id });
136
175
  }
137
176
 
138
177
  async handleCheckoutSuccess({ id }: IdDto): Promise<void> {
139
- await this.stripeClient.checkout.sessions.update(id, {
140
- metadata: {
141
- status: 'SUCCESS'
178
+ await this.stripeClient.checkout.sessions.update(
179
+ await this.stripeCheckoutSessionId(id),
180
+ {
181
+ metadata: {
182
+ status: 'SUCCESS'
183
+ }
142
184
  }
143
- });
185
+ );
144
186
  await this.baseCheckoutSessionService.handleCheckoutSuccess({ id });
145
187
  }
146
188
 
147
189
  async handleCheckoutFailure({ id }: IdDto): Promise<void> {
148
- await this.stripeClient.checkout.sessions.update(id, {
149
- metadata: {
150
- status: 'FAILED'
190
+ await this.stripeClient.checkout.sessions.update(
191
+ await this.stripeCheckoutSessionId(id),
192
+ {
193
+ metadata: {
194
+ status: 'FAILED'
195
+ }
151
196
  }
152
- });
197
+ );
153
198
  await this.baseCheckoutSessionService.handleCheckoutFailure({ id });
154
199
  }
155
200
  }
@@ -21,24 +21,44 @@ import {
21
21
  } from '../domain/types/stripe.dto.types';
22
22
  import { StripePaymentLinkEntities } from '../domain/types/stripe.entity.types';
23
23
 
24
+ /** Match the required provider object/line-items structure without modeling the SDK. */
25
+ function validatePaymentLinkCreateFields(value: unknown): void {
26
+ if (
27
+ !value ||
28
+ typeof value !== 'object' ||
29
+ Array.isArray(value) ||
30
+ !Array.isArray((value as Record<string, unknown>).line_items) ||
31
+ !(value as { line_items: unknown[] }).line_items.every(
32
+ (item) =>
33
+ item !== null && typeof item === 'object' && !Array.isArray(item)
34
+ )
35
+ ) {
36
+ throw new Error(
37
+ 'Payment link provider options require a line_items array of objects'
38
+ );
39
+ }
40
+ }
41
+
24
42
  export class StripePaymentLinkService<
25
43
  SchemaValidator extends AnySchemaValidator,
26
44
  StatusEnum,
27
45
  Entities extends StripePaymentLinkEntities,
28
- Dto extends StripePaymentLinkDtos<StatusEnum> =
29
- StripePaymentLinkDtos<StatusEnum>
30
- > implements PaymentLinkService<
31
- PaymentMethodEnum,
32
- typeof CurrencyEnum,
33
- StatusEnum,
34
- {
35
- CreatePaymentLinkDto: StripeCreatePaymentLinkDto<StatusEnum>;
36
- UpdatePaymentLinkDto: StripeUpdatePaymentLinkDto<StatusEnum>;
37
- PaymentLinkDto: StripePaymentLinkDto<StatusEnum>;
38
- IdDto: IdDto;
39
- IdsDto: IdsDto;
40
- }
41
- > {
46
+ Dto extends
47
+ StripePaymentLinkDtos<StatusEnum> = StripePaymentLinkDtos<StatusEnum>
48
+ > implements
49
+ PaymentLinkService<
50
+ PaymentMethodEnum,
51
+ typeof CurrencyEnum,
52
+ StatusEnum,
53
+ {
54
+ CreatePaymentLinkDto: StripeCreatePaymentLinkDto<StatusEnum>;
55
+ UpdatePaymentLinkDto: StripeUpdatePaymentLinkDto<StatusEnum>;
56
+ PaymentLinkDto: StripePaymentLinkDto<StatusEnum>;
57
+ IdDto: IdDto;
58
+ IdsDto: IdsDto;
59
+ }
60
+ >
61
+ {
42
62
  basePaymentLinkService: BasePaymentLinkService<
43
63
  SchemaValidator,
44
64
  PaymentMethodEnum,
@@ -47,6 +67,7 @@ export class StripePaymentLinkService<
47
67
  Entities,
48
68
  Dto
49
69
  >;
70
+ private readonly maxConfiguredListRecords: number;
50
71
  protected readonly stripeClient: Stripe;
51
72
  protected readonly em: EntityManager;
52
73
  protected readonly cache: TtlCache;
@@ -67,9 +88,19 @@ export class StripePaymentLinkService<
67
88
  mappers: StripePaymentLinkMappers<StatusEnum, Entities, Dto>,
68
89
  readonly options?: {
69
90
  enableDatabaseBackup?: boolean;
91
+ /** Trusted configured-cache list limit; defaults to 100, maximum 1000. */
92
+ maxConfiguredListRecords?: number;
70
93
  telemetry?: TelemetryOptions;
71
94
  }
72
95
  ) {
96
+ const limit =
97
+ options?.maxConfiguredListRecords === undefined
98
+ ? 100
99
+ : options.maxConfiguredListRecords;
100
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000) {
101
+ throw new Error('Invalid configured payment link list limit');
102
+ }
103
+ this.maxConfiguredListRecords = limit;
73
104
  this.stripeClient = stripeClient;
74
105
  this.em = em;
75
106
  this.cache = cache;
@@ -96,12 +127,19 @@ export class StripePaymentLinkService<
96
127
  paymentLinkDto: StripeCreatePaymentLinkDto<StatusEnum>,
97
128
  ...args: unknown[]
98
129
  ): Promise<Dto['PaymentLinkMapper']> {
130
+ await this.mappers.CreatePaymentLinkMapper.validateCreate?.(paymentLinkDto);
131
+
132
+ validatePaymentLinkCreateFields(paymentLinkDto.stripeFields);
99
133
  const session = await this.stripeClient.paymentLinks.create({
100
134
  ...paymentLinkDto.stripeFields,
101
135
  payment_method_types: paymentLinkDto.paymentMethods,
102
136
  currency: paymentLinkDto.currency as string
103
137
  });
104
138
 
139
+ await this.mappers.CreatePaymentLinkMapper.validateProviderCreate?.(
140
+ session
141
+ );
142
+
105
143
  const paymentLink = await this.basePaymentLinkService.createPaymentLink(
106
144
  {
107
145
  ...paymentLinkDto,
@@ -123,24 +161,68 @@ export class StripePaymentLinkService<
123
161
  return paymentLink;
124
162
  }
125
163
 
164
+ /**
165
+ * The Stripe id (`plink_...`) stored with a payment link when it was created.
166
+ * Public ids are local; Stripe only knows its own id. The stored provider
167
+ * object's id wins; a record without one is still usable when its own id is a
168
+ * Stripe id. Anything else fails here rather than sending Stripe a local id.
169
+ */
170
+ protected stripePaymentLinkId(
171
+ paymentLink: Dto['PaymentLinkMapper'],
172
+ id: string
173
+ ): string {
174
+ const stored = (paymentLink?.stripeFields as { id?: unknown } | undefined)
175
+ ?.id;
176
+ const stripeId = stored === undefined ? paymentLink?.id : stored;
177
+ if (
178
+ typeof stripeId !== 'string' ||
179
+ !/^plink_[A-Za-z0-9_]+$/.test(stripeId)
180
+ ) {
181
+ throw new Error(
182
+ `Payment link ${id} has no stored Stripe payment link id`
183
+ );
184
+ }
185
+ return stripeId;
186
+ }
187
+
126
188
  async updatePaymentLink(
127
189
  paymentLinkDto: StripeUpdatePaymentLinkDto<StatusEnum>,
128
190
  ...args: unknown[]
129
191
  ): Promise<Dto['PaymentLinkMapper']> {
130
- const session = await this.stripeClient.paymentLinks.update(
131
- paymentLinkDto.id,
132
- {
133
- ...paymentLinkDto.stripeFields,
134
- payment_method_types: paymentLinkDto.paymentMethods
135
- }
136
- );
192
+ const existing = await this.basePaymentLinkService.getPaymentLink({
193
+ id: paymentLinkDto.id
194
+ });
195
+ if (this.mappers.UpdatePaymentLinkMapper.validateCachedUpdate) {
196
+ await this.mappers.UpdatePaymentLinkMapper.validateCachedUpdate(
197
+ paymentLinkDto,
198
+ existing
199
+ );
200
+ }
201
+ const stripeId = this.stripePaymentLinkId(existing, paymentLinkDto.id);
202
+ const session = await this.stripeClient.paymentLinks.update(stripeId, {
203
+ ...paymentLinkDto.stripeFields,
204
+ payment_method_types: paymentLinkDto.paymentMethods
205
+ });
206
+ if (session.id !== stripeId) {
207
+ throw new Error('Invalid Stripe billing response');
208
+ }
209
+
210
+ const toLocalUpdate = this.mappers.UpdatePaymentLinkMapper.toLocalUpdate;
211
+ if (toLocalUpdate) {
212
+ const patch = await toLocalUpdate(paymentLinkDto, session);
213
+ return this.basePaymentLinkService.updatePaymentLink(
214
+ patch,
215
+ this.em,
216
+ session,
217
+ ...args
218
+ );
219
+ }
137
220
 
138
221
  const paymentLink = await this.basePaymentLinkService.updatePaymentLink(
139
222
  await this.mappers.PaymentLinkMapper.toDto(
140
223
  await this.mappers.UpdatePaymentLinkMapper.toEntity(
141
224
  {
142
225
  ...paymentLinkDto,
143
- id: session.id,
144
226
  amount:
145
227
  session.line_items?.data.reduce<number>(
146
228
  (total, item) => total + item.amount_total,
@@ -160,6 +242,16 @@ export class StripePaymentLinkService<
160
242
  }
161
243
 
162
244
  async getPaymentLink({ id }: IdDto): Promise<Dto['PaymentLinkMapper']> {
245
+ const readCachedDto = this.mappers.PaymentLinkMapper.readCachedDto;
246
+ if (readCachedDto) {
247
+ const existing = await this.basePaymentLinkService.getPaymentLink({ id });
248
+ if (existing.id !== id) {
249
+ throw new Error('Cached payment link identity does not match');
250
+ }
251
+ return readCachedDto(existing, (providerId) =>
252
+ this.stripeClient.paymentLinks.retrieve(providerId)
253
+ );
254
+ }
163
255
  const stripePaymentLink = await this.stripeClient.paymentLinks.retrieve(id);
164
256
 
165
257
  const databasePaymentLink =
@@ -171,7 +263,11 @@ export class StripePaymentLinkService<
171
263
  }
172
264
 
173
265
  async expirePaymentLink({ id }: IdDto): Promise<void> {
174
- await this.stripeClient.paymentLinks.update(id, {
266
+ const stripeId = this.stripePaymentLinkId(
267
+ await this.basePaymentLinkService.getPaymentLink({ id }),
268
+ id
269
+ );
270
+ await this.stripeClient.paymentLinks.update(stripeId, {
175
271
  metadata: {
176
272
  status: 'EXPIRED'
177
273
  }
@@ -180,7 +276,11 @@ export class StripePaymentLinkService<
180
276
  }
181
277
 
182
278
  async handlePaymentSuccess({ id }: IdDto): Promise<void> {
183
- await this.stripeClient.paymentLinks.update(id, {
279
+ const stripeId = this.stripePaymentLinkId(
280
+ await this.basePaymentLinkService.getPaymentLink({ id }),
281
+ id
282
+ );
283
+ await this.stripeClient.paymentLinks.update(stripeId, {
184
284
  metadata: {
185
285
  status: 'COMPLETED'
186
286
  }
@@ -189,7 +289,11 @@ export class StripePaymentLinkService<
189
289
  }
190
290
 
191
291
  async handlePaymentFailure({ id }: IdDto): Promise<void> {
192
- await this.stripeClient.paymentLinks.update(id, {
292
+ const stripeId = this.stripePaymentLinkId(
293
+ await this.basePaymentLinkService.getPaymentLink({ id }),
294
+ id
295
+ );
296
+ await this.stripeClient.paymentLinks.update(stripeId, {
193
297
  metadata: {
194
298
  status: 'FAILED'
195
299
  }
@@ -199,6 +303,26 @@ export class StripePaymentLinkService<
199
303
 
200
304
  async listPaymentLinks(idsDto?: IdsDto): Promise<Dto['PaymentLinkMapper'][]> {
201
305
  this.openTelemetryCollector.log('info', idsDto ?? 'idsDto is undefined');
306
+ const { readCachedDto, validateCachedRead } =
307
+ this.mappers.PaymentLinkMapper;
308
+ if (readCachedDto && validateCachedRead) {
309
+ const cached = await this.basePaymentLinkService.listPaymentLinks(
310
+ idsDto,
311
+ this.maxConfiguredListRecords
312
+ );
313
+ for (const value of cached) validateCachedRead(value);
314
+ const active: Dto['PaymentLinkMapper'][] = [];
315
+ for (const value of cached) {
316
+ const fresh = await readCachedDto(value, (providerId) =>
317
+ this.stripeClient.paymentLinks.retrieve(providerId)
318
+ );
319
+ if (typeof fresh.stripeFields.active !== 'boolean') {
320
+ throw new Error('Invalid payment link active state');
321
+ }
322
+ if (fresh.stripeFields.active) active.push(fresh);
323
+ }
324
+ return active;
325
+ }
202
326
  const stripePaymentLinks = await this.stripeClient.paymentLinks.list({
203
327
  active: true
204
328
  });