@elizaos/plugin-finances 2.0.3-beta.6 → 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.
Files changed (103) hide show
  1. package/dist/actions/finances.d.ts +38 -0
  2. package/dist/actions/finances.d.ts.map +1 -0
  3. package/dist/actions/finances.js +368 -0
  4. package/dist/actions/finances.js.map +1 -0
  5. package/dist/components/finances/FinancesSpatialView.d.ts +80 -0
  6. package/dist/components/finances/FinancesSpatialView.d.ts.map +1 -0
  7. package/dist/components/finances/FinancesSpatialView.js +157 -0
  8. package/dist/components/finances/FinancesSpatialView.js.map +1 -0
  9. package/dist/components/finances/FinancesView.d.ts +97 -0
  10. package/dist/components/finances/FinancesView.d.ts.map +1 -0
  11. package/dist/components/finances/FinancesView.js +231 -0
  12. package/dist/components/finances/FinancesView.js.map +1 -0
  13. package/dist/components/finances/finances-view-bundle.d.ts +10 -0
  14. package/dist/components/finances/finances-view-bundle.d.ts.map +1 -0
  15. package/dist/components/finances/finances-view-bundle.js +5 -0
  16. package/dist/components/finances/finances-view-bundle.js.map +1 -0
  17. package/dist/db/finances-repository.d.ts +51 -0
  18. package/dist/db/finances-repository.d.ts.map +1 -0
  19. package/dist/db/finances-repository.js +521 -0
  20. package/dist/db/finances-repository.js.map +1 -0
  21. package/dist/db/index.d.ts +3 -0
  22. package/dist/db/index.d.ts.map +1 -0
  23. package/dist/db/index.js +6 -0
  24. package/dist/db/index.js.map +1 -0
  25. package/dist/db/schema.d.ts +2615 -0
  26. package/dist/db/schema.d.ts.map +1 -0
  27. package/dist/db/schema.js +133 -0
  28. package/dist/db/schema.js.map +1 -0
  29. package/dist/db/sql.d.ts +65 -0
  30. package/dist/db/sql.d.ts.map +1 -0
  31. package/dist/db/sql.js +182 -0
  32. package/dist/db/sql.js.map +1 -0
  33. package/dist/finance-normalize.d.ts +24 -0
  34. package/dist/finance-normalize.d.ts.map +1 -0
  35. package/dist/finance-normalize.js +66 -0
  36. package/dist/finance-normalize.js.map +1 -0
  37. package/dist/finances-service.d.ts +179 -0
  38. package/dist/finances-service.d.ts.map +1 -0
  39. package/dist/finances-service.js +1122 -0
  40. package/dist/finances-service.js.map +1 -0
  41. package/dist/index.d.ts +32 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +109 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/payment-csv-import.d.ts +23 -0
  46. package/dist/payment-csv-import.d.ts.map +1 -0
  47. package/dist/payment-csv-import.js +271 -0
  48. package/dist/payment-csv-import.js.map +1 -0
  49. package/dist/payment-recurrence.d.ts +14 -0
  50. package/dist/payment-recurrence.d.ts.map +1 -0
  51. package/dist/payment-recurrence.js +190 -0
  52. package/dist/payment-recurrence.js.map +1 -0
  53. package/dist/payment-types.d.ts +158 -0
  54. package/dist/payment-types.d.ts.map +1 -0
  55. package/dist/payment-types.js +1 -0
  56. package/dist/payment-types.js.map +1 -0
  57. package/dist/plugin.d.ts +15 -0
  58. package/dist/plugin.d.ts.map +1 -0
  59. package/dist/plugin.js +31 -0
  60. package/dist/plugin.js.map +1 -0
  61. package/dist/register-terminal-view.d.ts +15 -0
  62. package/dist/register-terminal-view.d.ts.map +1 -0
  63. package/dist/register-terminal-view.js +21 -0
  64. package/dist/register-terminal-view.js.map +1 -0
  65. package/dist/register.d.ts +9 -0
  66. package/dist/register.d.ts.map +1 -0
  67. package/dist/register.js +5 -0
  68. package/dist/register.js.map +1 -0
  69. package/dist/services/browser-bridge-seam.d.ts +40 -0
  70. package/dist/services/browser-bridge-seam.d.ts.map +1 -0
  71. package/dist/services/browser-bridge-seam.js +39 -0
  72. package/dist/services/browser-bridge-seam.js.map +1 -0
  73. package/dist/services/gmail-seam.d.ts +40 -0
  74. package/dist/services/gmail-seam.d.ts.map +1 -0
  75. package/dist/services/gmail-seam.js +208 -0
  76. package/dist/services/gmail-seam.js.map +1 -0
  77. package/dist/services/migration.d.ts +65 -0
  78. package/dist/services/migration.d.ts.map +1 -0
  79. package/dist/services/migration.js +116 -0
  80. package/dist/services/migration.js.map +1 -0
  81. package/dist/services/subscriptions-service.d.ts +76 -0
  82. package/dist/services/subscriptions-service.d.ts.map +1 -0
  83. package/dist/services/subscriptions-service.js +1002 -0
  84. package/dist/services/subscriptions-service.js.map +1 -0
  85. package/dist/subscriptions-playbooks.d.ts +79 -0
  86. package/dist/subscriptions-playbooks.d.ts.map +1 -0
  87. package/dist/subscriptions-playbooks.js +871 -0
  88. package/dist/subscriptions-playbooks.js.map +1 -0
  89. package/dist/subscriptions-types.d.ts +80 -0
  90. package/dist/subscriptions-types.d.ts.map +1 -0
  91. package/dist/subscriptions-types.js +1 -0
  92. package/dist/subscriptions-types.js.map +1 -0
  93. package/dist/token-encryption.d.ts +42 -0
  94. package/dist/token-encryption.d.ts.map +1 -0
  95. package/dist/token-encryption.js +96 -0
  96. package/dist/token-encryption.js.map +1 -0
  97. package/dist/types.d.ts +55 -0
  98. package/dist/types.d.ts.map +1 -0
  99. package/dist/types.js +18 -0
  100. package/dist/types.js.map +1 -0
  101. package/dist/views/bundle.js +411 -0
  102. package/dist/views/bundle.js.map +1 -0
  103. 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