@open-mercato/core 0.8.1-develop.7442.1.4c1dbf1f0e → 0.8.1-develop.7447.1.ac22e0f0bc
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/dist/modules/payment_gateways/api/webhook/[provider]/route.js +201 -53
- package/dist/modules/payment_gateways/api/webhook/[provider]/route.js.map +2 -2
- package/dist/modules/payment_gateways/lib/gateway-service.js +37 -11
- package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/payment_gateways/api/webhook/[provider]/route.ts +243 -62
- package/src/modules/payment_gateways/lib/gateway-service.ts +39 -12
|
@@ -1,22 +1,33 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
|
+
import { z } from "zod";
|
|
2
3
|
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
3
4
|
import { findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
|
|
4
5
|
import { readJsonSafe } from "@open-mercato/shared/lib/http/readJsonSafe";
|
|
5
6
|
import { checkRateLimit, getClientIp, RATE_LIMIT_ERROR_FALLBACK } from "@open-mercato/shared/lib/ratelimit/helpers";
|
|
6
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
getWebhookHandler,
|
|
9
|
+
WebhookVerificationUnavailableError
|
|
10
|
+
} from "@open-mercato/shared/modules/payment_gateways/types";
|
|
7
11
|
import { markQueueJobOrigin } from "@open-mercato/shared/lib/queue/dispatchOrigin";
|
|
8
12
|
import { GatewayTransaction } from "../../../data/entities.js";
|
|
9
13
|
import { getPaymentGatewayQueue } from "../../../lib/queue.js";
|
|
10
14
|
import { processPaymentGatewayWebhookJob } from "../../../lib/webhook-processor.js";
|
|
11
15
|
import { paymentGatewaysTag } from "../../openapi.js";
|
|
12
16
|
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
13
|
-
import { readBoundedRequestBody, WebhookBodyTooLargeError } from "@open-mercato/shared/lib/webhooks";
|
|
17
|
+
import { readBoundedRequestBody, readBoundedRequestBytes, WebhookBodyTooLargeError } from "@open-mercato/shared/lib/webhooks";
|
|
18
|
+
import { getTelemetryRuntime } from "@open-mercato/shared/lib/telemetry/runtime";
|
|
14
19
|
const logger = createLogger("payment_gateways").child({ component: "webhook" });
|
|
15
20
|
const metadata = {
|
|
16
21
|
path: "/payment_gateways/webhook/[provider]",
|
|
17
22
|
POST: { requireAuth: false }
|
|
18
23
|
};
|
|
19
24
|
const WEBHOOK_VERIFICATION_FAILED = "Webhook verification failed";
|
|
25
|
+
const WEBHOOK_PAYLOAD_TOO_LARGE = "Webhook payload too large";
|
|
26
|
+
const INTERNAL_SERVER_ERROR = "Internal server error";
|
|
27
|
+
const DEFAULT_TEXT_CONTENT_TYPE = "text/plain; charset=utf-8";
|
|
28
|
+
const CONTROL_CHARACTERS = /[\u0000-\u001F\u007F]/;
|
|
29
|
+
const JSON_CONTENT_TYPE = /^application\/([\w.+-]+\+)?json(\s*;|$)/;
|
|
30
|
+
const paymentIdHintSchema = z.guid();
|
|
20
31
|
const paymentGatewayWebhookRateLimitConfig = {
|
|
21
32
|
points: 60,
|
|
22
33
|
duration: 60,
|
|
@@ -31,14 +42,27 @@ async function POST(req, { params }) {
|
|
|
31
42
|
return NextResponse.json({ error: `No webhook handler for provider: ${providerKey}` }, { status: 404 });
|
|
32
43
|
}
|
|
33
44
|
const rateLimitResponse = await checkProviderWebhookRateLimit(container, req, providerKey);
|
|
34
|
-
|
|
45
|
+
const outcome = rateLimitResponse ? "rate_limited" : await receiveWebhook(req, providerKey, registration, container);
|
|
46
|
+
const response = respond(registration, outcome, rateLimitResponse);
|
|
47
|
+
const logFields = { providerKey, outcome, status: response.status };
|
|
48
|
+
if (outcome === "accepted") logger.info("Webhook handled", logFields);
|
|
49
|
+
else logger.warn("Webhook handled", logFields);
|
|
50
|
+
return response;
|
|
51
|
+
}
|
|
52
|
+
async function receiveWebhook(req, providerKey, registration, container) {
|
|
35
53
|
let rawBody;
|
|
54
|
+
let bodyText;
|
|
36
55
|
try {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
56
|
+
if (registration.rawBody === "bytes") {
|
|
57
|
+
const bytes = registration.maxBodyBytes === void 0 ? new Uint8Array(await req.arrayBuffer()) : await readBoundedRequestBytes(req, { maxBytes: registration.maxBodyBytes });
|
|
58
|
+
rawBody = Buffer.from(bytes);
|
|
59
|
+
bodyText = new TextDecoder().decode(rawBody);
|
|
60
|
+
} else {
|
|
61
|
+
bodyText = registration.maxBodyBytes === void 0 ? await req.text() : await readBoundedRequestBody(req, { maxBytes: registration.maxBodyBytes });
|
|
62
|
+
rawBody = bodyText;
|
|
41
63
|
}
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (error instanceof WebhookBodyTooLargeError) return "payload_too_large";
|
|
42
66
|
throw error;
|
|
43
67
|
}
|
|
44
68
|
const headers = {};
|
|
@@ -49,62 +73,181 @@ async function POST(req, { params }) {
|
|
|
49
73
|
const em = container.resolve("em");
|
|
50
74
|
const integrationCredentialsService = container.resolve("integrationCredentialsService");
|
|
51
75
|
const queue = getPaymentGatewayQueue(registration.queue ?? "payment-gateways-webhook");
|
|
52
|
-
const payload = await readJsonSafe(
|
|
53
|
-
const
|
|
76
|
+
const payload = await readJsonSafe(bodyText);
|
|
77
|
+
const locatorContext = { rawBody, headers };
|
|
78
|
+
let sessionIdHint;
|
|
79
|
+
let paymentIdHint;
|
|
54
80
|
try {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
81
|
+
sessionIdHint = normalizeSessionIdHint(registration.readSessionIdHint?.(payload, locatorContext));
|
|
82
|
+
paymentIdHint = normalizePaymentIdHint(registration.readPaymentIdHint?.(payload, locatorContext));
|
|
83
|
+
} catch (error) {
|
|
84
|
+
reportWebhookError(error, "payment_gateways.webhook_locator_failed");
|
|
85
|
+
return "no_candidate";
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
let candidates;
|
|
89
|
+
try {
|
|
90
|
+
candidates = sessionIdHint || paymentIdHint ? await findWithDecryption(
|
|
91
|
+
em,
|
|
92
|
+
GatewayTransaction,
|
|
93
|
+
{
|
|
94
|
+
providerKey,
|
|
95
|
+
...sessionIdHint ? { providerSessionId: sessionIdHint } : {},
|
|
96
|
+
...paymentIdHint ? { paymentId: paymentIdHint } : {},
|
|
97
|
+
deletedAt: null
|
|
98
|
+
},
|
|
99
|
+
{ limit: 10, orderBy: { createdAt: "desc" } }
|
|
100
|
+
) : [];
|
|
101
|
+
} catch (error) {
|
|
102
|
+
reportWebhookError(error, "payment_gateways.webhook_lookup_failed");
|
|
103
|
+
return "processing_failed";
|
|
104
|
+
}
|
|
105
|
+
if (candidates.length === 0) return "no_candidate";
|
|
106
|
+
const verifiedMatches = [];
|
|
107
|
+
let verificationUnavailable = false;
|
|
69
108
|
for (const candidate of candidates) {
|
|
70
109
|
const candidateScope = { organizationId: candidate.organizationId, tenantId: candidate.tenantId };
|
|
71
|
-
|
|
110
|
+
let credentials;
|
|
72
111
|
try {
|
|
73
|
-
|
|
74
|
-
transaction = candidate;
|
|
75
|
-
matchedScope = candidateScope;
|
|
76
|
-
break;
|
|
112
|
+
credentials = await integrationCredentialsService.resolve(`gateway_${providerKey}`, candidateScope) ?? {};
|
|
77
113
|
} catch (error) {
|
|
78
|
-
|
|
114
|
+
reportWebhookError(error, "payment_gateways.webhook_credentials_failed");
|
|
115
|
+
verificationUnavailable = true;
|
|
116
|
+
continue;
|
|
79
117
|
}
|
|
118
|
+
try {
|
|
119
|
+
const candidateEvent = await registration.handler({
|
|
120
|
+
rawBody,
|
|
121
|
+
headers,
|
|
122
|
+
credentials,
|
|
123
|
+
candidate: {
|
|
124
|
+
transactionId: candidate.id,
|
|
125
|
+
paymentId: candidate.paymentId,
|
|
126
|
+
providerSessionId: candidate.providerSessionId ?? null,
|
|
127
|
+
amount: candidate.amount,
|
|
128
|
+
currencyCode: candidate.currencyCode
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
verifiedMatches.push({ transaction: candidate, scope: candidateScope, event: candidateEvent });
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (error instanceof WebhookVerificationUnavailableError) verificationUnavailable = true;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (verifiedMatches.length > 1) {
|
|
137
|
+
logger.error("Webhook matched multiple verified candidates", {
|
|
138
|
+
providerKey,
|
|
139
|
+
candidateCount: verifiedMatches.length
|
|
140
|
+
});
|
|
141
|
+
getTelemetryRuntime()?.reportError(
|
|
142
|
+
new Error("[internal] Webhook matched multiple verified candidates"),
|
|
143
|
+
{ module: "payment_gateways", code: "payment_gateways.webhook_ambiguous_candidates" }
|
|
144
|
+
);
|
|
145
|
+
return "verification_failed";
|
|
80
146
|
}
|
|
81
|
-
if (
|
|
82
|
-
|
|
147
|
+
if (verificationUnavailable && verifiedMatches.length === 1) {
|
|
148
|
+
logger.warn("Webhook candidate left unverified; ambiguity cannot be ruled out", {
|
|
149
|
+
providerKey,
|
|
150
|
+
candidateCount: candidates.length
|
|
151
|
+
});
|
|
152
|
+
return "verification_unavailable";
|
|
83
153
|
}
|
|
84
|
-
const
|
|
154
|
+
const [match] = verifiedMatches;
|
|
155
|
+
if (!match) return verificationUnavailable ? "verification_unavailable" : "verification_failed";
|
|
85
156
|
const jobPayload = markQueueJobOrigin({
|
|
86
157
|
providerKey,
|
|
87
|
-
event,
|
|
88
|
-
transactionId: transaction.id,
|
|
89
|
-
scope
|
|
158
|
+
event: match.event,
|
|
159
|
+
transactionId: match.transaction.id,
|
|
160
|
+
scope: match.scope
|
|
90
161
|
}, "inbound-webhook");
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
162
|
+
try {
|
|
163
|
+
if (process.env.QUEUE_STRATEGY === "async") {
|
|
164
|
+
await queue.enqueue(jobPayload);
|
|
165
|
+
} else {
|
|
166
|
+
await processPaymentGatewayWebhookJob(
|
|
167
|
+
{
|
|
168
|
+
em: container.resolve("em"),
|
|
169
|
+
paymentGatewayService: service,
|
|
170
|
+
integrationLogService: container.resolve("integrationLogService")
|
|
171
|
+
},
|
|
172
|
+
jobPayload
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
} catch (error) {
|
|
176
|
+
getTelemetryRuntime()?.reportError(error, {
|
|
177
|
+
module: "payment_gateways",
|
|
178
|
+
code: "payment_gateways.webhook_processing_failed"
|
|
179
|
+
});
|
|
180
|
+
return "processing_failed";
|
|
181
|
+
}
|
|
182
|
+
return "accepted";
|
|
183
|
+
} catch (error) {
|
|
184
|
+
reportWebhookError(error, "payment_gateways.webhook_unexpected_failure");
|
|
185
|
+
return "verification_failed";
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function respond(registration, outcome, rateLimitResponse) {
|
|
189
|
+
if (!registration.formatResponse) return legacyResponse(outcome, rateLimitResponse);
|
|
190
|
+
try {
|
|
191
|
+
const response = toValidatedResponse(registration.formatResponse(outcome));
|
|
192
|
+
if (outcome === "rate_limited" && rateLimitResponse) {
|
|
193
|
+
rateLimitResponse.headers.forEach((value, key) => {
|
|
194
|
+
if (key !== "content-type" && key !== "content-length") response.headers.set(key, value);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return response;
|
|
198
|
+
} catch (error) {
|
|
199
|
+
getTelemetryRuntime()?.reportError(error, {
|
|
200
|
+
module: "payment_gateways",
|
|
201
|
+
code: "payment_gateways.webhook_formatter_invalid"
|
|
202
|
+
});
|
|
203
|
+
return NextResponse.json({ error: INTERNAL_SERVER_ERROR }, { status: 500 });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function legacyResponse(outcome, rateLimitResponse) {
|
|
207
|
+
if (outcome === "rate_limited" && rateLimitResponse) return rateLimitResponse;
|
|
208
|
+
if (outcome === "accepted") return NextResponse.json({ received: true, queued: true }, { status: 202 });
|
|
209
|
+
if (outcome === "payload_too_large") return NextResponse.json({ error: WEBHOOK_PAYLOAD_TOO_LARGE }, { status: 413 });
|
|
210
|
+
return NextResponse.json({ error: WEBHOOK_VERIFICATION_FAILED }, { status: 401 });
|
|
211
|
+
}
|
|
212
|
+
function toValidatedResponse(formatted) {
|
|
213
|
+
const { status, body, contentType } = formatted;
|
|
214
|
+
if (!Number.isInteger(status) || status < 200 || status > 599) {
|
|
215
|
+
throw new Error("[internal] Webhook formatter returned an invalid status");
|
|
216
|
+
}
|
|
217
|
+
if (contentType !== void 0 && (typeof contentType !== "string" || CONTROL_CHARACTERS.test(contentType))) {
|
|
218
|
+
throw new Error("[internal] Webhook formatter returned an invalid content type");
|
|
219
|
+
}
|
|
220
|
+
const normalizedContentType = contentType?.toLowerCase();
|
|
221
|
+
if (typeof body === "string") {
|
|
222
|
+
if (normalizedContentType !== void 0 && !normalizedContentType.startsWith("text/")) {
|
|
223
|
+
throw new Error("[internal] Webhook formatter returned a non-text content type for a text body");
|
|
102
224
|
}
|
|
103
|
-
return NextResponse
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
|
|
225
|
+
return new NextResponse(body, { status, headers: { "content-type": contentType ?? DEFAULT_TEXT_CONTENT_TYPE } });
|
|
226
|
+
}
|
|
227
|
+
if (!isPlainObject(body)) {
|
|
228
|
+
throw new Error("[internal] Webhook formatter returned an invalid body");
|
|
107
229
|
}
|
|
230
|
+
if (normalizedContentType !== void 0 && !JSON_CONTENT_TYPE.test(normalizedContentType)) {
|
|
231
|
+
throw new Error("[internal] Webhook formatter returned a non-JSON content type for an object body");
|
|
232
|
+
}
|
|
233
|
+
const response = NextResponse.json(body, { status });
|
|
234
|
+
if (contentType !== void 0) response.headers.set("content-type", contentType);
|
|
235
|
+
return response;
|
|
236
|
+
}
|
|
237
|
+
function isPlainObject(value) {
|
|
238
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
239
|
+
const prototype = Object.getPrototypeOf(value);
|
|
240
|
+
return prototype === Object.prototype || prototype === null;
|
|
241
|
+
}
|
|
242
|
+
function reportWebhookError(error, code) {
|
|
243
|
+
getTelemetryRuntime()?.reportError(error, { module: "payment_gateways", code });
|
|
244
|
+
}
|
|
245
|
+
function normalizeSessionIdHint(value) {
|
|
246
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
247
|
+
}
|
|
248
|
+
function normalizePaymentIdHint(value) {
|
|
249
|
+
const parsed = paymentIdHintSchema.safeParse(value);
|
|
250
|
+
return parsed.success ? parsed.data : null;
|
|
108
251
|
}
|
|
109
252
|
async function checkProviderWebhookRateLimit(container, req, providerKey) {
|
|
110
253
|
const rateLimiterService = tryResolve(container, "rateLimiterService");
|
|
@@ -131,10 +274,15 @@ const openApi = {
|
|
|
131
274
|
summary: "Process inbound webhook from payment provider",
|
|
132
275
|
tags: [paymentGatewaysTag],
|
|
133
276
|
responses: [
|
|
134
|
-
{ status:
|
|
135
|
-
{ status:
|
|
277
|
+
{ status: 200, description: "Webhook accepted (provider-defined acknowledgement may apply)" },
|
|
278
|
+
{ status: 202, description: "Webhook accepted for processing (default acknowledgement)" },
|
|
279
|
+
{ status: 400, description: "Webhook rejected (provider-defined response may apply)" },
|
|
280
|
+
{ status: 401, description: "Webhook could not be verified or processed (default failure response)" },
|
|
281
|
+
{ status: 404, description: "Unknown provider" },
|
|
136
282
|
{ status: 413, description: "Webhook payload too large" },
|
|
137
|
-
{ status:
|
|
283
|
+
{ status: 429, description: "Too many webhook requests" },
|
|
284
|
+
{ status: 500, description: "Internal server error (provider-defined response may apply)" },
|
|
285
|
+
{ status: 503, description: "Webhook temporarily unavailable (provider-defined response may apply)" }
|
|
138
286
|
]
|
|
139
287
|
}
|
|
140
288
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/payment_gateways/api/webhook/%5Bprovider%5D/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport { checkRateLimit, getClientIp, RATE_LIMIT_ERROR_FALLBACK } from '@open-mercato/shared/lib/ratelimit/helpers'\nimport type { RateLimiterService } from '@open-mercato/shared/lib/ratelimit/service'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { getWebhookHandler } from '@open-mercato/shared/modules/payment_gateways/types'\nimport { markQueueJobOrigin } from '@open-mercato/shared/lib/queue/dispatchOrigin'\nimport type { IntegrationLogService } from '../../../../integrations/lib/log-service'\nimport type { PaymentGatewayService } from '../../../lib/gateway-service'\nimport type { CredentialsService } from '../../../../integrations/lib/credentials-service'\nimport { GatewayTransaction } from '../../../data/entities'\nimport { getPaymentGatewayQueue } from '../../../lib/queue'\nimport { processPaymentGatewayWebhookJob } from '../../../lib/webhook-processor'\nimport { paymentGatewaysTag } from '../../openapi'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { readBoundedRequestBody, WebhookBodyTooLargeError } from '@open-mercato/shared/lib/webhooks'\n\nconst logger = createLogger('payment_gateways').child({ component: 'webhook' })\n\nexport const metadata = {\n path: '/payment_gateways/webhook/[provider]',\n POST: { requireAuth: false },\n}\n\nconst WEBHOOK_VERIFICATION_FAILED = 'Webhook verification failed'\n\nconst paymentGatewayWebhookRateLimitConfig = {\n points: 60,\n duration: 60,\n keyPrefix: 'payment_gateways:webhook',\n}\n\nexport async function POST(req: Request, { params }: { params: Promise<{ provider: string }> | { provider: string } }) {\n const resolvedParams = await params\n const providerKey = resolvedParams.provider\n const container = await createRequestContainer()\n const registration = getWebhookHandler(providerKey)\n if (!registration) {\n return NextResponse.json({ error: `No webhook handler for provider: ${providerKey}` }, { status: 404 })\n }\n\n const rateLimitResponse = await checkProviderWebhookRateLimit(container, req, providerKey)\n if (rateLimitResponse) return rateLimitResponse\n\n let rawBody: string\n try {\n rawBody = registration.maxBodyBytes === undefined\n ? await req.text()\n : await readBoundedRequestBody(req, { maxBytes: registration.maxBodyBytes })\n } catch (error) {\n if (error instanceof WebhookBodyTooLargeError) {\n return NextResponse.json({ error: 'Webhook payload too large' }, { status: 413 })\n }\n throw error\n }\n const headers: Record<string, string> = {}\n req.headers.forEach((value, key) => {\n headers[key] = value\n })\n\n const service = container.resolve('paymentGatewayService') as PaymentGatewayService\n const em = container.resolve('em') as EntityManager\n const integrationCredentialsService = container.resolve('integrationCredentialsService') as CredentialsService\n const queue = getPaymentGatewayQueue(registration.queue ?? 'payment-gateways-webhook')\n const payload = await readJsonSafe<Record<string, unknown>>(rawBody)\n const sessionIdHint = registration.readSessionIdHint?.(payload) ?? null\n\n try {\n // The webhook endpoint is unauthenticated. Tenant/organization scope MUST come from a\n // GatewayTransaction whose per-tenant credentials successfully verify the inbound\n // signature \u2014 NEVER from attacker-controlled payload metadata. If no candidate\n // transaction can be located by the provider-reported session id, or no candidate's\n // credentials can verify the signature, we fail closed with 401. This prevents\n // forged webhooks (e.g. mock gateway PoC) from mutating another tenant's payment\n // state via `event.data.metadata.{organizationId,tenantId}`.\n const candidates = sessionIdHint\n ? await findWithDecryption(\n em,\n GatewayTransaction,\n {\n providerKey,\n providerSessionId: sessionIdHint,\n deletedAt: null,\n },\n { limit: 10, orderBy: { createdAt: 'desc' } },\n )\n : []\n\n let transaction: GatewayTransaction | null = null\n let matchedScope: { organizationId: string; tenantId: string } | null = null\n let event: Awaited<ReturnType<typeof registration.handler>> | null = null\n let lastVerificationError: unknown = null\n\n for (const candidate of candidates) {\n const candidateScope = { organizationId: candidate.organizationId, tenantId: candidate.tenantId }\n const credentials = await integrationCredentialsService.resolve(`gateway_${providerKey}`, candidateScope) ?? {}\n try {\n event = await registration.handler({ rawBody, headers, credentials })\n transaction = candidate\n matchedScope = candidateScope\n break\n } catch (error: unknown) {\n lastVerificationError = error\n }\n }\n\n if (!event || !transaction || !matchedScope) {\n throw lastVerificationError ?? new Error('Webhook verification failed: no matching transaction')\n }\n\n const scope = matchedScope\n\n const jobPayload = markQueueJobOrigin({\n providerKey,\n event,\n transactionId: transaction.id,\n scope,\n }, 'inbound-webhook')\n\n if (process.env.QUEUE_STRATEGY === 'async') {\n await queue.enqueue(jobPayload)\n } else {\n await processPaymentGatewayWebhookJob(\n {\n em: container.resolve('em') as EntityManager,\n paymentGatewayService: service,\n integrationLogService: container.resolve('integrationLogService') as IntegrationLogService,\n },\n jobPayload,\n )\n }\n\n return NextResponse.json({ received: true, queued: true }, { status: 202 })\n } catch (err: unknown) {\n logger.warn('Webhook verification failed', { providerKey, err })\n return NextResponse.json({ error: WEBHOOK_VERIFICATION_FAILED }, { status: 401 })\n }\n}\n\nasync function checkProviderWebhookRateLimit(\n container: { resolve: (name: string) => unknown },\n req: Request,\n providerKey: string,\n): Promise<NextResponse | null> {\n const rateLimiterService = tryResolve<RateLimiterService>(container, 'rateLimiterService')\n if (!rateLimiterService) return null\n\n return checkRateLimit(\n rateLimiterService,\n paymentGatewayWebhookRateLimitConfig,\n `${providerKey}:${getClientIp(req, rateLimiterService.trustProxyDepth) ?? 'unknown'}`,\n RATE_LIMIT_ERROR_FALLBACK,\n )\n}\n\nfunction tryResolve<T>(container: { resolve: (name: string) => unknown }, name: string): T | null {\n try {\n return container.resolve(name) as T\n } catch {\n return null\n }\n}\n\nexport const openApi = {\n tags: [paymentGatewaysTag],\n summary: 'Receive payment gateway webhook',\n methods: {\n POST: {\n summary: 'Process inbound webhook from payment provider',\n tags: [paymentGatewaysTag],\n responses: [\n { status: 202, description: 'Webhook accepted for async processing' },\n { status: 401, description: 'Signature verification failed' },\n { status: 413, description: 'Webhook payload too large' },\n { status: 404, description: 'Unknown provider' },\n ],\n },\n },\n}\n\nexport default POST\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,8BAA8B;AACvC,SAAS,0BAA0B;AACnC,SAAS,oBAAoB;AAC7B,SAAS,gBAAgB,aAAa,iCAAiC;AAGvE,
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport { checkRateLimit, getClientIp, RATE_LIMIT_ERROR_FALLBACK } from '@open-mercato/shared/lib/ratelimit/helpers'\nimport type { RateLimiterService } from '@open-mercato/shared/lib/ratelimit/service'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport {\n getWebhookHandler,\n WebhookVerificationUnavailableError,\n type WebhookHandlerRegistration,\n type WebhookHttpResponse,\n type WebhookResponseOutcome,\n} from '@open-mercato/shared/modules/payment_gateways/types'\nimport { markQueueJobOrigin } from '@open-mercato/shared/lib/queue/dispatchOrigin'\nimport type { IntegrationLogService } from '../../../../integrations/lib/log-service'\nimport type { PaymentGatewayService } from '../../../lib/gateway-service'\nimport type { CredentialsService } from '../../../../integrations/lib/credentials-service'\nimport { GatewayTransaction } from '../../../data/entities'\nimport { getPaymentGatewayQueue } from '../../../lib/queue'\nimport { processPaymentGatewayWebhookJob } from '../../../lib/webhook-processor'\nimport { paymentGatewaysTag } from '../../openapi'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { readBoundedRequestBody, readBoundedRequestBytes, WebhookBodyTooLargeError } from '@open-mercato/shared/lib/webhooks'\nimport { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'\n\nconst logger = createLogger('payment_gateways').child({ component: 'webhook' })\n\nexport const metadata = {\n path: '/payment_gateways/webhook/[provider]',\n POST: { requireAuth: false },\n}\n\nconst WEBHOOK_VERIFICATION_FAILED = 'Webhook verification failed'\nconst WEBHOOK_PAYLOAD_TOO_LARGE = 'Webhook payload too large'\nconst INTERNAL_SERVER_ERROR = 'Internal server error'\nconst DEFAULT_TEXT_CONTENT_TYPE = 'text/plain; charset=utf-8'\nconst CONTROL_CHARACTERS = /[\\u0000-\\u001F\\u007F]/\nconst JSON_CONTENT_TYPE = /^application\\/([\\w.+-]+\\+)?json(\\s*;|$)/\n\nconst paymentIdHintSchema = z.guid()\n\nconst paymentGatewayWebhookRateLimitConfig = {\n points: 60,\n duration: 60,\n keyPrefix: 'payment_gateways:webhook',\n}\n\ntype RequestContainer = Awaited<ReturnType<typeof createRequestContainer>>\n\nexport async function POST(req: Request, { params }: { params: Promise<{ provider: string }> | { provider: string } }) {\n const resolvedParams = await params\n const providerKey = resolvedParams.provider\n const container = await createRequestContainer()\n const registration = getWebhookHandler(providerKey)\n if (!registration) {\n return NextResponse.json({ error: `No webhook handler for provider: ${providerKey}` }, { status: 404 })\n }\n\n const rateLimitResponse = await checkProviderWebhookRateLimit(container, req, providerKey)\n const outcome: WebhookResponseOutcome = rateLimitResponse\n ? 'rate_limited'\n : await receiveWebhook(req, providerKey, registration, container)\n const response = respond(registration, outcome, rateLimitResponse)\n const logFields = { providerKey, outcome, status: response.status }\n if (outcome === 'accepted') logger.info('Webhook handled', logFields)\n else logger.warn('Webhook handled', logFields)\n return response\n}\n\nasync function receiveWebhook(\n req: Request,\n providerKey: string,\n registration: WebhookHandlerRegistration,\n container: RequestContainer,\n): Promise<WebhookResponseOutcome> {\n let rawBody: string | Buffer\n let bodyText: string\n try {\n if (registration.rawBody === 'bytes') {\n const bytes = registration.maxBodyBytes === undefined\n ? new Uint8Array(await req.arrayBuffer())\n : await readBoundedRequestBytes(req, { maxBytes: registration.maxBodyBytes })\n rawBody = Buffer.from(bytes)\n bodyText = new TextDecoder().decode(rawBody)\n } else {\n bodyText = registration.maxBodyBytes === undefined\n ? await req.text()\n : await readBoundedRequestBody(req, { maxBytes: registration.maxBodyBytes })\n rawBody = bodyText\n }\n } catch (error) {\n if (error instanceof WebhookBodyTooLargeError) return 'payload_too_large'\n throw error\n }\n const headers: Record<string, string> = {}\n req.headers.forEach((value, key) => {\n headers[key] = value\n })\n\n const service = container.resolve('paymentGatewayService') as PaymentGatewayService\n const em = container.resolve('em') as EntityManager\n const integrationCredentialsService = container.resolve('integrationCredentialsService') as CredentialsService\n const queue = getPaymentGatewayQueue(registration.queue ?? 'payment-gateways-webhook')\n const payload = await readJsonSafe<Record<string, unknown>>(bodyText)\n const locatorContext = { rawBody, headers }\n let sessionIdHint: string | null\n let paymentIdHint: string | null\n try {\n sessionIdHint = normalizeSessionIdHint(registration.readSessionIdHint?.(payload, locatorContext))\n paymentIdHint = normalizePaymentIdHint(registration.readPaymentIdHint?.(payload, locatorContext))\n } catch (error: unknown) {\n reportWebhookError(error, 'payment_gateways.webhook_locator_failed')\n return 'no_candidate'\n }\n\n try {\n // The webhook endpoint is unauthenticated. Tenant/organization scope MUST come from a\n // GatewayTransaction whose per-tenant credentials successfully verify the inbound\n // signature \u2014 NEVER from attacker-controlled payload metadata. If no candidate\n // transaction can be located by the provider-reported session or payment id, or no\n // candidate's credentials can verify the signature, we fail closed. This\n // prevents forged webhooks (e.g. mock gateway PoC) from mutating another tenant's\n // payment state via `event.data.metadata.{organizationId,tenantId}`. When more than one\n // candidate verifies, the scope is ambiguous and we also fail closed.\n let candidates: GatewayTransaction[]\n try {\n candidates = sessionIdHint || paymentIdHint\n ? await findWithDecryption(\n em,\n GatewayTransaction,\n {\n providerKey,\n ...(sessionIdHint ? { providerSessionId: sessionIdHint } : {}),\n ...(paymentIdHint ? { paymentId: paymentIdHint } : {}),\n deletedAt: null,\n },\n { limit: 10, orderBy: { createdAt: 'desc' } },\n )\n : []\n } catch (error: unknown) {\n reportWebhookError(error, 'payment_gateways.webhook_lookup_failed')\n return 'processing_failed'\n }\n\n if (candidates.length === 0) return 'no_candidate'\n\n const verifiedMatches: Array<{\n transaction: GatewayTransaction\n scope: { organizationId: string; tenantId: string }\n event: Awaited<ReturnType<typeof registration.handler>>\n }> = []\n let verificationUnavailable = false\n\n for (const candidate of candidates) {\n const candidateScope = { organizationId: candidate.organizationId, tenantId: candidate.tenantId }\n let credentials: Record<string, unknown>\n try {\n credentials = await integrationCredentialsService.resolve(`gateway_${providerKey}`, candidateScope) ?? {}\n } catch (error: unknown) {\n reportWebhookError(error, 'payment_gateways.webhook_credentials_failed')\n verificationUnavailable = true\n continue\n }\n try {\n const candidateEvent = await registration.handler({\n rawBody,\n headers,\n credentials,\n candidate: {\n transactionId: candidate.id,\n paymentId: candidate.paymentId,\n providerSessionId: candidate.providerSessionId ?? null,\n amount: candidate.amount,\n currencyCode: candidate.currencyCode,\n },\n })\n verifiedMatches.push({ transaction: candidate, scope: candidateScope, event: candidateEvent })\n } catch (error: unknown) {\n if (error instanceof WebhookVerificationUnavailableError) verificationUnavailable = true\n }\n }\n\n if (verifiedMatches.length > 1) {\n logger.error('Webhook matched multiple verified candidates', {\n providerKey,\n candidateCount: verifiedMatches.length,\n })\n getTelemetryRuntime()?.reportError(\n new Error('[internal] Webhook matched multiple verified candidates'),\n { module: 'payment_gateways', code: 'payment_gateways.webhook_ambiguous_candidates' },\n )\n return 'verification_failed'\n }\n\n if (verificationUnavailable && verifiedMatches.length === 1) {\n logger.warn('Webhook candidate left unverified; ambiguity cannot be ruled out', {\n providerKey,\n candidateCount: candidates.length,\n })\n return 'verification_unavailable'\n }\n\n const [match] = verifiedMatches\n if (!match) return verificationUnavailable ? 'verification_unavailable' : 'verification_failed'\n\n const jobPayload = markQueueJobOrigin({\n providerKey,\n event: match.event,\n transactionId: match.transaction.id,\n scope: match.scope,\n }, 'inbound-webhook')\n\n try {\n if (process.env.QUEUE_STRATEGY === 'async') {\n await queue.enqueue(jobPayload)\n } else {\n await processPaymentGatewayWebhookJob(\n {\n em: container.resolve('em') as EntityManager,\n paymentGatewayService: service,\n integrationLogService: container.resolve('integrationLogService') as IntegrationLogService,\n },\n jobPayload,\n )\n }\n } catch (error: unknown) {\n getTelemetryRuntime()?.reportError(error, {\n module: 'payment_gateways',\n code: 'payment_gateways.webhook_processing_failed',\n })\n return 'processing_failed'\n }\n\n return 'accepted'\n } catch (error: unknown) {\n reportWebhookError(error, 'payment_gateways.webhook_unexpected_failure')\n return 'verification_failed'\n }\n}\n\nfunction respond(\n registration: WebhookHandlerRegistration,\n outcome: WebhookResponseOutcome,\n rateLimitResponse: NextResponse | null,\n): NextResponse {\n if (!registration.formatResponse) return legacyResponse(outcome, rateLimitResponse)\n try {\n const response = toValidatedResponse(registration.formatResponse(outcome))\n if (outcome === 'rate_limited' && rateLimitResponse) {\n rateLimitResponse.headers.forEach((value, key) => {\n if (key !== 'content-type' && key !== 'content-length') response.headers.set(key, value)\n })\n }\n return response\n } catch (error: unknown) {\n getTelemetryRuntime()?.reportError(error, {\n module: 'payment_gateways',\n code: 'payment_gateways.webhook_formatter_invalid',\n })\n return NextResponse.json({ error: INTERNAL_SERVER_ERROR }, { status: 500 })\n }\n}\n\nfunction legacyResponse(outcome: WebhookResponseOutcome, rateLimitResponse: NextResponse | null): NextResponse {\n if (outcome === 'rate_limited' && rateLimitResponse) return rateLimitResponse\n if (outcome === 'accepted') return NextResponse.json({ received: true, queued: true }, { status: 202 })\n if (outcome === 'payload_too_large') return NextResponse.json({ error: WEBHOOK_PAYLOAD_TOO_LARGE }, { status: 413 })\n return NextResponse.json({ error: WEBHOOK_VERIFICATION_FAILED }, { status: 401 })\n}\n\nfunction toValidatedResponse(formatted: WebhookHttpResponse): NextResponse {\n const { status, body, contentType } = formatted\n if (!Number.isInteger(status) || status < 200 || status > 599) {\n throw new Error('[internal] Webhook formatter returned an invalid status')\n }\n if (contentType !== undefined && (typeof contentType !== 'string' || CONTROL_CHARACTERS.test(contentType))) {\n throw new Error('[internal] Webhook formatter returned an invalid content type')\n }\n const normalizedContentType = contentType?.toLowerCase()\n if (typeof body === 'string') {\n if (normalizedContentType !== undefined && !normalizedContentType.startsWith('text/')) {\n throw new Error('[internal] Webhook formatter returned a non-text content type for a text body')\n }\n return new NextResponse(body, { status, headers: { 'content-type': contentType ?? DEFAULT_TEXT_CONTENT_TYPE } })\n }\n if (!isPlainObject(body)) {\n throw new Error('[internal] Webhook formatter returned an invalid body')\n }\n if (normalizedContentType !== undefined && !JSON_CONTENT_TYPE.test(normalizedContentType)) {\n throw new Error('[internal] Webhook formatter returned a non-JSON content type for an object body')\n }\n const response = NextResponse.json(body, { status })\n if (contentType !== undefined) response.headers.set('content-type', contentType)\n return response\n}\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) return false\n const prototype = Object.getPrototypeOf(value)\n return prototype === Object.prototype || prototype === null\n}\n\nfunction reportWebhookError(error: unknown, code: string): void {\n getTelemetryRuntime()?.reportError(error, { module: 'payment_gateways', code })\n}\n\nfunction normalizeSessionIdHint(value: unknown): string | null {\n return typeof value === 'string' && value.length > 0 ? value : null\n}\n\nfunction normalizePaymentIdHint(value: unknown): string | null {\n const parsed = paymentIdHintSchema.safeParse(value)\n return parsed.success ? parsed.data : null\n}\n\nasync function checkProviderWebhookRateLimit(\n container: { resolve: (name: string) => unknown },\n req: Request,\n providerKey: string,\n): Promise<NextResponse | null> {\n const rateLimiterService = tryResolve<RateLimiterService>(container, 'rateLimiterService')\n if (!rateLimiterService) return null\n\n return checkRateLimit(\n rateLimiterService,\n paymentGatewayWebhookRateLimitConfig,\n `${providerKey}:${getClientIp(req, rateLimiterService.trustProxyDepth) ?? 'unknown'}`,\n RATE_LIMIT_ERROR_FALLBACK,\n )\n}\n\nfunction tryResolve<T>(container: { resolve: (name: string) => unknown }, name: string): T | null {\n try {\n return container.resolve(name) as T\n } catch {\n return null\n }\n}\n\nexport const openApi = {\n tags: [paymentGatewaysTag],\n summary: 'Receive payment gateway webhook',\n methods: {\n POST: {\n summary: 'Process inbound webhook from payment provider',\n tags: [paymentGatewaysTag],\n responses: [\n { status: 200, description: 'Webhook accepted (provider-defined acknowledgement may apply)' },\n { status: 202, description: 'Webhook accepted for processing (default acknowledgement)' },\n { status: 400, description: 'Webhook rejected (provider-defined response may apply)' },\n { status: 401, description: 'Webhook could not be verified or processed (default failure response)' },\n { status: 404, description: 'Unknown provider' },\n { status: 413, description: 'Webhook payload too large' },\n { status: 429, description: 'Too many webhook requests' },\n { status: 500, description: 'Internal server error (provider-defined response may apply)' },\n { status: 503, description: 'Webhook temporarily unavailable (provider-defined response may apply)' },\n ],\n },\n },\n}\n\nexport default POST\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { QueryOrder } from "@mikro-orm/core";
|
|
2
3
|
import { findOneWithDecryption, findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
|
|
3
4
|
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
4
5
|
import {
|
|
@@ -109,6 +110,13 @@ function createPaymentGatewayService(deps) {
|
|
|
109
110
|
payload: payload ?? null
|
|
110
111
|
}, scope);
|
|
111
112
|
}
|
|
113
|
+
async function stampLastPolledAt(transaction, polledAt) {
|
|
114
|
+
await em.nativeUpdate(
|
|
115
|
+
GatewayTransaction,
|
|
116
|
+
{ id: transaction.id, organizationId: transaction.organizationId, tenantId: transaction.tenantId },
|
|
117
|
+
{ lastPolledAt: polledAt }
|
|
118
|
+
);
|
|
119
|
+
}
|
|
112
120
|
async function resolveAdapterAndCredentials(providerKey, scope) {
|
|
113
121
|
const integrationId = `gateway_${providerKey}`;
|
|
114
122
|
const selectedVersion = deps.integrationStateService ? await deps.integrationStateService.resolveApiVersion(integrationId, scope) : void 0;
|
|
@@ -520,21 +528,37 @@ function createPaymentGatewayService(deps) {
|
|
|
520
528
|
},
|
|
521
529
|
async getPaymentStatus(transactionId, scope) {
|
|
522
530
|
const transaction = await findTransactionOrThrow(transactionId, scope);
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
{
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
531
|
+
let status;
|
|
532
|
+
try {
|
|
533
|
+
const { adapter, credentials } = await resolveAdapterAndCredentials(
|
|
534
|
+
transaction.providerKey,
|
|
535
|
+
{ organizationId: transaction.organizationId, tenantId: transaction.tenantId }
|
|
536
|
+
);
|
|
537
|
+
status = await adapter.getStatus({
|
|
538
|
+
sessionId: readProviderSessionId(transaction),
|
|
539
|
+
credentials
|
|
540
|
+
});
|
|
541
|
+
} catch (error) {
|
|
542
|
+
try {
|
|
543
|
+
await stampLastPolledAt(transaction, /* @__PURE__ */ new Date());
|
|
544
|
+
} catch (stampError) {
|
|
545
|
+
logger.warn("Failed to record last poll time after a failed status read", {
|
|
546
|
+
transactionId: transaction.id,
|
|
547
|
+
providerKey: transaction.providerKey,
|
|
548
|
+
err: stampError
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
throw error;
|
|
552
|
+
}
|
|
553
|
+
const polledAt = /* @__PURE__ */ new Date();
|
|
554
|
+
const shouldApplyStatus = status.status !== transaction.unifiedStatus && isValidTransition(transaction.unifiedStatus, status.status);
|
|
555
|
+
if (shouldApplyStatus) {
|
|
532
556
|
const previousStatus = transaction.unifiedStatus;
|
|
533
557
|
transaction.unifiedStatus = status.status;
|
|
534
558
|
alignCapturedAmountWithStatus(transaction, status.status);
|
|
535
559
|
transaction.gatewayStatus = status.status;
|
|
536
560
|
transaction.gatewayMetadata = { ...readGatewayMetadata(transaction.gatewayMetadata), statusResult: status.providerData ?? null };
|
|
537
|
-
transaction.lastPolledAt =
|
|
561
|
+
transaction.lastPolledAt = polledAt;
|
|
538
562
|
await em.flush();
|
|
539
563
|
await emitStatusEvent(status.status, {
|
|
540
564
|
transactionId: transaction.id,
|
|
@@ -555,6 +579,8 @@ function createPaymentGatewayService(deps) {
|
|
|
555
579
|
nextStatus: status.status
|
|
556
580
|
}
|
|
557
581
|
);
|
|
582
|
+
} else {
|
|
583
|
+
await stampLastPolledAt(transaction, polledAt);
|
|
558
584
|
}
|
|
559
585
|
return status;
|
|
560
586
|
},
|
|
@@ -654,7 +680,7 @@ function createPaymentGatewayService(deps) {
|
|
|
654
680
|
GatewayTransaction,
|
|
655
681
|
where,
|
|
656
682
|
{
|
|
657
|
-
orderBy: {
|
|
683
|
+
orderBy: { lastPolledAt: QueryOrder.ASC_NULLS_FIRST, createdAt: QueryOrder.ASC },
|
|
658
684
|
limit: scope?.limit ?? 100
|
|
659
685
|
},
|
|
660
686
|
scope
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/payment_gateways/lib/gateway-service.ts"],
|
|
4
|
-
"sourcesContent": ["import { randomUUID } from 'node:crypto'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport {\n getGatewayAdapter,\n type CreateSessionInput,\n type CreateSessionResult,\n type CaptureResult,\n type RefundResult,\n type CancelResult,\n type GatewayAdapter,\n type GatewayPaymentStatus,\n type PaymentGatewayPresentationRequest,\n type PaymentOrderTotalResolver,\n type UnifiedPaymentStatus,\n} from '@open-mercato/shared/modules/payment_gateways/types'\nimport type { CredentialsService } from '../../integrations/lib/credentials-service'\nimport type { IntegrationStateService } from '../../integrations/lib/state-service'\nimport type { IntegrationLogService } from '../../integrations/lib/log-service'\nimport { conflict, CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { GatewayPaymentOperation, GatewaySessionInitialization, GatewayTransaction } from '../data/entities'\nimport { canApplyManualAction, isValidTransition, type ManualGatewayAction } from './status-machine'\nimport { emitPaymentGatewayEvent } from '../events'\nimport { readGatewayMetadata, readWebhookLog } from './transaction-fields'\nimport { reconcileSessionAmountWithOrder } from './order-amount-reconciliation'\nimport {\n alignCapturedAmountWithStatus,\n assertCaptureWithinRemaining,\n formatAmountUnits,\n parseAmountUnits,\n releaseCaptureAmount,\n reserveCaptureAmount,\n settleCapturedAmount,\n} from './capture-ledger'\nimport {\n completePaymentOperation,\n failPaymentOperation,\n preparePaymentOperation,\n} from './payment-operation-idempotency'\nimport {\n buildPaymentSessionOperationKey,\n claimPaymentSessionInitialization,\n findPaymentSessionInitialization,\n reclaimPaymentSessionInitialization,\n refreshPaymentSessionInitialization,\n releasePaymentSessionInitialization,\n} from './session-idempotency'\n\nconst PAYMENT_SESSION_CLAIM_STALE_MS = 30_000\nconst PAYMENT_SESSION_WAIT_INTERVAL_MS = 25\nconst logger = createLogger('payment_gateways').child({ component: 'gateway-service' })\n\nfunction assertManualActionAllowed(action: ManualGatewayAction, transaction: GatewayTransaction): void {\n const current = transaction.unifiedStatus as UnifiedPaymentStatus\n if (!canApplyManualAction(action, current)) {\n throw conflict(`Cannot ${action} a payment in status \"${current}\"`)\n }\n}\n\nfunction applyAdapterResultStatus(\n action: ManualGatewayAction,\n transaction: GatewayTransaction,\n resultStatus: UnifiedPaymentStatus,\n): boolean {\n const current = transaction.unifiedStatus as UnifiedPaymentStatus\n if (resultStatus === current) return false\n if (isValidTransition(resultStatus, current)) return false\n if (!isValidTransition(current, resultStatus)) {\n throw conflict(\n `Gateway returned status \"${resultStatus}\" which is not a valid transition from \"${current}\" for ${action}`,\n )\n }\n transaction.unifiedStatus = resultStatus\n return true\n}\n\nexport interface PaymentGatewayServiceDeps {\n em: EntityManager\n integrationCredentialsService: CredentialsService\n integrationStateService?: IntegrationStateService\n integrationLogService?: IntegrationLogService\n /**\n * Optional seam supplied by the module that owns orders (`sales` by default).\n * When absent, session amounts cannot be reconciled and are accepted as-is.\n */\n paymentOrderTotalResolver?: PaymentOrderTotalResolver | null\n sessionClaimOptions?: {\n staleAfterMs?: number\n heartbeatIntervalMs?: number\n pollIntervalMs?: number\n }\n}\n\nexport interface CreatePaymentSessionInput {\n providerKey: string\n paymentId: string\n idempotencyKey?: string\n orderId?: string\n amount: number\n currencyCode: string\n captureMethod?: 'automatic' | 'manual'\n paymentTypes?: string[]\n description?: string\n successUrl?: string\n cancelUrl?: string\n metadata?: Record<string, unknown>\n presentation?: PaymentGatewayPresentationRequest\n organizationId: string\n tenantId: string\n}\n\nexport function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {\n const { em, integrationCredentialsService, integrationLogService } = deps\n const claimStaleAfterMs = Math.max(1, deps.sessionClaimOptions?.staleAfterMs ?? PAYMENT_SESSION_CLAIM_STALE_MS)\n const claimHeartbeatIntervalMs = Math.max(\n 1,\n deps.sessionClaimOptions?.heartbeatIntervalMs ?? Math.floor(claimStaleAfterMs / 3),\n )\n const claimPollIntervalMs = Math.max(1, deps.sessionClaimOptions?.pollIntervalMs ?? PAYMENT_SESSION_WAIT_INTERVAL_MS)\n\n async function findTransactionOrThrow(\n transactionId: string,\n scope: { organizationId: string; tenantId: string },\n targetEm: EntityManager = em,\n ): Promise<GatewayTransaction> {\n const transaction = await findOneWithDecryption(\n targetEm,\n GatewayTransaction,\n {\n id: transactionId,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n deletedAt: null,\n },\n undefined,\n scope,\n )\n if (!transaction) {\n throw new Error('Transaction not found')\n }\n return transaction\n }\n\n function readProviderSessionId(transaction: GatewayTransaction): string {\n if (typeof transaction.providerSessionId === 'string' && transaction.providerSessionId.trim().length > 0) {\n return transaction.providerSessionId\n }\n throw new Error('Transaction is missing provider session id')\n }\n\n async function emitStatusEvent(status: UnifiedPaymentStatus, payload: Record<string, unknown>) {\n type PaymentGatewayEventId = Parameters<typeof emitPaymentGatewayEvent>[0]\n const eventMap: Partial<Record<UnifiedPaymentStatus, PaymentGatewayEventId>> = {\n authorized: 'payment_gateways.payment.authorized',\n captured: 'payment_gateways.payment.captured',\n failed: 'payment_gateways.payment.failed',\n refunded: 'payment_gateways.payment.refunded',\n cancelled: 'payment_gateways.payment.cancelled',\n }\n const eventId = eventMap[status]\n if (!eventId) return\n await emitPaymentGatewayEvent(eventId, payload)\n }\n\n async function writeTransactionLog(\n providerKey: string,\n scope: { organizationId: string; tenantId: string },\n transactionId: string,\n level: 'info' | 'warn' | 'error',\n message: string,\n payload?: Record<string, unknown> | null,\n code?: string | null,\n ) {\n if (!integrationLogService) return\n await integrationLogService.write({\n integrationId: `gateway_${providerKey}`,\n scopeEntityType: 'payment_transaction',\n scopeEntityId: transactionId,\n level,\n message,\n code,\n payload: payload ?? null,\n }, scope)\n }\n\n async function resolveAdapterAndCredentials(providerKey: string, scope: { organizationId: string; tenantId: string }) {\n const integrationId = `gateway_${providerKey}`\n const selectedVersion = deps.integrationStateService\n ? await deps.integrationStateService.resolveApiVersion(integrationId, scope)\n : undefined\n const adapter = getGatewayAdapter(providerKey, selectedVersion)\n if (!adapter) {\n const message = selectedVersion\n ? `No gateway adapter registered for provider: ${providerKey} (version: ${selectedVersion})`\n : `No gateway adapter registered for provider: ${providerKey}`\n throw new CrudHttpError(422, { error: message })\n }\n const credentials = await integrationCredentialsService.resolve(integrationId, scope) ?? {}\n\n return { adapter, credentials }\n }\n\n type ManualOperationResult = CaptureResult | RefundResult | CancelResult\n\n async function executeManualOperation<TResult extends ManualOperationResult>(input: {\n action: ManualGatewayAction\n transactionId: string\n operationId?: string\n payload: Record<string, unknown>\n scope: { organizationId: string; tenantId: string }\n assertInitialAllowed?: (transaction: GatewayTransaction) => void\n beforeInvoke?: (context: { transaction: GatewayTransaction; operation: GatewayPaymentOperation }) => Promise<void>\n releaseOnFailure?: (context: {\n transaction: GatewayTransaction\n operation: GatewayPaymentOperation\n providerInvoked: boolean\n }) => Promise<void>\n invoke: (context: {\n adapter: GatewayAdapter\n credentials: Record<string, unknown>\n transaction: GatewayTransaction\n idempotencyKey: string\n }) => Promise<TResult>\n applyResult: (transaction: GatewayTransaction, result: TResult) => void\n afterCommit: (transaction: GatewayTransaction, result: TResult) => Promise<void>\n }): Promise<TResult> {\n const transaction = await findTransactionOrThrow(input.transactionId, input.scope)\n const prepared = await preparePaymentOperation({\n em,\n transactionId: transaction.id,\n providerKey: transaction.providerKey,\n action: input.action,\n operationId: input.operationId,\n payload: input.payload,\n scope: input.scope,\n assertInitialAllowed: () => {\n assertManualActionAllowed(input.action, transaction)\n input.assertInitialAllowed?.(transaction)\n },\n })\n if (prepared.kind === 'completed') {\n if (prepared.result.status !== transaction.unifiedStatus) {\n throw conflict(\n `Cannot replay ${input.action} because the payment status has changed since the operation completed`,\n )\n }\n return prepared.result as unknown as TResult\n }\n\n const operation = prepared.claim.record\n // Everything after this flips to true is post-settlement: the provider moved the money and the\n // completion transaction committed it. A later event or logging failure must not undo either,\n // so the failure path below only runs while the operation can still be rolled back.\n let settled = false\n // Flips once the provider call returned, so the failure path can tell \"the provider never\n // moved the money\" from \"the provider moved it and only our bookkeeping failed\" \u2014 the two\n // cases need opposite rollback decisions.\n let providerInvoked = false\n try {\n await input.beforeInvoke?.({ transaction, operation })\n const { adapter, credentials } = await resolveAdapterAndCredentials(\n transaction.providerKey,\n { organizationId: transaction.organizationId, tenantId: transaction.tenantId },\n )\n const result = await input.invoke({\n adapter,\n credentials,\n transaction,\n idempotencyKey: prepared.claim.providerIdempotencyKey,\n })\n providerInvoked = true\n const statusChanged = await completePaymentOperation(\n em,\n prepared.claim,\n result as unknown as Record<string, unknown>,\n async (tx) => {\n const current = await findTransactionOrThrow(input.transactionId, input.scope, tx)\n const changed = applyAdapterResultStatus(input.action, current, result.status)\n input.applyResult(current, result)\n return changed\n },\n )\n settled = true\n if (statusChanged) {\n await emitStatusEvent(result.status, {\n transactionId: transaction.id,\n paymentId: transaction.paymentId,\n providerKey: transaction.providerKey,\n organizationId: transaction.organizationId,\n tenantId: transaction.tenantId,\n })\n }\n await input.afterCommit(transaction, result)\n return result\n } catch (error: unknown) {\n if (!settled) {\n if (input.releaseOnFailure) {\n await input.releaseOnFailure({ transaction, operation, providerInvoked }).catch(() => undefined)\n }\n await Promise.allSettled([failPaymentOperation(em, prepared.claim)])\n }\n throw error\n }\n }\n\n function createGatewayTransaction(\n manager: EntityManager,\n input: CreatePaymentSessionInput,\n session: CreateSessionResult,\n id?: string,\n ): GatewayTransaction {\n const data = {\n paymentId: input.paymentId,\n providerKey: input.providerKey,\n providerSessionId: session.sessionId,\n unifiedStatus: session.status,\n redirectUrl: session.redirectUrl\n ?? (session.clientSession?.type === 'redirect' ? session.clientSession.redirectUrl : null),\n clientSecret: session.clientSecret ?? null,\n amount: String(input.amount),\n // An automatic-capture session comes back already captured, so the ledger has to start\n // at the full amount \u2014 otherwise a later manual capture would look like the first one.\n capturedAmount: session.status === 'captured' ? String(input.amount) : '0',\n currencyCode: input.currencyCode,\n gatewayMetadata: {\n ...(session.providerData ?? {}),\n ...(session.clientSession ? { clientSession: session.clientSession } : {}),\n },\n organizationId: input.organizationId,\n tenantId: input.tenantId,\n deletedAt: null,\n }\n return id\n ? manager.create(GatewayTransaction, { id, ...data })\n : manager.create(GatewayTransaction, data)\n }\n\n function restoreSession(transaction: GatewayTransaction): CreateSessionResult {\n const metadata = readGatewayMetadata(transaction.gatewayMetadata)\n const { clientSession, ...providerData } = metadata\n return {\n sessionId: readProviderSessionId(transaction),\n status: transaction.unifiedStatus as UnifiedPaymentStatus,\n ...(transaction.clientSecret ? { clientSecret: transaction.clientSecret } : {}),\n ...(transaction.redirectUrl ? { redirectUrl: transaction.redirectUrl } : {}),\n ...(Object.keys(providerData).length > 0 ? { providerData } : {}),\n ...(clientSession && typeof clientSession === 'object'\n ? { clientSession: clientSession as CreateSessionResult['clientSession'] }\n : {}),\n }\n }\n\n async function recordCreatedSession(\n transaction: GatewayTransaction,\n input: CreatePaymentSessionInput,\n scope: { organizationId: string; tenantId: string },\n ): Promise<void> {\n await emitPaymentGatewayEvent('payment_gateways.session.created', {\n transactionId: transaction.id,\n paymentId: transaction.paymentId,\n providerKey: transaction.providerKey,\n status: transaction.unifiedStatus,\n organizationId: transaction.organizationId,\n tenantId: transaction.tenantId,\n })\n await writeTransactionLog(\n transaction.providerKey,\n scope,\n transaction.id,\n 'info',\n 'Payment session created',\n {\n paymentId: transaction.paymentId,\n providerSessionId: transaction.providerSessionId,\n status: transaction.unifiedStatus,\n amount: input.amount,\n currencyCode: input.currencyCode,\n },\n )\n }\n\n function startClaimHeartbeat(\n ownership: { id: string; claimToken: string },\n scope: { organizationId: string; tenantId: string },\n ): () => Promise<void> {\n let stopped = false\n let timer: ReturnType<typeof setTimeout> | null = null\n let pendingRefresh = Promise.resolve()\n\n const schedule = () => {\n timer = setTimeout(() => {\n pendingRefresh = refreshPaymentSessionInitialization(em, ownership, scope, new Date())\n .then(\n (refreshed) => {\n if (refreshed && !stopped) schedule()\n },\n () => {\n if (!stopped) schedule()\n },\n )\n }, claimHeartbeatIntervalMs)\n }\n schedule()\n\n return async () => {\n stopped = true\n if (timer) clearTimeout(timer)\n await pendingRefresh\n }\n }\n\n return {\n async createPaymentSession(input: CreatePaymentSessionInput): Promise<{ transaction: GatewayTransaction; session: CreateSessionResult }> {\n const scope = { organizationId: input.organizationId, tenantId: input.tenantId }\n await reconcileSessionAmountWithOrder({\n orderId: input.orderId,\n amount: input.amount,\n currencyCode: input.currencyCode,\n scope,\n resolver: deps.paymentOrderTotalResolver,\n })\n const { adapter, credentials } = await resolveAdapterAndCredentials(input.providerKey, scope)\n\n const sessionInput: CreateSessionInput = {\n paymentId: input.paymentId,\n orderId: input.orderId,\n idempotencyKey: input.idempotencyKey,\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n amount: input.amount,\n currencyCode: input.currencyCode,\n captureMethod: input.captureMethod,\n paymentTypes: input.paymentTypes,\n description: input.description,\n successUrl: input.successUrl,\n cancelUrl: input.cancelUrl,\n metadata: input.metadata,\n presentation: input.presentation,\n credentials,\n }\n\n if (!input.idempotencyKey) {\n const session = await adapter.createSession(sessionInput)\n const transaction = createGatewayTransaction(em, input, session)\n await em.persist(transaction).flush()\n await recordCreatedSession(transaction, input, scope)\n return { transaction, session }\n }\n\n const operationKey = buildPaymentSessionOperationKey({\n idempotencyKey: input.idempotencyKey,\n paymentId: input.paymentId,\n providerKey: input.providerKey,\n scope,\n })\n while (true) {\n const existing = await findPaymentSessionInitialization(em, operationKey, input.providerKey, scope)\n if (existing?.gatewayTransactionId) {\n const transaction = await findOneWithDecryption(\n em.fork(),\n GatewayTransaction,\n {\n id: existing.gatewayTransactionId,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n deletedAt: null,\n },\n undefined,\n scope,\n )\n if (!transaction) {\n throw new Error('[internal] Completed payment session is missing its gateway transaction')\n }\n return { transaction, session: restoreSession(transaction) }\n }\n\n const claimedAt = new Date()\n const staleBefore = new Date(claimedAt.getTime() - claimStaleAfterMs)\n const ownership = existing\n ? await reclaimPaymentSessionInitialization(em, existing, scope, claimedAt, staleBefore)\n : await claimPaymentSessionInitialization(em, operationKey, input.providerKey, scope, claimedAt)\n\n if (!ownership) {\n await new Promise((resolve) => setTimeout(resolve, claimPollIntervalMs))\n continue\n }\n\n let session: CreateSessionResult\n const stopHeartbeat = startClaimHeartbeat(ownership, scope)\n try {\n session = await adapter.createSession({ ...sessionInput, idempotencyKey: operationKey })\n } catch (error) {\n try {\n await releasePaymentSessionInitialization(em, ownership, scope)\n } catch (releaseError) {\n logger.warn('Failed to release payment session initialization claim', {\n claimId: ownership.id,\n providerKey: input.providerKey,\n err: releaseError,\n })\n }\n throw error\n } finally {\n await stopHeartbeat()\n }\n\n const transactionId = randomUUID()\n const transaction = await em.fork().transactional(async (transactionEm) => {\n const finalizedRows = await transactionEm.nativeUpdate(\n GatewaySessionInitialization,\n {\n id: ownership.id,\n claimToken: ownership.claimToken,\n gatewayTransactionId: null,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n },\n {\n gatewayTransactionId: transactionId,\n claimToken: null,\n claimedAt: null,\n updatedAt: new Date(),\n },\n )\n if (finalizedRows === 0) return null\n const created = createGatewayTransaction(transactionEm, input, session, transactionId)\n await transactionEm.persist(created).flush()\n return created\n })\n\n if (!transaction) {\n continue\n }\n await recordCreatedSession(transaction, input, scope)\n return { transaction, session }\n }\n },\n\n async capturePayment(\n transactionId: string,\n amount: number | undefined,\n scope: { organizationId: string; tenantId: string },\n operationId?: string,\n ): Promise<CaptureResult> {\n let reservedUnits: bigint | null = null\n let providerAmount = amount\n return executeManualOperation({\n action: 'capture',\n transactionId,\n operationId,\n payload: { amount: amount ?? null },\n scope,\n assertInitialAllowed: (transaction) => { assertCaptureWithinRemaining(transaction, amount) },\n beforeInvoke: async ({ transaction, operation }) => {\n const alreadyCapturedUnits = parseAmountUnits(transaction.capturedAmount)\n reservedUnits = await reserveCaptureAmount(em, { transaction, operation, amount, scope })\n // \"Capture the rest\" has to name the remaining amount once part of the authorization is\n // already captured, otherwise the provider would capture the full amount a second time.\n if (amount === undefined && alreadyCapturedUnits > 0n) {\n providerAmount = Number(formatAmountUnits(reservedUnits))\n }\n },\n // The slice goes back only while the provider has not been called yet. Once the capture\n // call returned, the money may already have moved and only the completion transaction\n // failed, so releasing would reopen the authorization for a fresh operation id and allow an\n // over-capture. Holding the reservation is the conservative outcome: a retry of this same\n // operation id reuses both the reservation and the provider idempotency key, so the provider\n // itself collapses the duplicate.\n releaseOnFailure: async ({ transaction, operation, providerInvoked }) => {\n if (reservedUnits === null) return\n if (providerInvoked) {\n await writeTransactionLog(\n transaction.providerKey,\n { organizationId: transaction.organizationId, tenantId: transaction.tenantId },\n transaction.id,\n 'warn',\n 'Capture reservation stays outstanding because the provider call already returned',\n { operationId: operation.operationId, reservedAmount: formatAmountUnits(reservedUnits) },\n )\n return\n }\n const released = await releaseCaptureAmount(em, { transactionId, operation, reservedUnits, scope })\n if (released) return\n await writeTransactionLog(\n transaction.providerKey,\n { organizationId: transaction.organizationId, tenantId: transaction.tenantId },\n transaction.id,\n 'warn',\n 'Capture reservation stays outstanding after a failed capture',\n { operationId: operation.operationId, reservedAmount: formatAmountUnits(reservedUnits) },\n )\n },\n invoke: ({ adapter, credentials, transaction, idempotencyKey }) => adapter.capture({\n sessionId: readProviderSessionId(transaction),\n amount: providerAmount,\n credentials,\n idempotencyKey,\n }),\n applyResult: (transaction, result) => {\n transaction.gatewayMetadata = {\n ...readGatewayMetadata(transaction.gatewayMetadata),\n captureResult: result.providerData,\n }\n if (reservedUnits !== null) settleCapturedAmount(transaction, reservedUnits, result.capturedAmount)\n },\n afterCommit: (transaction, result) => writeTransactionLog(\n transaction.providerKey,\n { organizationId: transaction.organizationId, tenantId: transaction.tenantId },\n transaction.id,\n 'info',\n 'Payment captured',\n { amount: amount ?? null, status: result.status, capturedAmount: result.capturedAmount },\n ),\n })\n },\n\n async refundPayment(\n transactionId: string,\n amount: number | undefined,\n reason: string | undefined,\n scope: { organizationId: string; tenantId: string },\n operationId?: string,\n ): Promise<RefundResult> {\n return executeManualOperation({\n action: 'refund',\n transactionId,\n operationId,\n payload: { amount: amount ?? null, reason: reason ?? null },\n scope,\n invoke: ({ adapter, credentials, transaction, idempotencyKey }) => adapter.refund({\n sessionId: readProviderSessionId(transaction),\n amount,\n reason,\n credentials,\n idempotencyKey,\n }),\n applyResult: (transaction, result) => {\n transaction.gatewayRefundId = result.refundId\n transaction.gatewayMetadata = {\n ...readGatewayMetadata(transaction.gatewayMetadata),\n refundResult: result.providerData,\n }\n },\n afterCommit: (transaction, result) => writeTransactionLog(\n transaction.providerKey,\n { organizationId: transaction.organizationId, tenantId: transaction.tenantId },\n transaction.id,\n 'info',\n 'Payment refunded',\n { amount: amount ?? null, reason: reason ?? null, status: result.status, refundId: result.refundId },\n ),\n })\n },\n\n async cancelPayment(\n transactionId: string,\n reason: string | undefined,\n scope: { organizationId: string; tenantId: string },\n operationId?: string,\n ): Promise<CancelResult> {\n return executeManualOperation({\n action: 'cancel',\n transactionId,\n operationId,\n payload: { reason: reason ?? null },\n scope,\n invoke: ({ adapter, credentials, transaction, idempotencyKey }) => adapter.cancel({\n sessionId: readProviderSessionId(transaction),\n reason,\n credentials,\n idempotencyKey,\n }),\n applyResult: () => {},\n afterCommit: (transaction, result) => writeTransactionLog(\n transaction.providerKey,\n { organizationId: transaction.organizationId, tenantId: transaction.tenantId },\n transaction.id,\n 'info',\n 'Payment cancelled',\n { reason: reason ?? null, status: result.status },\n ),\n })\n },\n\n async getPaymentStatus(transactionId: string, scope: { organizationId: string; tenantId: string }): Promise<GatewayPaymentStatus> {\n const transaction = await findTransactionOrThrow(transactionId, scope)\n const { adapter, credentials } = await resolveAdapterAndCredentials(\n transaction.providerKey,\n { organizationId: transaction.organizationId, tenantId: transaction.tenantId },\n )\n\n const status = await adapter.getStatus({\n sessionId: readProviderSessionId(transaction),\n credentials,\n })\n\n if (status.status !== transaction.unifiedStatus && isValidTransition(transaction.unifiedStatus as UnifiedPaymentStatus, status.status)) {\n const previousStatus = transaction.unifiedStatus\n transaction.unifiedStatus = status.status\n alignCapturedAmountWithStatus(transaction, status.status)\n transaction.gatewayStatus = status.status\n transaction.gatewayMetadata = { ...readGatewayMetadata(transaction.gatewayMetadata), statusResult: status.providerData ?? null }\n transaction.lastPolledAt = new Date()\n await em.flush()\n await emitStatusEvent(status.status, {\n transactionId: transaction.id,\n paymentId: transaction.paymentId,\n providerKey: transaction.providerKey,\n previousStatus,\n organizationId: transaction.organizationId,\n tenantId: transaction.tenantId,\n })\n await writeTransactionLog(\n transaction.providerKey,\n { organizationId: transaction.organizationId, tenantId: transaction.tenantId },\n transaction.id,\n 'info',\n 'Payment status updated by poller',\n {\n previousStatus,\n nextStatus: status.status,\n },\n )\n }\n\n return status\n },\n\n async syncTransactionStatus(transactionId: string, update: {\n unifiedStatus: UnifiedPaymentStatus\n providerStatus?: string\n providerData?: Record<string, unknown>\n webhookEvent?: {\n eventType: string\n idempotencyKey: string\n processed: boolean\n receivedAt?: string\n }\n }, scope: { organizationId: string; tenantId: string }): Promise<void> {\n const transaction = await findTransactionOrThrow(transactionId, scope)\n const currentStatus = transaction.unifiedStatus as UnifiedPaymentStatus\n const canTransition = isValidTransition(currentStatus, update.unifiedStatus)\n const shouldApplyStatus = canTransition && update.unifiedStatus !== currentStatus\n const previousStatus = transaction.unifiedStatus\n if (shouldApplyStatus) {\n transaction.unifiedStatus = update.unifiedStatus\n alignCapturedAmountWithStatus(transaction, update.unifiedStatus)\n }\n if (update.providerStatus) {\n transaction.gatewayStatus = update.providerStatus\n }\n if (update.providerData) {\n transaction.gatewayMetadata = { ...readGatewayMetadata(transaction.gatewayMetadata), ...update.providerData }\n }\n if (update.webhookEvent) {\n const webhookLog = readWebhookLog(transaction.webhookLog)\n webhookLog.push({\n eventType: update.webhookEvent.eventType,\n receivedAt: update.webhookEvent.receivedAt ?? new Date().toISOString(),\n idempotencyKey: update.webhookEvent.idempotencyKey,\n unifiedStatus: update.unifiedStatus,\n processed: update.webhookEvent.processed,\n })\n transaction.webhookLog = webhookLog\n }\n transaction.lastWebhookAt = new Date()\n await em.flush()\n if (shouldApplyStatus) {\n await emitStatusEvent(update.unifiedStatus, {\n transactionId: transaction.id,\n paymentId: transaction.paymentId,\n providerKey: transaction.providerKey,\n previousStatus,\n organizationId: transaction.organizationId,\n tenantId: transaction.tenantId,\n })\n }\n await writeTransactionLog(\n transaction.providerKey,\n { organizationId: transaction.organizationId, tenantId: transaction.tenantId },\n transaction.id,\n shouldApplyStatus ? 'info' : 'warn',\n shouldApplyStatus ? 'Payment status synchronized from webhook' : 'Webhook received with no status transition',\n {\n previousStatus,\n nextStatus: update.unifiedStatus,\n providerStatus: update.providerStatus ?? null,\n eventType: update.webhookEvent?.eventType ?? null,\n idempotencyKey: update.webhookEvent?.idempotencyKey ?? null,\n },\n )\n },\n\n async findTransaction(id: string, scope: { organizationId: string; tenantId: string }): Promise<GatewayTransaction | null> {\n return findOneWithDecryption(\n em,\n GatewayTransaction,\n {\n id,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n deletedAt: null,\n },\n undefined,\n scope,\n )\n },\n\n async findTransactionBySessionId(\n providerSessionId: string,\n scope: { organizationId: string; tenantId: string },\n providerKey?: string,\n ): Promise<GatewayTransaction | null> {\n return findOneWithDecryption(\n em,\n GatewayTransaction,\n {\n providerSessionId,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n deletedAt: null,\n ...(providerKey ? { providerKey } : {}),\n },\n undefined,\n scope,\n )\n },\n\n async listTransactionsForStatusPolling(scope?: {\n organizationId?: string\n tenantId?: string\n providerKey?: string\n limit?: number\n }): Promise<GatewayTransaction[]> {\n const where: Record<string, unknown> = {\n unifiedStatus: { $in: ['pending', 'authorized', 'partially_captured'] },\n deletedAt: null,\n }\n if (scope?.organizationId) where.organizationId = scope.organizationId\n if (scope?.tenantId) where.tenantId = scope.tenantId\n if (scope?.providerKey) where.providerKey = scope.providerKey\n\n return findWithDecryption(\n em,\n GatewayTransaction,\n where,\n {\n orderBy: { updatedAt: 'asc' },\n limit: scope?.limit ?? 100,\n },\n scope,\n )\n },\n }\n}\n\nexport type PaymentGatewayService = ReturnType<typeof createPaymentGatewayService>\n"],
|
|
5
|
-
"mappings": "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",
|
|
4
|
+
"sourcesContent": ["import { randomUUID } from 'node:crypto'\nimport { QueryOrder } from '@mikro-orm/core'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport {\n getGatewayAdapter,\n type CreateSessionInput,\n type CreateSessionResult,\n type CaptureResult,\n type RefundResult,\n type CancelResult,\n type GatewayAdapter,\n type GatewayPaymentStatus,\n type PaymentGatewayPresentationRequest,\n type PaymentOrderTotalResolver,\n type UnifiedPaymentStatus,\n} from '@open-mercato/shared/modules/payment_gateways/types'\nimport type { CredentialsService } from '../../integrations/lib/credentials-service'\nimport type { IntegrationStateService } from '../../integrations/lib/state-service'\nimport type { IntegrationLogService } from '../../integrations/lib/log-service'\nimport { conflict, CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { GatewayPaymentOperation, GatewaySessionInitialization, GatewayTransaction } from '../data/entities'\nimport { canApplyManualAction, isValidTransition, type ManualGatewayAction } from './status-machine'\nimport { emitPaymentGatewayEvent } from '../events'\nimport { readGatewayMetadata, readWebhookLog } from './transaction-fields'\nimport { reconcileSessionAmountWithOrder } from './order-amount-reconciliation'\nimport {\n alignCapturedAmountWithStatus,\n assertCaptureWithinRemaining,\n formatAmountUnits,\n parseAmountUnits,\n releaseCaptureAmount,\n reserveCaptureAmount,\n settleCapturedAmount,\n} from './capture-ledger'\nimport {\n completePaymentOperation,\n failPaymentOperation,\n preparePaymentOperation,\n} from './payment-operation-idempotency'\nimport {\n buildPaymentSessionOperationKey,\n claimPaymentSessionInitialization,\n findPaymentSessionInitialization,\n reclaimPaymentSessionInitialization,\n refreshPaymentSessionInitialization,\n releasePaymentSessionInitialization,\n} from './session-idempotency'\n\nconst PAYMENT_SESSION_CLAIM_STALE_MS = 30_000\nconst PAYMENT_SESSION_WAIT_INTERVAL_MS = 25\nconst logger = createLogger('payment_gateways').child({ component: 'gateway-service' })\n\nfunction assertManualActionAllowed(action: ManualGatewayAction, transaction: GatewayTransaction): void {\n const current = transaction.unifiedStatus as UnifiedPaymentStatus\n if (!canApplyManualAction(action, current)) {\n throw conflict(`Cannot ${action} a payment in status \"${current}\"`)\n }\n}\n\nfunction applyAdapterResultStatus(\n action: ManualGatewayAction,\n transaction: GatewayTransaction,\n resultStatus: UnifiedPaymentStatus,\n): boolean {\n const current = transaction.unifiedStatus as UnifiedPaymentStatus\n if (resultStatus === current) return false\n if (isValidTransition(resultStatus, current)) return false\n if (!isValidTransition(current, resultStatus)) {\n throw conflict(\n `Gateway returned status \"${resultStatus}\" which is not a valid transition from \"${current}\" for ${action}`,\n )\n }\n transaction.unifiedStatus = resultStatus\n return true\n}\n\nexport interface PaymentGatewayServiceDeps {\n em: EntityManager\n integrationCredentialsService: CredentialsService\n integrationStateService?: IntegrationStateService\n integrationLogService?: IntegrationLogService\n /**\n * Optional seam supplied by the module that owns orders (`sales` by default).\n * When absent, session amounts cannot be reconciled and are accepted as-is.\n */\n paymentOrderTotalResolver?: PaymentOrderTotalResolver | null\n sessionClaimOptions?: {\n staleAfterMs?: number\n heartbeatIntervalMs?: number\n pollIntervalMs?: number\n }\n}\n\nexport interface CreatePaymentSessionInput {\n providerKey: string\n paymentId: string\n idempotencyKey?: string\n orderId?: string\n amount: number\n currencyCode: string\n captureMethod?: 'automatic' | 'manual'\n paymentTypes?: string[]\n description?: string\n successUrl?: string\n cancelUrl?: string\n metadata?: Record<string, unknown>\n presentation?: PaymentGatewayPresentationRequest\n organizationId: string\n tenantId: string\n}\n\nexport function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {\n const { em, integrationCredentialsService, integrationLogService } = deps\n const claimStaleAfterMs = Math.max(1, deps.sessionClaimOptions?.staleAfterMs ?? PAYMENT_SESSION_CLAIM_STALE_MS)\n const claimHeartbeatIntervalMs = Math.max(\n 1,\n deps.sessionClaimOptions?.heartbeatIntervalMs ?? Math.floor(claimStaleAfterMs / 3),\n )\n const claimPollIntervalMs = Math.max(1, deps.sessionClaimOptions?.pollIntervalMs ?? PAYMENT_SESSION_WAIT_INTERVAL_MS)\n\n async function findTransactionOrThrow(\n transactionId: string,\n scope: { organizationId: string; tenantId: string },\n targetEm: EntityManager = em,\n ): Promise<GatewayTransaction> {\n const transaction = await findOneWithDecryption(\n targetEm,\n GatewayTransaction,\n {\n id: transactionId,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n deletedAt: null,\n },\n undefined,\n scope,\n )\n if (!transaction) {\n throw new Error('Transaction not found')\n }\n return transaction\n }\n\n function readProviderSessionId(transaction: GatewayTransaction): string {\n if (typeof transaction.providerSessionId === 'string' && transaction.providerSessionId.trim().length > 0) {\n return transaction.providerSessionId\n }\n throw new Error('Transaction is missing provider session id')\n }\n\n async function emitStatusEvent(status: UnifiedPaymentStatus, payload: Record<string, unknown>) {\n type PaymentGatewayEventId = Parameters<typeof emitPaymentGatewayEvent>[0]\n const eventMap: Partial<Record<UnifiedPaymentStatus, PaymentGatewayEventId>> = {\n authorized: 'payment_gateways.payment.authorized',\n captured: 'payment_gateways.payment.captured',\n failed: 'payment_gateways.payment.failed',\n refunded: 'payment_gateways.payment.refunded',\n cancelled: 'payment_gateways.payment.cancelled',\n }\n const eventId = eventMap[status]\n if (!eventId) return\n await emitPaymentGatewayEvent(eventId, payload)\n }\n\n async function writeTransactionLog(\n providerKey: string,\n scope: { organizationId: string; tenantId: string },\n transactionId: string,\n level: 'info' | 'warn' | 'error',\n message: string,\n payload?: Record<string, unknown> | null,\n code?: string | null,\n ) {\n if (!integrationLogService) return\n await integrationLogService.write({\n integrationId: `gateway_${providerKey}`,\n scopeEntityType: 'payment_transaction',\n scopeEntityId: transactionId,\n level,\n message,\n code,\n payload: payload ?? null,\n }, scope)\n }\n\n async function stampLastPolledAt(transaction: GatewayTransaction, polledAt: Date): Promise<void> {\n await em.nativeUpdate(\n GatewayTransaction,\n { id: transaction.id, organizationId: transaction.organizationId, tenantId: transaction.tenantId },\n { lastPolledAt: polledAt },\n )\n }\n\n async function resolveAdapterAndCredentials(providerKey: string, scope: { organizationId: string; tenantId: string }) {\n const integrationId = `gateway_${providerKey}`\n const selectedVersion = deps.integrationStateService\n ? await deps.integrationStateService.resolveApiVersion(integrationId, scope)\n : undefined\n const adapter = getGatewayAdapter(providerKey, selectedVersion)\n if (!adapter) {\n const message = selectedVersion\n ? `No gateway adapter registered for provider: ${providerKey} (version: ${selectedVersion})`\n : `No gateway adapter registered for provider: ${providerKey}`\n throw new CrudHttpError(422, { error: message })\n }\n const credentials = await integrationCredentialsService.resolve(integrationId, scope) ?? {}\n\n return { adapter, credentials }\n }\n\n type ManualOperationResult = CaptureResult | RefundResult | CancelResult\n\n async function executeManualOperation<TResult extends ManualOperationResult>(input: {\n action: ManualGatewayAction\n transactionId: string\n operationId?: string\n payload: Record<string, unknown>\n scope: { organizationId: string; tenantId: string }\n assertInitialAllowed?: (transaction: GatewayTransaction) => void\n beforeInvoke?: (context: { transaction: GatewayTransaction; operation: GatewayPaymentOperation }) => Promise<void>\n releaseOnFailure?: (context: {\n transaction: GatewayTransaction\n operation: GatewayPaymentOperation\n providerInvoked: boolean\n }) => Promise<void>\n invoke: (context: {\n adapter: GatewayAdapter\n credentials: Record<string, unknown>\n transaction: GatewayTransaction\n idempotencyKey: string\n }) => Promise<TResult>\n applyResult: (transaction: GatewayTransaction, result: TResult) => void\n afterCommit: (transaction: GatewayTransaction, result: TResult) => Promise<void>\n }): Promise<TResult> {\n const transaction = await findTransactionOrThrow(input.transactionId, input.scope)\n const prepared = await preparePaymentOperation({\n em,\n transactionId: transaction.id,\n providerKey: transaction.providerKey,\n action: input.action,\n operationId: input.operationId,\n payload: input.payload,\n scope: input.scope,\n assertInitialAllowed: () => {\n assertManualActionAllowed(input.action, transaction)\n input.assertInitialAllowed?.(transaction)\n },\n })\n if (prepared.kind === 'completed') {\n if (prepared.result.status !== transaction.unifiedStatus) {\n throw conflict(\n `Cannot replay ${input.action} because the payment status has changed since the operation completed`,\n )\n }\n return prepared.result as unknown as TResult\n }\n\n const operation = prepared.claim.record\n // Everything after this flips to true is post-settlement: the provider moved the money and the\n // completion transaction committed it. A later event or logging failure must not undo either,\n // so the failure path below only runs while the operation can still be rolled back.\n let settled = false\n // Flips once the provider call returned, so the failure path can tell \"the provider never\n // moved the money\" from \"the provider moved it and only our bookkeeping failed\" \u2014 the two\n // cases need opposite rollback decisions.\n let providerInvoked = false\n try {\n await input.beforeInvoke?.({ transaction, operation })\n const { adapter, credentials } = await resolveAdapterAndCredentials(\n transaction.providerKey,\n { organizationId: transaction.organizationId, tenantId: transaction.tenantId },\n )\n const result = await input.invoke({\n adapter,\n credentials,\n transaction,\n idempotencyKey: prepared.claim.providerIdempotencyKey,\n })\n providerInvoked = true\n const statusChanged = await completePaymentOperation(\n em,\n prepared.claim,\n result as unknown as Record<string, unknown>,\n async (tx) => {\n const current = await findTransactionOrThrow(input.transactionId, input.scope, tx)\n const changed = applyAdapterResultStatus(input.action, current, result.status)\n input.applyResult(current, result)\n return changed\n },\n )\n settled = true\n if (statusChanged) {\n await emitStatusEvent(result.status, {\n transactionId: transaction.id,\n paymentId: transaction.paymentId,\n providerKey: transaction.providerKey,\n organizationId: transaction.organizationId,\n tenantId: transaction.tenantId,\n })\n }\n await input.afterCommit(transaction, result)\n return result\n } catch (error: unknown) {\n if (!settled) {\n if (input.releaseOnFailure) {\n await input.releaseOnFailure({ transaction, operation, providerInvoked }).catch(() => undefined)\n }\n await Promise.allSettled([failPaymentOperation(em, prepared.claim)])\n }\n throw error\n }\n }\n\n function createGatewayTransaction(\n manager: EntityManager,\n input: CreatePaymentSessionInput,\n session: CreateSessionResult,\n id?: string,\n ): GatewayTransaction {\n const data = {\n paymentId: input.paymentId,\n providerKey: input.providerKey,\n providerSessionId: session.sessionId,\n unifiedStatus: session.status,\n redirectUrl: session.redirectUrl\n ?? (session.clientSession?.type === 'redirect' ? session.clientSession.redirectUrl : null),\n clientSecret: session.clientSecret ?? null,\n amount: String(input.amount),\n // An automatic-capture session comes back already captured, so the ledger has to start\n // at the full amount \u2014 otherwise a later manual capture would look like the first one.\n capturedAmount: session.status === 'captured' ? String(input.amount) : '0',\n currencyCode: input.currencyCode,\n gatewayMetadata: {\n ...(session.providerData ?? {}),\n ...(session.clientSession ? { clientSession: session.clientSession } : {}),\n },\n organizationId: input.organizationId,\n tenantId: input.tenantId,\n deletedAt: null,\n }\n return id\n ? manager.create(GatewayTransaction, { id, ...data })\n : manager.create(GatewayTransaction, data)\n }\n\n function restoreSession(transaction: GatewayTransaction): CreateSessionResult {\n const metadata = readGatewayMetadata(transaction.gatewayMetadata)\n const { clientSession, ...providerData } = metadata\n return {\n sessionId: readProviderSessionId(transaction),\n status: transaction.unifiedStatus as UnifiedPaymentStatus,\n ...(transaction.clientSecret ? { clientSecret: transaction.clientSecret } : {}),\n ...(transaction.redirectUrl ? { redirectUrl: transaction.redirectUrl } : {}),\n ...(Object.keys(providerData).length > 0 ? { providerData } : {}),\n ...(clientSession && typeof clientSession === 'object'\n ? { clientSession: clientSession as CreateSessionResult['clientSession'] }\n : {}),\n }\n }\n\n async function recordCreatedSession(\n transaction: GatewayTransaction,\n input: CreatePaymentSessionInput,\n scope: { organizationId: string; tenantId: string },\n ): Promise<void> {\n await emitPaymentGatewayEvent('payment_gateways.session.created', {\n transactionId: transaction.id,\n paymentId: transaction.paymentId,\n providerKey: transaction.providerKey,\n status: transaction.unifiedStatus,\n organizationId: transaction.organizationId,\n tenantId: transaction.tenantId,\n })\n await writeTransactionLog(\n transaction.providerKey,\n scope,\n transaction.id,\n 'info',\n 'Payment session created',\n {\n paymentId: transaction.paymentId,\n providerSessionId: transaction.providerSessionId,\n status: transaction.unifiedStatus,\n amount: input.amount,\n currencyCode: input.currencyCode,\n },\n )\n }\n\n function startClaimHeartbeat(\n ownership: { id: string; claimToken: string },\n scope: { organizationId: string; tenantId: string },\n ): () => Promise<void> {\n let stopped = false\n let timer: ReturnType<typeof setTimeout> | null = null\n let pendingRefresh = Promise.resolve()\n\n const schedule = () => {\n timer = setTimeout(() => {\n pendingRefresh = refreshPaymentSessionInitialization(em, ownership, scope, new Date())\n .then(\n (refreshed) => {\n if (refreshed && !stopped) schedule()\n },\n () => {\n if (!stopped) schedule()\n },\n )\n }, claimHeartbeatIntervalMs)\n }\n schedule()\n\n return async () => {\n stopped = true\n if (timer) clearTimeout(timer)\n await pendingRefresh\n }\n }\n\n return {\n async createPaymentSession(input: CreatePaymentSessionInput): Promise<{ transaction: GatewayTransaction; session: CreateSessionResult }> {\n const scope = { organizationId: input.organizationId, tenantId: input.tenantId }\n await reconcileSessionAmountWithOrder({\n orderId: input.orderId,\n amount: input.amount,\n currencyCode: input.currencyCode,\n scope,\n resolver: deps.paymentOrderTotalResolver,\n })\n const { adapter, credentials } = await resolveAdapterAndCredentials(input.providerKey, scope)\n\n const sessionInput: CreateSessionInput = {\n paymentId: input.paymentId,\n orderId: input.orderId,\n idempotencyKey: input.idempotencyKey,\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n amount: input.amount,\n currencyCode: input.currencyCode,\n captureMethod: input.captureMethod,\n paymentTypes: input.paymentTypes,\n description: input.description,\n successUrl: input.successUrl,\n cancelUrl: input.cancelUrl,\n metadata: input.metadata,\n presentation: input.presentation,\n credentials,\n }\n\n if (!input.idempotencyKey) {\n const session = await adapter.createSession(sessionInput)\n const transaction = createGatewayTransaction(em, input, session)\n await em.persist(transaction).flush()\n await recordCreatedSession(transaction, input, scope)\n return { transaction, session }\n }\n\n const operationKey = buildPaymentSessionOperationKey({\n idempotencyKey: input.idempotencyKey,\n paymentId: input.paymentId,\n providerKey: input.providerKey,\n scope,\n })\n while (true) {\n const existing = await findPaymentSessionInitialization(em, operationKey, input.providerKey, scope)\n if (existing?.gatewayTransactionId) {\n const transaction = await findOneWithDecryption(\n em.fork(),\n GatewayTransaction,\n {\n id: existing.gatewayTransactionId,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n deletedAt: null,\n },\n undefined,\n scope,\n )\n if (!transaction) {\n throw new Error('[internal] Completed payment session is missing its gateway transaction')\n }\n return { transaction, session: restoreSession(transaction) }\n }\n\n const claimedAt = new Date()\n const staleBefore = new Date(claimedAt.getTime() - claimStaleAfterMs)\n const ownership = existing\n ? await reclaimPaymentSessionInitialization(em, existing, scope, claimedAt, staleBefore)\n : await claimPaymentSessionInitialization(em, operationKey, input.providerKey, scope, claimedAt)\n\n if (!ownership) {\n await new Promise((resolve) => setTimeout(resolve, claimPollIntervalMs))\n continue\n }\n\n let session: CreateSessionResult\n const stopHeartbeat = startClaimHeartbeat(ownership, scope)\n try {\n session = await adapter.createSession({ ...sessionInput, idempotencyKey: operationKey })\n } catch (error) {\n try {\n await releasePaymentSessionInitialization(em, ownership, scope)\n } catch (releaseError) {\n logger.warn('Failed to release payment session initialization claim', {\n claimId: ownership.id,\n providerKey: input.providerKey,\n err: releaseError,\n })\n }\n throw error\n } finally {\n await stopHeartbeat()\n }\n\n const transactionId = randomUUID()\n const transaction = await em.fork().transactional(async (transactionEm) => {\n const finalizedRows = await transactionEm.nativeUpdate(\n GatewaySessionInitialization,\n {\n id: ownership.id,\n claimToken: ownership.claimToken,\n gatewayTransactionId: null,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n },\n {\n gatewayTransactionId: transactionId,\n claimToken: null,\n claimedAt: null,\n updatedAt: new Date(),\n },\n )\n if (finalizedRows === 0) return null\n const created = createGatewayTransaction(transactionEm, input, session, transactionId)\n await transactionEm.persist(created).flush()\n return created\n })\n\n if (!transaction) {\n continue\n }\n await recordCreatedSession(transaction, input, scope)\n return { transaction, session }\n }\n },\n\n async capturePayment(\n transactionId: string,\n amount: number | undefined,\n scope: { organizationId: string; tenantId: string },\n operationId?: string,\n ): Promise<CaptureResult> {\n let reservedUnits: bigint | null = null\n let providerAmount = amount\n return executeManualOperation({\n action: 'capture',\n transactionId,\n operationId,\n payload: { amount: amount ?? null },\n scope,\n assertInitialAllowed: (transaction) => { assertCaptureWithinRemaining(transaction, amount) },\n beforeInvoke: async ({ transaction, operation }) => {\n const alreadyCapturedUnits = parseAmountUnits(transaction.capturedAmount)\n reservedUnits = await reserveCaptureAmount(em, { transaction, operation, amount, scope })\n // \"Capture the rest\" has to name the remaining amount once part of the authorization is\n // already captured, otherwise the provider would capture the full amount a second time.\n if (amount === undefined && alreadyCapturedUnits > 0n) {\n providerAmount = Number(formatAmountUnits(reservedUnits))\n }\n },\n // The slice goes back only while the provider has not been called yet. Once the capture\n // call returned, the money may already have moved and only the completion transaction\n // failed, so releasing would reopen the authorization for a fresh operation id and allow an\n // over-capture. Holding the reservation is the conservative outcome: a retry of this same\n // operation id reuses both the reservation and the provider idempotency key, so the provider\n // itself collapses the duplicate.\n releaseOnFailure: async ({ transaction, operation, providerInvoked }) => {\n if (reservedUnits === null) return\n if (providerInvoked) {\n await writeTransactionLog(\n transaction.providerKey,\n { organizationId: transaction.organizationId, tenantId: transaction.tenantId },\n transaction.id,\n 'warn',\n 'Capture reservation stays outstanding because the provider call already returned',\n { operationId: operation.operationId, reservedAmount: formatAmountUnits(reservedUnits) },\n )\n return\n }\n const released = await releaseCaptureAmount(em, { transactionId, operation, reservedUnits, scope })\n if (released) return\n await writeTransactionLog(\n transaction.providerKey,\n { organizationId: transaction.organizationId, tenantId: transaction.tenantId },\n transaction.id,\n 'warn',\n 'Capture reservation stays outstanding after a failed capture',\n { operationId: operation.operationId, reservedAmount: formatAmountUnits(reservedUnits) },\n )\n },\n invoke: ({ adapter, credentials, transaction, idempotencyKey }) => adapter.capture({\n sessionId: readProviderSessionId(transaction),\n amount: providerAmount,\n credentials,\n idempotencyKey,\n }),\n applyResult: (transaction, result) => {\n transaction.gatewayMetadata = {\n ...readGatewayMetadata(transaction.gatewayMetadata),\n captureResult: result.providerData,\n }\n if (reservedUnits !== null) settleCapturedAmount(transaction, reservedUnits, result.capturedAmount)\n },\n afterCommit: (transaction, result) => writeTransactionLog(\n transaction.providerKey,\n { organizationId: transaction.organizationId, tenantId: transaction.tenantId },\n transaction.id,\n 'info',\n 'Payment captured',\n { amount: amount ?? null, status: result.status, capturedAmount: result.capturedAmount },\n ),\n })\n },\n\n async refundPayment(\n transactionId: string,\n amount: number | undefined,\n reason: string | undefined,\n scope: { organizationId: string; tenantId: string },\n operationId?: string,\n ): Promise<RefundResult> {\n return executeManualOperation({\n action: 'refund',\n transactionId,\n operationId,\n payload: { amount: amount ?? null, reason: reason ?? null },\n scope,\n invoke: ({ adapter, credentials, transaction, idempotencyKey }) => adapter.refund({\n sessionId: readProviderSessionId(transaction),\n amount,\n reason,\n credentials,\n idempotencyKey,\n }),\n applyResult: (transaction, result) => {\n transaction.gatewayRefundId = result.refundId\n transaction.gatewayMetadata = {\n ...readGatewayMetadata(transaction.gatewayMetadata),\n refundResult: result.providerData,\n }\n },\n afterCommit: (transaction, result) => writeTransactionLog(\n transaction.providerKey,\n { organizationId: transaction.organizationId, tenantId: transaction.tenantId },\n transaction.id,\n 'info',\n 'Payment refunded',\n { amount: amount ?? null, reason: reason ?? null, status: result.status, refundId: result.refundId },\n ),\n })\n },\n\n async cancelPayment(\n transactionId: string,\n reason: string | undefined,\n scope: { organizationId: string; tenantId: string },\n operationId?: string,\n ): Promise<CancelResult> {\n return executeManualOperation({\n action: 'cancel',\n transactionId,\n operationId,\n payload: { reason: reason ?? null },\n scope,\n invoke: ({ adapter, credentials, transaction, idempotencyKey }) => adapter.cancel({\n sessionId: readProviderSessionId(transaction),\n reason,\n credentials,\n idempotencyKey,\n }),\n applyResult: () => {},\n afterCommit: (transaction, result) => writeTransactionLog(\n transaction.providerKey,\n { organizationId: transaction.organizationId, tenantId: transaction.tenantId },\n transaction.id,\n 'info',\n 'Payment cancelled',\n { reason: reason ?? null, status: result.status },\n ),\n })\n },\n\n async getPaymentStatus(transactionId: string, scope: { organizationId: string; tenantId: string }): Promise<GatewayPaymentStatus> {\n const transaction = await findTransactionOrThrow(transactionId, scope)\n let status: GatewayPaymentStatus\n try {\n const { adapter, credentials } = await resolveAdapterAndCredentials(\n transaction.providerKey,\n { organizationId: transaction.organizationId, tenantId: transaction.tenantId },\n )\n status = await adapter.getStatus({\n sessionId: readProviderSessionId(transaction),\n credentials,\n })\n } catch (error) {\n try {\n await stampLastPolledAt(transaction, new Date())\n } catch (stampError) {\n logger.warn('Failed to record last poll time after a failed status read', {\n transactionId: transaction.id,\n providerKey: transaction.providerKey,\n err: stampError,\n })\n }\n throw error\n }\n\n const polledAt = new Date()\n const shouldApplyStatus = status.status !== transaction.unifiedStatus\n && isValidTransition(transaction.unifiedStatus as UnifiedPaymentStatus, status.status)\n if (shouldApplyStatus) {\n const previousStatus = transaction.unifiedStatus\n transaction.unifiedStatus = status.status\n alignCapturedAmountWithStatus(transaction, status.status)\n transaction.gatewayStatus = status.status\n transaction.gatewayMetadata = { ...readGatewayMetadata(transaction.gatewayMetadata), statusResult: status.providerData ?? null }\n transaction.lastPolledAt = polledAt\n await em.flush()\n await emitStatusEvent(status.status, {\n transactionId: transaction.id,\n paymentId: transaction.paymentId,\n providerKey: transaction.providerKey,\n previousStatus,\n organizationId: transaction.organizationId,\n tenantId: transaction.tenantId,\n })\n await writeTransactionLog(\n transaction.providerKey,\n { organizationId: transaction.organizationId, tenantId: transaction.tenantId },\n transaction.id,\n 'info',\n 'Payment status updated by poller',\n {\n previousStatus,\n nextStatus: status.status,\n },\n )\n } else {\n await stampLastPolledAt(transaction, polledAt)\n }\n\n return status\n },\n\n async syncTransactionStatus(transactionId: string, update: {\n unifiedStatus: UnifiedPaymentStatus\n providerStatus?: string\n providerData?: Record<string, unknown>\n webhookEvent?: {\n eventType: string\n idempotencyKey: string\n processed: boolean\n receivedAt?: string\n }\n }, scope: { organizationId: string; tenantId: string }): Promise<void> {\n const transaction = await findTransactionOrThrow(transactionId, scope)\n const currentStatus = transaction.unifiedStatus as UnifiedPaymentStatus\n const canTransition = isValidTransition(currentStatus, update.unifiedStatus)\n const shouldApplyStatus = canTransition && update.unifiedStatus !== currentStatus\n const previousStatus = transaction.unifiedStatus\n if (shouldApplyStatus) {\n transaction.unifiedStatus = update.unifiedStatus\n alignCapturedAmountWithStatus(transaction, update.unifiedStatus)\n }\n if (update.providerStatus) {\n transaction.gatewayStatus = update.providerStatus\n }\n if (update.providerData) {\n transaction.gatewayMetadata = { ...readGatewayMetadata(transaction.gatewayMetadata), ...update.providerData }\n }\n if (update.webhookEvent) {\n const webhookLog = readWebhookLog(transaction.webhookLog)\n webhookLog.push({\n eventType: update.webhookEvent.eventType,\n receivedAt: update.webhookEvent.receivedAt ?? new Date().toISOString(),\n idempotencyKey: update.webhookEvent.idempotencyKey,\n unifiedStatus: update.unifiedStatus,\n processed: update.webhookEvent.processed,\n })\n transaction.webhookLog = webhookLog\n }\n transaction.lastWebhookAt = new Date()\n await em.flush()\n if (shouldApplyStatus) {\n await emitStatusEvent(update.unifiedStatus, {\n transactionId: transaction.id,\n paymentId: transaction.paymentId,\n providerKey: transaction.providerKey,\n previousStatus,\n organizationId: transaction.organizationId,\n tenantId: transaction.tenantId,\n })\n }\n await writeTransactionLog(\n transaction.providerKey,\n { organizationId: transaction.organizationId, tenantId: transaction.tenantId },\n transaction.id,\n shouldApplyStatus ? 'info' : 'warn',\n shouldApplyStatus ? 'Payment status synchronized from webhook' : 'Webhook received with no status transition',\n {\n previousStatus,\n nextStatus: update.unifiedStatus,\n providerStatus: update.providerStatus ?? null,\n eventType: update.webhookEvent?.eventType ?? null,\n idempotencyKey: update.webhookEvent?.idempotencyKey ?? null,\n },\n )\n },\n\n async findTransaction(id: string, scope: { organizationId: string; tenantId: string }): Promise<GatewayTransaction | null> {\n return findOneWithDecryption(\n em,\n GatewayTransaction,\n {\n id,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n deletedAt: null,\n },\n undefined,\n scope,\n )\n },\n\n async findTransactionBySessionId(\n providerSessionId: string,\n scope: { organizationId: string; tenantId: string },\n providerKey?: string,\n ): Promise<GatewayTransaction | null> {\n return findOneWithDecryption(\n em,\n GatewayTransaction,\n {\n providerSessionId,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n deletedAt: null,\n ...(providerKey ? { providerKey } : {}),\n },\n undefined,\n scope,\n )\n },\n\n async listTransactionsForStatusPolling(scope?: {\n organizationId?: string\n tenantId?: string\n providerKey?: string\n limit?: number\n }): Promise<GatewayTransaction[]> {\n const where: Record<string, unknown> = {\n unifiedStatus: { $in: ['pending', 'authorized', 'partially_captured'] },\n deletedAt: null,\n }\n if (scope?.organizationId) where.organizationId = scope.organizationId\n if (scope?.tenantId) where.tenantId = scope.tenantId\n if (scope?.providerKey) where.providerKey = scope.providerKey\n\n return findWithDecryption(\n em,\n GatewayTransaction,\n where,\n {\n orderBy: { lastPolledAt: QueryOrder.ASC_NULLS_FIRST, createdAt: QueryOrder.ASC },\n limit: scope?.limit ?? 100,\n },\n scope,\n )\n },\n }\n}\n\nexport type PaymentGatewayService = ReturnType<typeof createPaymentGatewayService>\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["transaction"]
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.8.1-develop.
|
|
3
|
+
"version": "0.8.1-develop.7447.1.ac22e0f0bc",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -264,16 +264,16 @@
|
|
|
264
264
|
"zod": "^4.4.3"
|
|
265
265
|
},
|
|
266
266
|
"peerDependencies": {
|
|
267
|
-
"@open-mercato/ai-assistant": "0.8.1-develop.
|
|
268
|
-
"@open-mercato/shared": "0.8.1-develop.
|
|
269
|
-
"@open-mercato/ui": "0.8.1-develop.
|
|
267
|
+
"@open-mercato/ai-assistant": "0.8.1-develop.7447.1.ac22e0f0bc",
|
|
268
|
+
"@open-mercato/shared": "0.8.1-develop.7447.1.ac22e0f0bc",
|
|
269
|
+
"@open-mercato/ui": "0.8.1-develop.7447.1.ac22e0f0bc",
|
|
270
270
|
"react": "^19.0.0",
|
|
271
271
|
"react-dom": "^19.0.0"
|
|
272
272
|
},
|
|
273
273
|
"devDependencies": {
|
|
274
|
-
"@open-mercato/ai-assistant": "0.8.1-develop.
|
|
275
|
-
"@open-mercato/shared": "0.8.1-develop.
|
|
276
|
-
"@open-mercato/ui": "0.8.1-develop.
|
|
274
|
+
"@open-mercato/ai-assistant": "0.8.1-develop.7447.1.ac22e0f0bc",
|
|
275
|
+
"@open-mercato/shared": "0.8.1-develop.7447.1.ac22e0f0bc",
|
|
276
|
+
"@open-mercato/ui": "0.8.1-develop.7447.1.ac22e0f0bc",
|
|
277
277
|
"@testing-library/dom": "^10.4.1",
|
|
278
278
|
"@testing-library/jest-dom": "^7.0.1",
|
|
279
279
|
"@testing-library/react": "^16.3.3",
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
|
+
import { z } from 'zod'
|
|
2
3
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
3
4
|
import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
4
5
|
import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
|
|
5
6
|
import { checkRateLimit, getClientIp, RATE_LIMIT_ERROR_FALLBACK } from '@open-mercato/shared/lib/ratelimit/helpers'
|
|
6
7
|
import type { RateLimiterService } from '@open-mercato/shared/lib/ratelimit/service'
|
|
7
8
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
8
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
getWebhookHandler,
|
|
11
|
+
WebhookVerificationUnavailableError,
|
|
12
|
+
type WebhookHandlerRegistration,
|
|
13
|
+
type WebhookHttpResponse,
|
|
14
|
+
type WebhookResponseOutcome,
|
|
15
|
+
} from '@open-mercato/shared/modules/payment_gateways/types'
|
|
9
16
|
import { markQueueJobOrigin } from '@open-mercato/shared/lib/queue/dispatchOrigin'
|
|
10
17
|
import type { IntegrationLogService } from '../../../../integrations/lib/log-service'
|
|
11
18
|
import type { PaymentGatewayService } from '../../../lib/gateway-service'
|
|
@@ -15,7 +22,8 @@ import { getPaymentGatewayQueue } from '../../../lib/queue'
|
|
|
15
22
|
import { processPaymentGatewayWebhookJob } from '../../../lib/webhook-processor'
|
|
16
23
|
import { paymentGatewaysTag } from '../../openapi'
|
|
17
24
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
18
|
-
import { readBoundedRequestBody, WebhookBodyTooLargeError } from '@open-mercato/shared/lib/webhooks'
|
|
25
|
+
import { readBoundedRequestBody, readBoundedRequestBytes, WebhookBodyTooLargeError } from '@open-mercato/shared/lib/webhooks'
|
|
26
|
+
import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
|
|
19
27
|
|
|
20
28
|
const logger = createLogger('payment_gateways').child({ component: 'webhook' })
|
|
21
29
|
|
|
@@ -25,6 +33,13 @@ export const metadata = {
|
|
|
25
33
|
}
|
|
26
34
|
|
|
27
35
|
const WEBHOOK_VERIFICATION_FAILED = 'Webhook verification failed'
|
|
36
|
+
const WEBHOOK_PAYLOAD_TOO_LARGE = 'Webhook payload too large'
|
|
37
|
+
const INTERNAL_SERVER_ERROR = 'Internal server error'
|
|
38
|
+
const DEFAULT_TEXT_CONTENT_TYPE = 'text/plain; charset=utf-8'
|
|
39
|
+
const CONTROL_CHARACTERS = /[\u0000-\u001F\u007F]/
|
|
40
|
+
const JSON_CONTENT_TYPE = /^application\/([\w.+-]+\+)?json(\s*;|$)/
|
|
41
|
+
|
|
42
|
+
const paymentIdHintSchema = z.guid()
|
|
28
43
|
|
|
29
44
|
const paymentGatewayWebhookRateLimitConfig = {
|
|
30
45
|
points: 60,
|
|
@@ -32,6 +47,8 @@ const paymentGatewayWebhookRateLimitConfig = {
|
|
|
32
47
|
keyPrefix: 'payment_gateways:webhook',
|
|
33
48
|
}
|
|
34
49
|
|
|
50
|
+
type RequestContainer = Awaited<ReturnType<typeof createRequestContainer>>
|
|
51
|
+
|
|
35
52
|
export async function POST(req: Request, { params }: { params: Promise<{ provider: string }> | { provider: string } }) {
|
|
36
53
|
const resolvedParams = await params
|
|
37
54
|
const providerKey = resolvedParams.provider
|
|
@@ -42,17 +59,39 @@ export async function POST(req: Request, { params }: { params: Promise<{ provide
|
|
|
42
59
|
}
|
|
43
60
|
|
|
44
61
|
const rateLimitResponse = await checkProviderWebhookRateLimit(container, req, providerKey)
|
|
45
|
-
|
|
62
|
+
const outcome: WebhookResponseOutcome = rateLimitResponse
|
|
63
|
+
? 'rate_limited'
|
|
64
|
+
: await receiveWebhook(req, providerKey, registration, container)
|
|
65
|
+
const response = respond(registration, outcome, rateLimitResponse)
|
|
66
|
+
const logFields = { providerKey, outcome, status: response.status }
|
|
67
|
+
if (outcome === 'accepted') logger.info('Webhook handled', logFields)
|
|
68
|
+
else logger.warn('Webhook handled', logFields)
|
|
69
|
+
return response
|
|
70
|
+
}
|
|
46
71
|
|
|
47
|
-
|
|
72
|
+
async function receiveWebhook(
|
|
73
|
+
req: Request,
|
|
74
|
+
providerKey: string,
|
|
75
|
+
registration: WebhookHandlerRegistration,
|
|
76
|
+
container: RequestContainer,
|
|
77
|
+
): Promise<WebhookResponseOutcome> {
|
|
78
|
+
let rawBody: string | Buffer
|
|
79
|
+
let bodyText: string
|
|
48
80
|
try {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
81
|
+
if (registration.rawBody === 'bytes') {
|
|
82
|
+
const bytes = registration.maxBodyBytes === undefined
|
|
83
|
+
? new Uint8Array(await req.arrayBuffer())
|
|
84
|
+
: await readBoundedRequestBytes(req, { maxBytes: registration.maxBodyBytes })
|
|
85
|
+
rawBody = Buffer.from(bytes)
|
|
86
|
+
bodyText = new TextDecoder().decode(rawBody)
|
|
87
|
+
} else {
|
|
88
|
+
bodyText = registration.maxBodyBytes === undefined
|
|
89
|
+
? await req.text()
|
|
90
|
+
: await readBoundedRequestBody(req, { maxBytes: registration.maxBodyBytes })
|
|
91
|
+
rawBody = bodyText
|
|
55
92
|
}
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (error instanceof WebhookBodyTooLargeError) return 'payload_too_large'
|
|
56
95
|
throw error
|
|
57
96
|
}
|
|
58
97
|
const headers: Record<string, string> = {}
|
|
@@ -64,81 +103,218 @@ export async function POST(req: Request, { params }: { params: Promise<{ provide
|
|
|
64
103
|
const em = container.resolve('em') as EntityManager
|
|
65
104
|
const integrationCredentialsService = container.resolve('integrationCredentialsService') as CredentialsService
|
|
66
105
|
const queue = getPaymentGatewayQueue(registration.queue ?? 'payment-gateways-webhook')
|
|
67
|
-
const payload = await readJsonSafe<Record<string, unknown>>(
|
|
68
|
-
const
|
|
106
|
+
const payload = await readJsonSafe<Record<string, unknown>>(bodyText)
|
|
107
|
+
const locatorContext = { rawBody, headers }
|
|
108
|
+
let sessionIdHint: string | null
|
|
109
|
+
let paymentIdHint: string | null
|
|
110
|
+
try {
|
|
111
|
+
sessionIdHint = normalizeSessionIdHint(registration.readSessionIdHint?.(payload, locatorContext))
|
|
112
|
+
paymentIdHint = normalizePaymentIdHint(registration.readPaymentIdHint?.(payload, locatorContext))
|
|
113
|
+
} catch (error: unknown) {
|
|
114
|
+
reportWebhookError(error, 'payment_gateways.webhook_locator_failed')
|
|
115
|
+
return 'no_candidate'
|
|
116
|
+
}
|
|
69
117
|
|
|
70
118
|
try {
|
|
71
119
|
// The webhook endpoint is unauthenticated. Tenant/organization scope MUST come from a
|
|
72
120
|
// GatewayTransaction whose per-tenant credentials successfully verify the inbound
|
|
73
121
|
// signature — NEVER from attacker-controlled payload metadata. If no candidate
|
|
74
|
-
// transaction can be located by the provider-reported session id, or no
|
|
75
|
-
// credentials can verify the signature, we fail closed
|
|
76
|
-
// forged webhooks (e.g. mock gateway PoC) from mutating another tenant's
|
|
77
|
-
// state via `event.data.metadata.{organizationId,tenantId}`.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
122
|
+
// transaction can be located by the provider-reported session or payment id, or no
|
|
123
|
+
// candidate's credentials can verify the signature, we fail closed. This
|
|
124
|
+
// prevents forged webhooks (e.g. mock gateway PoC) from mutating another tenant's
|
|
125
|
+
// payment state via `event.data.metadata.{organizationId,tenantId}`. When more than one
|
|
126
|
+
// candidate verifies, the scope is ambiguous and we also fail closed.
|
|
127
|
+
let candidates: GatewayTransaction[]
|
|
128
|
+
try {
|
|
129
|
+
candidates = sessionIdHint || paymentIdHint
|
|
130
|
+
? await findWithDecryption(
|
|
131
|
+
em,
|
|
132
|
+
GatewayTransaction,
|
|
133
|
+
{
|
|
134
|
+
providerKey,
|
|
135
|
+
...(sessionIdHint ? { providerSessionId: sessionIdHint } : {}),
|
|
136
|
+
...(paymentIdHint ? { paymentId: paymentIdHint } : {}),
|
|
137
|
+
deletedAt: null,
|
|
138
|
+
},
|
|
139
|
+
{ limit: 10, orderBy: { createdAt: 'desc' } },
|
|
140
|
+
)
|
|
141
|
+
: []
|
|
142
|
+
} catch (error: unknown) {
|
|
143
|
+
reportWebhookError(error, 'payment_gateways.webhook_lookup_failed')
|
|
144
|
+
return 'processing_failed'
|
|
145
|
+
}
|
|
90
146
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
147
|
+
if (candidates.length === 0) return 'no_candidate'
|
|
148
|
+
|
|
149
|
+
const verifiedMatches: Array<{
|
|
150
|
+
transaction: GatewayTransaction
|
|
151
|
+
scope: { organizationId: string; tenantId: string }
|
|
152
|
+
event: Awaited<ReturnType<typeof registration.handler>>
|
|
153
|
+
}> = []
|
|
154
|
+
let verificationUnavailable = false
|
|
95
155
|
|
|
96
156
|
for (const candidate of candidates) {
|
|
97
157
|
const candidateScope = { organizationId: candidate.organizationId, tenantId: candidate.tenantId }
|
|
98
|
-
|
|
158
|
+
let credentials: Record<string, unknown>
|
|
159
|
+
try {
|
|
160
|
+
credentials = await integrationCredentialsService.resolve(`gateway_${providerKey}`, candidateScope) ?? {}
|
|
161
|
+
} catch (error: unknown) {
|
|
162
|
+
reportWebhookError(error, 'payment_gateways.webhook_credentials_failed')
|
|
163
|
+
verificationUnavailable = true
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
99
166
|
try {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
167
|
+
const candidateEvent = await registration.handler({
|
|
168
|
+
rawBody,
|
|
169
|
+
headers,
|
|
170
|
+
credentials,
|
|
171
|
+
candidate: {
|
|
172
|
+
transactionId: candidate.id,
|
|
173
|
+
paymentId: candidate.paymentId,
|
|
174
|
+
providerSessionId: candidate.providerSessionId ?? null,
|
|
175
|
+
amount: candidate.amount,
|
|
176
|
+
currencyCode: candidate.currencyCode,
|
|
177
|
+
},
|
|
178
|
+
})
|
|
179
|
+
verifiedMatches.push({ transaction: candidate, scope: candidateScope, event: candidateEvent })
|
|
104
180
|
} catch (error: unknown) {
|
|
105
|
-
|
|
181
|
+
if (error instanceof WebhookVerificationUnavailableError) verificationUnavailable = true
|
|
106
182
|
}
|
|
107
183
|
}
|
|
108
184
|
|
|
109
|
-
if (
|
|
110
|
-
|
|
185
|
+
if (verifiedMatches.length > 1) {
|
|
186
|
+
logger.error('Webhook matched multiple verified candidates', {
|
|
187
|
+
providerKey,
|
|
188
|
+
candidateCount: verifiedMatches.length,
|
|
189
|
+
})
|
|
190
|
+
getTelemetryRuntime()?.reportError(
|
|
191
|
+
new Error('[internal] Webhook matched multiple verified candidates'),
|
|
192
|
+
{ module: 'payment_gateways', code: 'payment_gateways.webhook_ambiguous_candidates' },
|
|
193
|
+
)
|
|
194
|
+
return 'verification_failed'
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (verificationUnavailable && verifiedMatches.length === 1) {
|
|
198
|
+
logger.warn('Webhook candidate left unverified; ambiguity cannot be ruled out', {
|
|
199
|
+
providerKey,
|
|
200
|
+
candidateCount: candidates.length,
|
|
201
|
+
})
|
|
202
|
+
return 'verification_unavailable'
|
|
111
203
|
}
|
|
112
204
|
|
|
113
|
-
const
|
|
205
|
+
const [match] = verifiedMatches
|
|
206
|
+
if (!match) return verificationUnavailable ? 'verification_unavailable' : 'verification_failed'
|
|
114
207
|
|
|
115
208
|
const jobPayload = markQueueJobOrigin({
|
|
116
209
|
providerKey,
|
|
117
|
-
event,
|
|
118
|
-
transactionId: transaction.id,
|
|
119
|
-
scope,
|
|
210
|
+
event: match.event,
|
|
211
|
+
transactionId: match.transaction.id,
|
|
212
|
+
scope: match.scope,
|
|
120
213
|
}, 'inbound-webhook')
|
|
121
214
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
215
|
+
try {
|
|
216
|
+
if (process.env.QUEUE_STRATEGY === 'async') {
|
|
217
|
+
await queue.enqueue(jobPayload)
|
|
218
|
+
} else {
|
|
219
|
+
await processPaymentGatewayWebhookJob(
|
|
220
|
+
{
|
|
221
|
+
em: container.resolve('em') as EntityManager,
|
|
222
|
+
paymentGatewayService: service,
|
|
223
|
+
integrationLogService: container.resolve('integrationLogService') as IntegrationLogService,
|
|
224
|
+
},
|
|
225
|
+
jobPayload,
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
} catch (error: unknown) {
|
|
229
|
+
getTelemetryRuntime()?.reportError(error, {
|
|
230
|
+
module: 'payment_gateways',
|
|
231
|
+
code: 'payment_gateways.webhook_processing_failed',
|
|
232
|
+
})
|
|
233
|
+
return 'processing_failed'
|
|
133
234
|
}
|
|
134
235
|
|
|
135
|
-
return
|
|
136
|
-
} catch (
|
|
137
|
-
|
|
138
|
-
return
|
|
236
|
+
return 'accepted'
|
|
237
|
+
} catch (error: unknown) {
|
|
238
|
+
reportWebhookError(error, 'payment_gateways.webhook_unexpected_failure')
|
|
239
|
+
return 'verification_failed'
|
|
139
240
|
}
|
|
140
241
|
}
|
|
141
242
|
|
|
243
|
+
function respond(
|
|
244
|
+
registration: WebhookHandlerRegistration,
|
|
245
|
+
outcome: WebhookResponseOutcome,
|
|
246
|
+
rateLimitResponse: NextResponse | null,
|
|
247
|
+
): NextResponse {
|
|
248
|
+
if (!registration.formatResponse) return legacyResponse(outcome, rateLimitResponse)
|
|
249
|
+
try {
|
|
250
|
+
const response = toValidatedResponse(registration.formatResponse(outcome))
|
|
251
|
+
if (outcome === 'rate_limited' && rateLimitResponse) {
|
|
252
|
+
rateLimitResponse.headers.forEach((value, key) => {
|
|
253
|
+
if (key !== 'content-type' && key !== 'content-length') response.headers.set(key, value)
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
return response
|
|
257
|
+
} catch (error: unknown) {
|
|
258
|
+
getTelemetryRuntime()?.reportError(error, {
|
|
259
|
+
module: 'payment_gateways',
|
|
260
|
+
code: 'payment_gateways.webhook_formatter_invalid',
|
|
261
|
+
})
|
|
262
|
+
return NextResponse.json({ error: INTERNAL_SERVER_ERROR }, { status: 500 })
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function legacyResponse(outcome: WebhookResponseOutcome, rateLimitResponse: NextResponse | null): NextResponse {
|
|
267
|
+
if (outcome === 'rate_limited' && rateLimitResponse) return rateLimitResponse
|
|
268
|
+
if (outcome === 'accepted') return NextResponse.json({ received: true, queued: true }, { status: 202 })
|
|
269
|
+
if (outcome === 'payload_too_large') return NextResponse.json({ error: WEBHOOK_PAYLOAD_TOO_LARGE }, { status: 413 })
|
|
270
|
+
return NextResponse.json({ error: WEBHOOK_VERIFICATION_FAILED }, { status: 401 })
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function toValidatedResponse(formatted: WebhookHttpResponse): NextResponse {
|
|
274
|
+
const { status, body, contentType } = formatted
|
|
275
|
+
if (!Number.isInteger(status) || status < 200 || status > 599) {
|
|
276
|
+
throw new Error('[internal] Webhook formatter returned an invalid status')
|
|
277
|
+
}
|
|
278
|
+
if (contentType !== undefined && (typeof contentType !== 'string' || CONTROL_CHARACTERS.test(contentType))) {
|
|
279
|
+
throw new Error('[internal] Webhook formatter returned an invalid content type')
|
|
280
|
+
}
|
|
281
|
+
const normalizedContentType = contentType?.toLowerCase()
|
|
282
|
+
if (typeof body === 'string') {
|
|
283
|
+
if (normalizedContentType !== undefined && !normalizedContentType.startsWith('text/')) {
|
|
284
|
+
throw new Error('[internal] Webhook formatter returned a non-text content type for a text body')
|
|
285
|
+
}
|
|
286
|
+
return new NextResponse(body, { status, headers: { 'content-type': contentType ?? DEFAULT_TEXT_CONTENT_TYPE } })
|
|
287
|
+
}
|
|
288
|
+
if (!isPlainObject(body)) {
|
|
289
|
+
throw new Error('[internal] Webhook formatter returned an invalid body')
|
|
290
|
+
}
|
|
291
|
+
if (normalizedContentType !== undefined && !JSON_CONTENT_TYPE.test(normalizedContentType)) {
|
|
292
|
+
throw new Error('[internal] Webhook formatter returned a non-JSON content type for an object body')
|
|
293
|
+
}
|
|
294
|
+
const response = NextResponse.json(body, { status })
|
|
295
|
+
if (contentType !== undefined) response.headers.set('content-type', contentType)
|
|
296
|
+
return response
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
300
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
|
301
|
+
const prototype = Object.getPrototypeOf(value)
|
|
302
|
+
return prototype === Object.prototype || prototype === null
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function reportWebhookError(error: unknown, code: string): void {
|
|
306
|
+
getTelemetryRuntime()?.reportError(error, { module: 'payment_gateways', code })
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function normalizeSessionIdHint(value: unknown): string | null {
|
|
310
|
+
return typeof value === 'string' && value.length > 0 ? value : null
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function normalizePaymentIdHint(value: unknown): string | null {
|
|
314
|
+
const parsed = paymentIdHintSchema.safeParse(value)
|
|
315
|
+
return parsed.success ? parsed.data : null
|
|
316
|
+
}
|
|
317
|
+
|
|
142
318
|
async function checkProviderWebhookRateLimit(
|
|
143
319
|
container: { resolve: (name: string) => unknown },
|
|
144
320
|
req: Request,
|
|
@@ -171,10 +347,15 @@ export const openApi = {
|
|
|
171
347
|
summary: 'Process inbound webhook from payment provider',
|
|
172
348
|
tags: [paymentGatewaysTag],
|
|
173
349
|
responses: [
|
|
174
|
-
{ status:
|
|
175
|
-
{ status:
|
|
176
|
-
{ status:
|
|
350
|
+
{ status: 200, description: 'Webhook accepted (provider-defined acknowledgement may apply)' },
|
|
351
|
+
{ status: 202, description: 'Webhook accepted for processing (default acknowledgement)' },
|
|
352
|
+
{ status: 400, description: 'Webhook rejected (provider-defined response may apply)' },
|
|
353
|
+
{ status: 401, description: 'Webhook could not be verified or processed (default failure response)' },
|
|
177
354
|
{ status: 404, description: 'Unknown provider' },
|
|
355
|
+
{ status: 413, description: 'Webhook payload too large' },
|
|
356
|
+
{ status: 429, description: 'Too many webhook requests' },
|
|
357
|
+
{ status: 500, description: 'Internal server error (provider-defined response may apply)' },
|
|
358
|
+
{ status: 503, description: 'Webhook temporarily unavailable (provider-defined response may apply)' },
|
|
178
359
|
],
|
|
179
360
|
},
|
|
180
361
|
},
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { QueryOrder } from '@mikro-orm/core'
|
|
2
3
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
3
4
|
import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
4
5
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
@@ -184,6 +185,14 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
|
|
|
184
185
|
}, scope)
|
|
185
186
|
}
|
|
186
187
|
|
|
188
|
+
async function stampLastPolledAt(transaction: GatewayTransaction, polledAt: Date): Promise<void> {
|
|
189
|
+
await em.nativeUpdate(
|
|
190
|
+
GatewayTransaction,
|
|
191
|
+
{ id: transaction.id, organizationId: transaction.organizationId, tenantId: transaction.tenantId },
|
|
192
|
+
{ lastPolledAt: polledAt },
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
187
196
|
async function resolveAdapterAndCredentials(providerKey: string, scope: { organizationId: string; tenantId: string }) {
|
|
188
197
|
const integrationId = `gateway_${providerKey}`
|
|
189
198
|
const selectedVersion = deps.integrationStateService
|
|
@@ -685,23 +694,39 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
|
|
|
685
694
|
|
|
686
695
|
async getPaymentStatus(transactionId: string, scope: { organizationId: string; tenantId: string }): Promise<GatewayPaymentStatus> {
|
|
687
696
|
const transaction = await findTransactionOrThrow(transactionId, scope)
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
{
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
+
let status: GatewayPaymentStatus
|
|
698
|
+
try {
|
|
699
|
+
const { adapter, credentials } = await resolveAdapterAndCredentials(
|
|
700
|
+
transaction.providerKey,
|
|
701
|
+
{ organizationId: transaction.organizationId, tenantId: transaction.tenantId },
|
|
702
|
+
)
|
|
703
|
+
status = await adapter.getStatus({
|
|
704
|
+
sessionId: readProviderSessionId(transaction),
|
|
705
|
+
credentials,
|
|
706
|
+
})
|
|
707
|
+
} catch (error) {
|
|
708
|
+
try {
|
|
709
|
+
await stampLastPolledAt(transaction, new Date())
|
|
710
|
+
} catch (stampError) {
|
|
711
|
+
logger.warn('Failed to record last poll time after a failed status read', {
|
|
712
|
+
transactionId: transaction.id,
|
|
713
|
+
providerKey: transaction.providerKey,
|
|
714
|
+
err: stampError,
|
|
715
|
+
})
|
|
716
|
+
}
|
|
717
|
+
throw error
|
|
718
|
+
}
|
|
697
719
|
|
|
698
|
-
|
|
720
|
+
const polledAt = new Date()
|
|
721
|
+
const shouldApplyStatus = status.status !== transaction.unifiedStatus
|
|
722
|
+
&& isValidTransition(transaction.unifiedStatus as UnifiedPaymentStatus, status.status)
|
|
723
|
+
if (shouldApplyStatus) {
|
|
699
724
|
const previousStatus = transaction.unifiedStatus
|
|
700
725
|
transaction.unifiedStatus = status.status
|
|
701
726
|
alignCapturedAmountWithStatus(transaction, status.status)
|
|
702
727
|
transaction.gatewayStatus = status.status
|
|
703
728
|
transaction.gatewayMetadata = { ...readGatewayMetadata(transaction.gatewayMetadata), statusResult: status.providerData ?? null }
|
|
704
|
-
transaction.lastPolledAt =
|
|
729
|
+
transaction.lastPolledAt = polledAt
|
|
705
730
|
await em.flush()
|
|
706
731
|
await emitStatusEvent(status.status, {
|
|
707
732
|
transactionId: transaction.id,
|
|
@@ -722,6 +747,8 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
|
|
|
722
747
|
nextStatus: status.status,
|
|
723
748
|
},
|
|
724
749
|
)
|
|
750
|
+
} else {
|
|
751
|
+
await stampLastPolledAt(transaction, polledAt)
|
|
725
752
|
}
|
|
726
753
|
|
|
727
754
|
return status
|
|
@@ -846,7 +873,7 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
|
|
|
846
873
|
GatewayTransaction,
|
|
847
874
|
where,
|
|
848
875
|
{
|
|
849
|
-
orderBy: {
|
|
876
|
+
orderBy: { lastPolledAt: QueryOrder.ASC_NULLS_FIRST, createdAt: QueryOrder.ASC },
|
|
850
877
|
limit: scope?.limit ?? 100,
|
|
851
878
|
},
|
|
852
879
|
scope,
|