@fayz-ai/plugin-banking-br 0.9.1 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,62 +1,81 @@
1
1
  import { useState, useEffect } from 'react';
2
- import { Download, Loader2, History, CheckCircle2, AlertCircle } from 'lucide-react';
2
+ import { Download, Loader2 } from 'lucide-react';
3
3
  import { DatePicker, Button, toast } from '@fayz-ai/ui';
4
- import { getActiveTenantId, getSupabaseClientOptional } from '@fayz-ai/core';
4
+ import { connectionStatus, createConnectionStore, getSupabaseClientOptional } from '@fayz-ai/core';
5
5
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
6
- import { pgTable, timestamps, timestamp, boolean, text, uuid, tenantId, createdAt, integer, date } from '@fayz-ai/db';
7
6
 
8
7
  // src/connectors/connectorDef.tsx
9
8
  var FUNCTION = "plugbank-sync";
9
+ var PLUGBANK_CONNECTOR_ID = "plugbank";
10
+ var SETTINGS_KEYS = ["environment"];
11
+ function storeFor(bankAccountId) {
12
+ return createConnectionStore(PLUGBANK_CONNECTOR_ID, {
13
+ settingsKeys: SETTINGS_KEYS,
14
+ instanceKey: bankAccountId ?? ""
15
+ });
16
+ }
10
17
  function sb() {
11
18
  const supabase = getSupabaseClientOptional();
12
19
  if (!supabase) throw new Error("Supabase not initialized");
13
20
  return supabase;
14
21
  }
15
- function rowToIntegration(r) {
22
+ function toIntegration(connection) {
23
+ return {
24
+ id: connection.id ?? "",
25
+ provider: connection.connectorId,
26
+ // Back to `undefined` for the default account, so callers that pass this
27
+ // value straight back into getIntegration() address the same row.
28
+ bankAccountId: connection.instanceKey || void 0,
29
+ environment: connection.settings?.environment ?? "production",
30
+ active: connection.active,
31
+ lastSyncAt: connection.lastSyncAt ?? void 0
32
+ };
33
+ }
34
+ function toSyncRun(run) {
35
+ const stats = run.stats ?? {};
16
36
  return {
17
- id: r.id,
18
- provider: r.provider,
19
- bankAccountId: r.bank_account_id ?? void 0,
20
- apiToken: r.api_token ?? void 0,
21
- cnpj: r.cnpj ?? void 0,
22
- environment: r.environment ?? "production",
23
- active: r.active ?? true,
24
- lastSyncAt: r.last_sync_at ?? void 0
37
+ ...run,
38
+ id: run.id,
39
+ connectionId: run.connectionId,
40
+ connectorId: run.connectorId,
41
+ direction: run.direction,
42
+ triggerKind: run.triggerKind,
43
+ status: run.status,
44
+ fetched: run.fetched ?? 0,
45
+ written: run.written ?? 0,
46
+ error: run.error ?? void 0,
47
+ startedAt: run.startedAt,
48
+ finishedAt: run.finishedAt ?? void 0,
49
+ periodFrom: stats.period_from ?? void 0,
50
+ periodTo: stats.period_to ?? void 0,
51
+ duplicates: Number(stats.duplicates ?? 0),
52
+ // plg_sync_runs has no created_at: a run is identified by when it STARTED,
53
+ // which is what the old created_at recorded anyway (the row was inserted at
54
+ // the end, so the two disagreed by the length of the import).
55
+ createdAt: run.startedAt ?? ""
25
56
  };
26
57
  }
27
58
  function createOpenBankingProvider() {
28
59
  return {
29
60
  async getIntegration(bankAccountId) {
30
- const supabase = sb();
31
- let qb = supabase.from("bank_integrations").select("*").eq("provider", "plugbank");
32
- qb = bankAccountId ? qb.eq("bank_account_id", bankAccountId) : qb.is("bank_account_id", null);
33
- const { data } = await qb.limit(1).maybeSingle();
34
- return data ? rowToIntegration(data) : null;
61
+ const connection = await storeFor(bankAccountId).get();
62
+ return connection ? toIntegration(connection) : null;
35
63
  },
36
64
  async saveIntegration(input) {
37
- const supabase = sb();
38
- const tenantId2 = getActiveTenantId();
39
- const row = {
40
- tenant_id: tenantId2,
41
- provider: "plugbank",
42
- bank_account_id: input.bankAccountId ?? null,
43
- api_token: input.apiToken,
44
- cnpj: input.cnpj,
45
- environment: input.environment ?? "production",
46
- active: true,
47
- updated_at: (/* @__PURE__ */ new Date()).toISOString()
48
- };
49
- const { data, error } = await supabase.from("bank_integrations").upsert(input.id ? { id: input.id, ...row } : row, { onConflict: "tenant_id,bank_account_id,provider" }).select().single();
50
- if (error) throw error;
51
- return rowToIntegration(data);
52
- },
53
- async testConnection(input) {
54
- const supabase = sb();
55
- const { data, error } = await supabase.functions.invoke(FUNCTION, {
56
- body: { action: "test_connection", apiToken: input.apiToken, cnpj: input.cnpj, environment: input.environment ?? "production" }
65
+ const saved = await storeFor(input?.bankAccountId).save({
66
+ settings: { environment: input?.environment ?? "production" },
67
+ active: true
57
68
  });
58
- if (error) return { ok: false, message: error.message };
59
- return data;
69
+ return toIntegration(saved);
70
+ },
71
+ /**
72
+ * Scoped to ONE account, where the old provider deleted every plugbank row
73
+ * of the tenant. That breadth was invisible while the panel only ever drove
74
+ * the default account; with two accounts it means disconnecting Itaú also
75
+ * silently kills Banco do Brasil. The narrow verb is the correct one.
76
+ */
77
+ async disconnect(bankAccountId) {
78
+ await storeFor(bankAccountId).disconnect();
60
79
  },
61
80
  async fetchStatement(input) {
62
81
  const supabase = sb();
@@ -71,6 +90,10 @@ function createOpenBankingProvider() {
71
90
  const { data, error } = await supabase.functions.invoke(FUNCTION, {
72
91
  body: {
73
92
  action: "import_transactions",
93
+ // Still `integrationId` on the wire: it now carries a plg_connections
94
+ // id, and keeping the key means the deployed function needs no
95
+ // coordinated release. The RPC takes the tenant AND the bank account
96
+ // from that row, so neither can be steered from here.
74
97
  integrationId: input.integrationId,
75
98
  bankAccountId: input.bankAccountId,
76
99
  from: input.from,
@@ -81,24 +104,24 @@ function createOpenBankingProvider() {
81
104
  if (error) throw new Error(error.message);
82
105
  return data;
83
106
  },
84
- async getSyncLog(integrationId) {
85
- const supabase = sb();
86
- const { data } = await supabase.from("bank_integration_sync_log").select("*").eq("bank_integration_id", integrationId).order("created_at", { ascending: false }).limit(20);
87
- return (data ?? []).map((r) => ({
88
- id: r.id,
89
- periodFrom: r.period_from ?? void 0,
90
- periodTo: r.period_to ?? void 0,
91
- transactionsFetched: r.transactions_fetched ?? 0,
92
- transactionsImported: r.transactions_imported ?? 0,
93
- duplicates: r.duplicates ?? 0,
94
- status: r.status,
95
- errorMessage: r.error_message ?? void 0,
96
- createdAt: r.created_at
97
- }));
107
+ async getSyncRuns(bankAccountId) {
108
+ const runs = await storeFor(bankAccountId).listRuns({ limit: 20 });
109
+ return runs.map(toSyncRun);
98
110
  }
99
111
  };
100
112
  }
101
113
  var provider = createOpenBankingProvider();
114
+ function day(value) {
115
+ return typeof value === "string" && value.length >= 10 ? value.slice(8, 10) + "/" + value.slice(5, 7) : void 0;
116
+ }
117
+ function statementLine(run) {
118
+ const from = day(run.stats?.period_from);
119
+ const to = day(run.stats?.period_to);
120
+ const duplicates = Number(run.stats?.duplicates ?? 0);
121
+ if (!from || !to) return void 0;
122
+ const imported = `${run.written ?? 0} importadas de ${from} a ${to}`;
123
+ return duplicates > 0 ? `${imported} \xB7 ${duplicates} j\xE1 estavam aqui` : imported;
124
+ }
102
125
  function brl(n) {
103
126
  try {
104
127
  return new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL" }).format(n);
@@ -106,7 +129,7 @@ function brl(n) {
106
129
  return `R$ ${n.toFixed(2)}`;
107
130
  }
108
131
  }
109
- function OpenBankingExtraPanel() {
132
+ function OpenBankingImport({ onRan }) {
110
133
  const [integration, setIntegration] = useState(null);
111
134
  const [loaded, setLoaded] = useState(false);
112
135
  const [from, setFrom] = useState(() => {
@@ -119,17 +142,14 @@ function OpenBankingExtraPanel() {
119
142
  const [selected, setSelected] = useState(/* @__PURE__ */ new Set());
120
143
  const [fetching, setFetching] = useState(false);
121
144
  const [importing, setImporting] = useState(false);
122
- const [log, setLog] = useState([]);
123
145
  async function load() {
124
- const found = await provider.getIntegration();
125
- setIntegration(found);
126
- if (found) void provider.getSyncLog(found.id).then(setLog);
146
+ setIntegration(await provider.getIntegration().catch(() => null));
127
147
  setLoaded(true);
128
148
  }
129
149
  useEffect(() => {
130
150
  void load();
131
151
  }, []);
132
- if (!loaded || !integration) return null;
152
+ if (!loaded || !integration?.active) return null;
133
153
  async function handleFetch() {
134
154
  if (!integration) return;
135
155
  setFetching(true);
@@ -152,11 +172,17 @@ function OpenBankingExtraPanel() {
152
172
  }
153
173
  setImporting(true);
154
174
  try {
155
- const res = await provider.importTransactions({ integrationId: integration.id, from, to, lines: toImport });
175
+ const res = await provider.importTransactions({
176
+ integrationId: integration.id,
177
+ bankAccountId: integration.bankAccountId,
178
+ from,
179
+ to,
180
+ lines: toImport
181
+ });
156
182
  toast.success(`${res.imported} importadas${res.duplicates ? ` \xB7 ${res.duplicates} duplicadas` : ""}`);
157
183
  setLines([]);
158
184
  setSelected(/* @__PURE__ */ new Set());
159
- void provider.getSyncLog(integration.id).then(setLog);
185
+ onRan?.();
160
186
  } catch (e) {
161
187
  toast.error(e?.message ?? "Erro ao importar");
162
188
  } finally {
@@ -170,7 +196,7 @@ function OpenBankingExtraPanel() {
170
196
  return next;
171
197
  });
172
198
  }
173
- return /* @__PURE__ */ jsxs("div", { className: "space-y-4 border-t pt-4", children: [
199
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [
174
200
  /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
175
201
  /* @__PURE__ */ jsx(Download, { className: "h-4 w-4 text-muted-foreground" }),
176
202
  /* @__PURE__ */ jsx("h4", { className: "text-sm font-semibold", children: "Importar extrato" })
@@ -212,81 +238,699 @@ function OpenBankingExtraPanel() {
212
238
  ] })
213
239
  ] })
214
240
  ] }),
215
- log.length > 0 && /* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
216
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
217
- /* @__PURE__ */ jsx(History, { className: "h-4 w-4 text-muted-foreground" }),
218
- /* @__PURE__ */ jsx("h4", { className: "text-sm font-semibold", children: "Hist\xF3rico" })
219
- ] }),
220
- /* @__PURE__ */ jsx("div", { className: "rounded-md border divide-y text-sm", children: log.map((r) => /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 px-3 py-2", children: [
221
- /* @__PURE__ */ jsx("span", { className: "text-muted-foreground text-xs", children: new Date(r.createdAt).toLocaleString("pt-BR") }),
222
- /* @__PURE__ */ jsxs("span", { className: "text-xs text-muted-foreground", children: [
223
- r.periodFrom,
224
- " \u2192 ",
225
- r.periodTo
226
- ] }),
227
- /* @__PURE__ */ jsxs("span", { className: "ml-auto text-xs", children: [
228
- r.transactionsImported,
229
- "/",
230
- r.transactionsFetched,
231
- " importadas",
232
- r.duplicates ? ` \xB7 ${r.duplicates} dup` : ""
233
- ] }),
234
- r.status === "success" ? /* @__PURE__ */ jsx(CheckCircle2, { className: "h-3.5 w-3.5 text-success" }) : /* @__PURE__ */ jsx(AlertCircle, { className: "h-3.5 w-3.5 text-warning" })
235
- ] }, r.id)) })
236
- ] })
241
+ /* @__PURE__ */ jsx("p", { className: "text-[11px] text-muted-foreground", children: "O token do PlugBank e o CNPJ da conta ficam guardados na plataforma Fayz, n\xE3o neste aplicativo. Quem importa o extrato \xE9 o servidor, que pede as credenciais na hora da consulta." })
237
242
  ] });
238
243
  }
239
244
  var openBankingConnector = {
240
245
  id: "plugbank",
246
+ // The product is PlugBank, the company in the directory is TecnoSpeed. Same
247
+ // row, two names — and until this said so, the connector took neither the
248
+ // catalogue's copy nor its mark.
249
+ catalogSlug: "tecnospeed",
241
250
  hostPluginId: "financial",
242
251
  name: "Open Banking \xB7 Tecnospeed PlugBank",
243
252
  description: "Importe o extrato banc\xE1rio e concilie em Financeiro \u2192 Concilia\xE7\xE3o.",
244
253
  icon: "Landmark",
245
254
  authKind: "api-key",
246
- fields: [
247
- { key: "cnpj", label: "CNPJ", type: "text", placeholder: "00.000.000/0001-00" },
248
- { key: "apiToken", label: "Token API PlugBank", type: "password", placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" }
255
+ // `banking`, not `payments`: reading a statement is not taking a payment, and
256
+ // the platform catalogue files the provider (slug `tecnospeed`) under banking.
257
+ category: "banking",
258
+ // Inbound because WE reach the bank: PlugBank has never heard of Fayz, which
259
+ // is what makes this the canonical pull connector and why it ships an edge
260
+ // function at all.
261
+ direction: "inbound",
262
+ // Sync only. The statement has to exist INSIDE Fayz to be listed, filtered and
263
+ // matched against internal movements — reconciliation is not a question the
264
+ // assistant can answer by asking the bank at the moment it is asked.
265
+ planes: ["sync"],
266
+ docsUrl: "https://tecnospeed.com.br/plugbank/",
267
+ // Write and NOT read, which is unusual enough to be worth saying out loud:
268
+ // `plugbank_import_movements` (migrations/003) only ever INSERTs, with
269
+ // `ON CONFLICT (tenant_id, external_source, external_id) DO NOTHING`. It never
270
+ // SELECTs a movement, so declaring `read` here would be an access the code does
271
+ // not take — and the whole value of this list is that it is not padded.
272
+ //
273
+ // The `emit` is the second half, and it is the one a tenant would not guess:
274
+ // every imported line fires `financial.movement.imported` through the trigger
275
+ // in migrations/002, and that payload carries amount, date, description and
276
+ // bank account to whatever automation is listening. Importing a statement is
277
+ // therefore also publishing it internally.
278
+ permissions: [
279
+ {
280
+ kind: "data",
281
+ resource: "movement",
282
+ actions: ["write"],
283
+ reason: "Gravar no seu financeiro as linhas do extrato banc\xE1rio \u2014 valor, data, descri\xE7\xE3o e conta. Lan\xE7amentos j\xE1 importados n\xE3o entram de novo.",
284
+ sensitive: true
285
+ },
286
+ {
287
+ kind: "event",
288
+ resource: "financial.movement.imported",
289
+ actions: ["emit"],
290
+ reason: "Avisar o app a cada lan\xE7amento importado, para que suas automa\xE7\xF5es de concilia\xE7\xE3o possam reagir. O aviso leva valor, data e conta.",
291
+ sensitive: true
292
+ }
249
293
  ],
250
- async getStatus() {
251
- const found = await provider.getIntegration();
252
- return { connected: !!found?.apiToken };
294
+ invokedAt: [
295
+ {
296
+ key: "financial.statement.import",
297
+ label: "Voc\xEA escolhe um per\xEDodo aqui e o extrato do banco \xE9 importado",
298
+ plane: "sync",
299
+ trigger: "manual",
300
+ touches: ["movement", "financial.movement.imported"]
301
+ }
302
+ ],
303
+ // No credential field. The token and the CNPJ that identify the account are
304
+ // registered in the Fayz platform; anything typed here would be stored in a
305
+ // table every member of the tenant can read.
306
+ //
307
+ // `subscribesTo` is deliberately absent, and so is `managedAutomations`.
308
+ // Nothing that happens inside Fayz should wake this connector: it pulls a
309
+ // statement, and what makes it pull is the operator picking a period below or
310
+ // the Fayz SCHEDULE worker (FAY-1347) — never a domain fact. A subscription
311
+ // written to fill the field would put this connector on the ingress's
312
+ // delivery list for events it has no reaction to.
313
+ //
314
+ // The reconciliation automations ("avise quando entrar um valor acima de X")
315
+ // are real, but they are the TENANT's: they react to
316
+ // `financial.movement.imported` and the tenant may delete them without the
317
+ // connection becoming half-wired. `managedAutomations` is for the wiring the
318
+ // integration IS, and this integration is the import itself.
319
+ // Read from the row the list already batched — one request for N connectors.
320
+ // The account-less default instance (`''`) is the one the panel addresses.
321
+ ...connectionStatus(PLUGBANK_CONNECTOR_ID, (connection) => {
322
+ const found = connection ? toIntegration(connection) : null;
323
+ return { connected: !!found?.active, detail: found?.environment, plane: "sync" };
324
+ }),
325
+ // `testConnection` is deleted rather than stubbed: the credential is not in
326
+ // this app, so there is nothing this side can validate, and ConnectorDetail
327
+ // renders the button only when the verb exists.
328
+ saveConnection: async () => {
329
+ await provider.saveIntegration();
253
330
  },
254
- testConnection: (values) => provider.testConnection({ apiToken: values.apiToken, cnpj: values.cnpj }),
255
- saveConnection: async (values) => {
256
- await provider.saveIntegration({ apiToken: values.apiToken, cnpj: values.cnpj });
331
+ disconnect: () => provider.disconnect(),
332
+ // Every import files a `plg_sync_runs` row, and the shared surface renders it.
333
+ // What is connector-specific is not the VIEW, it is the meaning of `stats`:
334
+ // "de 01/08 a 12/08 · 3 duplicadas" is three keys only this connector can
335
+ // read, turned here into the one sentence every run carries. Scoped to the
336
+ // default instance — the account this panel addresses.
337
+ async listRuns(options) {
338
+ try {
339
+ const runs = await createConnectionStore(PLUGBANK_CONNECTOR_ID).listRuns(options);
340
+ return runs.map((run) => ({ ...run, message: run.message ?? statementLine(run) }));
341
+ } catch {
342
+ return [];
343
+ }
257
344
  },
258
- ExtraPanel: OpenBankingExtraPanel
345
+ RunActions: OpenBankingImport
259
346
  };
260
- var bankIntegrations = pgTable("bank_integrations", {
261
- id: uuid("id").primaryKey().defaultRandom(),
262
- tenantId: tenantId(),
263
- provider: text("provider").notNull().default("plugbank"),
264
- bankAccountId: uuid("bank_account_id"),
265
- // Tecnospeed PlugBank credentials
266
- apiToken: text("api_token"),
267
- cnpj: text("cnpj"),
268
- environment: text("environment").notNull().default("production"),
269
- // production | sandbox
270
- active: boolean("active").notNull().default(true),
271
- lastSyncAt: timestamp("last_sync_at", { withTimezone: true }),
272
- ...timestamps
273
- });
274
- var bankIntegrationSyncLog = pgTable("bank_integration_sync_log", {
275
- id: uuid("id").primaryKey().defaultRandom(),
276
- tenantId: tenantId(),
277
- bankIntegrationId: uuid("bank_integration_id").notNull().references(() => bankIntegrations.id, { onDelete: "cascade" }),
278
- bankAccountId: uuid("bank_account_id"),
279
- periodFrom: date("period_from"),
280
- periodTo: date("period_to"),
281
- transactionsFetched: integer("transactions_fetched").notNull().default(0),
282
- transactionsImported: integer("transactions_imported").notNull().default(0),
283
- duplicates: integer("duplicates").notNull().default(0),
284
- status: text("status").notNull().default("success"),
285
- // success | partial | error
286
- errorMessage: text("error_message"),
287
- triggeredByUserId: uuid("triggered_by_user_id"),
288
- ...createdAt
289
- });
347
+
348
+ // src/lib/banking-events.ts
349
+ var MOVEMENT_IMPORTED_FIELDS = [
350
+ { name: "movement_id", label: "ID do lan\xE7amento", type: "id", example: "8f14e45f-\u2026" },
351
+ { name: "provider", label: "Origem", type: "string", example: "plugbank" },
352
+ { name: "external_id", label: "Identificador no banco", type: "string", example: "LINE-1" },
353
+ { name: "direction", label: "Tipo", type: "string", example: "credit" },
354
+ { name: "amount", label: "Valor", type: "money", example: "R$ 150,00" },
355
+ { name: "payment_date", label: "Data", type: "date", example: "03/08/2026" },
356
+ { name: "description", label: "Descri\xE7\xE3o", type: "string", example: "Recebimento PIX" },
357
+ { name: "bank_account_id", label: "Conta banc\xE1ria", type: "id", example: "3f2a91c4-\u2026" }
358
+ ];
359
+ var PAYLOAD_SCHEMA = {
360
+ type: "object",
361
+ properties: {
362
+ movement_id: { type: "string", format: "uuid", description: "Movement id in the tenant ledger" },
363
+ provider: {
364
+ type: "string",
365
+ description: "Which integration imported the line \u2014 'plugbank' today. Reads from plg_financial_movements.external_source, so a second bank connector produces the same fact."
366
+ },
367
+ external_id: {
368
+ type: "string",
369
+ description: "The bank's own id for the statement line. With `provider` it is the import idempotency key."
370
+ },
371
+ direction: { type: "string", enum: ["credit", "debit"], description: "Money in or money out" },
372
+ amount: { type: "number", description: "Always positive; `direction` carries the sign" },
373
+ payment_date: { type: "string", format: "date", description: "Date the bank settled the line (YYYY-MM-DD)" },
374
+ description: { type: ["string", "null"], description: "Statement narrative, as the bank wrote it" },
375
+ bank_account_id: { type: ["string", "null"], format: "uuid", description: "Account the line landed in, when known" }
376
+ },
377
+ required: ["movement_id", "provider", "external_id", "direction", "amount", "payment_date"],
378
+ additionalProperties: false
379
+ };
380
+ function buildBankingDomainEvents() {
381
+ return [
382
+ {
383
+ name: "financial.movement.imported",
384
+ title: "Lan\xE7amento banc\xE1rio importado",
385
+ description: "Uma linha do extrato entrou no financeiro",
386
+ trigger: "Quando o extrato \xE9 importado \u2014 pelo painel, pelo conector ou por uma carga manual",
387
+ icon: "Landmark",
388
+ audit: true,
389
+ fields: MOVEMENT_IMPORTED_FIELDS,
390
+ payloadSchema: PAYLOAD_SCHEMA
391
+ }
392
+ ];
393
+ }
394
+
395
+ // src/migrations/index.ts
396
+ var MIGRATION_002_CREDENTIAL_AND_FACTS = `-- ============================================================================
397
+ -- plugin-banking-br 002: the credential leaves the tenant database, the import
398
+ -- gets a door it cannot be routed around, and the import becomes a FACT.
399
+ --
400
+ -- \xA71 credential drop api_token + cnpj from public.bank_integrations
401
+ -- \xA72 sync runs bank_integration_sync_log speaks the SyncRun vocabulary
402
+ -- \xA73 import plugbank_import_movements \u2014 the tenant-safe door
403
+ -- \xA74 facts financial.movement.imported
404
+ --
405
+ -- Additive and idempotent: no table is created, no policy is rewritten, and the
406
+ -- tenant RLS from 001 already covers every column added here.
407
+ -- ============================================================================
408
+
409
+ -- \xA71 \u2500\u2500 credential \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
410
+ -- THE EXPOSURE THIS CLOSES: 001 stored \`api_token text\` and \`cnpj text\` on
411
+ -- public.bank_integrations. That table is read from the browser with the
412
+ -- publishable key under tenant RLS, so the bank credential was readable by ANY
413
+ -- authenticated member of the tenant; and a pool is one Supabase project serving
414
+ -- N tenants (beauty-saas and espaco-renova-rio share one), so anything running
415
+ -- there with service_role could read every clinic's bank token at once.
416
+ --
417
+ -- PlugBank differs from RankLayer, which dropped its key with nothing to replace
418
+ -- it: PlugBank is a PULL connector and genuinely needs a credential at fetch
419
+ -- time. It just must not be THIS copy. The destination is the platform's
420
+ -- encrypted ConnectorConnection store, redeemed per call (FAY-1360 + FAY-1389).
421
+ -- NEITHER HAS HAPPENED YET: as of 2026-08-12 the live pool still holds the
422
+ -- token in this column, this migration is unapplied there, and the deployed
423
+ -- plugbank-sync (v6) still reads it. The FAY-1360 rollout must register the
424
+ -- credential at the platform BEFORE this drop runs on that pool \u2014 otherwise
425
+ -- what is removed here is the only copy, and the connection dies.
426
+ --
427
+ -- \`cnpj\` goes with it rather than surviving as a masked remnant. It is not
428
+ -- decoration: paired with the token it is what identifies the account at the
429
+ -- provider (\`/v1/extrato?cnpj=\u2026\`), which makes it half of the credential and
430
+ -- therefore the platform's to hold. And nothing on this side reads it \u2014 the
431
+ -- live connector panel never renders it, and the unified connect form starts
432
+ -- every field empty rather than pre-filling from the row. A masked last-N would
433
+ -- be a column added to serve a screen that does not exist, on data that
434
+ -- identifies a real company.
435
+ --
436
+ -- ROTATE any token a pool already stored. DROP COLUMN is a catalog change: the
437
+ -- old values stay in the heap until the next table rewrite.
438
+
439
+ DO $$
440
+ BEGIN
441
+ IF to_regclass('public.bank_integrations') IS NOT NULL THEN
442
+ ALTER TABLE public.bank_integrations DROP COLUMN IF EXISTS api_token;
443
+ ALTER TABLE public.bank_integrations DROP COLUMN IF EXISTS cnpj;
444
+ END IF;
445
+ END $$;
446
+
447
+ -- \xA72 \u2500\u2500 sync runs \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
448
+ -- bank_integration_sync_log already IS a SyncRun: one row per execution, with
449
+ -- status/fetched/written/error. It gains the four facts the shared vocabulary
450
+ -- has and it lacked, rather than being replaced by a second table beside it \u2014
451
+ -- two audit trails for one import is how "the history is wrong" starts.
452
+ --
453
+ -- The count columns keep their names. \`transactions_fetched\`/\`transactions_
454
+ -- imported\` map to SyncRun's \`fetched\`/\`written\` in the data provider, because
455
+ -- renaming them would break the deployed edge function's INSERT the moment this
456
+ -- migration lands, and a rename that has to be choreographed across two
457
+ -- repositories is not worth a nicer column name.
458
+ DO $$
459
+ BEGIN
460
+ IF to_regclass('public.bank_integration_sync_log') IS NULL THEN RETURN; END IF;
461
+
462
+ -- Which provider produced the run. One row per execution says nothing about
463
+ -- who ran it once a second bank connector shares the table.
464
+ ALTER TABLE public.bank_integration_sync_log
465
+ ADD COLUMN IF NOT EXISTS provider text NOT NULL DEFAULT 'plugbank';
466
+ -- Inbound: we reach the bank, never the other way round.
467
+ ALTER TABLE public.bank_integration_sync_log
468
+ ADD COLUMN IF NOT EXISTS direction text NOT NULL DEFAULT 'inbound';
469
+ -- \`trigger\` is a reserved word; the column is trigger_kind and the TypeScript
470
+ -- side reads it as SyncRun.trigger.
471
+ ALTER TABLE public.bank_integration_sync_log
472
+ ADD COLUMN IF NOT EXISTS trigger_kind text NOT NULL DEFAULT 'manual';
473
+ ALTER TABLE public.bank_integration_sync_log
474
+ ADD COLUMN IF NOT EXISTS started_at timestamptz;
475
+ ALTER TABLE public.bank_integration_sync_log
476
+ ADD COLUMN IF NOT EXISTS finished_at timestamptz;
477
+
478
+ COMMENT ON COLUMN public.bank_integration_sync_log.trigger_kind IS
479
+ 'SyncRun.trigger \u2014 on-write | scheduled | manual | webhook. Named _kind because trigger is reserved.';
480
+ END $$;
481
+
482
+ -- \xA73 \u2500\u2500 import \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
483
+ -- The one door the statement import passes through.
484
+ --
485
+ -- Today the edge function inserts into plg_financial_movements directly with
486
+ -- service_role, and takes \`integrationId\` from the request body without ever
487
+ -- comparing it to the caller \u2014 so any authenticated member of ANY tenant in the
488
+ -- pool can name another tenant's integration and write into that tenant's
489
+ -- ledger. A guard in the function would be a guard the next caller routes
490
+ -- around; the database is the only chokepoint that cannot be.
491
+ --
492
+ -- The tenant is taken from the INTEGRATION ROW, never from the payload, and a
493
+ -- caller presenting a user credential must be a member of it. A caller with no
494
+ -- auth.uid() is the server (service_role, a migration, the future tenant-scoped
495
+ -- token) and is exempt \u2014 the same exemption plg_emit_event makes, for the same
496
+ -- reason: it legitimately writes for any tenant. When FAY-1372 mints the
497
+ -- tenant-scoped credential, that exemption stops applying to connector code and
498
+ -- this function needs no change.
499
+ CREATE OR REPLACE FUNCTION public.plugbank_import_movements(p_payload jsonb)
500
+ RETURNS jsonb
501
+ LANGUAGE plpgsql
502
+ SECURITY DEFINER
503
+ SET search_path = public
504
+ AS $fn$
505
+ DECLARE
506
+ v_tenants uuid[];
507
+ v_tenant uuid;
508
+ v_integration uuid;
509
+ v_account uuid;
510
+ v_provider text;
511
+ v_trigger text;
512
+ v_from date;
513
+ v_to date;
514
+ v_started timestamptz := now();
515
+ v_line jsonb;
516
+ v_external text;
517
+ v_amount numeric(14,2);
518
+ v_date date;
519
+ v_fetched int := 0;
520
+ v_imported int := 0;
521
+ v_duplicates int := 0;
522
+ v_inserted uuid;
523
+ v_run uuid;
524
+ BEGIN
525
+ v_integration := nullif(btrim(p_payload->>'integration_id'), '')::uuid;
526
+ IF v_integration IS NULL THEN
527
+ RAISE EXCEPTION 'plugbank_import_movements: integration_id is required'
528
+ USING ERRCODE = 'invalid_parameter_value';
529
+ END IF;
530
+
531
+ SELECT bi.tenant_id, bi.bank_account_id, bi.provider
532
+ INTO v_tenant, v_account, v_provider
533
+ FROM public.bank_integrations bi
534
+ WHERE bi.id = v_integration AND bi.active;
535
+
536
+ IF v_tenant IS NULL THEN
537
+ RAISE EXCEPTION 'plugbank_import_movements: no active integration %', v_integration
538
+ USING ERRCODE = 'no_data_found';
539
+ END IF;
540
+
541
+ -- SECURITY DEFINER bypasses RLS, so the membership check the policy would
542
+ -- have made has to happen here.
543
+ SELECT array_agg(t) INTO v_tenants FROM public.user_tenant_ids() AS t;
544
+ IF auth.uid() IS NOT NULL AND NOT (v_tenant = ANY (COALESCE(v_tenants, '{}'::uuid[]))) THEN
545
+ RAISE EXCEPTION 'plugbank_import_movements: caller is not a member of tenant %', v_tenant
546
+ USING ERRCODE = 'insufficient_privilege';
547
+ END IF;
548
+
549
+ -- Refusing beats ignoring: a caller that believes it is importing into
550
+ -- another tenant must be told, not silently redirected into this one.
551
+ IF nullif(btrim(p_payload->>'tenant_id'), '') IS NOT NULL
552
+ AND (p_payload->>'tenant_id')::uuid IS DISTINCT FROM v_tenant THEN
553
+ RAISE EXCEPTION 'plugbank_import_movements: payload names another tenant; the tenant comes from the integration'
554
+ USING ERRCODE = 'insufficient_privilege';
555
+ END IF;
556
+
557
+ IF nullif(btrim(p_payload->>'bank_account_id'), '') IS NOT NULL THEN
558
+ v_account := (p_payload->>'bank_account_id')::uuid;
559
+ END IF;
560
+
561
+ v_from := nullif(btrim(p_payload->>'period_from'), '')::date;
562
+ v_to := nullif(btrim(p_payload->>'period_to'), '')::date;
563
+ v_trigger := lower(coalesce(nullif(btrim(p_payload->>'trigger'), ''), 'manual'));
564
+ IF v_trigger NOT IN ('on-write', 'scheduled', 'manual', 'webhook') THEN
565
+ RAISE EXCEPTION 'plugbank_import_movements: unknown trigger %', v_trigger
566
+ USING ERRCODE = 'invalid_parameter_value';
567
+ END IF;
568
+
569
+ FOR v_line IN SELECT * FROM jsonb_array_elements(coalesce(p_payload->'lines', '[]'::jsonb))
570
+ LOOP
571
+ v_fetched := v_fetched + 1;
572
+ v_external := nullif(btrim(v_line->>'external_id'), '');
573
+ v_date := nullif(btrim(v_line->>'date'), '')::date;
574
+ v_amount := abs(coalesce((v_line->>'amount')::numeric, 0));
575
+
576
+ -- A line with no provider id cannot be recognised on re-delivery, so it
577
+ -- would import again on every run. Skipping it is the lesser wrong.
578
+ IF v_external IS NULL OR v_date IS NULL OR v_amount = 0 THEN
579
+ CONTINUE;
580
+ END IF;
581
+
582
+ -- Idempotency is the partial unique index from the financial plugin's
583
+ -- 007_reconciliation.sql, not a SELECT-then-INSERT: two runs of the same
584
+ -- window racing each other would both find nothing and both write.
585
+ INSERT INTO public.plg_financial_movements (
586
+ tenant_id, direction, movement_kind, amount, paid_amount, status,
587
+ due_date, payment_date, bank_account_id, notes, external_id, external_source)
588
+ VALUES (
589
+ v_tenant,
590
+ CASE WHEN upper(coalesce(v_line->>'type', 'C')) LIKE 'C%' THEN 'credit' ELSE 'debit' END,
591
+ 'payment', v_amount, v_amount, 'paid',
592
+ v_date, v_date, v_account,
593
+ nullif(btrim(v_line->>'description'), ''),
594
+ v_external, coalesce(v_provider, 'plugbank'))
595
+ ON CONFLICT (tenant_id, external_source, external_id)
596
+ WHERE external_id IS NOT NULL AND external_source IS NOT NULL
597
+ DO NOTHING
598
+ RETURNING id INTO v_inserted;
599
+
600
+ IF v_inserted IS NULL THEN
601
+ v_duplicates := v_duplicates + 1;
602
+ ELSE
603
+ v_imported := v_imported + 1;
604
+ END IF;
605
+ v_inserted := NULL;
606
+ END LOOP;
607
+
608
+ UPDATE public.bank_integrations SET last_sync_at = now(), updated_at = now()
609
+ WHERE id = v_integration;
610
+
611
+ INSERT INTO public.bank_integration_sync_log (
612
+ tenant_id, bank_integration_id, bank_account_id, period_from, period_to,
613
+ transactions_fetched, transactions_imported, duplicates, status,
614
+ triggered_by_user_id, provider, direction, trigger_kind, started_at, finished_at)
615
+ VALUES (
616
+ v_tenant, v_integration, v_account, v_from, v_to,
617
+ v_fetched, v_imported, v_duplicates, 'success',
618
+ auth.uid(), coalesce(v_provider, 'plugbank'), 'inbound', v_trigger, v_started, now())
619
+ RETURNING id INTO v_run;
620
+
621
+ RETURN jsonb_build_object(
622
+ 'ok', true,
623
+ 'sync_run_id', v_run,
624
+ 'fetched', v_fetched,
625
+ 'imported', v_imported,
626
+ 'duplicates', v_duplicates);
627
+ END;
628
+ $fn$;
629
+
630
+ REVOKE ALL ON FUNCTION public.plugbank_import_movements(jsonb) FROM public, anon;
631
+ GRANT EXECUTE ON FUNCTION public.plugbank_import_movements(jsonb) TO authenticated, service_role;
632
+
633
+ -- \xA74 \u2500\u2500 facts \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
634
+ -- One trigger on the ledger rather than an emit call inside the importer. The
635
+ -- RPC above, the edge function still inserting directly, a future scheduled
636
+ -- pull and a hand-run backfill all end up in the same row \u2014 so the fact exists
637
+ -- for all four without any of them having to remember to say so. That is the
638
+ -- entire reason plg_emit_event exists (packages/db/migrations/027_domain_events.sql).
639
+ --
640
+ -- It fires only when external_source is set, which is exactly what "imported"
641
+ -- means: a movement that came from outside. An app-native movement typed into
642
+ -- the cash register is not an import and must not wake a bank connector.
643
+ --
644
+ -- Provider-agnostic on purpose. \`provider\` is in the payload, so a second bank
645
+ -- connector importing into the same ledger produces the same fact instead of a
646
+ -- second vocabulary for the same event.
647
+ CREATE OR REPLACE FUNCTION public.plg_bank_emit_movement_imported()
648
+ RETURNS trigger
649
+ LANGUAGE plpgsql
650
+ SECURITY DEFINER
651
+ SET search_path = public
652
+ AS $fn$
653
+ DECLARE
654
+ v_payload jsonb;
655
+ BEGIN
656
+ IF NEW.external_source IS NULL OR NEW.external_id IS NULL THEN
657
+ RETURN NULL;
658
+ END IF;
659
+
660
+ -- The contract every consumer reads. Not the row: a whole-row payload would
661
+ -- put the ledger's internal bookkeeping inside every webhook by default.
662
+ v_payload := jsonb_build_object(
663
+ 'movement_id', NEW.id::text,
664
+ 'provider', NEW.external_source,
665
+ 'external_id', NEW.external_id,
666
+ 'direction', NEW.direction,
667
+ 'amount', NEW.amount,
668
+ 'payment_date', NEW.payment_date,
669
+ 'description', NEW.notes,
670
+ 'bank_account_id', NEW.bank_account_id);
671
+
672
+ BEGIN
673
+ PERFORM public.plg_emit_event(
674
+ 'financial.movement.imported', v_payload, 'financial_movement',
675
+ NEW.id::text, NEW.tenant_id, NULL);
676
+ EXCEPTION WHEN OTHERS THEN
677
+ -- Observability must never be able to reject the write that caused it.
678
+ RAISE WARNING '[plg_bank_emit_movement_imported] % on %: %', TG_OP, TG_TABLE_NAME, SQLERRM;
679
+ END;
680
+
681
+ RETURN NULL;
682
+ END;
683
+ $fn$;
684
+
685
+ REVOKE ALL ON FUNCTION public.plg_bank_emit_movement_imported() FROM public, anon;
686
+
687
+ -- Guarded on the COLUMN, not just the table: the financial plugin owns both, and
688
+ -- attaching this to a ledger without 007_reconciliation.sql would make every
689
+ -- movement insert fail on an unknown field \u2014 an addon breaking its own host.
690
+ DO $$
691
+ BEGIN
692
+ IF EXISTS (
693
+ SELECT 1 FROM information_schema.columns
694
+ WHERE table_schema = 'public'
695
+ AND table_name = 'plg_financial_movements'
696
+ AND column_name = 'external_source'
697
+ ) THEN
698
+ DROP TRIGGER IF EXISTS plg_financial_movements_emit_imported ON public.plg_financial_movements;
699
+ CREATE TRIGGER plg_financial_movements_emit_imported
700
+ AFTER INSERT ON public.plg_financial_movements
701
+ FOR EACH ROW EXECUTE FUNCTION public.plg_bank_emit_movement_imported();
702
+ END IF;
703
+ END $$;
704
+ `;
705
+ var MIGRATION_003_ONTO_CONNECTIONS = `-- ============================================================================
706
+ -- plugin-banking-br 003 \u2014 PlugBank stops owning tables.
707
+ --
708
+ -- Retires public.bank_integrations and public.bank_integration_sync_log. The
709
+ -- connection is now a row of public.plg_connections and each import is a row of
710
+ -- public.plg_sync_runs (packages/db 029). See FAY-1376.
711
+ --
712
+ -- This connector is the one that tests the shape, because it is the only one
713
+ -- whose key was never (tenant, provider): 001 keyed on
714
+ -- UNIQUE (tenant_id, bank_account_id, provider), and the provider filtered by
715
+ -- bank_account_id. Two accounts of one clinic are two connections, and they must
716
+ -- stay two. That is exactly what \`instance_key\` is:
717
+ --
718
+ -- bank_integrations.bank_account_id \u2192 plg_connections.instance_key
719
+ -- (the account uuid as text; '' is the
720
+ -- account-less default the panel uses,
721
+ -- which is what \`bank_account_id IS NULL\`
722
+ -- meant before)
723
+ -- bank_integrations.provider \u2192 plg_connections.connector_id
724
+ -- bank_integrations.environment \u2192 plg_connections.settings->>'environment'
725
+ -- bank_integrations.active \u2192 plg_connections.active
726
+ -- bank_integrations.last_sync_at \u2192 plg_connections.last_sync_at
727
+ -- UNIQUE (tenant_id, bank_account_id, provider)
728
+ -- \u2192 UNIQUE (tenant_id, connector_id, instance_key)
729
+ --
730
+ -- And the run, column by column \u2014 nothing the panel renders is lost:
731
+ -- sync_log.bank_integration_id \u2192 plg_sync_runs.connection_id
732
+ -- sync_log.transactions_fetched \u2192 plg_sync_runs.fetched
733
+ -- sync_log.transactions_imported \u2192 plg_sync_runs.written
734
+ -- sync_log.duplicates \u2192 plg_sync_runs.stats->>'duplicates'
735
+ -- sync_log.period_from / _to \u2192 plg_sync_runs.stats->>'period_from' / '_to'
736
+ -- sync_log.triggered_by_user_id \u2192 plg_sync_runs.stats->>'triggered_by_user_id'
737
+ -- sync_log.error_message \u2192 plg_sync_runs.error
738
+ -- sync_log.bank_account_id \u2192 dropped: it is the connection's
739
+ -- instance_key, and a second copy is a
740
+ -- second thing to disagree
741
+ -- sync_log.provider \u2192 plg_sync_runs.connector_id
742
+ -- sync_log.created_at \u2192 plg_sync_runs.started_at
743
+ -- direction / trigger_kind / status / started_at / finished_at \u2192 same names
744
+ --
745
+ -- The count columns finally get the contract's names. 002 \xA72 kept
746
+ -- transactions_fetched/_imported because renaming them would have broken the
747
+ -- deployed edge function's INSERT \u2014 but the function no longer inserts into a
748
+ -- log at all: it calls plugbank_import_movements, which is the only writer, and
749
+ -- the run is filed here. The choreography 002 was avoiding does not exist.
750
+ --
751
+ -- Nothing is in production, so this is a drop and not a data migration.
752
+ -- ============================================================================
753
+
754
+ -- \xA71 \u2500\u2500 the door, re-pointed \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
755
+ -- Same name, same payload, same guarantees. \`integration_id\` keeps its key on
756
+ -- the wire so the deployed plugbank-sync function needs no coordinated release;
757
+ -- what changed is that the uuid it carries now names a plg_connections row.
758
+ --
759
+ -- Everything 002 \xA73 established still holds and for the same reasons: the tenant
760
+ -- comes from the CONNECTION ROW and never from the payload, a caller presenting
761
+ -- a user credential must be a member of it, a caller with no auth.uid() is the
762
+ -- server and is exempt, and idempotency is the partial unique index rather than
763
+ -- a SELECT-then-INSERT that two racing runs would both pass.
764
+ --
765
+ -- One thing is new and is the point of the migration: the run is filed against
766
+ -- \`connection_id\`, so a tenant with two bank accounts gets two histories instead
767
+ -- of one interleaved list. plg_sync_runs carries no INSERT grant for
768
+ -- \`authenticated\` \u2014 this SECURITY DEFINER function is the server-side writer the
769
+ -- table's grants assume.
770
+ CREATE OR REPLACE FUNCTION public.plugbank_import_movements(p_payload jsonb)
771
+ RETURNS jsonb
772
+ LANGUAGE plpgsql
773
+ SECURITY DEFINER
774
+ SET search_path = public
775
+ AS $fn$
776
+ DECLARE
777
+ v_tenants uuid[];
778
+ v_tenant uuid;
779
+ v_connection uuid;
780
+ v_instance text;
781
+ v_account uuid;
782
+ v_connector text;
783
+ v_trigger text;
784
+ v_from date;
785
+ v_to date;
786
+ v_started timestamptz := now();
787
+ v_line jsonb;
788
+ v_external text;
789
+ v_amount numeric(14,2);
790
+ v_date date;
791
+ v_fetched int := 0;
792
+ v_imported int := 0;
793
+ v_duplicates int := 0;
794
+ v_inserted uuid;
795
+ v_run uuid;
796
+ BEGIN
797
+ v_connection := nullif(btrim(p_payload->>'integration_id'), '')::uuid;
798
+ IF v_connection IS NULL THEN
799
+ RAISE EXCEPTION 'plugbank_import_movements: integration_id is required'
800
+ USING ERRCODE = 'invalid_parameter_value';
801
+ END IF;
802
+
803
+ SELECT c.tenant_id, c.instance_key, c.connector_id
804
+ INTO v_tenant, v_instance, v_connector
805
+ FROM public.plg_connections c
806
+ WHERE c.id = v_connection AND c.active;
807
+
808
+ IF v_tenant IS NULL THEN
809
+ RAISE EXCEPTION 'plugbank_import_movements: no active connection %', v_connection
810
+ USING ERRCODE = 'no_data_found';
811
+ END IF;
812
+
813
+ -- The account this connection is for. \`instance_key\` is text because it also
814
+ -- carries things that are not uuids for other connectors (a Bling company, a
815
+ -- gateway instance); here it is the bank account, and '' is the account-less
816
+ -- default that \`bank_account_id IS NULL\` used to mean.
817
+ v_account := nullif(btrim(v_instance), '')::uuid;
818
+
819
+ -- SECURITY DEFINER bypasses RLS, so the membership check the policy would
820
+ -- have made has to happen here.
821
+ SELECT array_agg(t) INTO v_tenants FROM public.user_tenant_ids() AS t;
822
+ IF auth.uid() IS NOT NULL AND NOT (v_tenant = ANY (COALESCE(v_tenants, '{}'::uuid[]))) THEN
823
+ RAISE EXCEPTION 'plugbank_import_movements: caller is not a member of tenant %', v_tenant
824
+ USING ERRCODE = 'insufficient_privilege';
825
+ END IF;
826
+
827
+ -- Refusing beats ignoring: a caller that believes it is importing into
828
+ -- another tenant must be told, not silently redirected into this one.
829
+ IF nullif(btrim(p_payload->>'tenant_id'), '') IS NOT NULL
830
+ AND (p_payload->>'tenant_id')::uuid IS DISTINCT FROM v_tenant THEN
831
+ RAISE EXCEPTION 'plugbank_import_movements: payload names another tenant; the tenant comes from the connection'
832
+ USING ERRCODE = 'insufficient_privilege';
833
+ END IF;
834
+
835
+ -- Same rule as the tenant, one level down: the account is the connection's,
836
+ -- and a payload naming a different one is a caller importing into the wrong
837
+ -- ledger account. 002 let the payload override it silently \u2014 with one
838
+ -- connection per account that override is how two accounts get mixed.
839
+ IF nullif(btrim(p_payload->>'bank_account_id'), '') IS NOT NULL
840
+ AND (p_payload->>'bank_account_id')::uuid IS DISTINCT FROM v_account THEN
841
+ RAISE EXCEPTION 'plugbank_import_movements: payload names another bank account; the account comes from the connection'
842
+ USING ERRCODE = 'insufficient_privilege';
843
+ END IF;
844
+
845
+ v_from := nullif(btrim(p_payload->>'period_from'), '')::date;
846
+ v_to := nullif(btrim(p_payload->>'period_to'), '')::date;
847
+ v_trigger := lower(coalesce(nullif(btrim(p_payload->>'trigger'), ''), 'manual'));
848
+ IF v_trigger NOT IN ('on-write', 'scheduled', 'manual', 'webhook') THEN
849
+ RAISE EXCEPTION 'plugbank_import_movements: unknown trigger %', v_trigger
850
+ USING ERRCODE = 'invalid_parameter_value';
851
+ END IF;
852
+
853
+ FOR v_line IN SELECT * FROM jsonb_array_elements(coalesce(p_payload->'lines', '[]'::jsonb))
854
+ LOOP
855
+ v_fetched := v_fetched + 1;
856
+ v_external := nullif(btrim(v_line->>'external_id'), '');
857
+ v_date := nullif(btrim(v_line->>'date'), '')::date;
858
+ v_amount := abs(coalesce((v_line->>'amount')::numeric, 0));
859
+
860
+ -- A line with no provider id cannot be recognised on re-delivery, so it
861
+ -- would import again on every run. Skipping it is the lesser wrong.
862
+ IF v_external IS NULL OR v_date IS NULL OR v_amount = 0 THEN
863
+ CONTINUE;
864
+ END IF;
865
+
866
+ INSERT INTO public.plg_financial_movements (
867
+ tenant_id, direction, movement_kind, amount, paid_amount, status,
868
+ due_date, payment_date, bank_account_id, notes, external_id, external_source)
869
+ VALUES (
870
+ v_tenant,
871
+ CASE WHEN upper(coalesce(v_line->>'type', 'C')) LIKE 'C%' THEN 'credit' ELSE 'debit' END,
872
+ 'payment', v_amount, v_amount, 'paid',
873
+ v_date, v_date, v_account,
874
+ nullif(btrim(v_line->>'description'), ''),
875
+ v_external, coalesce(v_connector, 'plugbank'))
876
+ ON CONFLICT (tenant_id, external_source, external_id)
877
+ WHERE external_id IS NOT NULL AND external_source IS NOT NULL
878
+ DO NOTHING
879
+ RETURNING id INTO v_inserted;
880
+
881
+ IF v_inserted IS NULL THEN
882
+ v_duplicates := v_duplicates + 1;
883
+ ELSE
884
+ v_imported := v_imported + 1;
885
+ END IF;
886
+ v_inserted := NULL;
887
+ END LOOP;
888
+
889
+ UPDATE public.plg_connections
890
+ SET last_sync_at = now(), status = 'connected', status_detail = NULL, updated_at = now()
891
+ WHERE id = v_connection;
892
+
893
+ -- \`stats\` is where the three facts a manual statement import has and a generic
894
+ -- run does not go: the operator picked a period, a re-import of that period is
895
+ -- mostly duplicates by design, and someone pressed the button. The panel reads
896
+ -- all three back from here.
897
+ INSERT INTO public.plg_sync_runs (
898
+ tenant_id, connection_id, connector_id, direction, trigger_kind, status,
899
+ stream, fetched, written, stats, started_at, finished_at)
900
+ VALUES (
901
+ v_tenant, v_connection, coalesce(v_connector, 'plugbank'), 'inbound', v_trigger, 'success',
902
+ 'statement', v_fetched, v_imported,
903
+ jsonb_strip_nulls(jsonb_build_object(
904
+ 'duplicates', v_duplicates,
905
+ 'period_from', v_from,
906
+ 'period_to', v_to,
907
+ 'triggered_by_user_id', auth.uid())),
908
+ v_started, now())
909
+ RETURNING id INTO v_run;
910
+
911
+ RETURN jsonb_build_object(
912
+ 'ok', true,
913
+ 'sync_run_id', v_run,
914
+ 'fetched', v_fetched,
915
+ 'imported', v_imported,
916
+ 'duplicates', v_duplicates);
917
+ END;
918
+ $fn$;
919
+
920
+ REVOKE ALL ON FUNCTION public.plugbank_import_movements(jsonb) FROM public, anon;
921
+ GRANT EXECUTE ON FUNCTION public.plugbank_import_movements(jsonb) TO authenticated, service_role;
922
+
923
+ COMMENT ON FUNCTION public.plugbank_import_movements(jsonb) IS
924
+ 'Import bank-statement lines into the financial ledger and file the run in '
925
+ 'plg_sync_runs. The tenant AND the bank account come from the plg_connections '
926
+ 'row named by integration_id, never from the payload.';
927
+
928
+ -- \xA72 \u2500\u2500 the bespoke tables go \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
929
+ -- After the function above, so a pool is never left with the old door pointing
930
+ -- at a table that no longer exists.
931
+ DROP TABLE IF EXISTS public.bank_integration_sync_log CASCADE;
932
+ DROP TABLE IF EXISTS public.bank_integrations CASCADE;
933
+ `;
290
934
 
291
935
  // src/index.ts
292
936
  function createOpenBankingPlugin() {
@@ -294,16 +938,84 @@ function createOpenBankingPlugin() {
294
938
  id: "openbanking",
295
939
  name: "Open Banking",
296
940
  icon: "Landmark",
297
- version: "0.1.0",
941
+ version: "0.9.1",
942
+ apiVersion: 1,
298
943
  scope: "addon",
299
944
  defaultEnabled: true,
300
945
  dependencies: ["financial"],
301
946
  navigation: [],
302
947
  routes: [],
303
- connectors: [openBankingConnector]
948
+ connectors: [openBankingConnector],
949
+ // The fact the import produces. Declared HERE and not on plugin-financial
950
+ // because the trigger that emits it ships in this plugin's 002: a host
951
+ // advertising an event that only exists when an addon is installed is the
952
+ // same broken promise as an automation the ingress never delivers.
953
+ events: buildBankingDomainEvents(),
954
+ // `emitsFrom` is deliberately absent. It generates
955
+ // `registry.<entity>.<action>` keys for every row change, and "imported" is
956
+ // not a row action — it is an INSERT carrying an external_source, which only
957
+ // a purpose-built trigger can tell apart from a sale typed into the till.
958
+ functions: [
959
+ {
960
+ slug: "plugbank-sync",
961
+ // Invoked from the browser by an authenticated operator picking a
962
+ // period, so Supabase must check the caller's JWT. It is not reached
963
+ // through the Fayz ingress — nothing in Fayz wakes a pull connector —
964
+ // which is what lets verifyJwt stay true.
965
+ ingress: "none",
966
+ verifyJwt: true,
967
+ // NAMES only. PLUGBANK_BASE_URL selects production vs sandbox; the
968
+ // per-tenant token and account CNPJ are NOT here, because a Supabase
969
+ // function secret is per-POOL and a pool serves N tenants with N
970
+ // different bank accounts. Those come from the Fayz platform per call.
971
+ secrets: ["PLUGBANK_BASE_URL", "SUPABASE_URL", "SUPABASE_SERVICE_ROLE_KEY"],
972
+ // What this connector's credential may touch. Direct table access is
973
+ // not on the list on purpose (2026-08-11 ADR): the import goes through
974
+ // the RPC, which takes the tenant from the integration row instead of
975
+ // from the request body.
976
+ grants: ["plugbank_import_movements"]
977
+ }
978
+ ],
979
+ declaredRpcs: [
980
+ {
981
+ name: "plugbank_import_movements",
982
+ kind: "write",
983
+ description: "Import bank-statement lines into the financial ledger. Idempotent on (tenant, provider, external id); the tenant comes from the integration row, never from the payload.",
984
+ audits: false
985
+ }
986
+ ],
987
+ // This plugin creates no table. 003 retired the two it had; what is left is
988
+ // the RPC that is the import's one door, and the trigger that turns an
989
+ // imported movement into a fact. The connection and its run history are rows
990
+ // of plg_connections / plg_sync_runs, owned by packages/db.
991
+ migrations: [
992
+ {
993
+ id: "openbanking-002_credential_and_facts",
994
+ version: "0.9.1",
995
+ sql: MIGRATION_002_CREDENTIAL_AND_FACTS,
996
+ description: "Drop bank_integrations.api_token/cnpj (destination: the Fayz platform credential store, FAY-1360 \u2014 register there BEFORE applying this), add plugbank_import_movements and the financial.movement.imported trigger"
997
+ },
998
+ {
999
+ id: "openbanking-003_onto_connections",
1000
+ version: "1.0.0",
1001
+ sql: MIGRATION_003_ONTO_CONNECTIONS,
1002
+ description: "Retire bank_integrations + bank_integration_sync_log \u2014 the connection is a plg_connections row keyed by instance_key (the bank account) and each import a plg_sync_runs row; plugbank_import_movements re-pointed at both"
1003
+ }
1004
+ ],
1005
+ diagnostics: [
1006
+ {
1007
+ id: "openbanking-ledger-ready",
1008
+ description: "The import writes into the financial ledger and needs its reconciliation columns (plugin-financial 007), the generic connection tables (packages/db 029) and the row-level event emitter.",
1009
+ requires: {
1010
+ tables: ["plg_connections", "plg_sync_runs", "plg_financial_movements"],
1011
+ rpcs: ["plugbank_import_movements", "plg_emit_event"]
1012
+ },
1013
+ level: "error"
1014
+ }
1015
+ ]
304
1016
  };
305
1017
  }
306
1018
 
307
- export { bankIntegrationSyncLog, bankIntegrations, createOpenBankingPlugin, createOpenBankingProvider, openBankingConnector };
1019
+ export { MOVEMENT_IMPORTED_FIELDS, buildBankingDomainEvents, createOpenBankingPlugin, createOpenBankingProvider, openBankingConnector };
308
1020
  //# sourceMappingURL=index.js.map
309
1021
  //# sourceMappingURL=index.js.map