@dineway-ai/plugin-payment 0.2.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 +9 -0
- package/package.json +48 -0
- package/src/admin/api.ts +12 -0
- package/src/admin/form.ts +56 -0
- package/src/admin.tsx +151 -0
- package/src/configuration/index.ts +4 -0
- package/src/configuration/repository.ts +103 -0
- package/src/configuration/secrets.ts +43 -0
- package/src/configuration/service.ts +241 -0
- package/src/configuration/types.ts +38 -0
- package/src/constants.ts +1 -0
- package/src/index.ts +55 -0
- package/src/routes/index.ts +85 -0
- package/src/routes/schemas.ts +15 -0
- package/src/stripe/account-probe.ts +71 -0
- package/src/stripe/checkout-provider.ts +567 -0
- package/src/stripe/errors.ts +28 -0
- package/src/stripe/index.ts +5 -0
- package/src/stripe/refund-provider.ts +144 -0
- package/src/stripe/webhook-verifier.ts +223 -0
- package/src/types.ts +290 -0
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
import Stripe from "stripe";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
StripeAddress,
|
|
5
|
+
StripeCancelPaymentIntentInput,
|
|
6
|
+
StripeCapturePaymentIntentInput,
|
|
7
|
+
StripeCheckoutProvider,
|
|
8
|
+
StripeIncrementAuthorizationInput,
|
|
9
|
+
StripePaymentIntentInput,
|
|
10
|
+
StripePaymentIntentOperationResult,
|
|
11
|
+
StripePaymentIntentResult,
|
|
12
|
+
StripePaymentIntentStatus,
|
|
13
|
+
StripeTaxCalculationInput,
|
|
14
|
+
StripeTaxCalculationResult,
|
|
15
|
+
StripeTaxLineBreakdown,
|
|
16
|
+
} from "../types.js";
|
|
17
|
+
import { stripeProviderFailure } from "./errors.js";
|
|
18
|
+
|
|
19
|
+
const CURRENCY_PATTERN = /^[A-Z]{3}$/i;
|
|
20
|
+
|
|
21
|
+
export interface StripeCheckoutTaxCalculationResponse {
|
|
22
|
+
id: string | null;
|
|
23
|
+
apiVersion: string;
|
|
24
|
+
livemode: boolean;
|
|
25
|
+
currency: string;
|
|
26
|
+
expiresAt: number | null;
|
|
27
|
+
amountTotalMinor: number;
|
|
28
|
+
taxAmountExclusiveMinor: number;
|
|
29
|
+
taxAmountInclusiveMinor: number;
|
|
30
|
+
lineItems: Array<{
|
|
31
|
+
reference: string;
|
|
32
|
+
amountMinor: number;
|
|
33
|
+
taxMinor: number;
|
|
34
|
+
quantity: number;
|
|
35
|
+
taxBehavior: "exclusive" | "inclusive";
|
|
36
|
+
taxCode: string;
|
|
37
|
+
breakdown: StripeTaxLineBreakdown[];
|
|
38
|
+
}>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface StripeCheckoutPaymentIntentResponse {
|
|
42
|
+
id: string;
|
|
43
|
+
apiVersion: string;
|
|
44
|
+
clientSecret: string | null;
|
|
45
|
+
livemode: boolean;
|
|
46
|
+
status: StripePaymentIntentStatus;
|
|
47
|
+
amountMinor: number;
|
|
48
|
+
amountReceivedMinor: number;
|
|
49
|
+
amountCapturableMinor: number;
|
|
50
|
+
currency: string;
|
|
51
|
+
incrementalAuthorizationSupported?: boolean | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface StripeCheckoutClient {
|
|
55
|
+
createTaxCalculation(
|
|
56
|
+
params: Stripe.Tax.CalculationCreateParams,
|
|
57
|
+
options: Pick<Stripe.RequestOptions, "idempotencyKey">,
|
|
58
|
+
): Promise<StripeCheckoutTaxCalculationResponse>;
|
|
59
|
+
createPaymentIntent(
|
|
60
|
+
params: Stripe.PaymentIntentCreateParams,
|
|
61
|
+
options: Pick<Stripe.RequestOptions, "idempotencyKey">,
|
|
62
|
+
): Promise<StripeCheckoutPaymentIntentResponse>;
|
|
63
|
+
capturePaymentIntent?(
|
|
64
|
+
paymentIntentId: string,
|
|
65
|
+
params: Stripe.PaymentIntentCaptureParams,
|
|
66
|
+
options: Pick<Stripe.RequestOptions, "idempotencyKey">,
|
|
67
|
+
): Promise<StripeCheckoutPaymentIntentResponse>;
|
|
68
|
+
incrementAuthorization?(
|
|
69
|
+
paymentIntentId: string,
|
|
70
|
+
params: Stripe.PaymentIntentIncrementAuthorizationParams,
|
|
71
|
+
options: Pick<Stripe.RequestOptions, "idempotencyKey">,
|
|
72
|
+
): Promise<StripeCheckoutPaymentIntentResponse>;
|
|
73
|
+
cancelPaymentIntent?(
|
|
74
|
+
paymentIntentId: string,
|
|
75
|
+
params: Stripe.PaymentIntentCancelParams,
|
|
76
|
+
options: Pick<Stripe.RequestOptions, "idempotencyKey">,
|
|
77
|
+
): Promise<StripeCheckoutPaymentIntentResponse>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type StripeCheckoutClientFactory = (secretKey: string) => StripeCheckoutClient;
|
|
81
|
+
|
|
82
|
+
export class StripeNodeCheckoutProvider implements StripeCheckoutProvider {
|
|
83
|
+
constructor(
|
|
84
|
+
private readonly createClient: StripeCheckoutClientFactory = createStripeCheckoutClient,
|
|
85
|
+
) {}
|
|
86
|
+
|
|
87
|
+
async calculateTax(input: StripeTaxCalculationInput): Promise<StripeTaxCalculationResult> {
|
|
88
|
+
try {
|
|
89
|
+
assertTaxCalculationInput(input);
|
|
90
|
+
const calculation = await this.createClient(input.secretKey).createTaxCalculation(
|
|
91
|
+
{
|
|
92
|
+
currency: input.currency.toLowerCase(),
|
|
93
|
+
customer_details: {
|
|
94
|
+
address: stripeAddress(input.customerAddress),
|
|
95
|
+
address_source: input.customerAddressSource,
|
|
96
|
+
},
|
|
97
|
+
ship_from_details: { address: stripeAddress(input.shipFromAddress) },
|
|
98
|
+
line_items: input.lines.map((line) => ({
|
|
99
|
+
amount: line.amountMinor,
|
|
100
|
+
quantity: line.quantity,
|
|
101
|
+
reference: line.reference,
|
|
102
|
+
tax_behavior: line.taxBehavior,
|
|
103
|
+
...(line.taxCode ? { tax_code: line.taxCode } : {}),
|
|
104
|
+
})),
|
|
105
|
+
...(input.taxDate === undefined ? {} : { tax_date: input.taxDate }),
|
|
106
|
+
expand: ["line_items"],
|
|
107
|
+
},
|
|
108
|
+
{ idempotencyKey: input.idempotencyKey },
|
|
109
|
+
);
|
|
110
|
+
assertTaxCalculationResponse(input, calculation);
|
|
111
|
+
const linesByReference = new Map(
|
|
112
|
+
calculation.lineItems.map((line) => [line.reference, line] as const),
|
|
113
|
+
);
|
|
114
|
+
return {
|
|
115
|
+
ok: true,
|
|
116
|
+
calculationId: calculation.id,
|
|
117
|
+
apiVersion: calculation.apiVersion,
|
|
118
|
+
livemode: calculation.livemode,
|
|
119
|
+
currency: calculation.currency.toUpperCase(),
|
|
120
|
+
expiresAt: calculation.expiresAt,
|
|
121
|
+
amountTotalMinor: calculation.amountTotalMinor,
|
|
122
|
+
taxAmountExclusiveMinor: calculation.taxAmountExclusiveMinor,
|
|
123
|
+
taxAmountInclusiveMinor: calculation.taxAmountInclusiveMinor,
|
|
124
|
+
lines: input.lines.map((line) => {
|
|
125
|
+
const projected = linesByReference.get(line.reference);
|
|
126
|
+
if (!projected) throw new Error("Stripe Tax response omitted a requested line");
|
|
127
|
+
return projected;
|
|
128
|
+
}),
|
|
129
|
+
};
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return stripeProviderFailure(error);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async createPaymentIntent(input: StripePaymentIntentInput): Promise<StripePaymentIntentResult> {
|
|
136
|
+
try {
|
|
137
|
+
assertPaymentIntentInput(input);
|
|
138
|
+
const paymentIntent = await this.createClient(input.secretKey).createPaymentIntent(
|
|
139
|
+
{
|
|
140
|
+
amount: input.amountMinor,
|
|
141
|
+
currency: input.currency.toLowerCase(),
|
|
142
|
+
automatic_payment_methods: { enabled: true },
|
|
143
|
+
...(input.captureMethod ? { capture_method: input.captureMethod } : {}),
|
|
144
|
+
...(input.requestIncrementalAuthorizationSupport || input.requestOvercapture
|
|
145
|
+
? {
|
|
146
|
+
payment_method_options: {
|
|
147
|
+
card: {
|
|
148
|
+
...(input.requestIncrementalAuthorizationSupport
|
|
149
|
+
? { request_incremental_authorization: "if_available" as const }
|
|
150
|
+
: {}),
|
|
151
|
+
...(input.requestOvercapture
|
|
152
|
+
? { request_overcapture: "if_available" as const }
|
|
153
|
+
: {}),
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
: {}),
|
|
158
|
+
description: input.description,
|
|
159
|
+
...(input.metadata ? { metadata: input.metadata } : {}),
|
|
160
|
+
...(input.receiptEmail ? { receipt_email: input.receiptEmail } : {}),
|
|
161
|
+
},
|
|
162
|
+
{ idempotencyKey: input.idempotencyKey },
|
|
163
|
+
);
|
|
164
|
+
assertPaymentIntentResponse(input, paymentIntent);
|
|
165
|
+
return {
|
|
166
|
+
ok: true,
|
|
167
|
+
paymentIntentId: paymentIntent.id,
|
|
168
|
+
apiVersion: paymentIntent.apiVersion,
|
|
169
|
+
clientSecret: paymentIntent.clientSecret,
|
|
170
|
+
livemode: paymentIntent.livemode,
|
|
171
|
+
status: paymentIntent.status,
|
|
172
|
+
amountMinor: paymentIntent.amountMinor,
|
|
173
|
+
amountReceivedMinor: paymentIntent.amountReceivedMinor,
|
|
174
|
+
amountCapturableMinor: paymentIntent.amountCapturableMinor,
|
|
175
|
+
currency: paymentIntent.currency.toUpperCase(),
|
|
176
|
+
...(paymentIntent.incrementalAuthorizationSupported === undefined
|
|
177
|
+
? {}
|
|
178
|
+
: { incrementalAuthorizationSupported: paymentIntent.incrementalAuthorizationSupported }),
|
|
179
|
+
};
|
|
180
|
+
} catch (error) {
|
|
181
|
+
return stripeProviderFailure(error);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async capturePaymentIntent(
|
|
186
|
+
input: StripeCapturePaymentIntentInput,
|
|
187
|
+
): Promise<StripePaymentIntentOperationResult> {
|
|
188
|
+
try {
|
|
189
|
+
assertOperationInput(input, true);
|
|
190
|
+
const client = this.createClient(input.secretKey);
|
|
191
|
+
if (!client.capturePaymentIntent) throw new Error("Stripe capture is unavailable");
|
|
192
|
+
const paymentIntent = await client.capturePaymentIntent(
|
|
193
|
+
input.paymentIntentId,
|
|
194
|
+
{
|
|
195
|
+
amount_to_capture: input.amountMinor,
|
|
196
|
+
...(input.metadata ? { metadata: input.metadata } : {}),
|
|
197
|
+
},
|
|
198
|
+
{ idempotencyKey: input.idempotencyKey },
|
|
199
|
+
);
|
|
200
|
+
assertCaptureResponse(input, paymentIntent);
|
|
201
|
+
return operationSuccess(paymentIntent);
|
|
202
|
+
} catch (error) {
|
|
203
|
+
return stripeProviderFailure(error);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async incrementAuthorization(
|
|
208
|
+
input: StripeIncrementAuthorizationInput,
|
|
209
|
+
): Promise<StripePaymentIntentOperationResult> {
|
|
210
|
+
try {
|
|
211
|
+
assertOperationInput(input, true);
|
|
212
|
+
const client = this.createClient(input.secretKey);
|
|
213
|
+
if (!client.incrementAuthorization) {
|
|
214
|
+
throw new Error("Stripe incremental authorization is unavailable");
|
|
215
|
+
}
|
|
216
|
+
const paymentIntent = await client.incrementAuthorization(
|
|
217
|
+
input.paymentIntentId,
|
|
218
|
+
{
|
|
219
|
+
amount: input.amountMinor,
|
|
220
|
+
...(input.metadata ? { metadata: input.metadata } : {}),
|
|
221
|
+
},
|
|
222
|
+
{ idempotencyKey: input.idempotencyKey },
|
|
223
|
+
);
|
|
224
|
+
assertIncrementResponse(input, paymentIntent);
|
|
225
|
+
return operationSuccess(paymentIntent);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
return stripeProviderFailure(error);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async cancelPaymentIntent(
|
|
232
|
+
input: StripeCancelPaymentIntentInput,
|
|
233
|
+
): Promise<StripePaymentIntentOperationResult> {
|
|
234
|
+
try {
|
|
235
|
+
assertOperationInput(input, false);
|
|
236
|
+
const client = this.createClient(input.secretKey);
|
|
237
|
+
if (!client.cancelPaymentIntent) throw new Error("Stripe cancellation is unavailable");
|
|
238
|
+
const paymentIntent = await client.cancelPaymentIntent(
|
|
239
|
+
input.paymentIntentId,
|
|
240
|
+
{ cancellation_reason: input.reason ?? "requested_by_customer" },
|
|
241
|
+
{ idempotencyKey: input.idempotencyKey },
|
|
242
|
+
);
|
|
243
|
+
assertCancelResponse(input, paymentIntent);
|
|
244
|
+
return operationSuccess(paymentIntent);
|
|
245
|
+
} catch (error) {
|
|
246
|
+
return stripeProviderFailure(error);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function createStripeCheckoutClient(secretKey: string): StripeCheckoutClient {
|
|
252
|
+
const stripe = new Stripe(secretKey, {
|
|
253
|
+
appInfo: { name: "Dineway Payments" },
|
|
254
|
+
maxNetworkRetries: 1,
|
|
255
|
+
timeout: 15_000,
|
|
256
|
+
typescript: true,
|
|
257
|
+
});
|
|
258
|
+
return {
|
|
259
|
+
async createTaxCalculation(params, options) {
|
|
260
|
+
const calculation = await stripe.tax.calculations.create(params, options);
|
|
261
|
+
const expandedLines = calculation.line_items;
|
|
262
|
+
const lines =
|
|
263
|
+
expandedLines &&
|
|
264
|
+
!expandedLines.has_more &&
|
|
265
|
+
expandedLines.data.length === params.line_items.length
|
|
266
|
+
? expandedLines.data
|
|
267
|
+
: calculation.id
|
|
268
|
+
? await stripe.tax.calculations
|
|
269
|
+
.listLineItems(calculation.id, { limit: 100 })
|
|
270
|
+
.autoPagingToArray({ limit: 100 })
|
|
271
|
+
: [];
|
|
272
|
+
return {
|
|
273
|
+
id: calculation.id,
|
|
274
|
+
apiVersion: calculation.lastResponse.apiVersion ?? Stripe.API_VERSION,
|
|
275
|
+
livemode: calculation.livemode,
|
|
276
|
+
currency: calculation.currency,
|
|
277
|
+
expiresAt: calculation.expires_at,
|
|
278
|
+
amountTotalMinor: calculation.amount_total,
|
|
279
|
+
taxAmountExclusiveMinor: calculation.tax_amount_exclusive,
|
|
280
|
+
taxAmountInclusiveMinor: calculation.tax_amount_inclusive,
|
|
281
|
+
lineItems: lines.map((line) => ({
|
|
282
|
+
reference: line.reference,
|
|
283
|
+
amountMinor: line.amount,
|
|
284
|
+
taxMinor: line.amount_tax,
|
|
285
|
+
quantity: line.quantity,
|
|
286
|
+
taxBehavior: line.tax_behavior,
|
|
287
|
+
taxCode: line.tax_code,
|
|
288
|
+
breakdown: (line.tax_breakdown ?? []).map((item) => ({
|
|
289
|
+
amountMinor: item.amount,
|
|
290
|
+
taxableAmountMinor: item.taxable_amount,
|
|
291
|
+
taxabilityReason: item.taxability_reason,
|
|
292
|
+
sourcing: item.sourcing,
|
|
293
|
+
jurisdiction: {
|
|
294
|
+
countryCode: item.jurisdiction.country,
|
|
295
|
+
regionCode: item.jurisdiction.state,
|
|
296
|
+
displayName: item.jurisdiction.display_name,
|
|
297
|
+
level: item.jurisdiction.level,
|
|
298
|
+
},
|
|
299
|
+
rate: item.tax_rate_details
|
|
300
|
+
? {
|
|
301
|
+
displayName: item.tax_rate_details.display_name,
|
|
302
|
+
percentage: item.tax_rate_details.percentage_decimal,
|
|
303
|
+
taxType: item.tax_rate_details.tax_type,
|
|
304
|
+
}
|
|
305
|
+
: null,
|
|
306
|
+
})),
|
|
307
|
+
})),
|
|
308
|
+
};
|
|
309
|
+
},
|
|
310
|
+
async createPaymentIntent(params, options) {
|
|
311
|
+
return projectPaymentIntent(await stripe.paymentIntents.create(params, options));
|
|
312
|
+
},
|
|
313
|
+
async capturePaymentIntent(paymentIntentId, params, options) {
|
|
314
|
+
return projectPaymentIntent(
|
|
315
|
+
await stripe.paymentIntents.capture(paymentIntentId, params, options),
|
|
316
|
+
);
|
|
317
|
+
},
|
|
318
|
+
async incrementAuthorization(paymentIntentId, params, options) {
|
|
319
|
+
return projectPaymentIntent(
|
|
320
|
+
await stripe.paymentIntents.incrementAuthorization(paymentIntentId, params, options),
|
|
321
|
+
);
|
|
322
|
+
},
|
|
323
|
+
async cancelPaymentIntent(paymentIntentId, params, options) {
|
|
324
|
+
return projectPaymentIntent(
|
|
325
|
+
await stripe.paymentIntents.cancel(paymentIntentId, params, options),
|
|
326
|
+
);
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function projectPaymentIntent(
|
|
332
|
+
paymentIntent: Stripe.Response<Stripe.PaymentIntent>,
|
|
333
|
+
): StripeCheckoutPaymentIntentResponse {
|
|
334
|
+
const incrementalAuthorizationSupported = readIncrementalAuthorizationSupported(paymentIntent);
|
|
335
|
+
return {
|
|
336
|
+
id: paymentIntent.id,
|
|
337
|
+
apiVersion: paymentIntent.lastResponse.apiVersion ?? Stripe.API_VERSION,
|
|
338
|
+
clientSecret: paymentIntent.client_secret,
|
|
339
|
+
livemode: paymentIntent.livemode,
|
|
340
|
+
status: paymentIntent.status,
|
|
341
|
+
amountMinor: paymentIntent.amount,
|
|
342
|
+
amountReceivedMinor: paymentIntent.amount_received,
|
|
343
|
+
amountCapturableMinor: paymentIntent.amount_capturable,
|
|
344
|
+
currency: paymentIntent.currency,
|
|
345
|
+
...(incrementalAuthorizationSupported === undefined
|
|
346
|
+
? {}
|
|
347
|
+
: { incrementalAuthorizationSupported }),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function stripeAddress(address: StripeAddress) {
|
|
352
|
+
return {
|
|
353
|
+
country: address.countryCode.toUpperCase(),
|
|
354
|
+
...(address.line1 ? { line1: address.line1 } : {}),
|
|
355
|
+
...(address.line2 ? { line2: address.line2 } : {}),
|
|
356
|
+
...(address.city ? { city: address.city } : {}),
|
|
357
|
+
...(address.region ? { state: address.region } : {}),
|
|
358
|
+
...(address.postalCode ? { postal_code: address.postalCode } : {}),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function assertTaxCalculationInput(input: StripeTaxCalculationInput): void {
|
|
363
|
+
if (!input.secretKey.trim() || !input.idempotencyKey.trim() || !CURRENCY_PATTERN.test(input.currency)) {
|
|
364
|
+
throw new Error("Stripe Tax credentials or currency are invalid");
|
|
365
|
+
}
|
|
366
|
+
if (input.lines.length === 0 || input.lines.length > 100) {
|
|
367
|
+
throw new Error("Stripe Tax requires between one and 100 lines");
|
|
368
|
+
}
|
|
369
|
+
const references = new Set<string>();
|
|
370
|
+
for (const line of input.lines) {
|
|
371
|
+
if (!line.reference || references.has(line.reference)) {
|
|
372
|
+
throw new Error("Stripe Tax line references must be non-empty and unique");
|
|
373
|
+
}
|
|
374
|
+
references.add(line.reference);
|
|
375
|
+
if (!Number.isSafeInteger(line.amountMinor) || line.amountMinor <= 0) {
|
|
376
|
+
throw new Error("Stripe Tax line amounts must be positive safe integers");
|
|
377
|
+
}
|
|
378
|
+
if (!Number.isSafeInteger(line.quantity) || line.quantity <= 0) {
|
|
379
|
+
throw new Error("Stripe Tax line quantities must be positive safe integers");
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function assertTaxCalculationResponse(
|
|
385
|
+
input: StripeTaxCalculationInput,
|
|
386
|
+
response: StripeCheckoutTaxCalculationResponse,
|
|
387
|
+
): asserts response is StripeCheckoutTaxCalculationResponse & { id: string } {
|
|
388
|
+
if (!response.id || !response.apiVersion) throw new Error("Stripe Tax response is incomplete");
|
|
389
|
+
if (response.currency.toUpperCase() !== input.currency.toUpperCase()) {
|
|
390
|
+
throw new Error("Stripe Tax response currency does not match the request");
|
|
391
|
+
}
|
|
392
|
+
for (const amount of [
|
|
393
|
+
response.amountTotalMinor,
|
|
394
|
+
response.taxAmountExclusiveMinor,
|
|
395
|
+
response.taxAmountInclusiveMinor,
|
|
396
|
+
]) {
|
|
397
|
+
if (!Number.isSafeInteger(amount) || amount < 0) {
|
|
398
|
+
throw new Error("Stripe Tax response contains an invalid amount");
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
const responseLines = new Map(response.lineItems.map((line) => [line.reference, line] as const));
|
|
402
|
+
if (response.lineItems.length !== input.lines.length || responseLines.size !== input.lines.length) {
|
|
403
|
+
throw new Error("Stripe Tax response line set does not match the request");
|
|
404
|
+
}
|
|
405
|
+
let calculatedTaxMinor = 0;
|
|
406
|
+
let calculatedTotalMinor = 0;
|
|
407
|
+
for (const line of input.lines) {
|
|
408
|
+
const responseLine = responseLines.get(line.reference);
|
|
409
|
+
if (
|
|
410
|
+
!responseLine ||
|
|
411
|
+
responseLine.amountMinor !== line.amountMinor ||
|
|
412
|
+
responseLine.quantity !== line.quantity ||
|
|
413
|
+
responseLine.taxBehavior !== line.taxBehavior ||
|
|
414
|
+
(line.taxCode !== null && responseLine.taxCode !== line.taxCode) ||
|
|
415
|
+
!Number.isSafeInteger(responseLine.taxMinor) ||
|
|
416
|
+
responseLine.taxMinor < 0
|
|
417
|
+
) {
|
|
418
|
+
throw new Error("Stripe Tax response line does not match the request");
|
|
419
|
+
}
|
|
420
|
+
calculatedTaxMinor += responseLine.taxMinor;
|
|
421
|
+
calculatedTotalMinor += responseLine.amountMinor;
|
|
422
|
+
}
|
|
423
|
+
if (
|
|
424
|
+
calculatedTaxMinor !== response.taxAmountExclusiveMinor + response.taxAmountInclusiveMinor ||
|
|
425
|
+
calculatedTotalMinor + response.taxAmountExclusiveMinor !== response.amountTotalMinor
|
|
426
|
+
) {
|
|
427
|
+
throw new Error("Stripe Tax response totals are inconsistent");
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function assertPaymentIntentInput(input: StripePaymentIntentInput): void {
|
|
432
|
+
if (
|
|
433
|
+
!input.secretKey.trim() ||
|
|
434
|
+
!input.idempotencyKey.trim() ||
|
|
435
|
+
!Number.isSafeInteger(input.amountMinor) ||
|
|
436
|
+
input.amountMinor <= 0 ||
|
|
437
|
+
!CURRENCY_PATTERN.test(input.currency) ||
|
|
438
|
+
!input.description.trim() ||
|
|
439
|
+
input.description.length > 500 ||
|
|
440
|
+
!validMetadata(input.metadata)
|
|
441
|
+
) {
|
|
442
|
+
throw new Error("Stripe PaymentIntent input is invalid");
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function assertPaymentIntentResponse(
|
|
447
|
+
input: StripePaymentIntentInput,
|
|
448
|
+
response: StripeCheckoutPaymentIntentResponse,
|
|
449
|
+
): asserts response is StripeCheckoutPaymentIntentResponse & { clientSecret: string } {
|
|
450
|
+
if (!response.id || !response.apiVersion || !response.clientSecret) {
|
|
451
|
+
throw new Error("Stripe PaymentIntent response is incomplete");
|
|
452
|
+
}
|
|
453
|
+
if (
|
|
454
|
+
response.amountMinor !== input.amountMinor ||
|
|
455
|
+
response.currency.toUpperCase() !== input.currency.toUpperCase()
|
|
456
|
+
) {
|
|
457
|
+
throw new Error("Stripe PaymentIntent response does not match the request");
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function assertOperationInput(
|
|
462
|
+
input: StripeCancelPaymentIntentInput | StripeCapturePaymentIntentInput | StripeIncrementAuthorizationInput,
|
|
463
|
+
requireAmount: boolean,
|
|
464
|
+
): void {
|
|
465
|
+
if (
|
|
466
|
+
!input.secretKey.trim() ||
|
|
467
|
+
!input.idempotencyKey.trim() ||
|
|
468
|
+
!input.paymentIntentId.startsWith("pi_") ||
|
|
469
|
+
!CURRENCY_PATTERN.test(input.currency) ||
|
|
470
|
+
!validMetadata(input.metadata)
|
|
471
|
+
) {
|
|
472
|
+
throw new Error("Stripe PaymentIntent operation input is invalid");
|
|
473
|
+
}
|
|
474
|
+
if (requireAmount) {
|
|
475
|
+
const amount = (input as StripeCapturePaymentIntentInput).amountMinor;
|
|
476
|
+
if (!Number.isSafeInteger(amount) || amount <= 0) {
|
|
477
|
+
throw new Error("Stripe PaymentIntent operation amount is invalid");
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function assertCaptureResponse(
|
|
483
|
+
input: StripeCapturePaymentIntentInput,
|
|
484
|
+
response: StripeCheckoutPaymentIntentResponse,
|
|
485
|
+
): void {
|
|
486
|
+
assertMatchingOperationResponse(input, response);
|
|
487
|
+
if (
|
|
488
|
+
response.amountMinor < input.amountMinor ||
|
|
489
|
+
response.amountReceivedMinor !== input.amountMinor ||
|
|
490
|
+
response.amountReceivedMinor > response.amountMinor
|
|
491
|
+
) {
|
|
492
|
+
throw new Error("Stripe capture response amount is invalid");
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function assertIncrementResponse(
|
|
497
|
+
input: StripeIncrementAuthorizationInput,
|
|
498
|
+
response: StripeCheckoutPaymentIntentResponse,
|
|
499
|
+
): void {
|
|
500
|
+
assertMatchingOperationResponse(input, response);
|
|
501
|
+
if (
|
|
502
|
+
response.amountMinor !== input.amountMinor ||
|
|
503
|
+
response.amountReceivedMinor + response.amountCapturableMinor > response.amountMinor
|
|
504
|
+
) {
|
|
505
|
+
throw new Error("Stripe incremental authorization response is invalid");
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function assertCancelResponse(
|
|
510
|
+
input: StripeCancelPaymentIntentInput,
|
|
511
|
+
response: StripeCheckoutPaymentIntentResponse,
|
|
512
|
+
): void {
|
|
513
|
+
assertMatchingOperationResponse(input, response);
|
|
514
|
+
if (response.status !== "canceled" || response.amountCapturableMinor !== 0) {
|
|
515
|
+
throw new Error("Stripe cancellation response is invalid");
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function assertMatchingOperationResponse(
|
|
520
|
+
input: StripeCancelPaymentIntentInput | StripeCapturePaymentIntentInput | StripeIncrementAuthorizationInput,
|
|
521
|
+
response: StripeCheckoutPaymentIntentResponse,
|
|
522
|
+
): void {
|
|
523
|
+
if (
|
|
524
|
+
!response.id.startsWith("pi_") ||
|
|
525
|
+
!response.apiVersion ||
|
|
526
|
+
response.id !== input.paymentIntentId ||
|
|
527
|
+
response.currency.toUpperCase() !== input.currency.toUpperCase()
|
|
528
|
+
) {
|
|
529
|
+
throw new Error("Stripe PaymentIntent operation response does not match the request");
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function operationSuccess(response: StripeCheckoutPaymentIntentResponse) {
|
|
534
|
+
return {
|
|
535
|
+
ok: true as const,
|
|
536
|
+
paymentIntentId: response.id,
|
|
537
|
+
apiVersion: response.apiVersion,
|
|
538
|
+
livemode: response.livemode,
|
|
539
|
+
status: response.status,
|
|
540
|
+
amountMinor: response.amountMinor,
|
|
541
|
+
amountReceivedMinor: response.amountReceivedMinor,
|
|
542
|
+
amountCapturableMinor: response.amountCapturableMinor,
|
|
543
|
+
currency: response.currency.toUpperCase(),
|
|
544
|
+
...(response.incrementalAuthorizationSupported === undefined
|
|
545
|
+
? {}
|
|
546
|
+
: { incrementalAuthorizationSupported: response.incrementalAuthorizationSupported }),
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function validMetadata(metadata: Record<string, string> | undefined): boolean {
|
|
551
|
+
if (!metadata) return true;
|
|
552
|
+
const entries = Object.entries(metadata);
|
|
553
|
+
return (
|
|
554
|
+
entries.length <= 50 &&
|
|
555
|
+
entries.every(
|
|
556
|
+
([key, value]) => key.length > 0 && key.length <= 40 && value.length > 0 && value.length <= 500,
|
|
557
|
+
)
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function readIncrementalAuthorizationSupported(
|
|
562
|
+
paymentIntent: Stripe.PaymentIntent,
|
|
563
|
+
): boolean | null | undefined {
|
|
564
|
+
const latestCharge = paymentIntent.latest_charge;
|
|
565
|
+
if (!latestCharge || typeof latestCharge !== "object") return undefined;
|
|
566
|
+
return latestCharge.payment_method_details?.card_present?.incremental_authorization_supported;
|
|
567
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import Stripe from "stripe";
|
|
2
|
+
|
|
3
|
+
import type { StripeProviderFailure } from "../types.js";
|
|
4
|
+
|
|
5
|
+
export function stripeProviderFailure(error: unknown): StripeProviderFailure {
|
|
6
|
+
if (error instanceof Stripe.errors.StripeAuthenticationError) {
|
|
7
|
+
return { ok: false, errorCode: "authentication_failed", retryable: false };
|
|
8
|
+
}
|
|
9
|
+
if (error instanceof Stripe.errors.StripePermissionError) {
|
|
10
|
+
return { ok: false, errorCode: "permission_denied", retryable: false };
|
|
11
|
+
}
|
|
12
|
+
if (error instanceof Stripe.errors.StripeRateLimitError) {
|
|
13
|
+
return { ok: false, errorCode: "rate_limited", retryable: true };
|
|
14
|
+
}
|
|
15
|
+
if (error instanceof Stripe.errors.StripeConnectionError) {
|
|
16
|
+
return { ok: false, errorCode: "connection_error", retryable: true };
|
|
17
|
+
}
|
|
18
|
+
if (
|
|
19
|
+
error instanceof Stripe.errors.StripeInvalidRequestError ||
|
|
20
|
+
error instanceof Stripe.errors.StripeIdempotencyError
|
|
21
|
+
) {
|
|
22
|
+
return { ok: false, errorCode: "invalid_request", retryable: false };
|
|
23
|
+
}
|
|
24
|
+
if (error instanceof Stripe.errors.StripeAPIError) {
|
|
25
|
+
return { ok: false, errorCode: "provider_error", retryable: true };
|
|
26
|
+
}
|
|
27
|
+
return { ok: false, errorCode: "provider_error", retryable: false };
|
|
28
|
+
}
|