settld 0.2.3 → 0.2.5
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/docs/CONFIG.md +12 -0
- package/docs/README.md +3 -0
- package/docs/ops/HOSTED_BASELINE_R2.md +4 -2
- package/docs/ops/MINIMUM_PRODUCTION_TOPOLOGY.md +19 -7
- package/docs/ops/PRODUCTION_DEPLOYMENT_CHECKLIST.md +8 -3
- package/package.json +3 -1
- package/scripts/ci/run-public-onboarding-gate.mjs +136 -0
- package/scripts/setup/login.mjs +111 -17
- package/scripts/setup/onboard.mjs +176 -40
- package/scripts/setup/onboarding-failure-taxonomy.mjs +96 -0
- package/scripts/setup/onboarding-state-machine.mjs +102 -0
- package/services/magic-link/README.md +343 -0
- package/services/magic-link/assets/samples/closepack/known-bad/acceptance/acceptance_criteria.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/acceptance/acceptance_evaluation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/attestation/bundle_head_attestation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/evidence/evidence_index.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/governance/policy.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/governance/revocations.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/manifest.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/attestation/bundle_head_attestation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/governance/policy.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/governance/revocations.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/invoice/invoice_claim.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/manifest.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/metering/metering_report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/attestation/bundle_head_attestation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/events/events.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/events/payload_material.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/global/events/events.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/global/events/payload_material.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/global/snapshot.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/policy.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/revocations.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/events/events.jsonl +0 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/events/payload_material.jsonl +0 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/snapshot.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/job/snapshot.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/keys/public_keys.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/manifest.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/verify/report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/verify/verification_report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/pricing/pricing_matrix.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/pricing/pricing_matrix_signatures.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/settld.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/verify/verification_report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/settld.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/sla/sla_definition.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/sla/sla_evaluation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/verify/verification_report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/acceptance/acceptance_criteria.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/acceptance/acceptance_evaluation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/attestation/bundle_head_attestation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/evidence/evidence_index.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/governance/policy.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/governance/revocations.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/manifest.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/attestation/bundle_head_attestation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/governance/policy.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/governance/revocations.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/invoice/invoice_claim.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/manifest.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/metering/metering_report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/attestation/bundle_head_attestation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/events/events.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/events/payload_material.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/global/events/events.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/global/events/payload_material.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/global/snapshot.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/policy.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/revocations.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/events/events.jsonl +0 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/events/payload_material.jsonl +0 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/snapshot.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/job/snapshot.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/keys/public_keys.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/manifest.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/verify/report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/verify/verification_report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/pricing/pricing_matrix.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/pricing/pricing_matrix_signatures.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/settld.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/verify/verification_report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/settld.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/sla/sla_definition.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/sla/sla_evaluation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/verify/verification_report.json +1 -0
- package/services/magic-link/assets/samples/trust.json +11 -0
- package/services/magic-link/src/audit-log.js +24 -0
- package/services/magic-link/src/buyer-auth.js +220 -0
- package/services/magic-link/src/buyer-notifications.js +402 -0
- package/services/magic-link/src/buyer-users.js +129 -0
- package/services/magic-link/src/decision-otp.js +156 -0
- package/services/magic-link/src/decisions.js +92 -0
- package/services/magic-link/src/ingest-keys.js +137 -0
- package/services/magic-link/src/maintenance.js +70 -0
- package/services/magic-link/src/onboarding-email-sequence.js +331 -0
- package/services/magic-link/src/payment-triggers.js +733 -0
- package/services/magic-link/src/pdf.js +149 -0
- package/services/magic-link/src/policy.js +69 -0
- package/services/magic-link/src/redaction.js +6 -0
- package/services/magic-link/src/render-model.js +70 -0
- package/services/magic-link/src/retention-gc.js +158 -0
- package/services/magic-link/src/run-records.js +496 -0
- package/services/magic-link/src/s3.js +171 -0
- package/services/magic-link/src/server.js +15788 -0
- package/services/magic-link/src/settlement-decisions.js +84 -0
- package/services/magic-link/src/smtp.js +202 -0
- package/services/magic-link/src/storage-cli.js +88 -0
- package/services/magic-link/src/storage-format.js +59 -0
- package/services/magic-link/src/tenant-billing.js +115 -0
- package/services/magic-link/src/tenant-onboarding.js +467 -0
- package/services/magic-link/src/tenant-settings.js +1140 -0
- package/services/magic-link/src/usage.js +80 -0
- package/services/magic-link/src/verify-queue.js +179 -0
- package/services/magic-link/src/verify-worker.js +157 -0
- package/services/magic-link/src/webhook-retries.js +542 -0
- package/services/magic-link/src/webhooks.js +218 -0
- package/src/api/app.js +129 -0
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import http from "node:http";
|
|
5
|
+
import https from "node:https";
|
|
6
|
+
|
|
7
|
+
import { decryptStoredSecret } from "./tenant-settings.js";
|
|
8
|
+
|
|
9
|
+
function nowIso() {
|
|
10
|
+
return new Date().toISOString();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function sha256Hex(value) {
|
|
14
|
+
return crypto.createHash("sha256").update(String(value ?? ""), "utf8").digest("hex");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function hmacSha256Hex(secret, message) {
|
|
18
|
+
return crypto.createHmac("sha256", String(secret ?? "")).update(String(message ?? ""), "utf8").digest("hex");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isPlainObject(v) {
|
|
22
|
+
return Boolean(v && typeof v === "object" && !Array.isArray(v) && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function safeInt(value, fallback) {
|
|
26
|
+
const n = Number.parseInt(String(value ?? ""), 10);
|
|
27
|
+
if (!Number.isInteger(n)) return fallback;
|
|
28
|
+
return n;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function clampRetryConfig({ retryMaxAttempts, retryBackoffMs } = {}) {
|
|
32
|
+
const maxAttempts = Math.max(1, Math.min(50, safeInt(retryMaxAttempts, 5)));
|
|
33
|
+
const backoffMs = Math.max(0, Math.min(86_400_000, safeInt(retryBackoffMs, 5_000)));
|
|
34
|
+
return { maxAttempts, backoffMs };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function statusFromPublicSummary(publicSummary) {
|
|
38
|
+
const ok = Boolean(publicSummary?.verification?.ok);
|
|
39
|
+
const verificationOk = Boolean(publicSummary?.verification?.verificationOk);
|
|
40
|
+
const warnings = Array.isArray(publicSummary?.verification?.warningCodes) ? publicSummary.verification.warningCodes : [];
|
|
41
|
+
if (!ok || !verificationOk) return "red";
|
|
42
|
+
if (warnings.length) return "amber";
|
|
43
|
+
return "green";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function triggerStatePath({ dataDir, tenantId, token }) {
|
|
47
|
+
return path.join(dataDir, "payment_triggers", tenantId, `${token}.json`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function retryPendingDir(dataDir) {
|
|
51
|
+
return path.join(dataDir, "payment_trigger_retry", "pending");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function retryDeadLetterDir(dataDir) {
|
|
55
|
+
return path.join(dataDir, "payment_trigger_retry", "dead-letter");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function retryAttemptsDir(dataDir) {
|
|
59
|
+
return path.join(dataDir, "payment_trigger_retry", "attempts");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function retryJobId({ tenantId, token, idempotencyKey }) {
|
|
63
|
+
const hash = sha256Hex(`${tenantId}\n${token}\n${idempotencyKey}`).slice(0, 24);
|
|
64
|
+
return `${tenantId}_${token}_${hash}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function retryPendingPath({ dataDir, tenantId, token, idempotencyKey }) {
|
|
68
|
+
return path.join(retryPendingDir(dataDir), `${retryJobId({ tenantId, token, idempotencyKey })}.json`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function retryDeadLetterPath({ dataDir, tenantId, token, idempotencyKey }) {
|
|
72
|
+
return path.join(retryDeadLetterDir(dataDir), `${retryJobId({ tenantId, token, idempotencyKey })}.json`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function triggerOutboxPath({ dataDir, tenantId, token, idempotencyKey }) {
|
|
76
|
+
return path.join(dataDir, "payment-trigger-outbox", `${retryJobId({ tenantId, token, idempotencyKey })}.json`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function loadStateBestEffort({ dataDir, tenantId, token }) {
|
|
80
|
+
const fp = triggerStatePath({ dataDir, tenantId, token });
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(await fs.readFile(fp, "utf8"));
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function writeState({ dataDir, tenantId, token, state }) {
|
|
89
|
+
const fp = triggerStatePath({ dataDir, tenantId, token });
|
|
90
|
+
await fs.mkdir(path.dirname(fp), { recursive: true });
|
|
91
|
+
await fs.writeFile(fp, JSON.stringify(state, null, 2) + "\n", "utf8");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function loadRetryJobBestEffort({ dataDir, tenantId, token, idempotencyKey }) {
|
|
95
|
+
const fp = retryPendingPath({ dataDir, tenantId, token, idempotencyKey });
|
|
96
|
+
try {
|
|
97
|
+
const raw = await fs.readFile(fp, "utf8");
|
|
98
|
+
const parsed = JSON.parse(raw);
|
|
99
|
+
return isPlainObject(parsed) ? parsed : null;
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function readJsonIfExists(fp) {
|
|
106
|
+
try {
|
|
107
|
+
const raw = await fs.readFile(fp, "utf8");
|
|
108
|
+
const parsed = JSON.parse(raw);
|
|
109
|
+
return isPlainObject(parsed) ? parsed : null;
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function fileExists(fp) {
|
|
116
|
+
try {
|
|
117
|
+
await fs.access(fp);
|
|
118
|
+
return true;
|
|
119
|
+
} catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function absoluteUrl(baseUrl, relPath) {
|
|
125
|
+
const rel = String(relPath ?? "");
|
|
126
|
+
const base = typeof baseUrl === "string" && baseUrl.trim() ? baseUrl.trim().replace(/\/+$/, "") : "";
|
|
127
|
+
if (!base) return rel;
|
|
128
|
+
if (!rel.startsWith("/")) return rel;
|
|
129
|
+
return `${base}${rel}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function request({ url, method, headers, body, timeoutMs }) {
|
|
133
|
+
const u = new URL(url);
|
|
134
|
+
const lib = u.protocol === "https:" ? https : http;
|
|
135
|
+
return await new Promise((resolve) => {
|
|
136
|
+
const req = lib.request(
|
|
137
|
+
{
|
|
138
|
+
protocol: u.protocol,
|
|
139
|
+
hostname: u.hostname,
|
|
140
|
+
port: u.port ? Number(u.port) : u.protocol === "https:" ? 443 : 80,
|
|
141
|
+
path: u.pathname + u.search,
|
|
142
|
+
method,
|
|
143
|
+
headers,
|
|
144
|
+
timeout: timeoutMs
|
|
145
|
+
},
|
|
146
|
+
(res) => {
|
|
147
|
+
const chunks = [];
|
|
148
|
+
res.on("data", (d) => chunks.push(d));
|
|
149
|
+
res.on("end", () => resolve({ ok: true, statusCode: res.statusCode ?? 0, body: Buffer.concat(chunks).toString("utf8") }));
|
|
150
|
+
}
|
|
151
|
+
);
|
|
152
|
+
req.on("timeout", () => {
|
|
153
|
+
try {
|
|
154
|
+
req.destroy(new Error("timeout"));
|
|
155
|
+
} catch {
|
|
156
|
+
// ignore
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
req.on("error", (err) => resolve({ ok: false, error: err?.message ?? String(err ?? "request failed") }));
|
|
160
|
+
req.end(body);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function buildPayload({ tenantId, token, decisionReport, publicSummary, closePackZipUrl, publicBaseUrl, idempotencyKey }) {
|
|
165
|
+
const decision = isPlainObject(decisionReport) ? decisionReport : {};
|
|
166
|
+
const invoice = isPlainObject(publicSummary?.invoiceClaim) ? publicSummary.invoiceClaim : null;
|
|
167
|
+
const magicLinkPath = `/r/${token}`;
|
|
168
|
+
const decisionReportPath = `${magicLinkPath}/settlement_decision_report.json`;
|
|
169
|
+
const status = statusFromPublicSummary(publicSummary);
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
schemaVersion: "MagicLinkPaymentTrigger.v1",
|
|
173
|
+
event: "payment.approval_ready",
|
|
174
|
+
triggeredAt: nowIso(),
|
|
175
|
+
tenantId,
|
|
176
|
+
token,
|
|
177
|
+
idempotencyKey,
|
|
178
|
+
decision: {
|
|
179
|
+
decision: typeof decision.decision === "string" ? decision.decision : null,
|
|
180
|
+
decidedAt: typeof decision.decidedAt === "string" ? decision.decidedAt : null,
|
|
181
|
+
reportHash: typeof decision.reportHash === "string" ? decision.reportHash : null,
|
|
182
|
+
signerKeyId: typeof decision.signerKeyId === "string" ? decision.signerKeyId : null,
|
|
183
|
+
actorEmail: typeof decision?.actor?.email === "string" ? decision.actor.email : null
|
|
184
|
+
},
|
|
185
|
+
verification: {
|
|
186
|
+
status,
|
|
187
|
+
ok: Boolean(publicSummary?.verification?.ok),
|
|
188
|
+
verificationOk: Boolean(publicSummary?.verification?.verificationOk)
|
|
189
|
+
},
|
|
190
|
+
invoice: invoice
|
|
191
|
+
? {
|
|
192
|
+
invoiceId: typeof invoice.invoiceId === "string" ? invoice.invoiceId : null,
|
|
193
|
+
currency: typeof invoice.currency === "string" ? invoice.currency : null,
|
|
194
|
+
totalCents: typeof invoice.totalCents === "string" ? invoice.totalCents : null
|
|
195
|
+
}
|
|
196
|
+
: null,
|
|
197
|
+
artifacts: {
|
|
198
|
+
magicLinkUrl: absoluteUrl(publicBaseUrl, magicLinkPath),
|
|
199
|
+
decisionReportUrl: absoluteUrl(publicBaseUrl, decisionReportPath),
|
|
200
|
+
closePackZipUrl:
|
|
201
|
+
typeof closePackZipUrl === "string" && closePackZipUrl
|
|
202
|
+
? absoluteUrl(publicBaseUrl, closePackZipUrl)
|
|
203
|
+
: absoluteUrl(publicBaseUrl, `${magicLinkPath}/closepack.zip`)
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function nextRetryBackoffMs({ baseMs, attempt }) {
|
|
209
|
+
const exp = Math.max(0, Math.min(16, Number(attempt ?? 1) - 1));
|
|
210
|
+
return Math.min(86_400_000, Math.max(0, Number(baseMs ?? 0)) * (2 ** exp));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function buildAttemptResult({ statusCode = null, error = null } = {}) {
|
|
214
|
+
const ok = Number.isInteger(statusCode) && statusCode >= 200 && statusCode < 300;
|
|
215
|
+
return {
|
|
216
|
+
at: nowIso(),
|
|
217
|
+
ok,
|
|
218
|
+
statusCode: Number.isInteger(statusCode) ? statusCode : null,
|
|
219
|
+
error: typeof error === "string" && error ? error : null
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function buildWebhookHeaders({ body, idempotencyKey, webhookSecret }) {
|
|
224
|
+
const headers = {
|
|
225
|
+
"content-type": "application/json; charset=utf-8",
|
|
226
|
+
"content-length": String(Buffer.byteLength(body, "utf8")),
|
|
227
|
+
"x-settld-event": "payment.approval_ready",
|
|
228
|
+
"x-settld-idempotency-key": idempotencyKey
|
|
229
|
+
};
|
|
230
|
+
if (webhookSecret) {
|
|
231
|
+
const ts = nowIso();
|
|
232
|
+
const sig = hmacSha256Hex(webhookSecret, `${ts}.${body}`);
|
|
233
|
+
headers["x-settld-timestamp"] = ts;
|
|
234
|
+
headers["x-settld-signature"] = `v1=${sig}`;
|
|
235
|
+
}
|
|
236
|
+
return headers;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function deliverWebhookAttempt({ webhookUrl, webhookSecret, payload, idempotencyKey, timeoutMs }) {
|
|
240
|
+
const body = JSON.stringify(payload);
|
|
241
|
+
const headers = buildWebhookHeaders({ body, idempotencyKey, webhookSecret });
|
|
242
|
+
const httpRes = await request({ url: webhookUrl, method: "POST", headers, body, timeoutMs });
|
|
243
|
+
if (!httpRes.ok) return buildAttemptResult({ error: httpRes.error ?? "PAYMENT_TRIGGER_WEBHOOK_FAILED" });
|
|
244
|
+
if (httpRes.statusCode < 200 || httpRes.statusCode >= 300) return buildAttemptResult({ statusCode: httpRes.statusCode, error: "PAYMENT_TRIGGER_WEBHOOK_NON_2XX" });
|
|
245
|
+
return buildAttemptResult({ statusCode: httpRes.statusCode });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function appendAttemptLog({ dataDir, job, attempt }) {
|
|
249
|
+
const id = retryJobId({ tenantId: job.tenantId, token: job.token, idempotencyKey: job.idempotencyKey });
|
|
250
|
+
const fp = path.join(retryAttemptsDir(dataDir), `${id}.jsonl`);
|
|
251
|
+
await fs.mkdir(path.dirname(fp), { recursive: true });
|
|
252
|
+
await fs.appendFile(fp, JSON.stringify({ schemaVersion: "MagicLinkPaymentTriggerAttempt.v1", ...attempt }) + "\n", "utf8");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function persistRetryJob({ dataDir, job }) {
|
|
256
|
+
const fp = retryPendingPath({ dataDir, tenantId: job.tenantId, token: job.token, idempotencyKey: job.idempotencyKey });
|
|
257
|
+
await fs.mkdir(path.dirname(fp), { recursive: true });
|
|
258
|
+
await fs.writeFile(fp, JSON.stringify(job, null, 2) + "\n", "utf8");
|
|
259
|
+
return fp;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function enqueueRetryJob({
|
|
263
|
+
dataDir,
|
|
264
|
+
tenantId,
|
|
265
|
+
token,
|
|
266
|
+
payload,
|
|
267
|
+
idempotencyKey,
|
|
268
|
+
webhookUrl,
|
|
269
|
+
webhookSecretStored,
|
|
270
|
+
maxAttempts,
|
|
271
|
+
backoffMs,
|
|
272
|
+
firstAttempt
|
|
273
|
+
}) {
|
|
274
|
+
const attemptCount = 1;
|
|
275
|
+
const nextAttemptAtMs = Date.now() + nextRetryBackoffMs({ baseMs: backoffMs, attempt: 1 });
|
|
276
|
+
const job = {
|
|
277
|
+
schemaVersion: "MagicLinkPaymentTriggerRetryJob.v1",
|
|
278
|
+
tenantId,
|
|
279
|
+
token,
|
|
280
|
+
idempotencyKey,
|
|
281
|
+
payload,
|
|
282
|
+
webhookUrl,
|
|
283
|
+
webhookSecretStored: typeof webhookSecretStored === "string" && webhookSecretStored ? webhookSecretStored : null,
|
|
284
|
+
maxAttempts,
|
|
285
|
+
backoffMs,
|
|
286
|
+
attemptCount,
|
|
287
|
+
nextAttemptAt: new Date(nextAttemptAtMs).toISOString(),
|
|
288
|
+
attempts: [firstAttempt],
|
|
289
|
+
lastError: firstAttempt?.error ?? null,
|
|
290
|
+
enqueuedAt: nowIso(),
|
|
291
|
+
updatedAt: nowIso()
|
|
292
|
+
};
|
|
293
|
+
const fp = await persistRetryJob({ dataDir, job });
|
|
294
|
+
return { job, path: fp };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function moveToDeadLetter({ dataDir, job }) {
|
|
298
|
+
const src = retryPendingPath({ dataDir, tenantId: job.tenantId, token: job.token, idempotencyKey: job.idempotencyKey });
|
|
299
|
+
const dst = retryDeadLetterPath({ dataDir, tenantId: job.tenantId, token: job.token, idempotencyKey: job.idempotencyKey });
|
|
300
|
+
await fs.mkdir(path.dirname(dst), { recursive: true });
|
|
301
|
+
const dead = { ...job, deadLetteredAt: nowIso(), updatedAt: nowIso() };
|
|
302
|
+
await fs.writeFile(dst, JSON.stringify(dead, null, 2) + "\n", "utf8");
|
|
303
|
+
await fs.rm(src, { force: true });
|
|
304
|
+
return dst;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export async function sendPaymentTriggerOnApproval({
|
|
308
|
+
dataDir,
|
|
309
|
+
tenantId,
|
|
310
|
+
token,
|
|
311
|
+
tenantSettings,
|
|
312
|
+
decisionReport,
|
|
313
|
+
publicSummary,
|
|
314
|
+
closePackZipUrl = null,
|
|
315
|
+
publicBaseUrl = null,
|
|
316
|
+
settingsKey = null,
|
|
317
|
+
timeoutMs = 5_000,
|
|
318
|
+
retryMaxAttempts = 5,
|
|
319
|
+
retryBackoffMs = 5_000
|
|
320
|
+
} = {}) {
|
|
321
|
+
const cfg = isPlainObject(tenantSettings?.paymentTriggers) ? tenantSettings.paymentTriggers : null;
|
|
322
|
+
if (!cfg || !cfg.enabled) return { ok: true, skipped: true, reason: "PAYMENT_TRIGGER_DISABLED" };
|
|
323
|
+
|
|
324
|
+
const decision = typeof decisionReport?.decision === "string" ? decisionReport.decision : null;
|
|
325
|
+
if (decision !== "approve") return { ok: true, skipped: true, reason: "PAYMENT_TRIGGER_NOT_APPROVED" };
|
|
326
|
+
|
|
327
|
+
const deliveryMode = String(cfg.deliveryMode ?? "record").trim().toLowerCase();
|
|
328
|
+
if (deliveryMode !== "record" && deliveryMode !== "webhook") {
|
|
329
|
+
return { ok: false, skipped: true, reason: "PAYMENT_TRIGGER_INVALID_DELIVERY_MODE" };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const { maxAttempts, backoffMs } = clampRetryConfig({ retryMaxAttempts, retryBackoffMs });
|
|
333
|
+
const idempotencyKey = typeof decisionReport?.reportHash === "string" ? decisionReport.reportHash : sha256Hex(JSON.stringify(decisionReport ?? {}));
|
|
334
|
+
const previous = await loadStateBestEffort({ dataDir, tenantId, token });
|
|
335
|
+
if (previous && previous.ok === true && previous.idempotencyKey === idempotencyKey && typeof previous.deliveredAt === "string" && previous.deliveredAt) {
|
|
336
|
+
return {
|
|
337
|
+
ok: true,
|
|
338
|
+
skipped: true,
|
|
339
|
+
reason: "PAYMENT_TRIGGER_ALREADY_DELIVERED",
|
|
340
|
+
deliveredAt: previous.deliveredAt,
|
|
341
|
+
idempotencyKey
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const payload = buildPayload({ tenantId, token, decisionReport, publicSummary, closePackZipUrl, publicBaseUrl, idempotencyKey });
|
|
346
|
+
let result = null;
|
|
347
|
+
|
|
348
|
+
if (deliveryMode === "record") {
|
|
349
|
+
const fp = triggerOutboxPath({ dataDir, tenantId, token, idempotencyKey });
|
|
350
|
+
await fs.mkdir(path.dirname(fp), { recursive: true });
|
|
351
|
+
await fs.writeFile(fp, JSON.stringify(payload, null, 2) + "\n", "utf8");
|
|
352
|
+
result = { ok: true, mode: "record", recorded: true, outboxPath: fp };
|
|
353
|
+
} else {
|
|
354
|
+
const webhookUrl = typeof cfg.webhookUrl === "string" ? cfg.webhookUrl.trim() : "";
|
|
355
|
+
if (!webhookUrl) {
|
|
356
|
+
result = { ok: false, mode: "webhook", error: "PAYMENT_TRIGGER_WEBHOOK_URL_MISSING" };
|
|
357
|
+
} else {
|
|
358
|
+
const existingJob = await loadRetryJobBestEffort({ dataDir, tenantId, token, idempotencyKey });
|
|
359
|
+
if (existingJob) {
|
|
360
|
+
result = {
|
|
361
|
+
ok: false,
|
|
362
|
+
mode: "webhook",
|
|
363
|
+
queued: true,
|
|
364
|
+
reason: "PAYMENT_TRIGGER_RETRY_ALREADY_ENQUEUED",
|
|
365
|
+
idempotencyKey,
|
|
366
|
+
attemptCount: safeInt(existingJob.attemptCount, 1),
|
|
367
|
+
maxAttempts: safeInt(existingJob.maxAttempts, maxAttempts),
|
|
368
|
+
nextAttemptAt: typeof existingJob.nextAttemptAt === "string" ? existingJob.nextAttemptAt : null
|
|
369
|
+
};
|
|
370
|
+
} else {
|
|
371
|
+
const webhookSecret = decryptStoredSecret({ settingsKey, storedSecret: cfg.webhookSecret });
|
|
372
|
+
const firstAttempt = await deliverWebhookAttempt({ webhookUrl, webhookSecret, payload, idempotencyKey, timeoutMs });
|
|
373
|
+
await appendAttemptLog({
|
|
374
|
+
dataDir,
|
|
375
|
+
job: { tenantId, token, idempotencyKey },
|
|
376
|
+
attempt: { ...firstAttempt, attemptNumber: 1, source: "inline" }
|
|
377
|
+
});
|
|
378
|
+
if (firstAttempt.ok) {
|
|
379
|
+
result = { ok: true, mode: "webhook", statusCode: firstAttempt.statusCode };
|
|
380
|
+
} else if (maxAttempts > 1) {
|
|
381
|
+
const enq = await enqueueRetryJob({
|
|
382
|
+
dataDir,
|
|
383
|
+
tenantId,
|
|
384
|
+
token,
|
|
385
|
+
payload,
|
|
386
|
+
idempotencyKey,
|
|
387
|
+
webhookUrl,
|
|
388
|
+
webhookSecretStored: cfg.webhookSecret,
|
|
389
|
+
maxAttempts,
|
|
390
|
+
backoffMs,
|
|
391
|
+
firstAttempt
|
|
392
|
+
});
|
|
393
|
+
result = {
|
|
394
|
+
ok: false,
|
|
395
|
+
mode: "webhook",
|
|
396
|
+
queued: true,
|
|
397
|
+
reason: "PAYMENT_TRIGGER_RETRY_ENQUEUED",
|
|
398
|
+
error: firstAttempt.error ?? null,
|
|
399
|
+
attemptCount: 1,
|
|
400
|
+
maxAttempts,
|
|
401
|
+
nextAttemptAt: enq.job.nextAttemptAt
|
|
402
|
+
};
|
|
403
|
+
} else {
|
|
404
|
+
result = { ok: false, mode: "webhook", error: firstAttempt.error ?? "PAYMENT_TRIGGER_WEBHOOK_FAILED", statusCode: firstAttempt.statusCode ?? null };
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const state = {
|
|
411
|
+
schemaVersion: "MagicLinkPaymentTriggerState.v1",
|
|
412
|
+
attemptedAt: nowIso(),
|
|
413
|
+
deliveredAt: result && result.ok ? nowIso() : null,
|
|
414
|
+
ok: Boolean(result && result.ok),
|
|
415
|
+
tenantId,
|
|
416
|
+
token,
|
|
417
|
+
idempotencyKey,
|
|
418
|
+
deliveryMode,
|
|
419
|
+
result
|
|
420
|
+
};
|
|
421
|
+
await writeState({ dataDir, tenantId, token, state });
|
|
422
|
+
if (result && result.ok) return { ok: true, skipped: false, idempotencyKey, ...result };
|
|
423
|
+
return { ok: false, skipped: false, idempotencyKey, ...(result ?? { mode: deliveryMode, error: "PAYMENT_TRIGGER_FAILED" }) };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export async function processPaymentTriggerRetryQueueOnce({
|
|
427
|
+
dataDir,
|
|
428
|
+
settingsKey = null,
|
|
429
|
+
timeoutMs = 5_000,
|
|
430
|
+
nowMs = Date.now(),
|
|
431
|
+
tenantIdFilter = null
|
|
432
|
+
} = {}) {
|
|
433
|
+
const stats = { scanned: 0, skipped: 0, retried: 0, delivered: 0, deadLettered: 0, failed: 0 };
|
|
434
|
+
let names = [];
|
|
435
|
+
try {
|
|
436
|
+
names = (await fs.readdir(retryPendingDir(dataDir))).filter((name) => name.endsWith(".json")).sort();
|
|
437
|
+
} catch {
|
|
438
|
+
return stats;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
for (const name of names) {
|
|
442
|
+
stats.scanned += 1;
|
|
443
|
+
const fp = path.join(retryPendingDir(dataDir), name);
|
|
444
|
+
let job = null;
|
|
445
|
+
try {
|
|
446
|
+
// eslint-disable-next-line no-await-in-loop
|
|
447
|
+
job = JSON.parse(await fs.readFile(fp, "utf8"));
|
|
448
|
+
} catch {
|
|
449
|
+
stats.failed += 1;
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
if (!isPlainObject(job)) {
|
|
453
|
+
stats.failed += 1;
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const nextAt = Date.parse(String(job.nextAttemptAt ?? ""));
|
|
458
|
+
if (Number.isFinite(nextAt) && nextAt > nowMs) {
|
|
459
|
+
stats.skipped += 1;
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const tenantId = typeof job.tenantId === "string" ? job.tenantId : null;
|
|
464
|
+
const token = typeof job.token === "string" ? job.token : null;
|
|
465
|
+
const idempotencyKey = typeof job.idempotencyKey === "string" ? job.idempotencyKey : null;
|
|
466
|
+
const webhookUrl = typeof job.webhookUrl === "string" ? job.webhookUrl : null;
|
|
467
|
+
const payload = isPlainObject(job.payload) ? job.payload : null;
|
|
468
|
+
if (!tenantId || !token || !idempotencyKey || !webhookUrl || !payload) {
|
|
469
|
+
stats.failed += 1;
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
if (tenantIdFilter && tenantId !== tenantIdFilter) {
|
|
473
|
+
stats.skipped += 1;
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const maxAttempts = Math.max(1, safeInt(job.maxAttempts, 5));
|
|
478
|
+
const backoffMs = Math.max(0, safeInt(job.backoffMs, 5_000));
|
|
479
|
+
const attemptCount = Math.max(0, safeInt(job.attemptCount, 0));
|
|
480
|
+
const nextAttemptNumber = attemptCount + 1;
|
|
481
|
+
|
|
482
|
+
const webhookSecret = decryptStoredSecret({ settingsKey, storedSecret: job.webhookSecretStored });
|
|
483
|
+
// eslint-disable-next-line no-await-in-loop
|
|
484
|
+
const attempt = await deliverWebhookAttempt({ webhookUrl, webhookSecret, payload, idempotencyKey, timeoutMs });
|
|
485
|
+
// eslint-disable-next-line no-await-in-loop
|
|
486
|
+
await appendAttemptLog({
|
|
487
|
+
dataDir,
|
|
488
|
+
job: { tenantId, token, idempotencyKey },
|
|
489
|
+
attempt: { ...attempt, attemptNumber: nextAttemptNumber, source: "retry_worker" }
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
if (attempt.ok) {
|
|
493
|
+
// eslint-disable-next-line no-await-in-loop
|
|
494
|
+
await fs.rm(fp, { force: true });
|
|
495
|
+
// eslint-disable-next-line no-await-in-loop
|
|
496
|
+
await writeState({
|
|
497
|
+
dataDir,
|
|
498
|
+
tenantId,
|
|
499
|
+
token,
|
|
500
|
+
state: {
|
|
501
|
+
schemaVersion: "MagicLinkPaymentTriggerState.v1",
|
|
502
|
+
attemptedAt: attempt.at,
|
|
503
|
+
deliveredAt: attempt.at,
|
|
504
|
+
ok: true,
|
|
505
|
+
tenantId,
|
|
506
|
+
token,
|
|
507
|
+
idempotencyKey,
|
|
508
|
+
deliveryMode: "webhook",
|
|
509
|
+
result: {
|
|
510
|
+
ok: true,
|
|
511
|
+
mode: "webhook",
|
|
512
|
+
retried: true,
|
|
513
|
+
statusCode: attempt.statusCode,
|
|
514
|
+
attemptCount: nextAttemptNumber
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
stats.delivered += 1;
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const attempts = Array.isArray(job.attempts) ? [...job.attempts] : [];
|
|
523
|
+
attempts.push(attempt);
|
|
524
|
+
if (nextAttemptNumber >= maxAttempts) {
|
|
525
|
+
const deadJob = {
|
|
526
|
+
...job,
|
|
527
|
+
attemptCount: nextAttemptNumber,
|
|
528
|
+
attempts,
|
|
529
|
+
lastError: attempt.error ?? null,
|
|
530
|
+
updatedAt: nowIso()
|
|
531
|
+
};
|
|
532
|
+
// eslint-disable-next-line no-await-in-loop
|
|
533
|
+
await fs.writeFile(fp, JSON.stringify(deadJob, null, 2) + "\n", "utf8");
|
|
534
|
+
// eslint-disable-next-line no-await-in-loop
|
|
535
|
+
await moveToDeadLetter({ dataDir, job: deadJob });
|
|
536
|
+
// eslint-disable-next-line no-await-in-loop
|
|
537
|
+
await writeState({
|
|
538
|
+
dataDir,
|
|
539
|
+
tenantId,
|
|
540
|
+
token,
|
|
541
|
+
state: {
|
|
542
|
+
schemaVersion: "MagicLinkPaymentTriggerState.v1",
|
|
543
|
+
attemptedAt: attempt.at,
|
|
544
|
+
deliveredAt: null,
|
|
545
|
+
ok: false,
|
|
546
|
+
tenantId,
|
|
547
|
+
token,
|
|
548
|
+
idempotencyKey,
|
|
549
|
+
deliveryMode: "webhook",
|
|
550
|
+
result: {
|
|
551
|
+
ok: false,
|
|
552
|
+
mode: "webhook",
|
|
553
|
+
deadLetter: true,
|
|
554
|
+
error: attempt.error ?? null,
|
|
555
|
+
statusCode: attempt.statusCode ?? null,
|
|
556
|
+
attemptCount: nextAttemptNumber,
|
|
557
|
+
maxAttempts
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
stats.deadLettered += 1;
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const nextMs = nowMs + nextRetryBackoffMs({ baseMs: backoffMs, attempt: nextAttemptNumber });
|
|
566
|
+
const updated = {
|
|
567
|
+
...job,
|
|
568
|
+
attemptCount: nextAttemptNumber,
|
|
569
|
+
nextAttemptAt: new Date(nextMs).toISOString(),
|
|
570
|
+
attempts,
|
|
571
|
+
lastError: attempt.error ?? null,
|
|
572
|
+
updatedAt: nowIso()
|
|
573
|
+
};
|
|
574
|
+
// eslint-disable-next-line no-await-in-loop
|
|
575
|
+
await fs.writeFile(fp, JSON.stringify(updated, null, 2) + "\n", "utf8");
|
|
576
|
+
stats.retried += 1;
|
|
577
|
+
}
|
|
578
|
+
return stats;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
export function startPaymentTriggerRetryWorker({
|
|
582
|
+
dataDir,
|
|
583
|
+
settingsKey = null,
|
|
584
|
+
timeoutMs = 5_000,
|
|
585
|
+
intervalMs = 2_000,
|
|
586
|
+
onRetry = null,
|
|
587
|
+
onDeadLetter = null,
|
|
588
|
+
onDelivered = null
|
|
589
|
+
} = {}) {
|
|
590
|
+
const cadence = Math.max(100, safeInt(intervalMs, 2_000));
|
|
591
|
+
let running = false;
|
|
592
|
+
const timer = setInterval(async () => {
|
|
593
|
+
if (running) return;
|
|
594
|
+
running = true;
|
|
595
|
+
try {
|
|
596
|
+
const stats = await processPaymentTriggerRetryQueueOnce({ dataDir, settingsKey, timeoutMs });
|
|
597
|
+
if (typeof onRetry === "function" && stats.retried > 0) onRetry(stats.retried, stats);
|
|
598
|
+
if (typeof onDeadLetter === "function" && stats.deadLettered > 0) onDeadLetter(stats.deadLettered, stats);
|
|
599
|
+
if (typeof onDelivered === "function" && stats.delivered > 0) onDelivered(stats.delivered, stats);
|
|
600
|
+
} finally {
|
|
601
|
+
running = false;
|
|
602
|
+
}
|
|
603
|
+
}, cadence);
|
|
604
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
605
|
+
return {
|
|
606
|
+
stop() {
|
|
607
|
+
clearInterval(timer);
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
export async function paymentTriggerRetryQueueDepth({ dataDir } = {}) {
|
|
613
|
+
try {
|
|
614
|
+
const names = (await fs.readdir(retryPendingDir(dataDir))).filter((name) => name.endsWith(".json"));
|
|
615
|
+
return names.length;
|
|
616
|
+
} catch {
|
|
617
|
+
return 0;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
export async function paymentTriggerDeadLetterExists({ dataDir, tenantId, token, idempotencyKey } = {}) {
|
|
622
|
+
const fp = retryDeadLetterPath({ dataDir, tenantId, token, idempotencyKey });
|
|
623
|
+
return await fileExists(fp);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function normalizeRetryJobSummary({ job, state }) {
|
|
627
|
+
if (!isPlainObject(job)) return null;
|
|
628
|
+
const attempts = Array.isArray(job.attempts) ? job.attempts : [];
|
|
629
|
+
const lastAttempt = attempts.length ? attempts[attempts.length - 1] : null;
|
|
630
|
+
return {
|
|
631
|
+
schemaVersion: "MagicLinkPaymentTriggerRetrySummary.v1",
|
|
632
|
+
state,
|
|
633
|
+
tenantId: typeof job.tenantId === "string" ? job.tenantId : null,
|
|
634
|
+
token: typeof job.token === "string" ? job.token : null,
|
|
635
|
+
idempotencyKey: typeof job.idempotencyKey === "string" ? job.idempotencyKey : null,
|
|
636
|
+
attemptCount: safeInt(job.attemptCount, 0),
|
|
637
|
+
maxAttempts: safeInt(job.maxAttempts, 0),
|
|
638
|
+
nextAttemptAt: typeof job.nextAttemptAt === "string" ? job.nextAttemptAt : null,
|
|
639
|
+
enqueuedAt: typeof job.enqueuedAt === "string" ? job.enqueuedAt : null,
|
|
640
|
+
updatedAt: typeof job.updatedAt === "string" ? job.updatedAt : null,
|
|
641
|
+
deadLetteredAt: typeof job.deadLetteredAt === "string" ? job.deadLetteredAt : null,
|
|
642
|
+
lastError: typeof job.lastError === "string" && job.lastError ? job.lastError : typeof lastAttempt?.error === "string" ? lastAttempt.error : null
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
export async function listPaymentTriggerRetryJobs({
|
|
647
|
+
dataDir,
|
|
648
|
+
tenantId = null,
|
|
649
|
+
state = "pending",
|
|
650
|
+
limit = 100
|
|
651
|
+
} = {}) {
|
|
652
|
+
const targetState = state === "dead-letter" ? "dead-letter" : "pending";
|
|
653
|
+
const dir = targetState === "dead-letter" ? retryDeadLetterDir(dataDir) : retryPendingDir(dataDir);
|
|
654
|
+
const capped = Math.max(1, Math.min(500, safeInt(limit, 100)));
|
|
655
|
+
let names = [];
|
|
656
|
+
try {
|
|
657
|
+
names = (await fs.readdir(dir)).filter((name) => name.endsWith(".json")).sort();
|
|
658
|
+
} catch {
|
|
659
|
+
return [];
|
|
660
|
+
}
|
|
661
|
+
const rows = [];
|
|
662
|
+
for (const name of names) {
|
|
663
|
+
const fp = path.join(dir, name);
|
|
664
|
+
// eslint-disable-next-line no-await-in-loop
|
|
665
|
+
const job = await readJsonIfExists(fp);
|
|
666
|
+
if (!isPlainObject(job)) continue;
|
|
667
|
+
if (tenantId && String(job.tenantId ?? "") !== tenantId) continue;
|
|
668
|
+
const row = normalizeRetryJobSummary({ job, state: targetState });
|
|
669
|
+
if (!row) continue;
|
|
670
|
+
rows.push(row);
|
|
671
|
+
}
|
|
672
|
+
rows.sort((a, b) => Date.parse(String(b.updatedAt ?? "")) - Date.parse(String(a.updatedAt ?? "")));
|
|
673
|
+
return rows.slice(0, capped);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
export async function replayPaymentTriggerDeadLetterJob({
|
|
677
|
+
dataDir,
|
|
678
|
+
tenantId,
|
|
679
|
+
token,
|
|
680
|
+
idempotencyKey,
|
|
681
|
+
resetAttempts = false,
|
|
682
|
+
tenantSettings = null,
|
|
683
|
+
useCurrentSettings = true
|
|
684
|
+
} = {}) {
|
|
685
|
+
const deadPath = retryDeadLetterPath({ dataDir, tenantId, token, idempotencyKey });
|
|
686
|
+
const pendingPath = retryPendingPath({ dataDir, tenantId, token, idempotencyKey });
|
|
687
|
+
const deadJob = await readJsonIfExists(deadPath);
|
|
688
|
+
if (!deadJob) return { ok: false, code: "NOT_FOUND", message: "dead-letter job not found" };
|
|
689
|
+
|
|
690
|
+
const pendingExists = await fileExists(pendingPath);
|
|
691
|
+
if (pendingExists) return { ok: false, code: "PENDING_EXISTS", message: "pending retry job already exists" };
|
|
692
|
+
|
|
693
|
+
const now = nowIso();
|
|
694
|
+
const next = {
|
|
695
|
+
...deadJob,
|
|
696
|
+
deadLetteredAt: null,
|
|
697
|
+
replayedAt: now,
|
|
698
|
+
replayCount: safeInt(deadJob.replayCount, 0) + 1,
|
|
699
|
+
nextAttemptAt: now,
|
|
700
|
+
updatedAt: now
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
if (resetAttempts) {
|
|
704
|
+
next.attemptCount = 0;
|
|
705
|
+
next.attempts = [];
|
|
706
|
+
next.lastError = null;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if (useCurrentSettings && isPlainObject(tenantSettings?.paymentTriggers)) {
|
|
710
|
+
const cfg = tenantSettings.paymentTriggers;
|
|
711
|
+
const webhookUrl = typeof cfg.webhookUrl === "string" ? cfg.webhookUrl.trim() : "";
|
|
712
|
+
if (webhookUrl) next.webhookUrl = webhookUrl;
|
|
713
|
+
if (cfg.webhookSecret === null || cfg.webhookSecret === undefined || typeof cfg.webhookSecret === "string") {
|
|
714
|
+
next.webhookSecretStored = cfg.webhookSecret ?? null;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
await fs.mkdir(path.dirname(pendingPath), { recursive: true });
|
|
719
|
+
await fs.writeFile(pendingPath, JSON.stringify(next, null, 2) + "\n", "utf8");
|
|
720
|
+
await fs.rm(deadPath, { force: true });
|
|
721
|
+
|
|
722
|
+
return {
|
|
723
|
+
ok: true,
|
|
724
|
+
state: "pending",
|
|
725
|
+
tenantId,
|
|
726
|
+
token,
|
|
727
|
+
idempotencyKey,
|
|
728
|
+
attemptCount: safeInt(next.attemptCount, 0),
|
|
729
|
+
maxAttempts: safeInt(next.maxAttempts, 0),
|
|
730
|
+
nextAttemptAt: typeof next.nextAttemptAt === "string" ? next.nextAttemptAt : now,
|
|
731
|
+
replayCount: safeInt(next.replayCount, 1)
|
|
732
|
+
};
|
|
733
|
+
}
|