@elizaos/plugin-finances 2.0.3-beta.5 → 2.0.3-beta.7
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/actions/finances.d.ts +38 -0
- package/dist/actions/finances.d.ts.map +1 -0
- package/dist/actions/finances.js +368 -0
- package/dist/actions/finances.js.map +1 -0
- package/dist/components/finances/FinancesSpatialView.d.ts +80 -0
- package/dist/components/finances/FinancesSpatialView.d.ts.map +1 -0
- package/dist/components/finances/FinancesSpatialView.js +157 -0
- package/dist/components/finances/FinancesSpatialView.js.map +1 -0
- package/dist/components/finances/FinancesView.d.ts +97 -0
- package/dist/components/finances/FinancesView.d.ts.map +1 -0
- package/dist/components/finances/FinancesView.js +231 -0
- package/dist/components/finances/FinancesView.js.map +1 -0
- package/dist/components/finances/finances-view-bundle.d.ts +10 -0
- package/dist/components/finances/finances-view-bundle.d.ts.map +1 -0
- package/dist/components/finances/finances-view-bundle.js +5 -0
- package/dist/components/finances/finances-view-bundle.js.map +1 -0
- package/dist/db/finances-repository.d.ts +51 -0
- package/dist/db/finances-repository.d.ts.map +1 -0
- package/dist/db/finances-repository.js +521 -0
- package/dist/db/finances-repository.js.map +1 -0
- package/dist/db/index.d.ts +3 -0
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/index.js +6 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/schema.d.ts +2615 -0
- package/dist/db/schema.d.ts.map +1 -0
- package/dist/db/schema.js +133 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/db/sql.d.ts +65 -0
- package/dist/db/sql.d.ts.map +1 -0
- package/dist/db/sql.js +182 -0
- package/dist/db/sql.js.map +1 -0
- package/dist/finance-normalize.d.ts +24 -0
- package/dist/finance-normalize.d.ts.map +1 -0
- package/dist/finance-normalize.js +66 -0
- package/dist/finance-normalize.js.map +1 -0
- package/dist/finances-service.d.ts +179 -0
- package/dist/finances-service.d.ts.map +1 -0
- package/dist/finances-service.js +1122 -0
- package/dist/finances-service.js.map +1 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +109 -0
- package/dist/index.js.map +1 -0
- package/dist/payment-csv-import.d.ts +23 -0
- package/dist/payment-csv-import.d.ts.map +1 -0
- package/dist/payment-csv-import.js +271 -0
- package/dist/payment-csv-import.js.map +1 -0
- package/dist/payment-recurrence.d.ts +14 -0
- package/dist/payment-recurrence.d.ts.map +1 -0
- package/dist/payment-recurrence.js +190 -0
- package/dist/payment-recurrence.js.map +1 -0
- package/dist/payment-types.d.ts +158 -0
- package/dist/payment-types.d.ts.map +1 -0
- package/dist/payment-types.js +1 -0
- package/dist/payment-types.js.map +1 -0
- package/dist/plugin.d.ts +15 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +31 -0
- package/dist/plugin.js.map +1 -0
- package/dist/register-terminal-view.d.ts +15 -0
- package/dist/register-terminal-view.d.ts.map +1 -0
- package/dist/register-terminal-view.js +21 -0
- package/dist/register-terminal-view.js.map +1 -0
- package/dist/register.d.ts +9 -0
- package/dist/register.d.ts.map +1 -0
- package/dist/register.js +5 -0
- package/dist/register.js.map +1 -0
- package/dist/services/browser-bridge-seam.d.ts +40 -0
- package/dist/services/browser-bridge-seam.d.ts.map +1 -0
- package/dist/services/browser-bridge-seam.js +39 -0
- package/dist/services/browser-bridge-seam.js.map +1 -0
- package/dist/services/gmail-seam.d.ts +40 -0
- package/dist/services/gmail-seam.d.ts.map +1 -0
- package/dist/services/gmail-seam.js +208 -0
- package/dist/services/gmail-seam.js.map +1 -0
- package/dist/services/migration.d.ts +65 -0
- package/dist/services/migration.d.ts.map +1 -0
- package/dist/services/migration.js +116 -0
- package/dist/services/migration.js.map +1 -0
- package/dist/services/subscriptions-service.d.ts +76 -0
- package/dist/services/subscriptions-service.d.ts.map +1 -0
- package/dist/services/subscriptions-service.js +1002 -0
- package/dist/services/subscriptions-service.js.map +1 -0
- package/dist/subscriptions-playbooks.d.ts +79 -0
- package/dist/subscriptions-playbooks.d.ts.map +1 -0
- package/dist/subscriptions-playbooks.js +871 -0
- package/dist/subscriptions-playbooks.js.map +1 -0
- package/dist/subscriptions-types.d.ts +80 -0
- package/dist/subscriptions-types.d.ts.map +1 -0
- package/dist/subscriptions-types.js +1 -0
- package/dist/subscriptions-types.js.map +1 -0
- package/dist/token-encryption.d.ts +42 -0
- package/dist/token-encryption.d.ts.map +1 -0
- package/dist/token-encryption.js +96 -0
- package/dist/token-encryption.js.map +1 -0
- package/dist/types.d.ts +55 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +18 -0
- package/dist/types.js.map +1 -0
- package/dist/views/bundle.js +411 -0
- package/dist/views/bundle.js.map +1 -0
- package/package.json +11 -11
|
@@ -0,0 +1,1122 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { loadElizaConfig, resolveOAuthDir } from "@elizaos/agent";
|
|
4
|
+
import { logger } from "@elizaos/core";
|
|
5
|
+
import {
|
|
6
|
+
normalizeCloudSiteUrl,
|
|
7
|
+
normalizeElizaCloudApiKey,
|
|
8
|
+
PaypalManagedClient,
|
|
9
|
+
PaypalManagedClientError,
|
|
10
|
+
PlaidManagedClient,
|
|
11
|
+
PlaidManagedClientError,
|
|
12
|
+
resolveCloudApiBaseUrl
|
|
13
|
+
} from "@elizaos/plugin-elizacloud/cloud/managed-payment-clients";
|
|
14
|
+
import { FinancesRepository } from "./db/finances-repository.js";
|
|
15
|
+
import {
|
|
16
|
+
fail,
|
|
17
|
+
normalizeOptionalString,
|
|
18
|
+
requireAgentId,
|
|
19
|
+
requireNonEmptyString
|
|
20
|
+
} from "./finance-normalize.js";
|
|
21
|
+
import {
|
|
22
|
+
parseTransactionsCsv
|
|
23
|
+
} from "./payment-csv-import.js";
|
|
24
|
+
import {
|
|
25
|
+
detectRecurringCharges,
|
|
26
|
+
normalizeMerchant
|
|
27
|
+
} from "./payment-recurrence.js";
|
|
28
|
+
import { findLifeOpsSubscriptionPlaybook } from "./subscriptions-playbooks.js";
|
|
29
|
+
import {
|
|
30
|
+
decryptTokenEnvelope,
|
|
31
|
+
encryptTokenPayload,
|
|
32
|
+
isEncryptedTokenEnvelope,
|
|
33
|
+
resolveTokenEncryptionKey
|
|
34
|
+
} from "./token-encryption.js";
|
|
35
|
+
const DEFAULT_WINDOW_DAYS = 30;
|
|
36
|
+
const MS_PER_DAY = 864e5;
|
|
37
|
+
const VALID_SOURCE_KINDS = [
|
|
38
|
+
"csv",
|
|
39
|
+
"plaid",
|
|
40
|
+
"manual",
|
|
41
|
+
"paypal",
|
|
42
|
+
"email"
|
|
43
|
+
];
|
|
44
|
+
const EMAIL_SOURCE_LABEL = "Email bills";
|
|
45
|
+
const SENSITIVE_PAYMENT_SOURCE_METADATA_KEYS = /* @__PURE__ */ new Set(["plaid", "paypal"]);
|
|
46
|
+
function resolveFinancesCloudManagedClientConfig() {
|
|
47
|
+
let configKey = null;
|
|
48
|
+
let configBase = null;
|
|
49
|
+
try {
|
|
50
|
+
const config = loadElizaConfig();
|
|
51
|
+
const cloud = config.cloud && typeof config.cloud === "object" ? config.cloud : null;
|
|
52
|
+
if (cloud) {
|
|
53
|
+
if (typeof cloud.apiKey === "string") {
|
|
54
|
+
configKey = normalizeElizaCloudApiKey(cloud.apiKey);
|
|
55
|
+
}
|
|
56
|
+
if (typeof cloud.baseUrl === "string" && cloud.baseUrl.trim().length) {
|
|
57
|
+
configBase = cloud.baseUrl.trim();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
}
|
|
62
|
+
const apiKey = configKey ?? normalizeElizaCloudApiKey(process.env.ELIZAOS_CLOUD_API_KEY);
|
|
63
|
+
const baseUrl = configBase ?? process.env.ELIZAOS_CLOUD_BASE_URL ?? void 0;
|
|
64
|
+
return {
|
|
65
|
+
configured: Boolean(apiKey),
|
|
66
|
+
apiKey,
|
|
67
|
+
apiBaseUrl: resolveCloudApiBaseUrl(baseUrl),
|
|
68
|
+
siteUrl: normalizeCloudSiteUrl(baseUrl)
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function isRecord(value) {
|
|
72
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
73
|
+
}
|
|
74
|
+
function isPaypalCapability(value) {
|
|
75
|
+
return isRecord(value) && typeof value.hasReporting === "boolean" && typeof value.hasIdentity === "boolean";
|
|
76
|
+
}
|
|
77
|
+
function readPlaidPaymentMetadata(value) {
|
|
78
|
+
if (!isRecord(value)) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
const metadata = { ...value };
|
|
82
|
+
if (typeof metadata.cursor !== "string") {
|
|
83
|
+
delete metadata.cursor;
|
|
84
|
+
}
|
|
85
|
+
return metadata;
|
|
86
|
+
}
|
|
87
|
+
function readPaypalPaymentMetadata(value) {
|
|
88
|
+
if (!isRecord(value)) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const metadata = { ...value };
|
|
92
|
+
if (typeof metadata.tokenExpiresAt !== "string") {
|
|
93
|
+
delete metadata.tokenExpiresAt;
|
|
94
|
+
}
|
|
95
|
+
if (typeof metadata.scope !== "string") {
|
|
96
|
+
delete metadata.scope;
|
|
97
|
+
}
|
|
98
|
+
if (!isPaypalCapability(metadata.capability)) {
|
|
99
|
+
delete metadata.capability;
|
|
100
|
+
}
|
|
101
|
+
return metadata;
|
|
102
|
+
}
|
|
103
|
+
function paymentTokenStorageRoot(env = process.env) {
|
|
104
|
+
return path.join(resolveOAuthDir(env), "lifeops", "payments");
|
|
105
|
+
}
|
|
106
|
+
function encryptPaymentMetadataToken(token, env = process.env) {
|
|
107
|
+
const normalized = requireNonEmptyString(token, "token");
|
|
108
|
+
const key = resolveTokenEncryptionKey(paymentTokenStorageRoot(env), env);
|
|
109
|
+
return encryptTokenPayload(normalized, key);
|
|
110
|
+
}
|
|
111
|
+
function readPaymentMetadataToken(value, field, env = process.env) {
|
|
112
|
+
if (value === null || value === void 0) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
if (!isEncryptedTokenEnvelope(value)) {
|
|
116
|
+
fail(409, `${field} token metadata is malformed. Re-link the account.`);
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
return decryptTokenEnvelope(
|
|
120
|
+
value,
|
|
121
|
+
resolveTokenEncryptionKey(paymentTokenStorageRoot(env), env)
|
|
122
|
+
);
|
|
123
|
+
} catch {
|
|
124
|
+
fail(
|
|
125
|
+
409,
|
|
126
|
+
`${field} token metadata could not be decrypted. Restore ELIZA_TOKEN_ENCRYPTION_KEY or re-link the account.`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function sanitizePaymentSourceForClient(source) {
|
|
131
|
+
const metadata = {};
|
|
132
|
+
for (const [key, value] of Object.entries(source.metadata)) {
|
|
133
|
+
if (!SENSITIVE_PAYMENT_SOURCE_METADATA_KEYS.has(key.toLowerCase())) {
|
|
134
|
+
metadata[key] = value;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return { ...source, metadata };
|
|
138
|
+
}
|
|
139
|
+
function normalizeSourceKind(value) {
|
|
140
|
+
if (typeof value !== "string") {
|
|
141
|
+
fail(400, "paymentSource.kind must be a string.");
|
|
142
|
+
}
|
|
143
|
+
const normalized = value.trim().toLowerCase();
|
|
144
|
+
if (!VALID_SOURCE_KINDS.includes(normalized)) {
|
|
145
|
+
fail(
|
|
146
|
+
400,
|
|
147
|
+
`paymentSource.kind must be one of: ${VALID_SOURCE_KINDS.join(", ")}.`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
return normalized;
|
|
151
|
+
}
|
|
152
|
+
function buildTransactionId(args) {
|
|
153
|
+
const key = [
|
|
154
|
+
args.agentId,
|
|
155
|
+
args.sourceId,
|
|
156
|
+
args.parsed.postedAt,
|
|
157
|
+
args.parsed.amountUsd.toFixed(2),
|
|
158
|
+
args.parsed.merchantNormalized,
|
|
159
|
+
args.parsed.rowIndex
|
|
160
|
+
].join("|");
|
|
161
|
+
return crypto.createHash("sha1").update(key).digest("hex").slice(0, 32);
|
|
162
|
+
}
|
|
163
|
+
function computeSpendingSummary(args) {
|
|
164
|
+
const sinceMs = Date.now() - args.windowDays * MS_PER_DAY;
|
|
165
|
+
const scoped = args.transactions.filter((transaction) => {
|
|
166
|
+
const ms = Date.parse(transaction.postedAt);
|
|
167
|
+
return Number.isFinite(ms) && ms >= sinceMs;
|
|
168
|
+
});
|
|
169
|
+
let totalSpend = 0;
|
|
170
|
+
let totalIncome = 0;
|
|
171
|
+
const categoryTotals = /* @__PURE__ */ new Map();
|
|
172
|
+
const merchantTotals = /* @__PURE__ */ new Map();
|
|
173
|
+
for (const transaction of scoped) {
|
|
174
|
+
if (transaction.direction === "debit") {
|
|
175
|
+
totalSpend += transaction.amountUsd;
|
|
176
|
+
const categoryKey = transaction.category ?? "Uncategorized";
|
|
177
|
+
const existingCategory = categoryTotals.get(categoryKey);
|
|
178
|
+
if (existingCategory) {
|
|
179
|
+
existingCategory.total += transaction.amountUsd;
|
|
180
|
+
existingCategory.count += 1;
|
|
181
|
+
} else {
|
|
182
|
+
categoryTotals.set(categoryKey, {
|
|
183
|
+
total: transaction.amountUsd,
|
|
184
|
+
count: 1
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
const merchantKey = transaction.merchantNormalized;
|
|
188
|
+
const existingMerchant = merchantTotals.get(merchantKey);
|
|
189
|
+
if (existingMerchant) {
|
|
190
|
+
existingMerchant.total += transaction.amountUsd;
|
|
191
|
+
existingMerchant.count += 1;
|
|
192
|
+
} else {
|
|
193
|
+
merchantTotals.set(merchantKey, {
|
|
194
|
+
display: transaction.merchantRaw,
|
|
195
|
+
total: transaction.amountUsd,
|
|
196
|
+
count: 1
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
} else {
|
|
200
|
+
totalIncome += transaction.amountUsd;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const topCategories = Array.from(
|
|
204
|
+
categoryTotals.entries()
|
|
205
|
+
).map(([category, agg]) => ({
|
|
206
|
+
category,
|
|
207
|
+
totalUsd: Number(agg.total.toFixed(2)),
|
|
208
|
+
transactionCount: agg.count
|
|
209
|
+
})).sort((a, b) => b.totalUsd - a.totalUsd).slice(0, 6);
|
|
210
|
+
const topMerchants = Array.from(merchantTotals.entries()).map(([merchantNormalized, agg]) => ({
|
|
211
|
+
merchantNormalized,
|
|
212
|
+
merchantDisplay: agg.display,
|
|
213
|
+
totalUsd: Number(agg.total.toFixed(2)),
|
|
214
|
+
transactionCount: agg.count
|
|
215
|
+
})).sort((a, b) => b.totalUsd - a.totalUsd).slice(0, 10);
|
|
216
|
+
const recurringSpendUsd = args.recurring.reduce((total, charge) => {
|
|
217
|
+
if (charge.cadence === "irregular") {
|
|
218
|
+
return total;
|
|
219
|
+
}
|
|
220
|
+
const monthly = charge.cadence === "weekly" ? charge.averageAmountUsd * 4.33 : charge.cadence === "biweekly" ? charge.averageAmountUsd * 2.17 : charge.cadence === "monthly" ? charge.averageAmountUsd : charge.cadence === "quarterly" ? charge.averageAmountUsd / 3 : charge.averageAmountUsd / 12;
|
|
221
|
+
return total + monthly;
|
|
222
|
+
}, 0);
|
|
223
|
+
const toDate = (/* @__PURE__ */ new Date()).toISOString();
|
|
224
|
+
const fromDate = new Date(sinceMs).toISOString();
|
|
225
|
+
return {
|
|
226
|
+
windowDays: args.windowDays,
|
|
227
|
+
fromDate,
|
|
228
|
+
toDate,
|
|
229
|
+
totalSpendUsd: Number(totalSpend.toFixed(2)),
|
|
230
|
+
totalIncomeUsd: Number(totalIncome.toFixed(2)),
|
|
231
|
+
netUsd: Number((totalIncome - totalSpend).toFixed(2)),
|
|
232
|
+
transactionCount: scoped.length,
|
|
233
|
+
recurringSpendUsd: Number(recurringSpendUsd.toFixed(2)),
|
|
234
|
+
topCategories,
|
|
235
|
+
topMerchants
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
class FinancesService {
|
|
239
|
+
constructor(runtime, options = {}) {
|
|
240
|
+
this.runtime = runtime;
|
|
241
|
+
this.repository = new FinancesRepository(runtime);
|
|
242
|
+
this.ownerEntityId = normalizeOptionalString(options.ownerEntityId) ?? null;
|
|
243
|
+
}
|
|
244
|
+
runtime;
|
|
245
|
+
repository;
|
|
246
|
+
ownerEntityId;
|
|
247
|
+
plaidManagedClientCache = null;
|
|
248
|
+
paypalManagedClientCache = null;
|
|
249
|
+
agentId() {
|
|
250
|
+
return requireAgentId(this.runtime);
|
|
251
|
+
}
|
|
252
|
+
logFinancesWarn(operation, message, context = {}) {
|
|
253
|
+
logger.warn(
|
|
254
|
+
{
|
|
255
|
+
boundary: "finances",
|
|
256
|
+
operation,
|
|
257
|
+
agentId: this.agentId(),
|
|
258
|
+
...context
|
|
259
|
+
},
|
|
260
|
+
message
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
async listPaymentSources() {
|
|
264
|
+
const sources = await this.repository.listPaymentSources(this.agentId());
|
|
265
|
+
return sources.map((source) => sanitizePaymentSourceForClient(source));
|
|
266
|
+
}
|
|
267
|
+
async addPaymentSource(request) {
|
|
268
|
+
const kind = normalizeSourceKind(request.kind);
|
|
269
|
+
const label = requireNonEmptyString(request.label, "label").slice(0, 120);
|
|
270
|
+
const institution = normalizeOptionalString(request.institution)?.slice(0, 120) ?? null;
|
|
271
|
+
const accountMask = normalizeOptionalString(request.accountMask)?.slice(0, 16) ?? null;
|
|
272
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
273
|
+
const source = {
|
|
274
|
+
id: crypto.randomUUID(),
|
|
275
|
+
agentId: this.agentId(),
|
|
276
|
+
kind,
|
|
277
|
+
label,
|
|
278
|
+
institution,
|
|
279
|
+
accountMask,
|
|
280
|
+
status: kind === "plaid" ? "needs_attention" : "active",
|
|
281
|
+
lastSyncedAt: null,
|
|
282
|
+
transactionCount: 0,
|
|
283
|
+
metadata: request.metadata && typeof request.metadata === "object" ? { ...request.metadata } : {},
|
|
284
|
+
createdAt: now,
|
|
285
|
+
updatedAt: now
|
|
286
|
+
};
|
|
287
|
+
await this.repository.upsertPaymentSource(source);
|
|
288
|
+
return source;
|
|
289
|
+
}
|
|
290
|
+
async deletePaymentSource(sourceId) {
|
|
291
|
+
const trimmed = requireNonEmptyString(sourceId, "sourceId");
|
|
292
|
+
await this.repository.deletePaymentSource(this.agentId(), trimmed);
|
|
293
|
+
return { ok: true };
|
|
294
|
+
}
|
|
295
|
+
async importTransactionsCsv(request) {
|
|
296
|
+
const sourceId = requireNonEmptyString(request.sourceId, "sourceId");
|
|
297
|
+
const csvText = requireNonEmptyString(request.csvText, "csvText");
|
|
298
|
+
const source = await this.repository.getPaymentSource(
|
|
299
|
+
this.agentId(),
|
|
300
|
+
sourceId
|
|
301
|
+
);
|
|
302
|
+
if (!source) {
|
|
303
|
+
fail(404, `Payment source ${sourceId} not found.`);
|
|
304
|
+
}
|
|
305
|
+
const parsed = parseTransactionsCsv(csvText, {
|
|
306
|
+
dateColumn: request.dateColumn,
|
|
307
|
+
amountColumn: request.amountColumn,
|
|
308
|
+
merchantColumn: request.merchantColumn,
|
|
309
|
+
descriptionColumn: request.descriptionColumn,
|
|
310
|
+
categoryColumn: request.categoryColumn
|
|
311
|
+
});
|
|
312
|
+
let inserted = 0;
|
|
313
|
+
let skipped = 0;
|
|
314
|
+
for (const txn of parsed.transactions) {
|
|
315
|
+
const record = {
|
|
316
|
+
id: buildTransactionId({
|
|
317
|
+
agentId: this.agentId(),
|
|
318
|
+
sourceId,
|
|
319
|
+
parsed: txn
|
|
320
|
+
}),
|
|
321
|
+
agentId: this.agentId(),
|
|
322
|
+
sourceId,
|
|
323
|
+
externalId: txn.externalId,
|
|
324
|
+
postedAt: txn.postedAt,
|
|
325
|
+
amountUsd: Number(txn.amountUsd.toFixed(2)),
|
|
326
|
+
direction: txn.direction,
|
|
327
|
+
merchantRaw: txn.merchantRaw,
|
|
328
|
+
merchantNormalized: txn.merchantNormalized || normalizeMerchant(txn.merchantRaw),
|
|
329
|
+
description: txn.description,
|
|
330
|
+
category: txn.category,
|
|
331
|
+
currency: txn.currency,
|
|
332
|
+
metadata: { sourceRowIndex: txn.rowIndex },
|
|
333
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
334
|
+
};
|
|
335
|
+
const didInsert = await this.repository.insertPaymentTransaction(record);
|
|
336
|
+
if (didInsert) {
|
|
337
|
+
inserted += 1;
|
|
338
|
+
} else {
|
|
339
|
+
skipped += 1;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
const newCount = await this.repository.countPaymentTransactionsForSource(
|
|
343
|
+
this.agentId(),
|
|
344
|
+
sourceId
|
|
345
|
+
);
|
|
346
|
+
await this.repository.upsertPaymentSource({
|
|
347
|
+
...source,
|
|
348
|
+
status: "active",
|
|
349
|
+
lastSyncedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
350
|
+
transactionCount: newCount,
|
|
351
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
352
|
+
});
|
|
353
|
+
return {
|
|
354
|
+
sourceId,
|
|
355
|
+
rowsRead: parsed.rowsRead,
|
|
356
|
+
inserted,
|
|
357
|
+
skipped,
|
|
358
|
+
errors: parsed.errors
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
async listTransactions(request = {}) {
|
|
362
|
+
return this.repository.listPaymentTransactions(this.agentId(), {
|
|
363
|
+
sourceId: normalizeOptionalString(request.sourceId) ?? null,
|
|
364
|
+
sinceAt: normalizeOptionalString(request.sinceAt) ?? null,
|
|
365
|
+
untilAt: normalizeOptionalString(request.untilAt) ?? null,
|
|
366
|
+
limit: typeof request.limit === "number" && Number.isFinite(request.limit) ? Math.trunc(request.limit) : null,
|
|
367
|
+
merchantContains: normalizeOptionalString(request.merchantContains) ?? null,
|
|
368
|
+
onlyDebits: request.onlyDebits ?? null
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
async getRecurringCharges(args = {}) {
|
|
372
|
+
const sinceDays = Math.max(
|
|
373
|
+
30,
|
|
374
|
+
Math.min(
|
|
375
|
+
720,
|
|
376
|
+
typeof args.sinceDays === "number" && Number.isFinite(args.sinceDays) ? Math.trunc(args.sinceDays) : 365
|
|
377
|
+
)
|
|
378
|
+
);
|
|
379
|
+
const transactions = await this.listTransactions({
|
|
380
|
+
sourceId: args.sourceId ?? null,
|
|
381
|
+
sinceAt: new Date(Date.now() - sinceDays * MS_PER_DAY).toISOString(),
|
|
382
|
+
limit: 5e3,
|
|
383
|
+
onlyDebits: true
|
|
384
|
+
});
|
|
385
|
+
return detectRecurringCharges(transactions);
|
|
386
|
+
}
|
|
387
|
+
async getSpendingSummary(request = {}) {
|
|
388
|
+
const windowDays = Math.max(
|
|
389
|
+
1,
|
|
390
|
+
Math.min(
|
|
391
|
+
365,
|
|
392
|
+
typeof request.windowDays === "number" && Number.isFinite(request.windowDays) ? Math.trunc(request.windowDays) : DEFAULT_WINDOW_DAYS
|
|
393
|
+
)
|
|
394
|
+
);
|
|
395
|
+
const transactions = await this.listTransactions({
|
|
396
|
+
sourceId: request.sourceId ?? null,
|
|
397
|
+
sinceAt: new Date(Date.now() - windowDays * MS_PER_DAY).toISOString(),
|
|
398
|
+
limit: 5e3
|
|
399
|
+
});
|
|
400
|
+
const recurring = await this.getRecurringCharges({
|
|
401
|
+
sourceId: request.sourceId ?? null,
|
|
402
|
+
sinceDays: Math.max(windowDays, 180)
|
|
403
|
+
});
|
|
404
|
+
return computeSpendingSummary({
|
|
405
|
+
transactions,
|
|
406
|
+
recurring,
|
|
407
|
+
windowDays
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
async getPaymentsDashboard(args = {}) {
|
|
411
|
+
const windowDays = Math.max(
|
|
412
|
+
7,
|
|
413
|
+
Math.min(
|
|
414
|
+
365,
|
|
415
|
+
typeof args.windowDays === "number" && Number.isFinite(args.windowDays) ? Math.trunc(args.windowDays) : DEFAULT_WINDOW_DAYS
|
|
416
|
+
)
|
|
417
|
+
);
|
|
418
|
+
const [sources, recurring, spending, upcomingBills] = await Promise.all([
|
|
419
|
+
this.listPaymentSources(),
|
|
420
|
+
this.getRecurringCharges({}),
|
|
421
|
+
this.getSpendingSummary({ windowDays }),
|
|
422
|
+
this.getUpcomingBills()
|
|
423
|
+
]);
|
|
424
|
+
const latestAudit = await this.repository.getLatestSubscriptionAudit(
|
|
425
|
+
this.agentId()
|
|
426
|
+
);
|
|
427
|
+
const recurringPlaybookHits = recurring.map((charge) => {
|
|
428
|
+
const direct = findLifeOpsSubscriptionPlaybook(charge.merchantDisplay) ?? findLifeOpsSubscriptionPlaybook(charge.merchantNormalized);
|
|
429
|
+
if (!direct) {
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
return {
|
|
433
|
+
merchantNormalized: charge.merchantNormalized,
|
|
434
|
+
playbookKey: direct.key,
|
|
435
|
+
serviceName: direct.serviceName,
|
|
436
|
+
managementUrl: direct.managementUrl,
|
|
437
|
+
executorPreference: direct.executorPreference
|
|
438
|
+
};
|
|
439
|
+
}).filter((hit) => hit !== null);
|
|
440
|
+
return {
|
|
441
|
+
sources,
|
|
442
|
+
recurring,
|
|
443
|
+
recurringPlaybookHits,
|
|
444
|
+
spending,
|
|
445
|
+
upcomingBills,
|
|
446
|
+
gmailSubscriptionAuditId: latestAudit?.id ?? null,
|
|
447
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Look up the singleton "Email bills" payment source for this agent,
|
|
452
|
+
* creating it on first use. Bills detected from email are persisted
|
|
453
|
+
* against this source so the existing transactions table can carry them
|
|
454
|
+
* without a parallel schema.
|
|
455
|
+
*/
|
|
456
|
+
async getOrCreateEmailPaymentSource() {
|
|
457
|
+
const sources = await this.listPaymentSources();
|
|
458
|
+
const existing = sources.find((source2) => source2.kind === "email");
|
|
459
|
+
if (existing) return existing;
|
|
460
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
461
|
+
const source = {
|
|
462
|
+
id: crypto.randomUUID(),
|
|
463
|
+
agentId: this.agentId(),
|
|
464
|
+
kind: "email",
|
|
465
|
+
label: EMAIL_SOURCE_LABEL,
|
|
466
|
+
institution: null,
|
|
467
|
+
accountMask: null,
|
|
468
|
+
status: "active",
|
|
469
|
+
lastSyncedAt: now,
|
|
470
|
+
transactionCount: 0,
|
|
471
|
+
metadata: {},
|
|
472
|
+
createdAt: now,
|
|
473
|
+
updatedAt: now
|
|
474
|
+
};
|
|
475
|
+
await this.repository.upsertPaymentSource(source);
|
|
476
|
+
return source;
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Idempotent insert of a bill extracted from an email. The transaction
|
|
480
|
+
* id is derived from `(agent, sourceId, sourceMessageId)` so re-ingesting
|
|
481
|
+
* the same Gmail message never creates a duplicate row.
|
|
482
|
+
*/
|
|
483
|
+
async upsertBillFromEmail(args) {
|
|
484
|
+
const source = await this.getOrCreateEmailPaymentSource();
|
|
485
|
+
const merchantRaw = requireNonEmptyString(args.merchant, "merchant").slice(
|
|
486
|
+
0,
|
|
487
|
+
200
|
|
488
|
+
);
|
|
489
|
+
const externalId = `email:${args.sourceMessageId}`;
|
|
490
|
+
const transactionId = crypto.createHash("sha1").update(`${this.agentId()}|${source.id}|${args.sourceMessageId}`).digest("hex").slice(0, 32);
|
|
491
|
+
const postedAt = normalizeOptionalString(args.postedAt) ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
492
|
+
const record = {
|
|
493
|
+
id: transactionId,
|
|
494
|
+
agentId: this.agentId(),
|
|
495
|
+
sourceId: source.id,
|
|
496
|
+
externalId,
|
|
497
|
+
postedAt,
|
|
498
|
+
amountUsd: Number(Math.abs(args.amountUsd).toFixed(2)),
|
|
499
|
+
direction: "debit",
|
|
500
|
+
merchantRaw,
|
|
501
|
+
merchantNormalized: merchantRaw.toLowerCase().slice(0, 200),
|
|
502
|
+
description: null,
|
|
503
|
+
category: "Bills",
|
|
504
|
+
currency: args.currency || "USD",
|
|
505
|
+
metadata: {
|
|
506
|
+
kind: "bill",
|
|
507
|
+
sourceMessageId: args.sourceMessageId,
|
|
508
|
+
dueDate: args.dueDate,
|
|
509
|
+
confidence: Number(args.confidence.toFixed(2))
|
|
510
|
+
},
|
|
511
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
512
|
+
};
|
|
513
|
+
const inserted = await this.repository.insertPaymentTransaction(record);
|
|
514
|
+
if (inserted) {
|
|
515
|
+
const newCount = await this.repository.countPaymentTransactionsForSource(
|
|
516
|
+
this.agentId(),
|
|
517
|
+
source.id
|
|
518
|
+
);
|
|
519
|
+
await this.repository.upsertPaymentSource({
|
|
520
|
+
...source,
|
|
521
|
+
lastSyncedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
522
|
+
transactionCount: newCount,
|
|
523
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
return { inserted, transactionId };
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Mark a previously-extracted bill as paid. Idempotent — repeated calls
|
|
530
|
+
* just re-stamp the metadata. The row itself is not deleted so the
|
|
531
|
+
* transaction history stays intact.
|
|
532
|
+
*/
|
|
533
|
+
async markBillPaid(args) {
|
|
534
|
+
const billId = requireNonEmptyString(args.billId, "billId");
|
|
535
|
+
const transactions = await this.repository.listPaymentTransactions(
|
|
536
|
+
this.agentId(),
|
|
537
|
+
{ limit: 5e3 }
|
|
538
|
+
);
|
|
539
|
+
const target = transactions.find((tx) => tx.id === billId);
|
|
540
|
+
if (!target) {
|
|
541
|
+
fail(404, `Bill ${billId} not found.`);
|
|
542
|
+
}
|
|
543
|
+
const paidAt = normalizeOptionalString(args.paidAt) ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
544
|
+
const nextMetadata = {
|
|
545
|
+
...target.metadata,
|
|
546
|
+
kind: "bill_paid",
|
|
547
|
+
paidAt
|
|
548
|
+
};
|
|
549
|
+
await this.repository.deletePaymentTransactionById(this.agentId(), billId);
|
|
550
|
+
await this.repository.insertPaymentTransaction({
|
|
551
|
+
...target,
|
|
552
|
+
metadata: nextMetadata
|
|
553
|
+
});
|
|
554
|
+
return { ok: true };
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Push a bill's due date out by N days. Used for "Snooze 1w" UI.
|
|
558
|
+
*/
|
|
559
|
+
async snoozeBill(args) {
|
|
560
|
+
const billId = requireNonEmptyString(args.billId, "billId");
|
|
561
|
+
const days = Number.isFinite(args.days) && args.days > 0 ? Math.min(60, Math.trunc(args.days)) : 7;
|
|
562
|
+
const transactions = await this.repository.listPaymentTransactions(
|
|
563
|
+
this.agentId(),
|
|
564
|
+
{ limit: 5e3 }
|
|
565
|
+
);
|
|
566
|
+
const target = transactions.find((tx) => tx.id === billId);
|
|
567
|
+
if (!target) {
|
|
568
|
+
fail(404, `Bill ${billId} not found.`);
|
|
569
|
+
}
|
|
570
|
+
const currentDue = typeof target.metadata.dueDate === "string" ? target.metadata.dueDate : null;
|
|
571
|
+
const baseDate = currentDue ? /* @__PURE__ */ new Date(`${currentDue}T00:00:00.000Z`) : /* @__PURE__ */ new Date();
|
|
572
|
+
if (Number.isNaN(baseDate.getTime())) {
|
|
573
|
+
fail(409, "Bill has an unparseable due date.");
|
|
574
|
+
}
|
|
575
|
+
const nextDue = new Date(baseDate.getTime() + days * 864e5).toISOString().slice(0, 10);
|
|
576
|
+
await this.repository.deletePaymentTransactionById(this.agentId(), billId);
|
|
577
|
+
await this.repository.insertPaymentTransaction({
|
|
578
|
+
...target,
|
|
579
|
+
metadata: {
|
|
580
|
+
...target.metadata,
|
|
581
|
+
dueDate: nextDue
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
return { ok: true, dueDate: nextDue };
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Read bills extracted from email. This includes overdue and no-date bills
|
|
588
|
+
* so extraction misses do not disappear from the user's review queue.
|
|
589
|
+
*/
|
|
590
|
+
async getUpcomingBills(args = {}) {
|
|
591
|
+
const sources = await this.listPaymentSources();
|
|
592
|
+
const emailSource = sources.find((source) => source.kind === "email");
|
|
593
|
+
if (!emailSource) return [];
|
|
594
|
+
const transactions = await this.repository.listPaymentTransactions(
|
|
595
|
+
this.agentId(),
|
|
596
|
+
{
|
|
597
|
+
sourceId: emailSource.id,
|
|
598
|
+
limit: 200
|
|
599
|
+
}
|
|
600
|
+
);
|
|
601
|
+
const now = args.now ?? /* @__PURE__ */ new Date();
|
|
602
|
+
const todayIso = now.toISOString().slice(0, 10);
|
|
603
|
+
const bills = [];
|
|
604
|
+
for (const transaction of transactions) {
|
|
605
|
+
const metadata = transaction.metadata;
|
|
606
|
+
if (metadata.kind !== "bill") continue;
|
|
607
|
+
const dueDate = typeof metadata.dueDate === "string" ? metadata.dueDate : null;
|
|
608
|
+
const status = dueDate === null ? "needs_due_date" : dueDate < todayIso ? "overdue" : "upcoming";
|
|
609
|
+
const sourceMessageId = typeof metadata.sourceMessageId === "string" ? metadata.sourceMessageId : null;
|
|
610
|
+
const confidence = typeof metadata.confidence === "number" && Number.isFinite(metadata.confidence) ? metadata.confidence : 0.5;
|
|
611
|
+
bills.push({
|
|
612
|
+
id: transaction.id,
|
|
613
|
+
merchant: transaction.merchantRaw,
|
|
614
|
+
amountUsd: transaction.amountUsd,
|
|
615
|
+
currency: transaction.currency,
|
|
616
|
+
dueDate,
|
|
617
|
+
status,
|
|
618
|
+
postedAt: transaction.postedAt,
|
|
619
|
+
sourceMessageId,
|
|
620
|
+
confidence
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
const statusRank = {
|
|
624
|
+
overdue: 0,
|
|
625
|
+
needs_due_date: 1,
|
|
626
|
+
upcoming: 2
|
|
627
|
+
};
|
|
628
|
+
bills.sort((a, b) => {
|
|
629
|
+
const rankDelta = statusRank[a.status] - statusRank[b.status];
|
|
630
|
+
if (rankDelta !== 0) return rankDelta;
|
|
631
|
+
if (a.dueDate && b.dueDate) return a.dueDate.localeCompare(b.dueDate);
|
|
632
|
+
if (a.dueDate) return 1;
|
|
633
|
+
if (b.dueDate) return -1;
|
|
634
|
+
return b.postedAt.localeCompare(a.postedAt);
|
|
635
|
+
});
|
|
636
|
+
return bills;
|
|
637
|
+
}
|
|
638
|
+
summarizePaymentsDashboard(dashboard) {
|
|
639
|
+
const lines = [
|
|
640
|
+
`Spent $${dashboard.spending.totalSpendUsd.toFixed(2)} in the last ${dashboard.spending.windowDays} days across ${dashboard.spending.transactionCount} transactions.`
|
|
641
|
+
];
|
|
642
|
+
if (dashboard.recurring.length > 0) {
|
|
643
|
+
const annualized = dashboard.recurring.reduce(
|
|
644
|
+
(total, charge) => total + charge.annualizedCostUsd,
|
|
645
|
+
0
|
|
646
|
+
);
|
|
647
|
+
lines.push(
|
|
648
|
+
`Detected ${dashboard.recurring.length} recurring charge${dashboard.recurring.length === 1 ? "" : "s"} worth ~$${annualized.toFixed(2)}/yr.`
|
|
649
|
+
);
|
|
650
|
+
const topThree = dashboard.recurring.slice(0, 3);
|
|
651
|
+
for (const charge of topThree) {
|
|
652
|
+
lines.push(
|
|
653
|
+
`- ${charge.merchantDisplay} (${charge.cadence}, $${charge.averageAmountUsd.toFixed(2)})`
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
} else {
|
|
657
|
+
lines.push(
|
|
658
|
+
"No recurring charges detected yet. Import transactions to start tracking."
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
if (dashboard.sources.length === 0) {
|
|
662
|
+
lines.push(
|
|
663
|
+
"No payment sources connected. Add one (CSV import) to see your spending."
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
return lines.join("\n");
|
|
667
|
+
}
|
|
668
|
+
// -----------------------------------------------------------------------
|
|
669
|
+
// Plaid bridge — uses Eliza Cloud as the secret holder for the Plaid
|
|
670
|
+
// access_token. Cloud routes live at /api/v1/eliza/plaid/*.
|
|
671
|
+
// -----------------------------------------------------------------------
|
|
672
|
+
getPlaidManagedClient() {
|
|
673
|
+
if (!this.plaidManagedClientCache) {
|
|
674
|
+
this.plaidManagedClientCache = new PlaidManagedClient(
|
|
675
|
+
resolveFinancesCloudManagedClientConfig
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
return this.plaidManagedClientCache;
|
|
679
|
+
}
|
|
680
|
+
/** Returns a Plaid Link token for the frontend to drive the Plaid Link UI. */
|
|
681
|
+
async createPlaidLinkToken() {
|
|
682
|
+
try {
|
|
683
|
+
return await this.getPlaidManagedClient().createLinkToken();
|
|
684
|
+
} catch (error) {
|
|
685
|
+
if (error instanceof PlaidManagedClientError) {
|
|
686
|
+
fail(error.status, error.message);
|
|
687
|
+
}
|
|
688
|
+
throw error;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Completes a Plaid Link flow by exchanging the public_token for an
|
|
693
|
+
* access_token and creating (or updating) a payment_source row whose
|
|
694
|
+
* metadata holds the access_token + cursor for sync.
|
|
695
|
+
*/
|
|
696
|
+
async completePlaidLink(args) {
|
|
697
|
+
const publicToken = requireNonEmptyString(args.publicToken, "publicToken");
|
|
698
|
+
let result;
|
|
699
|
+
try {
|
|
700
|
+
result = await this.getPlaidManagedClient().exchangePublicToken({
|
|
701
|
+
publicToken
|
|
702
|
+
});
|
|
703
|
+
} catch (error) {
|
|
704
|
+
if (error instanceof PlaidManagedClientError) {
|
|
705
|
+
fail(error.status, error.message);
|
|
706
|
+
}
|
|
707
|
+
throw error;
|
|
708
|
+
}
|
|
709
|
+
const label = normalizeOptionalString(args.label) ?? `${result.institution.institutionName}${result.institution.primaryAccountMask ? ` \xB7\xB7${result.institution.primaryAccountMask}` : ""}`;
|
|
710
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
711
|
+
const source = {
|
|
712
|
+
id: crypto.randomUUID(),
|
|
713
|
+
agentId: this.agentId(),
|
|
714
|
+
kind: "plaid",
|
|
715
|
+
label: label.slice(0, 120),
|
|
716
|
+
institution: result.institution.institutionName.slice(0, 120),
|
|
717
|
+
accountMask: result.institution.primaryAccountMask?.slice(0, 16) ?? null,
|
|
718
|
+
status: "active",
|
|
719
|
+
lastSyncedAt: null,
|
|
720
|
+
transactionCount: 0,
|
|
721
|
+
metadata: {
|
|
722
|
+
plaid: {
|
|
723
|
+
accessToken: encryptPaymentMetadataToken(result.accessToken),
|
|
724
|
+
itemId: result.itemId,
|
|
725
|
+
institutionId: result.institution.institutionId,
|
|
726
|
+
cursor: "",
|
|
727
|
+
accounts: result.institution.accounts
|
|
728
|
+
}
|
|
729
|
+
},
|
|
730
|
+
createdAt: now,
|
|
731
|
+
updatedAt: now
|
|
732
|
+
};
|
|
733
|
+
await this.repository.upsertPaymentSource(source);
|
|
734
|
+
return source;
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Pulls the latest transaction delta for a Plaid-backed source and
|
|
738
|
+
* inserts the new rows into life_payment_transactions.
|
|
739
|
+
*/
|
|
740
|
+
async syncPlaidTransactions(args) {
|
|
741
|
+
const sourceId = requireNonEmptyString(args.sourceId, "sourceId");
|
|
742
|
+
const source = await this.repository.getPaymentSource(
|
|
743
|
+
this.agentId(),
|
|
744
|
+
sourceId
|
|
745
|
+
);
|
|
746
|
+
if (!source) {
|
|
747
|
+
fail(404, `Payment source ${sourceId} not found.`);
|
|
748
|
+
}
|
|
749
|
+
if (source.kind !== "plaid") {
|
|
750
|
+
fail(409, `Source ${sourceId} is not a Plaid source.`);
|
|
751
|
+
}
|
|
752
|
+
const plaidMetadata = readPlaidPaymentMetadata(source.metadata.plaid);
|
|
753
|
+
const accessToken = readPaymentMetadataToken(
|
|
754
|
+
plaidMetadata?.accessToken,
|
|
755
|
+
"Plaid access"
|
|
756
|
+
);
|
|
757
|
+
if (!accessToken) {
|
|
758
|
+
fail(
|
|
759
|
+
409,
|
|
760
|
+
"Plaid source is missing an access token. Re-link the account."
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
const cursor = plaidMetadata?.cursor ?? "";
|
|
764
|
+
let cumulativeInserted = 0;
|
|
765
|
+
let cumulativeSkipped = 0;
|
|
766
|
+
let pageCursor = cursor;
|
|
767
|
+
let hasMore = true;
|
|
768
|
+
let pageGuard = 0;
|
|
769
|
+
while (hasMore && pageGuard < 20) {
|
|
770
|
+
let delta;
|
|
771
|
+
try {
|
|
772
|
+
delta = await this.getPlaidManagedClient().syncTransactions({
|
|
773
|
+
accessToken,
|
|
774
|
+
cursor: pageCursor
|
|
775
|
+
});
|
|
776
|
+
} catch (error) {
|
|
777
|
+
if (error instanceof PlaidManagedClientError) {
|
|
778
|
+
fail(error.status, error.message);
|
|
779
|
+
}
|
|
780
|
+
throw error;
|
|
781
|
+
}
|
|
782
|
+
for (const transaction of delta.added) {
|
|
783
|
+
const inserted = await this.upsertPlaidTransaction({
|
|
784
|
+
sourceId,
|
|
785
|
+
transaction
|
|
786
|
+
});
|
|
787
|
+
if (inserted) {
|
|
788
|
+
cumulativeInserted += 1;
|
|
789
|
+
} else {
|
|
790
|
+
cumulativeSkipped += 1;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
for (const transaction of delta.modified) {
|
|
794
|
+
await this.upsertPlaidTransaction({
|
|
795
|
+
sourceId,
|
|
796
|
+
transaction
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
pageCursor = delta.nextCursor;
|
|
800
|
+
hasMore = delta.hasMore;
|
|
801
|
+
pageGuard += 1;
|
|
802
|
+
}
|
|
803
|
+
const newCount = await this.repository.countPaymentTransactionsForSource(
|
|
804
|
+
this.agentId(),
|
|
805
|
+
sourceId
|
|
806
|
+
);
|
|
807
|
+
await this.repository.upsertPaymentSource({
|
|
808
|
+
...source,
|
|
809
|
+
status: "active",
|
|
810
|
+
lastSyncedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
811
|
+
transactionCount: newCount,
|
|
812
|
+
metadata: {
|
|
813
|
+
...source.metadata,
|
|
814
|
+
plaid: {
|
|
815
|
+
...plaidMetadata,
|
|
816
|
+
accessToken: encryptPaymentMetadataToken(accessToken),
|
|
817
|
+
cursor: pageCursor
|
|
818
|
+
}
|
|
819
|
+
},
|
|
820
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
821
|
+
});
|
|
822
|
+
return {
|
|
823
|
+
inserted: cumulativeInserted,
|
|
824
|
+
skipped: cumulativeSkipped,
|
|
825
|
+
nextCursor: pageCursor
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
// -----------------------------------------------------------------------
|
|
829
|
+
// PayPal bridge — uses Eliza Cloud as the OAuth + Reporting API proxy.
|
|
830
|
+
// Cloud routes live at /api/v1/eliza/paypal/*.
|
|
831
|
+
//
|
|
832
|
+
// Personal-tier PayPal accounts CANNOT use the Reporting API. The cloud
|
|
833
|
+
// surfaces this as a 403 with `fallback: "csv_export"`; we propagate
|
|
834
|
+
// that to the caller via PaypalManagedClientError.fallback so the UI
|
|
835
|
+
// can route the user to CSV import.
|
|
836
|
+
// -----------------------------------------------------------------------
|
|
837
|
+
getPaypalManagedClient() {
|
|
838
|
+
if (!this.paypalManagedClientCache) {
|
|
839
|
+
this.paypalManagedClientCache = new PaypalManagedClient(
|
|
840
|
+
resolveFinancesCloudManagedClientConfig
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
return this.paypalManagedClientCache;
|
|
844
|
+
}
|
|
845
|
+
/** Returns a PayPal Login URL the frontend should open in a popup. */
|
|
846
|
+
async createPaypalAuthorizeUrl(args) {
|
|
847
|
+
const state = requireNonEmptyString(args.state, "state");
|
|
848
|
+
try {
|
|
849
|
+
return await this.getPaypalManagedClient().buildAuthorizeUrl({ state });
|
|
850
|
+
} catch (error) {
|
|
851
|
+
if (error instanceof PaypalManagedClientError) {
|
|
852
|
+
fail(error.status, error.message);
|
|
853
|
+
}
|
|
854
|
+
throw error;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* Completes the PayPal OAuth flow by exchanging the authorization code
|
|
859
|
+
* for tokens, then creating a payment_source row keyed to the PayPal
|
|
860
|
+
* payer. The access_token + refresh_token are stored in source.metadata
|
|
861
|
+
* so the runtime can refresh on demand without re-prompting the user.
|
|
862
|
+
*/
|
|
863
|
+
async completePaypalLink(args) {
|
|
864
|
+
const code = requireNonEmptyString(args.code, "code");
|
|
865
|
+
let exchange;
|
|
866
|
+
try {
|
|
867
|
+
exchange = await this.getPaypalManagedClient().exchangeCode({ code });
|
|
868
|
+
} catch (error) {
|
|
869
|
+
if (error instanceof PaypalManagedClientError) {
|
|
870
|
+
fail(error.status, error.message);
|
|
871
|
+
}
|
|
872
|
+
throw error;
|
|
873
|
+
}
|
|
874
|
+
const display = exchange.identity?.name ?? exchange.identity?.emails[0] ?? exchange.identity?.payerId ?? "PayPal";
|
|
875
|
+
const label = normalizeOptionalString(args.label) ?? `PayPal \xB7 ${display}`;
|
|
876
|
+
const tokenExpiresAt = new Date(
|
|
877
|
+
Date.now() + Math.max(0, exchange.expiresIn - 60) * 1e3
|
|
878
|
+
).toISOString();
|
|
879
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
880
|
+
const source = {
|
|
881
|
+
id: crypto.randomUUID(),
|
|
882
|
+
agentId: this.agentId(),
|
|
883
|
+
kind: "paypal",
|
|
884
|
+
label: label.slice(0, 120),
|
|
885
|
+
institution: "PayPal",
|
|
886
|
+
accountMask: null,
|
|
887
|
+
status: exchange.capability.hasReporting ? "active" : "needs_attention",
|
|
888
|
+
lastSyncedAt: null,
|
|
889
|
+
transactionCount: 0,
|
|
890
|
+
metadata: {
|
|
891
|
+
paypal: {
|
|
892
|
+
accessToken: encryptPaymentMetadataToken(exchange.accessToken),
|
|
893
|
+
refreshToken: exchange.refreshToken ? encryptPaymentMetadataToken(exchange.refreshToken) : null,
|
|
894
|
+
tokenExpiresAt,
|
|
895
|
+
scope: exchange.scope,
|
|
896
|
+
capability: exchange.capability,
|
|
897
|
+
payerId: exchange.identity?.payerId ?? null,
|
|
898
|
+
payerEmails: exchange.identity?.emails ?? []
|
|
899
|
+
}
|
|
900
|
+
},
|
|
901
|
+
createdAt: now,
|
|
902
|
+
updatedAt: now
|
|
903
|
+
};
|
|
904
|
+
await this.repository.upsertPaymentSource(source);
|
|
905
|
+
return { source, capability: exchange.capability };
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Pulls PayPal transactions for a date window via the Reporting API.
|
|
909
|
+
* Returns the imported count and an explicit `fallback: "csv_export"`
|
|
910
|
+
* flag when the account is personal-tier.
|
|
911
|
+
*/
|
|
912
|
+
async syncPaypalTransactions(args) {
|
|
913
|
+
const sourceId = requireNonEmptyString(args.sourceId, "sourceId");
|
|
914
|
+
const source = await this.repository.getPaymentSource(
|
|
915
|
+
this.agentId(),
|
|
916
|
+
sourceId
|
|
917
|
+
);
|
|
918
|
+
if (!source) {
|
|
919
|
+
fail(404, `Payment source ${sourceId} not found.`);
|
|
920
|
+
}
|
|
921
|
+
if (source.kind !== "paypal") {
|
|
922
|
+
fail(409, `Source ${sourceId} is not a PayPal source.`);
|
|
923
|
+
}
|
|
924
|
+
let paypalMetadata = readPaypalPaymentMetadata(source.metadata.paypal);
|
|
925
|
+
let accessToken = readPaymentMetadataToken(
|
|
926
|
+
paypalMetadata?.accessToken,
|
|
927
|
+
"PayPal access"
|
|
928
|
+
);
|
|
929
|
+
let refreshToken = readPaymentMetadataToken(
|
|
930
|
+
paypalMetadata?.refreshToken,
|
|
931
|
+
"PayPal refresh"
|
|
932
|
+
);
|
|
933
|
+
if (!accessToken) {
|
|
934
|
+
fail(409, "PayPal source is missing an access token. Re-link.");
|
|
935
|
+
}
|
|
936
|
+
const expiryMs = paypalMetadata?.tokenExpiresAt ? Date.parse(paypalMetadata.tokenExpiresAt) : 0;
|
|
937
|
+
if (Number.isFinite(expiryMs) && expiryMs <= Date.now() + 6e4) {
|
|
938
|
+
if (refreshToken) {
|
|
939
|
+
try {
|
|
940
|
+
const refreshed = await this.getPaypalManagedClient().refreshAccessToken({
|
|
941
|
+
refreshToken
|
|
942
|
+
});
|
|
943
|
+
accessToken = refreshed.accessToken;
|
|
944
|
+
refreshToken = refreshed.refreshToken ?? refreshToken;
|
|
945
|
+
const tokenExpiresAt = new Date(
|
|
946
|
+
Date.now() + Math.max(0, refreshed.expiresIn - 60) * 1e3
|
|
947
|
+
).toISOString();
|
|
948
|
+
paypalMetadata = {
|
|
949
|
+
...paypalMetadata,
|
|
950
|
+
accessToken: encryptPaymentMetadataToken(accessToken),
|
|
951
|
+
refreshToken: refreshToken ? encryptPaymentMetadataToken(refreshToken) : null,
|
|
952
|
+
tokenExpiresAt,
|
|
953
|
+
scope: refreshed.scope
|
|
954
|
+
};
|
|
955
|
+
await this.repository.upsertPaymentSource({
|
|
956
|
+
...source,
|
|
957
|
+
metadata: {
|
|
958
|
+
...source.metadata,
|
|
959
|
+
paypal: paypalMetadata
|
|
960
|
+
},
|
|
961
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
962
|
+
});
|
|
963
|
+
} catch (error) {
|
|
964
|
+
this.logFinancesWarn(
|
|
965
|
+
"paypal_refresh",
|
|
966
|
+
`PayPal refresh failed for ${sourceId}: ${error instanceof Error ? error.message : String(error)}`
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
const windowDays = Math.max(
|
|
972
|
+
7,
|
|
973
|
+
Math.min(
|
|
974
|
+
365,
|
|
975
|
+
typeof args.windowDays === "number" && Number.isFinite(args.windowDays) ? Math.trunc(args.windowDays) : 90
|
|
976
|
+
)
|
|
977
|
+
);
|
|
978
|
+
const now = /* @__PURE__ */ new Date();
|
|
979
|
+
const startDate = new Date(
|
|
980
|
+
now.getTime() - windowDays * MS_PER_DAY
|
|
981
|
+
).toISOString();
|
|
982
|
+
const endDate = now.toISOString();
|
|
983
|
+
let inserted = 0;
|
|
984
|
+
let skipped = 0;
|
|
985
|
+
let page = 1;
|
|
986
|
+
let totalPages = 1;
|
|
987
|
+
try {
|
|
988
|
+
do {
|
|
989
|
+
const result = await this.getPaypalManagedClient().searchTransactions({
|
|
990
|
+
accessToken,
|
|
991
|
+
startDate,
|
|
992
|
+
endDate,
|
|
993
|
+
page
|
|
994
|
+
});
|
|
995
|
+
totalPages = result.totalPages;
|
|
996
|
+
for (const transaction of result.transactions) {
|
|
997
|
+
const wasInserted = await this.upsertPaypalTransaction({
|
|
998
|
+
sourceId,
|
|
999
|
+
transaction
|
|
1000
|
+
});
|
|
1001
|
+
if (wasInserted) {
|
|
1002
|
+
inserted += 1;
|
|
1003
|
+
} else {
|
|
1004
|
+
skipped += 1;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
page += 1;
|
|
1008
|
+
} while (page <= totalPages && page <= 50);
|
|
1009
|
+
} catch (error) {
|
|
1010
|
+
if (error instanceof PaypalManagedClientError && error.fallback === "csv_export") {
|
|
1011
|
+
await this.repository.upsertPaymentSource({
|
|
1012
|
+
...source,
|
|
1013
|
+
status: "needs_attention",
|
|
1014
|
+
metadata: {
|
|
1015
|
+
...source.metadata,
|
|
1016
|
+
paypal: {
|
|
1017
|
+
...paypalMetadata,
|
|
1018
|
+
accessToken: encryptPaymentMetadataToken(accessToken),
|
|
1019
|
+
refreshToken: refreshToken ? encryptPaymentMetadataToken(refreshToken) : null,
|
|
1020
|
+
capability: { hasReporting: false, hasIdentity: true },
|
|
1021
|
+
lastFallbackError: error.message
|
|
1022
|
+
}
|
|
1023
|
+
},
|
|
1024
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1025
|
+
});
|
|
1026
|
+
return { inserted: 0, skipped: 0, fallback: "csv_export" };
|
|
1027
|
+
}
|
|
1028
|
+
if (error instanceof PaypalManagedClientError) {
|
|
1029
|
+
fail(error.status, error.message);
|
|
1030
|
+
}
|
|
1031
|
+
throw error;
|
|
1032
|
+
}
|
|
1033
|
+
const newCount = await this.repository.countPaymentTransactionsForSource(
|
|
1034
|
+
this.agentId(),
|
|
1035
|
+
sourceId
|
|
1036
|
+
);
|
|
1037
|
+
await this.repository.upsertPaymentSource({
|
|
1038
|
+
...source,
|
|
1039
|
+
status: "active",
|
|
1040
|
+
lastSyncedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1041
|
+
transactionCount: newCount,
|
|
1042
|
+
metadata: {
|
|
1043
|
+
...source.metadata,
|
|
1044
|
+
paypal: {
|
|
1045
|
+
...paypalMetadata,
|
|
1046
|
+
accessToken: encryptPaymentMetadataToken(accessToken),
|
|
1047
|
+
refreshToken: refreshToken ? encryptPaymentMetadataToken(refreshToken) : null
|
|
1048
|
+
}
|
|
1049
|
+
},
|
|
1050
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1051
|
+
});
|
|
1052
|
+
return { inserted, skipped, fallback: null };
|
|
1053
|
+
}
|
|
1054
|
+
async upsertPaypalTransaction(args) {
|
|
1055
|
+
const txn = args.transaction;
|
|
1056
|
+
const amountValue = Number(txn.transaction_info.transaction_amount.value);
|
|
1057
|
+
if (!Number.isFinite(amountValue)) {
|
|
1058
|
+
return false;
|
|
1059
|
+
}
|
|
1060
|
+
const direction = amountValue < 0 ? "debit" : "credit";
|
|
1061
|
+
const merchantRaw = (txn.payer_info?.payer_name?.alternate_full_name ?? txn.payer_info?.email_address ?? txn.shipping_info?.name ?? txn.transaction_info.transaction_subject ?? "PayPal payment").trim();
|
|
1062
|
+
const merchantNormalized = normalizeMerchant(merchantRaw);
|
|
1063
|
+
const description = txn.transaction_info.transaction_subject ?? txn.transaction_info.transaction_note ?? txn.cart_info?.item_details?.[0]?.item_name ?? null;
|
|
1064
|
+
const record = {
|
|
1065
|
+
id: crypto.randomUUID(),
|
|
1066
|
+
agentId: this.agentId(),
|
|
1067
|
+
sourceId: args.sourceId,
|
|
1068
|
+
externalId: txn.transaction_info.transaction_id,
|
|
1069
|
+
postedAt: new Date(
|
|
1070
|
+
txn.transaction_info.transaction_initiation_date
|
|
1071
|
+
).toISOString(),
|
|
1072
|
+
amountUsd: Number(Math.abs(amountValue).toFixed(2)),
|
|
1073
|
+
direction,
|
|
1074
|
+
merchantRaw,
|
|
1075
|
+
merchantNormalized,
|
|
1076
|
+
description,
|
|
1077
|
+
category: null,
|
|
1078
|
+
currency: txn.transaction_info.transaction_amount.currency_code,
|
|
1079
|
+
metadata: {
|
|
1080
|
+
paypalTransactionId: txn.transaction_info.transaction_id,
|
|
1081
|
+
paypalStatus: txn.transaction_info.transaction_status
|
|
1082
|
+
},
|
|
1083
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1084
|
+
};
|
|
1085
|
+
return this.repository.insertPaymentTransaction(record);
|
|
1086
|
+
}
|
|
1087
|
+
async upsertPlaidTransaction(args) {
|
|
1088
|
+
const txn = args.transaction;
|
|
1089
|
+
const direction = txn.amount >= 0 ? "debit" : "credit";
|
|
1090
|
+
const merchantRaw = (txn.merchant_name ?? txn.name).trim();
|
|
1091
|
+
const merchantNormalized = normalizeMerchant(merchantRaw);
|
|
1092
|
+
const category = txn.personal_finance_category?.detailed ?? txn.personal_finance_category?.primary ?? txn.category?.[0] ?? null;
|
|
1093
|
+
const record = {
|
|
1094
|
+
id: crypto.randomUUID(),
|
|
1095
|
+
agentId: this.agentId(),
|
|
1096
|
+
sourceId: args.sourceId,
|
|
1097
|
+
externalId: txn.transaction_id,
|
|
1098
|
+
postedAt: txn.authorized_date ? `${txn.authorized_date}T00:00:00.000Z` : `${txn.date}T00:00:00.000Z`,
|
|
1099
|
+
amountUsd: Number(Math.abs(txn.amount).toFixed(2)),
|
|
1100
|
+
direction,
|
|
1101
|
+
merchantRaw,
|
|
1102
|
+
merchantNormalized,
|
|
1103
|
+
description: txn.name,
|
|
1104
|
+
category,
|
|
1105
|
+
currency: txn.iso_currency_code ?? "USD",
|
|
1106
|
+
metadata: {
|
|
1107
|
+
accountId: txn.account_id,
|
|
1108
|
+
pending: txn.pending,
|
|
1109
|
+
plaidTransactionId: txn.transaction_id
|
|
1110
|
+
},
|
|
1111
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1112
|
+
};
|
|
1113
|
+
return this.repository.insertPaymentTransaction(record);
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
export {
|
|
1117
|
+
FinancesService,
|
|
1118
|
+
encryptPaymentMetadataToken,
|
|
1119
|
+
readPaymentMetadataToken,
|
|
1120
|
+
sanitizePaymentSourceForClient
|
|
1121
|
+
};
|
|
1122
|
+
//# sourceMappingURL=finances-service.js.map
|