@forklaunch/implementation-ecommerce-stripe 1.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 forklaunch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,2 @@
1
+ export * from './payment.mapper.types';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../domain/types/index.ts"],"names":[],"mappings":"AAAA,cAAc,wBAAwB,CAAC"}
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __copyProps = (to, from, except, desc) => {
7
+ if (from && typeof from === "object" || typeof from === "function") {
8
+ for (let key of __getOwnPropNames(from))
9
+ if (!__hasOwnProp.call(to, key) && key !== except)
10
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
11
+ }
12
+ return to;
13
+ };
14
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
15
+
16
+ // domain/types/index.ts
17
+ var types_exports = {};
18
+ module.exports = __toCommonJS(types_exports);
File without changes
@@ -0,0 +1,20 @@
1
+ import { BasePaymentDtos, BasePaymentEntities } from '@forklaunch/implementation-ecommerce-base/types';
2
+ import { EntityManager, InferEntity } from '@mikro-orm/core';
3
+ import Stripe from 'stripe';
4
+ /**
5
+ * Stripe-specific override of PaymentMappers — CreatePaymentMapper.toEntity
6
+ * takes the concrete Stripe.PaymentIntent (created by StripePaymentService
7
+ * before persistence), not the base's generic ...args: unknown[]. Same
8
+ * pattern as StripePlanMappers in billing overriding the generic PlanMappers.
9
+ */
10
+ export type StripePaymentMappers<Entities extends BasePaymentEntities, Dto extends BasePaymentDtos> = {
11
+ PaymentMapper: {
12
+ entity: Entities['PaymentMapper'];
13
+ toDto: (entity: InferEntity<Entities['PaymentMapper']>) => Promise<Dto['PaymentMapper']>;
14
+ };
15
+ CreatePaymentMapper: {
16
+ entity: Entities['CreatePaymentMapper'];
17
+ toEntity: (dto: Dto['CreatePaymentMapper'], em: EntityManager, paymentIntent: Stripe.PaymentIntent, ...args: unknown[]) => Promise<InferEntity<Entities['CreatePaymentMapper']>>;
18
+ };
19
+ };
20
+ //# sourceMappingURL=payment.mapper.types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"payment.mapper.types.d.ts","sourceRoot":"","sources":["../../../domain/types/payment.mapper.types.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,mBAAmB,EAAE,MAAM,iDAAiD,CAAC;AACvG,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAC7D,OAAO,MAAM,MAAM,QAAQ,CAAC;AAE5B;;;;;GAKG;AACH,MAAM,MAAM,oBAAoB,CAC9B,QAAQ,SAAS,mBAAmB,EACpC,GAAG,SAAS,eAAe,IACzB;IACF,aAAa,EAAE;QACb,MAAM,EAAE,QAAQ,CAAC,eAAe,CAAC,CAAC;QAClC,KAAK,EAAE,CACL,MAAM,EAAE,WAAW,CAAC,QAAQ,CAAC,eAAe,CAAC,CAAC,KAC3C,OAAO,CAAC,GAAG,CAAC,eAAe,CAAC,CAAC,CAAC;KACpC,CAAC;IACF,mBAAmB,EAAE;QACnB,MAAM,EAAE,QAAQ,CAAC,qBAAqB,CAAC,CAAC;QACxC,QAAQ,EAAE,CACR,GAAG,EAAE,GAAG,CAAC,qBAAqB,CAAC,EAC/B,EAAE,EAAE,aAAa,EACjB,aAAa,EAAE,MAAM,CAAC,aAAa,EACnC,GAAG,IAAI,EAAE,OAAO,EAAE,KACf,OAAO,CAAC,WAAW,CAAC,QAAQ,CAAC,qBAAqB,CAAC,CAAC,CAAC,CAAC;KAC5D,CAAC;CACH,CAAC"}
@@ -0,0 +1 @@
1
+ export * from './payment.mapper.types';
@@ -0,0 +1,30 @@
1
+ import { BasePaymentDtos, BasePaymentEntities } from '@forklaunch/implementation-ecommerce-base/types';
2
+ import { EntityManager, InferEntity } from '@mikro-orm/core';
3
+ import Stripe from 'stripe';
4
+
5
+ /**
6
+ * Stripe-specific override of PaymentMappers — CreatePaymentMapper.toEntity
7
+ * takes the concrete Stripe.PaymentIntent (created by StripePaymentService
8
+ * before persistence), not the base's generic ...args: unknown[]. Same
9
+ * pattern as StripePlanMappers in billing overriding the generic PlanMappers.
10
+ */
11
+ export type StripePaymentMappers<
12
+ Entities extends BasePaymentEntities,
13
+ Dto extends BasePaymentDtos
14
+ > = {
15
+ PaymentMapper: {
16
+ entity: Entities['PaymentMapper'];
17
+ toDto: (
18
+ entity: InferEntity<Entities['PaymentMapper']>
19
+ ) => Promise<Dto['PaymentMapper']>;
20
+ };
21
+ CreatePaymentMapper: {
22
+ entity: Entities['CreatePaymentMapper'];
23
+ toEntity: (
24
+ dto: Dto['CreatePaymentMapper'],
25
+ em: EntityManager,
26
+ paymentIntent: Stripe.PaymentIntent,
27
+ ...args: unknown[]
28
+ ) => Promise<InferEntity<Entities['CreatePaymentMapper']>>;
29
+ };
30
+ };
@@ -0,0 +1 @@
1
+ export * from './payment.service';
@@ -0,0 +1,243 @@
1
+ import {
2
+ MetricsDefinition,
3
+ OpenTelemetryCollector,
4
+ TelemetryOptions
5
+ } from '@forklaunch/core/http';
6
+ import { BasePaymentService } from '@forklaunch/implementation-ecommerce-base/services';
7
+ import {
8
+ BasePaymentDtos,
9
+ BasePaymentEntities
10
+ } from '@forklaunch/implementation-ecommerce-base/types';
11
+ import { PaymentService } from '@forklaunch/interfaces-ecommerce/interfaces';
12
+ import {
13
+ ConfirmPaymentDto,
14
+ CreatePaymentDto,
15
+ FailPaymentDto
16
+ } from '@forklaunch/interfaces-ecommerce/types';
17
+ import { AnySchemaValidator } from '@forklaunch/validator';
18
+ import { EntityManager } from '@mikro-orm/core';
19
+ import Stripe from 'stripe';
20
+ import { StripePaymentMappers } from '../domain/types/payment.mapper.types';
21
+
22
+ /**
23
+ * Wraps BasePaymentService with real Stripe PaymentIntent calls — the base
24
+ * does DB persistence, this class calls the provider API and delegates
25
+ * persistence back to base (same split as StripePlanService/BasePlanService
26
+ * in billing). Fills the payment seam: PaymentIntent drives the order
27
+ * toward paid; confirmation is idempotent and driven by the webhook.
28
+ */
29
+ export class StripePaymentService<
30
+ SchemaValidator extends AnySchemaValidator,
31
+ Entities extends BasePaymentEntities,
32
+ Dto extends BasePaymentDtos = BasePaymentDtos
33
+ > implements PaymentService
34
+ {
35
+ basePaymentService: BasePaymentService<SchemaValidator, Entities, Dto>;
36
+ protected readonly stripeClient: Stripe;
37
+ protected readonly em: EntityManager;
38
+ protected readonly openTelemetryCollector: OpenTelemetryCollector<MetricsDefinition>;
39
+ protected readonly connect?: {
40
+ connectedAccountId: string;
41
+ platformFeeBps: number;
42
+ };
43
+
44
+ constructor(
45
+ stripeClient: Stripe,
46
+ em: EntityManager,
47
+ openTelemetryCollector: OpenTelemetryCollector<MetricsDefinition>,
48
+ schemaValidator: SchemaValidator,
49
+ mappers: StripePaymentMappers<Entities, Dto>,
50
+ options?: {
51
+ telemetry?: TelemetryOptions;
52
+ /**
53
+ * Stripe Connect (platform) mode. When set, PaymentIntents are created
54
+ * as DIRECT charges on the merchant's connected account — the merchant
55
+ * is the merchant of record and settles the funds — with
56
+ * `application_fee_amount` computed from platformFeeBps. The launch
57
+ * business model is NO markup (fee 0, see the Guild deck: "payments
58
+ * flat at launch"), so platformFeeBps defaults to 0 and the fee is
59
+ * omitted entirely; it exists as a dial, not a revenue assumption.
60
+ * When absent, behavior is unchanged: the deploy's own STRIPE_API_KEY
61
+ * account is the merchant (bring-your-own-key mode).
62
+ */
63
+ connect?: {
64
+ connectedAccountId: string;
65
+ platformFeeBps?: number;
66
+ };
67
+ }
68
+ ) {
69
+ this.stripeClient = stripeClient;
70
+ this.em = em;
71
+ this.openTelemetryCollector = openTelemetryCollector;
72
+ if (options?.connect) {
73
+ // Validate the fee dial here rather than trusting the caller: it comes
74
+ // from an env var, and every bad value fails SILENTLY downstream
75
+ // otherwise. A non-numeric STRIPE_PLATFORM_FEE_BPS makes Number()
76
+ // return NaN, `feeCents > 0` is then false, and the application fee is
77
+ // quietly omitted — an operator who meant to charge a fee would get
78
+ // none, with nothing logged. A negative value does the same. Failing
79
+ // at construction turns a silent misconfiguration into a startup error.
80
+ const platformFeeBps = options.connect.platformFeeBps ?? 0;
81
+ // Upper bound is exclusive: Stripe requires application_fee_amount to be
82
+ // strictly less than the charge, so 10000 bps (the whole charge) is never
83
+ // a usable value and is rejected here rather than at the first checkout.
84
+ if (
85
+ !Number.isInteger(platformFeeBps) ||
86
+ platformFeeBps < 0 ||
87
+ platformFeeBps >= 10000
88
+ ) {
89
+ throw new Error(
90
+ `Invalid Stripe platform fee: expected an integer from 0 to 9999 basis points, got ${platformFeeBps}. ` +
91
+ 'Check STRIPE_PLATFORM_FEE_BPS.'
92
+ );
93
+ }
94
+ this.connect = {
95
+ connectedAccountId: options.connect.connectedAccountId,
96
+ platformFeeBps
97
+ };
98
+ }
99
+ // CreatePaymentMapper.toEntity's 3rd param is concretely Stripe.PaymentIntent
100
+ // here (narrower than base's generic ...args: unknown[]) — safe at runtime,
101
+ // this cast just satisfies the base constructor's looser declared type.
102
+ this.basePaymentService = new BasePaymentService(
103
+ em,
104
+ openTelemetryCollector,
105
+ schemaValidator,
106
+ mappers as unknown as ConstructorParameters<
107
+ typeof BasePaymentService<SchemaValidator, Entities, Dto>
108
+ >[3],
109
+ options
110
+ );
111
+ }
112
+
113
+ /**
114
+ * Creates the Stripe PaymentIntent, then persists the pending record with
115
+ * its id as providerRef. The PaymentIntent's `client_secret` is bolted
116
+ * onto the returned DTO (never persisted — Payment has no clientSecret
117
+ * column, deliberately: it's a one-time, provider-issued credential the
118
+ * frontend needs *right now* to actually collect the charge via
119
+ * Stripe.js, not something that should still be fetchable from GET
120
+ * /payment/:id later). This widens the return type over the
121
+ * PaymentService interface's `Promise<Params['PaymentDto']>` — a covariant,
122
+ * backward-compatible narrowing (an intersection type is always assignable
123
+ * to its base), the same relationship StripePaymentMappers already has to
124
+ * the base PaymentMappers for this class's mapper argument.
125
+ */
126
+ async createPayment(
127
+ paymentDto: CreatePaymentDto,
128
+ em?: EntityManager
129
+ ): Promise<Dto['PaymentMapper'] & { clientSecret?: string }> {
130
+ // In Connect mode this is a DIRECT charge: created on the merchant's
131
+ // connected account (stripeAccount request option), so the merchant is
132
+ // merchant-of-record and keeps the funds. The platform fee is only
133
+ // attached when non-zero — at launch it always is zero (no markup).
134
+ const requestedFeeCents = this.connect
135
+ ? Math.round(
136
+ (paymentDto.amountCents * this.connect.platformFeeBps) / 10000
137
+ )
138
+ : 0;
139
+ // application_fee_amount has to stay strictly below the charge. The
140
+ // constructor already rejects 10000 bps, but rounding can still reach the
141
+ // full amount on a small enough charge (1 cent at 9999 bps rounds to 1),
142
+ // and Stripe would reject the PaymentIntent outright. Cap it so a fee
143
+ // setting cannot fail a customer's checkout, and record it when it bites.
144
+ const feeCents = Math.min(
145
+ requestedFeeCents,
146
+ Math.max(paymentDto.amountCents - 1, 0)
147
+ );
148
+ if (feeCents !== requestedFeeCents) {
149
+ this.openTelemetryCollector.warn(
150
+ 'Stripe platform fee capped below the charge amount',
151
+ {
152
+ orderId: paymentDto.orderId,
153
+ amountCents: paymentDto.amountCents,
154
+ requestedFeeCents,
155
+ appliedFeeCents: feeCents
156
+ }
157
+ );
158
+ }
159
+ const paymentIntent = await this.stripeClient.paymentIntents.create(
160
+ {
161
+ amount: paymentDto.amountCents,
162
+ currency: paymentDto.currency,
163
+ metadata: { orderId: paymentDto.orderId },
164
+ ...(this.connect && feeCents > 0
165
+ ? { application_fee_amount: feeCents }
166
+ : {})
167
+ },
168
+ this.connect
169
+ ? { stripeAccount: this.connect.connectedAccountId }
170
+ : undefined
171
+ );
172
+ const payment = await this.basePaymentService.createPayment(
173
+ paymentDto,
174
+ em ?? this.em,
175
+ paymentIntent
176
+ );
177
+ return {
178
+ ...payment,
179
+ clientSecret: paymentIntent.client_secret ?? undefined
180
+ };
181
+ }
182
+
183
+ /**
184
+ * Re-issues the client_secret for a payment that already has a live
185
+ * PaymentIntent, so a checkout retry resumes that intent instead of
186
+ * opening a second one against the same order. Two live intents for one
187
+ * order are both confirmable, and a customer who confirms both is charged
188
+ * twice — see PaymentOrderLookupService in ecommerce-stripe for the full
189
+ * failure mode.
190
+ *
191
+ * The secret has to be fetched back from Stripe because it is
192
+ * deliberately never persisted (see createPayment) — Stripe is the only
193
+ * place it exists after that first response.
194
+ *
195
+ * A payment with no providerRef never reached Stripe, so there is no
196
+ * intent to resume and the record is returned unchanged, without a
197
+ * secret. The caller treats that the same way it treats no payment at
198
+ * all: create a fresh one.
199
+ */
200
+ async resumePayment(
201
+ idDto: { id: string },
202
+ em?: EntityManager
203
+ ): Promise<Dto['PaymentMapper'] & { clientSecret?: string }> {
204
+ const payment = await this.basePaymentService.getPayment(idDto, em);
205
+ if (!payment.providerRef) {
206
+ return payment;
207
+ }
208
+ const paymentIntent = await this.stripeClient.paymentIntents.retrieve(
209
+ payment.providerRef,
210
+ {},
211
+ this.connect
212
+ ? { stripeAccount: this.connect.connectedAccountId }
213
+ : undefined
214
+ );
215
+ return {
216
+ ...payment,
217
+ clientSecret: paymentIntent.client_secret ?? undefined
218
+ };
219
+ }
220
+
221
+ async getPayment(
222
+ idDto: { id: string },
223
+ em?: EntityManager
224
+ ): Promise<Dto['PaymentMapper']> {
225
+ return this.basePaymentService.getPayment(idDto, em);
226
+ }
227
+
228
+ /** Driven by the Stripe webhook (signature-verified at the controller layer) — idempotent. */
229
+ async confirmPayment(
230
+ confirmDto: ConfirmPaymentDto,
231
+ em?: EntityManager
232
+ ): Promise<Dto['PaymentMapper']> {
233
+ return this.basePaymentService.confirmPayment(confirmDto, em);
234
+ }
235
+
236
+ /** Failed payment emits the event dunning hooks into, at the deployable-app layer. */
237
+ async failPayment(
238
+ failDto: FailPaymentDto,
239
+ em?: EntityManager
240
+ ): Promise<Dto['PaymentMapper']> {
241
+ return this.basePaymentService.failPayment(failDto, em);
242
+ }
243
+ }
@@ -0,0 +1,2 @@
1
+ export * from './payment.service';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../services/index.ts"],"names":[],"mappings":"AAAA,cAAc,mBAAmB,CAAC"}
@@ -0,0 +1,157 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // services/index.ts
21
+ var services_exports = {};
22
+ __export(services_exports, {
23
+ StripePaymentService: () => StripePaymentService
24
+ });
25
+ module.exports = __toCommonJS(services_exports);
26
+
27
+ // services/payment.service.ts
28
+ var import_services = require("@forklaunch/implementation-ecommerce-base/services");
29
+ var StripePaymentService = class {
30
+ basePaymentService;
31
+ stripeClient;
32
+ em;
33
+ openTelemetryCollector;
34
+ connect;
35
+ constructor(stripeClient, em, openTelemetryCollector, schemaValidator, mappers, options) {
36
+ this.stripeClient = stripeClient;
37
+ this.em = em;
38
+ this.openTelemetryCollector = openTelemetryCollector;
39
+ if (options?.connect) {
40
+ const platformFeeBps = options.connect.platformFeeBps ?? 0;
41
+ if (!Number.isInteger(platformFeeBps) || platformFeeBps < 0 || platformFeeBps >= 1e4) {
42
+ throw new Error(
43
+ `Invalid Stripe platform fee: expected an integer from 0 to 9999 basis points, got ${platformFeeBps}. Check STRIPE_PLATFORM_FEE_BPS.`
44
+ );
45
+ }
46
+ this.connect = {
47
+ connectedAccountId: options.connect.connectedAccountId,
48
+ platformFeeBps
49
+ };
50
+ }
51
+ this.basePaymentService = new import_services.BasePaymentService(
52
+ em,
53
+ openTelemetryCollector,
54
+ schemaValidator,
55
+ mappers,
56
+ options
57
+ );
58
+ }
59
+ /**
60
+ * Creates the Stripe PaymentIntent, then persists the pending record with
61
+ * its id as providerRef. The PaymentIntent's `client_secret` is bolted
62
+ * onto the returned DTO (never persisted — Payment has no clientSecret
63
+ * column, deliberately: it's a one-time, provider-issued credential the
64
+ * frontend needs *right now* to actually collect the charge via
65
+ * Stripe.js, not something that should still be fetchable from GET
66
+ * /payment/:id later). This widens the return type over the
67
+ * PaymentService interface's `Promise<Params['PaymentDto']>` — a covariant,
68
+ * backward-compatible narrowing (an intersection type is always assignable
69
+ * to its base), the same relationship StripePaymentMappers already has to
70
+ * the base PaymentMappers for this class's mapper argument.
71
+ */
72
+ async createPayment(paymentDto, em) {
73
+ const requestedFeeCents = this.connect ? Math.round(
74
+ paymentDto.amountCents * this.connect.platformFeeBps / 1e4
75
+ ) : 0;
76
+ const feeCents = Math.min(
77
+ requestedFeeCents,
78
+ Math.max(paymentDto.amountCents - 1, 0)
79
+ );
80
+ if (feeCents !== requestedFeeCents) {
81
+ this.openTelemetryCollector.warn(
82
+ "Stripe platform fee capped below the charge amount",
83
+ {
84
+ orderId: paymentDto.orderId,
85
+ amountCents: paymentDto.amountCents,
86
+ requestedFeeCents,
87
+ appliedFeeCents: feeCents
88
+ }
89
+ );
90
+ }
91
+ const paymentIntent = await this.stripeClient.paymentIntents.create(
92
+ {
93
+ amount: paymentDto.amountCents,
94
+ currency: paymentDto.currency,
95
+ metadata: { orderId: paymentDto.orderId },
96
+ ...this.connect && feeCents > 0 ? { application_fee_amount: feeCents } : {}
97
+ },
98
+ this.connect ? { stripeAccount: this.connect.connectedAccountId } : void 0
99
+ );
100
+ const payment = await this.basePaymentService.createPayment(
101
+ paymentDto,
102
+ em ?? this.em,
103
+ paymentIntent
104
+ );
105
+ return {
106
+ ...payment,
107
+ clientSecret: paymentIntent.client_secret ?? void 0
108
+ };
109
+ }
110
+ /**
111
+ * Re-issues the client_secret for a payment that already has a live
112
+ * PaymentIntent, so a checkout retry resumes that intent instead of
113
+ * opening a second one against the same order. Two live intents for one
114
+ * order are both confirmable, and a customer who confirms both is charged
115
+ * twice — see PaymentOrderLookupService in ecommerce-stripe for the full
116
+ * failure mode.
117
+ *
118
+ * The secret has to be fetched back from Stripe because it is
119
+ * deliberately never persisted (see createPayment) — Stripe is the only
120
+ * place it exists after that first response.
121
+ *
122
+ * A payment with no providerRef never reached Stripe, so there is no
123
+ * intent to resume and the record is returned unchanged, without a
124
+ * secret. The caller treats that the same way it treats no payment at
125
+ * all: create a fresh one.
126
+ */
127
+ async resumePayment(idDto, em) {
128
+ const payment = await this.basePaymentService.getPayment(idDto, em);
129
+ if (!payment.providerRef) {
130
+ return payment;
131
+ }
132
+ const paymentIntent = await this.stripeClient.paymentIntents.retrieve(
133
+ payment.providerRef,
134
+ {},
135
+ this.connect ? { stripeAccount: this.connect.connectedAccountId } : void 0
136
+ );
137
+ return {
138
+ ...payment,
139
+ clientSecret: paymentIntent.client_secret ?? void 0
140
+ };
141
+ }
142
+ async getPayment(idDto, em) {
143
+ return this.basePaymentService.getPayment(idDto, em);
144
+ }
145
+ /** Driven by the Stripe webhook (signature-verified at the controller layer) — idempotent. */
146
+ async confirmPayment(confirmDto, em) {
147
+ return this.basePaymentService.confirmPayment(confirmDto, em);
148
+ }
149
+ /** Failed payment emits the event dunning hooks into, at the deployable-app layer. */
150
+ async failPayment(failDto, em) {
151
+ return this.basePaymentService.failPayment(failDto, em);
152
+ }
153
+ };
154
+ // Annotate the CommonJS export names for ESM import in node:
155
+ 0 && (module.exports = {
156
+ StripePaymentService
157
+ });
@@ -0,0 +1,130 @@
1
+ // services/payment.service.ts
2
+ import { BasePaymentService } from "@forklaunch/implementation-ecommerce-base/services";
3
+ var StripePaymentService = class {
4
+ basePaymentService;
5
+ stripeClient;
6
+ em;
7
+ openTelemetryCollector;
8
+ connect;
9
+ constructor(stripeClient, em, openTelemetryCollector, schemaValidator, mappers, options) {
10
+ this.stripeClient = stripeClient;
11
+ this.em = em;
12
+ this.openTelemetryCollector = openTelemetryCollector;
13
+ if (options?.connect) {
14
+ const platformFeeBps = options.connect.platformFeeBps ?? 0;
15
+ if (!Number.isInteger(platformFeeBps) || platformFeeBps < 0 || platformFeeBps >= 1e4) {
16
+ throw new Error(
17
+ `Invalid Stripe platform fee: expected an integer from 0 to 9999 basis points, got ${platformFeeBps}. Check STRIPE_PLATFORM_FEE_BPS.`
18
+ );
19
+ }
20
+ this.connect = {
21
+ connectedAccountId: options.connect.connectedAccountId,
22
+ platformFeeBps
23
+ };
24
+ }
25
+ this.basePaymentService = new BasePaymentService(
26
+ em,
27
+ openTelemetryCollector,
28
+ schemaValidator,
29
+ mappers,
30
+ options
31
+ );
32
+ }
33
+ /**
34
+ * Creates the Stripe PaymentIntent, then persists the pending record with
35
+ * its id as providerRef. The PaymentIntent's `client_secret` is bolted
36
+ * onto the returned DTO (never persisted — Payment has no clientSecret
37
+ * column, deliberately: it's a one-time, provider-issued credential the
38
+ * frontend needs *right now* to actually collect the charge via
39
+ * Stripe.js, not something that should still be fetchable from GET
40
+ * /payment/:id later). This widens the return type over the
41
+ * PaymentService interface's `Promise<Params['PaymentDto']>` — a covariant,
42
+ * backward-compatible narrowing (an intersection type is always assignable
43
+ * to its base), the same relationship StripePaymentMappers already has to
44
+ * the base PaymentMappers for this class's mapper argument.
45
+ */
46
+ async createPayment(paymentDto, em) {
47
+ const requestedFeeCents = this.connect ? Math.round(
48
+ paymentDto.amountCents * this.connect.platformFeeBps / 1e4
49
+ ) : 0;
50
+ const feeCents = Math.min(
51
+ requestedFeeCents,
52
+ Math.max(paymentDto.amountCents - 1, 0)
53
+ );
54
+ if (feeCents !== requestedFeeCents) {
55
+ this.openTelemetryCollector.warn(
56
+ "Stripe platform fee capped below the charge amount",
57
+ {
58
+ orderId: paymentDto.orderId,
59
+ amountCents: paymentDto.amountCents,
60
+ requestedFeeCents,
61
+ appliedFeeCents: feeCents
62
+ }
63
+ );
64
+ }
65
+ const paymentIntent = await this.stripeClient.paymentIntents.create(
66
+ {
67
+ amount: paymentDto.amountCents,
68
+ currency: paymentDto.currency,
69
+ metadata: { orderId: paymentDto.orderId },
70
+ ...this.connect && feeCents > 0 ? { application_fee_amount: feeCents } : {}
71
+ },
72
+ this.connect ? { stripeAccount: this.connect.connectedAccountId } : void 0
73
+ );
74
+ const payment = await this.basePaymentService.createPayment(
75
+ paymentDto,
76
+ em ?? this.em,
77
+ paymentIntent
78
+ );
79
+ return {
80
+ ...payment,
81
+ clientSecret: paymentIntent.client_secret ?? void 0
82
+ };
83
+ }
84
+ /**
85
+ * Re-issues the client_secret for a payment that already has a live
86
+ * PaymentIntent, so a checkout retry resumes that intent instead of
87
+ * opening a second one against the same order. Two live intents for one
88
+ * order are both confirmable, and a customer who confirms both is charged
89
+ * twice — see PaymentOrderLookupService in ecommerce-stripe for the full
90
+ * failure mode.
91
+ *
92
+ * The secret has to be fetched back from Stripe because it is
93
+ * deliberately never persisted (see createPayment) — Stripe is the only
94
+ * place it exists after that first response.
95
+ *
96
+ * A payment with no providerRef never reached Stripe, so there is no
97
+ * intent to resume and the record is returned unchanged, without a
98
+ * secret. The caller treats that the same way it treats no payment at
99
+ * all: create a fresh one.
100
+ */
101
+ async resumePayment(idDto, em) {
102
+ const payment = await this.basePaymentService.getPayment(idDto, em);
103
+ if (!payment.providerRef) {
104
+ return payment;
105
+ }
106
+ const paymentIntent = await this.stripeClient.paymentIntents.retrieve(
107
+ payment.providerRef,
108
+ {},
109
+ this.connect ? { stripeAccount: this.connect.connectedAccountId } : void 0
110
+ );
111
+ return {
112
+ ...payment,
113
+ clientSecret: paymentIntent.client_secret ?? void 0
114
+ };
115
+ }
116
+ async getPayment(idDto, em) {
117
+ return this.basePaymentService.getPayment(idDto, em);
118
+ }
119
+ /** Driven by the Stripe webhook (signature-verified at the controller layer) — idempotent. */
120
+ async confirmPayment(confirmDto, em) {
121
+ return this.basePaymentService.confirmPayment(confirmDto, em);
122
+ }
123
+ /** Failed payment emits the event dunning hooks into, at the deployable-app layer. */
124
+ async failPayment(failDto, em) {
125
+ return this.basePaymentService.failPayment(failDto, em);
126
+ }
127
+ };
128
+ export {
129
+ StripePaymentService
130
+ };
@@ -0,0 +1,90 @@
1
+ import { MetricsDefinition, OpenTelemetryCollector, TelemetryOptions } from '@forklaunch/core/http';
2
+ import { BasePaymentService } from '@forklaunch/implementation-ecommerce-base/services';
3
+ import { BasePaymentDtos, BasePaymentEntities } from '@forklaunch/implementation-ecommerce-base/types';
4
+ import { PaymentService } from '@forklaunch/interfaces-ecommerce/interfaces';
5
+ import { ConfirmPaymentDto, CreatePaymentDto, FailPaymentDto } from '@forklaunch/interfaces-ecommerce/types';
6
+ import { AnySchemaValidator } from '@forklaunch/validator';
7
+ import { EntityManager } from '@mikro-orm/core';
8
+ import Stripe from 'stripe';
9
+ import { StripePaymentMappers } from '../domain/types/payment.mapper.types';
10
+ /**
11
+ * Wraps BasePaymentService with real Stripe PaymentIntent calls — the base
12
+ * does DB persistence, this class calls the provider API and delegates
13
+ * persistence back to base (same split as StripePlanService/BasePlanService
14
+ * in billing). Fills the payment seam: PaymentIntent drives the order
15
+ * toward paid; confirmation is idempotent and driven by the webhook.
16
+ */
17
+ export declare class StripePaymentService<SchemaValidator extends AnySchemaValidator, Entities extends BasePaymentEntities, Dto extends BasePaymentDtos = BasePaymentDtos> implements PaymentService {
18
+ basePaymentService: BasePaymentService<SchemaValidator, Entities, Dto>;
19
+ protected readonly stripeClient: Stripe;
20
+ protected readonly em: EntityManager;
21
+ protected readonly openTelemetryCollector: OpenTelemetryCollector<MetricsDefinition>;
22
+ protected readonly connect?: {
23
+ connectedAccountId: string;
24
+ platformFeeBps: number;
25
+ };
26
+ constructor(stripeClient: Stripe, em: EntityManager, openTelemetryCollector: OpenTelemetryCollector<MetricsDefinition>, schemaValidator: SchemaValidator, mappers: StripePaymentMappers<Entities, Dto>, options?: {
27
+ telemetry?: TelemetryOptions;
28
+ /**
29
+ * Stripe Connect (platform) mode. When set, PaymentIntents are created
30
+ * as DIRECT charges on the merchant's connected account — the merchant
31
+ * is the merchant of record and settles the funds — with
32
+ * `application_fee_amount` computed from platformFeeBps. The launch
33
+ * business model is NO markup (fee 0, see the Guild deck: "payments
34
+ * flat at launch"), so platformFeeBps defaults to 0 and the fee is
35
+ * omitted entirely; it exists as a dial, not a revenue assumption.
36
+ * When absent, behavior is unchanged: the deploy's own STRIPE_API_KEY
37
+ * account is the merchant (bring-your-own-key mode).
38
+ */
39
+ connect?: {
40
+ connectedAccountId: string;
41
+ platformFeeBps?: number;
42
+ };
43
+ });
44
+ /**
45
+ * Creates the Stripe PaymentIntent, then persists the pending record with
46
+ * its id as providerRef. The PaymentIntent's `client_secret` is bolted
47
+ * onto the returned DTO (never persisted — Payment has no clientSecret
48
+ * column, deliberately: it's a one-time, provider-issued credential the
49
+ * frontend needs *right now* to actually collect the charge via
50
+ * Stripe.js, not something that should still be fetchable from GET
51
+ * /payment/:id later). This widens the return type over the
52
+ * PaymentService interface's `Promise<Params['PaymentDto']>` — a covariant,
53
+ * backward-compatible narrowing (an intersection type is always assignable
54
+ * to its base), the same relationship StripePaymentMappers already has to
55
+ * the base PaymentMappers for this class's mapper argument.
56
+ */
57
+ createPayment(paymentDto: CreatePaymentDto, em?: EntityManager): Promise<Dto['PaymentMapper'] & {
58
+ clientSecret?: string;
59
+ }>;
60
+ /**
61
+ * Re-issues the client_secret for a payment that already has a live
62
+ * PaymentIntent, so a checkout retry resumes that intent instead of
63
+ * opening a second one against the same order. Two live intents for one
64
+ * order are both confirmable, and a customer who confirms both is charged
65
+ * twice — see PaymentOrderLookupService in ecommerce-stripe for the full
66
+ * failure mode.
67
+ *
68
+ * The secret has to be fetched back from Stripe because it is
69
+ * deliberately never persisted (see createPayment) — Stripe is the only
70
+ * place it exists after that first response.
71
+ *
72
+ * A payment with no providerRef never reached Stripe, so there is no
73
+ * intent to resume and the record is returned unchanged, without a
74
+ * secret. The caller treats that the same way it treats no payment at
75
+ * all: create a fresh one.
76
+ */
77
+ resumePayment(idDto: {
78
+ id: string;
79
+ }, em?: EntityManager): Promise<Dto['PaymentMapper'] & {
80
+ clientSecret?: string;
81
+ }>;
82
+ getPayment(idDto: {
83
+ id: string;
84
+ }, em?: EntityManager): Promise<Dto['PaymentMapper']>;
85
+ /** Driven by the Stripe webhook (signature-verified at the controller layer) — idempotent. */
86
+ confirmPayment(confirmDto: ConfirmPaymentDto, em?: EntityManager): Promise<Dto['PaymentMapper']>;
87
+ /** Failed payment emits the event dunning hooks into, at the deployable-app layer. */
88
+ failPayment(failDto: FailPaymentDto, em?: EntityManager): Promise<Dto['PaymentMapper']>;
89
+ }
90
+ //# sourceMappingURL=payment.service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"payment.service.d.ts","sourceRoot":"","sources":["../../services/payment.service.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,iBAAiB,EACjB,sBAAsB,EACtB,gBAAgB,EACjB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,kBAAkB,EAAE,MAAM,oDAAoD,CAAC;AACxF,OAAO,EACL,eAAe,EACf,mBAAmB,EACpB,MAAM,iDAAiD,CAAC;AACzD,OAAO,EAAE,cAAc,EAAE,MAAM,6CAA6C,CAAC;AAC7E,OAAO,EACL,iBAAiB,EACjB,gBAAgB,EAChB,cAAc,EACf,MAAM,wCAAwC,CAAC;AAChD,OAAO,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAChD,OAAO,MAAM,MAAM,QAAQ,CAAC;AAC5B,OAAO,EAAE,oBAAoB,EAAE,MAAM,sCAAsC,CAAC;AAE5E;;;;;;GAMG;AACH,qBAAa,oBAAoB,CAC/B,eAAe,SAAS,kBAAkB,EAC1C,QAAQ,SAAS,mBAAmB,EACpC,GAAG,SAAS,eAAe,GAAG,eAAe,CAC7C,YAAW,cAAc;IAEzB,kBAAkB,EAAE,kBAAkB,CAAC,eAAe,EAAE,QAAQ,EAAE,GAAG,CAAC,CAAC;IACvE,SAAS,CAAC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IACxC,SAAS,CAAC,QAAQ,CAAC,EAAE,EAAE,aAAa,CAAC;IACrC,SAAS,CAAC,QAAQ,CAAC,sBAAsB,EAAE,sBAAsB,CAAC,iBAAiB,CAAC,CAAC;IACrF,SAAS,CAAC,QAAQ,CAAC,OAAO,CAAC,EAAE;QAC3B,kBAAkB,EAAE,MAAM,CAAC;QAC3B,cAAc,EAAE,MAAM,CAAC;KACxB,CAAC;IAEF,YACE,YAAY,EAAE,MAAM,EACpB,EAAE,EAAE,aAAa,EACjB,sBAAsB,EAAE,sBAAsB,CAAC,iBAAiB,CAAC,EACjE,eAAe,EAAE,eAAe,EAChC,OAAO,EAAE,oBAAoB,CAAC,QAAQ,EAAE,GAAG,CAAC,EAC5C,OAAO,CAAC,EAAE;QACR,SAAS,CAAC,EAAE,gBAAgB,CAAC;QAC7B;;;;;;;;;;WAUG;QACH,OAAO,CAAC,EAAE;YACR,kBAAkB,EAAE,MAAM,CAAC;YAC3B,cAAc,CAAC,EAAE,MAAM,CAAC;SACzB,CAAC;KACH,EA4CF;IAED;;;;;;;;;;;;OAYG;IACG,aAAa,CACjB,UAAU,EAAE,gBAAgB,EAC5B,EAAE,CAAC,EAAE,aAAa,GACjB,OAAO,CAAC,GAAG,CAAC,eAAe,CAAC,GAAG;QAAE,YAAY,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAoD3D;IAED;;;;;;;;;;;;;;;;OAgBG;IACG,aAAa,CACjB,KAAK,EAAE;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,EACrB,EAAE,CAAC,EAAE,aAAa,GACjB,OAAO,CAAC,GAAG,CAAC,eAAe,CAAC,GAAG;QAAE,YAAY,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAgB3D;IAEK,UAAU,CACd,KAAK,EAAE;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,EACrB,EAAE,CAAC,EAAE,aAAa,GACjB,OAAO,CAAC,GAAG,CAAC,eAAe,CAAC,CAAC,CAE/B;IAED,8FAA8F;IACxF,cAAc,CAClB,UAAU,EAAE,iBAAiB,EAC7B,EAAE,CAAC,EAAE,aAAa,GACjB,OAAO,CAAC,GAAG,CAAC,eAAe,CAAC,CAAC,CAE/B;IAED,sFAAsF;IAChF,WAAW,CACf,OAAO,EAAE,cAAc,EACvB,EAAE,CAAC,EAAE,aAAa,GACjB,OAAO,CAAC,GAAG,CAAC,eAAe,CAAC,CAAC,CAE/B;CACF"}
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@forklaunch/implementation-ecommerce-stripe",
3
+ "version": "1.0.1",
4
+ "description": "Stripe implementation for forklaunch ecommerce",
5
+ "homepage": "https://github.com/forklaunch/forklaunch-js#readme",
6
+ "bugs": {
7
+ "url": "https://github.com/forklaunch/forklaunch-js/issues"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/forklaunch/forklaunch-js.git"
12
+ },
13
+ "license": "MIT",
14
+ "author": "Forklift Technologies, Inc.",
15
+ "exports": {
16
+ "./services": {
17
+ "types": "./lib/services/index.d.ts",
18
+ "import": "./lib/services/index.mjs",
19
+ "require": "./lib/services/index.js",
20
+ "default": "./lib/services/index.js"
21
+ },
22
+ "./types": {
23
+ "types": "./lib/domain/types/index.d.ts",
24
+ "import": "./lib/domain/types/index.mjs",
25
+ "require": "./lib/domain/types/index.js",
26
+ "default": "./lib/domain/types/index.js"
27
+ }
28
+ },
29
+ "files": [
30
+ "lib/**"
31
+ ],
32
+ "dependencies": {
33
+ "@forklaunch/common": "^1.2.24",
34
+ "@forklaunch/core": "^1.5.16",
35
+ "@forklaunch/internal": "^1.2.26",
36
+ "@forklaunch/validator": "^1.2.25",
37
+ "@mikro-orm/core": "7.1.14",
38
+ "@sinclair/typebox": "^0.34.52",
39
+ "ajv": "^8.20.0",
40
+ "stripe": "^22.5.0",
41
+ "zod": "^4.4.3",
42
+ "@forklaunch/implementation-ecommerce-base": "^1.0.6",
43
+ "@forklaunch/interfaces-ecommerce": "^1.0.6"
44
+ },
45
+ "devDependencies": {
46
+ "@typescript/native-preview": "7.0.0-dev.20260707.2",
47
+ "depcheck": "^1.4.7",
48
+ "prettier": "^3.9.6",
49
+ "typedoc": "^0.28.20"
50
+ },
51
+ "publishConfig": {
52
+ "access": "public"
53
+ },
54
+ "scripts": {
55
+ "build": "tsgo --noEmit && tsup services/index.ts domain/types/index.ts --format cjs,esm --no-splitting --tsconfig tsconfig.json --out-dir lib --clean && tsgo -p tsconfig.build.json --emitDeclarationOnly && if [ -f eject-package.bash ]; then pnpm package:eject; fi",
56
+ "clean": "rm -rf lib pnpm.lock.yaml node_modules",
57
+ "docs": "typedoc --out docs *",
58
+ "format": "prettier --ignore-path=.prettierignore --config .prettierrc '**/*.{ts,tsx,json}' --write",
59
+ "lint": "eslint . -c eslint.config.mjs",
60
+ "lint:fix": "eslint . -c eslint.config.mjs --fix",
61
+ "package:eject": "./eject-package.bash",
62
+ "publish:package": "./publish-package.bash",
63
+ "test": "vitest --passWithNoTests"
64
+ }
65
+ }