@fayz-ai/plugin-financial 0.8.0 → 0.8.3
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/FinancialContext.d.ts +6 -0
- package/dist/FinancialContext.d.ts.map +1 -1
- package/dist/FinancialPage.d.ts.map +1 -1
- package/dist/components/FinancialGeneralSettings.d.ts.map +1 -1
- package/dist/data/supabase.d.ts.map +1 -1
- package/dist/index.cjs +281 -29
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +283 -32
- package/dist/index.js.map +1 -1
- package/dist/locales/en.d.ts.map +1 -1
- package/dist/locales/pt-BR.d.ts.map +1 -1
- package/dist/migrations/index.d.ts +3 -1
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/views/CardsView.d.ts.map +1 -1
- package/dist/views/InvoiceFormView.d.ts.map +1 -1
- package/dist/views/InvoiceListView.d.ts.map +1 -1
- package/dist/views/QuickTransactionForm.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/FinancialContext.tsx +6 -0
- package/src/FinancialPage.tsx +56 -12
- package/src/components/FinancialGeneralSettings.tsx +21 -8
- package/src/data/supabase.ts +6 -1
- package/src/index.ts +128 -0
- package/src/locales/en.ts +10 -0
- package/src/locales/pt-BR.ts +10 -0
- package/src/migrations/004_order_to_cash.sql +15 -1
- package/src/migrations/009_agent_rpcs.sql +165 -0
- package/src/migrations/010_invoice_internal.sql +133 -0
- package/src/migrations/index.ts +320 -2
- package/src/views/CardsView.tsx +12 -9
- package/src/views/InvoiceFormView.tsx +10 -0
- package/src/views/InvoiceListView.tsx +98 -9
- package/src/views/QuickTransactionForm.tsx +11 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- plugin-financial 009: server-plane agent write RPC.
|
|
3
|
+
--
|
|
4
|
+
-- public.agent_financial_mark_payment_received — "marca o atendimento como
|
|
5
|
+
-- pago em cartão 2x". Resolves the order (directly or via a booking id),
|
|
6
|
+
-- guard-checks the actor (agent_guard, spine 015), promotes the order into an
|
|
7
|
+
-- invoice + installment ledger through the EXISTING invariant function
|
|
8
|
+
-- fn_invoice_from_order (004) — never re-implementing its numbering/allocation
|
|
9
|
+
-- logic — then settles the installments following 008's split model (bill rows
|
|
10
|
+
-- flip to paid; each cash event is its own movement_kind='payment' row).
|
|
11
|
+
--
|
|
12
|
+
-- fn_invoice_from_order authorizes via user_tenant_ids() (auth.uid()). This
|
|
13
|
+
-- RPC runs under the broker's service key, so it impersonates the ALREADY
|
|
14
|
+
-- GUARD-VERIFIED actor for the duration of the transaction by setting
|
|
15
|
+
-- request.jwt.claims locally — the invariant function keeps working unchanged
|
|
16
|
+
-- and the audit trail carries the real acting user.
|
|
17
|
+
--
|
|
18
|
+
-- Contract: (p_tenant_id, p_actor_user_id, p_payload) → jsonb
|
|
19
|
+
-- payload {order_id? uuid, booking_id? uuid, method text
|
|
20
|
+
-- ('cash'|'pix'|'credit_card'|'debit_card'|'transfer'),
|
|
21
|
+
-- installments? int (credit_card only, 1–12), amount? numeric}
|
|
22
|
+
-- ============================================================================
|
|
23
|
+
|
|
24
|
+
CREATE OR REPLACE FUNCTION public.agent_financial_mark_payment_received(
|
|
25
|
+
p_tenant_id uuid,
|
|
26
|
+
p_actor_user_id uuid,
|
|
27
|
+
p_payload jsonb
|
|
28
|
+
) RETURNS jsonb
|
|
29
|
+
LANGUAGE plpgsql SECURITY DEFINER
|
|
30
|
+
SET search_path = public
|
|
31
|
+
AS $$
|
|
32
|
+
DECLARE
|
|
33
|
+
v_denial jsonb;
|
|
34
|
+
v_order_id uuid;
|
|
35
|
+
v_booking_id uuid;
|
|
36
|
+
v_method text;
|
|
37
|
+
v_installments int;
|
|
38
|
+
v_amount numeric;
|
|
39
|
+
v_order record;
|
|
40
|
+
v_bill record;
|
|
41
|
+
v_paid_count int := 0;
|
|
42
|
+
v_total_paid numeric := 0;
|
|
43
|
+
BEGIN
|
|
44
|
+
-- ── payload ──────────────────────────────────────────────────────────────
|
|
45
|
+
BEGIN
|
|
46
|
+
v_order_id := (p_payload->>'order_id')::uuid;
|
|
47
|
+
v_booking_id := (p_payload->>'booking_id')::uuid;
|
|
48
|
+
v_amount := (p_payload->>'amount')::numeric;
|
|
49
|
+
v_installments := COALESCE((p_payload->>'installments')::int, 1);
|
|
50
|
+
EXCEPTION WHEN OTHERS THEN
|
|
51
|
+
RETURN jsonb_build_object('ok', false, 'error', 'invalid payload: ' || SQLERRM);
|
|
52
|
+
END;
|
|
53
|
+
v_method := lower(COALESCE(p_payload->>'method', 'cash'));
|
|
54
|
+
IF v_method NOT IN ('cash', 'pix', 'credit_card', 'debit_card', 'transfer') THEN
|
|
55
|
+
RETURN jsonb_build_object('ok', false, 'error', 'unknown payment method');
|
|
56
|
+
END IF;
|
|
57
|
+
IF v_installments NOT BETWEEN 1 AND 12
|
|
58
|
+
OR (v_installments > 1 AND v_method <> 'credit_card') THEN
|
|
59
|
+
RETURN jsonb_build_object('ok', false, 'error', 'installments require credit_card (1-12)');
|
|
60
|
+
END IF;
|
|
61
|
+
|
|
62
|
+
-- booking → order
|
|
63
|
+
IF v_order_id IS NULL AND v_booking_id IS NOT NULL THEN
|
|
64
|
+
SELECT b.order_id INTO v_order_id
|
|
65
|
+
FROM appointments b
|
|
66
|
+
WHERE b.id = v_booking_id AND b.tenant_id = p_tenant_id;
|
|
67
|
+
END IF;
|
|
68
|
+
IF v_order_id IS NULL THEN
|
|
69
|
+
RETURN jsonb_build_object('ok', false, 'error', 'order_id or booking_id required (booking must have an order)');
|
|
70
|
+
END IF;
|
|
71
|
+
|
|
72
|
+
SELECT o.id, o.status, o.total INTO v_order
|
|
73
|
+
FROM orders o
|
|
74
|
+
WHERE o.id = v_order_id AND o.tenant_id = p_tenant_id;
|
|
75
|
+
IF NOT FOUND THEN
|
|
76
|
+
RETURN jsonb_build_object('ok', false, 'error', 'unknown order for this tenant');
|
|
77
|
+
END IF;
|
|
78
|
+
IF v_order.status = 'cancelled' THEN
|
|
79
|
+
RETURN jsonb_build_object('ok', false, 'error', 'cannot receive payment on a cancelled order');
|
|
80
|
+
END IF;
|
|
81
|
+
|
|
82
|
+
-- ── authorization ────────────────────────────────────────────────────────
|
|
83
|
+
v_denial := agent_guard(p_tenant_id, p_actor_user_id, 'financial.receivables', 'update');
|
|
84
|
+
IF v_denial IS NOT NULL THEN
|
|
85
|
+
RETURN jsonb_build_object('ok', false, 'denial', v_denial);
|
|
86
|
+
END IF;
|
|
87
|
+
|
|
88
|
+
-- already fully paid?
|
|
89
|
+
IF EXISTS (
|
|
90
|
+
SELECT 1 FROM plg_financial_movements m
|
|
91
|
+
WHERE m.invoice_id = v_order_id AND m.movement_kind = 'bill'
|
|
92
|
+
) AND NOT EXISTS (
|
|
93
|
+
SELECT 1 FROM plg_financial_movements m
|
|
94
|
+
WHERE m.invoice_id = v_order_id AND m.movement_kind = 'bill' AND m.status <> 'paid'
|
|
95
|
+
) THEN
|
|
96
|
+
RETURN jsonb_build_object('ok', false, 'error', 'order is already fully paid');
|
|
97
|
+
END IF;
|
|
98
|
+
|
|
99
|
+
-- ── invoice via the existing invariant function (impersonate the verified
|
|
100
|
+
-- actor so fn_invoice_from_order's user_tenant_ids() check passes) ─────
|
|
101
|
+
PERFORM set_config('request.jwt.claims',
|
|
102
|
+
jsonb_build_object('sub', p_actor_user_id, 'role', 'authenticated')::text,
|
|
103
|
+
true);
|
|
104
|
+
PERFORM fn_invoice_from_order(v_order_id, current_date, NULL, v_installments, 'credit');
|
|
105
|
+
|
|
106
|
+
-- ── settle: flip pending bill installments to paid + emit payment rows ───
|
|
107
|
+
FOR v_bill IN
|
|
108
|
+
SELECT m.* FROM plg_financial_movements m
|
|
109
|
+
WHERE m.invoice_id = v_order_id
|
|
110
|
+
AND m.tenant_id = p_tenant_id
|
|
111
|
+
AND m.movement_kind = 'bill'
|
|
112
|
+
AND m.status <> 'paid'
|
|
113
|
+
ORDER BY m.installment_number NULLS FIRST
|
|
114
|
+
LOOP
|
|
115
|
+
UPDATE plg_financial_movements SET
|
|
116
|
+
status = 'paid',
|
|
117
|
+
paid_amount = v_bill.amount,
|
|
118
|
+
payment_date = current_date,
|
|
119
|
+
card_installments = CASE WHEN v_method = 'credit_card' THEN v_installments ELSE NULL END,
|
|
120
|
+
metadata = COALESCE(metadata, '{}'::jsonb)
|
|
121
|
+
|| jsonb_build_object('paidVia', 'agent', 'method', v_method),
|
|
122
|
+
updated_at = now()
|
|
123
|
+
WHERE id = v_bill.id;
|
|
124
|
+
|
|
125
|
+
INSERT INTO plg_financial_movements
|
|
126
|
+
(tenant_id, invoice_id, direction, movement_kind, amount, paid_amount, status,
|
|
127
|
+
due_date, payment_date, installment_number, card_installments, metadata)
|
|
128
|
+
VALUES
|
|
129
|
+
(p_tenant_id, v_order_id, v_bill.direction, 'payment', v_bill.amount, v_bill.amount,
|
|
130
|
+
'paid', v_bill.due_date, current_date, v_bill.installment_number,
|
|
131
|
+
CASE WHEN v_method = 'credit_card' THEN v_installments ELSE NULL END,
|
|
132
|
+
jsonb_build_object('source', 'agent', 'method', v_method));
|
|
133
|
+
|
|
134
|
+
v_paid_count := v_paid_count + 1;
|
|
135
|
+
v_total_paid := v_total_paid + v_bill.amount;
|
|
136
|
+
END LOOP;
|
|
137
|
+
|
|
138
|
+
IF v_paid_count = 0 THEN
|
|
139
|
+
RETURN jsonb_build_object('ok', false, 'error', 'no open installments found to settle');
|
|
140
|
+
END IF;
|
|
141
|
+
|
|
142
|
+
INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
|
|
143
|
+
VALUES (p_tenant_id, p_actor_user_id, 'agent.markPaymentReceived', 'order', v_order_id::text,
|
|
144
|
+
jsonb_build_object('payload', p_payload, 'installmentsSettled', v_paid_count,
|
|
145
|
+
'totalPaid', v_total_paid));
|
|
146
|
+
|
|
147
|
+
RETURN jsonb_build_object(
|
|
148
|
+
'ok', true,
|
|
149
|
+
'id', v_order_id,
|
|
150
|
+
'record', jsonb_build_object(
|
|
151
|
+
'ref', jsonb_build_object('id', v_order_id, 'resource', 'orders',
|
|
152
|
+
'archetype', 'order:service_order'),
|
|
153
|
+
'method', v_method,
|
|
154
|
+
'installments', v_installments,
|
|
155
|
+
'installments_settled', v_paid_count,
|
|
156
|
+
'total_paid', v_total_paid
|
|
157
|
+
)
|
|
158
|
+
);
|
|
159
|
+
END;
|
|
160
|
+
$$;
|
|
161
|
+
|
|
162
|
+
REVOKE ALL ON FUNCTION public.agent_financial_mark_payment_received(uuid, uuid, jsonb) FROM public;
|
|
163
|
+
REVOKE EXECUTE ON FUNCTION public.agent_financial_mark_payment_received(uuid, uuid, jsonb) FROM anon;
|
|
164
|
+
GRANT EXECUTE ON FUNCTION public.agent_financial_mark_payment_received(uuid, uuid, jsonb)
|
|
165
|
+
TO authenticated, service_role;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
-- Financial plugin — separate WHO MAY invoice from HOW an invoice is built.
|
|
2
|
+
--
|
|
3
|
+
-- fn_invoice_from_order (004) mixes the two: it checks that the caller is a
|
|
4
|
+
-- member of the order's tenant, then does the work. That check is right for a
|
|
5
|
+
-- human clicking "Faturar" in the admin, and impossible for anything else — a
|
|
6
|
+
-- storefront checkout runs as the SHOPPER, whose user_tenant_ids() is empty by
|
|
7
|
+
-- definition, so an order that should raise a receivable the moment it is placed
|
|
8
|
+
-- cannot raise one at all. SECURITY DEFINER does not help: auth.uid() still
|
|
9
|
+
-- belongs to the caller inside the definer context.
|
|
10
|
+
--
|
|
11
|
+
-- So the body moves to an internal function with no authorization of its own,
|
|
12
|
+
-- and fn_invoice_from_order keeps its signature, keeps its check, and delegates.
|
|
13
|
+
-- Callers that exist today (CRM approveQuote, the agenda bridge) are untouched.
|
|
14
|
+
-- The internal one is granted to NOBODY: it is reachable only from other
|
|
15
|
+
-- SECURITY DEFINER functions and triggers that have already decided the caller
|
|
16
|
+
-- is entitled — the shop's order-to-cash bridge being the first.
|
|
17
|
+
--
|
|
18
|
+
-- Idempotent.
|
|
19
|
+
|
|
20
|
+
CREATE OR REPLACE FUNCTION public.fn_invoice_from_order_internal(
|
|
21
|
+
p_order_id uuid,
|
|
22
|
+
p_due_date date DEFAULT NULL,
|
|
23
|
+
p_status text DEFAULT NULL,
|
|
24
|
+
p_installments int DEFAULT 1,
|
|
25
|
+
p_direction text DEFAULT 'credit'
|
|
26
|
+
) RETURNS uuid
|
|
27
|
+
LANGUAGE plpgsql
|
|
28
|
+
SECURITY DEFINER
|
|
29
|
+
SET search_path = public
|
|
30
|
+
AS $$
|
|
31
|
+
DECLARE
|
|
32
|
+
v_order public.orders%ROWTYPE;
|
|
33
|
+
v_ref text;
|
|
34
|
+
v_seq bigint;
|
|
35
|
+
v_summary text;
|
|
36
|
+
v_contact text;
|
|
37
|
+
v_existing uuid;
|
|
38
|
+
v_kind text;
|
|
39
|
+
v_prefix text;
|
|
40
|
+
v_n int := GREATEST(COALESCE(p_installments, 1), 1);
|
|
41
|
+
v_total numeric;
|
|
42
|
+
v_base numeric;
|
|
43
|
+
v_amt numeric;
|
|
44
|
+
v_alloc numeric := 0;
|
|
45
|
+
v_due date;
|
|
46
|
+
i int;
|
|
47
|
+
BEGIN
|
|
48
|
+
SELECT * INTO v_order FROM public.orders WHERE id = p_order_id;
|
|
49
|
+
IF NOT FOUND THEN RAISE EXCEPTION 'fn_invoice_from_order: order % not found', p_order_id; END IF;
|
|
50
|
+
|
|
51
|
+
-- Idempotent: an order already carrying a bill is already invoiced.
|
|
52
|
+
SELECT id INTO v_existing FROM public.plg_financial_movements
|
|
53
|
+
WHERE invoice_id = p_order_id AND movement_kind = 'bill' LIMIT 1;
|
|
54
|
+
IF v_existing IS NOT NULL THEN RETURN p_order_id; END IF;
|
|
55
|
+
|
|
56
|
+
IF p_direction = 'debit' THEN v_kind := 'invoice_payable'; v_prefix := 'PAG';
|
|
57
|
+
ELSE v_kind := 'invoice_receivable'; v_prefix := 'REC'; END IF;
|
|
58
|
+
|
|
59
|
+
SELECT public.next_sequence(v_order.tenant_id, v_kind) INTO v_seq;
|
|
60
|
+
v_ref := v_prefix || '-' || lpad(v_seq::text, 5, '0');
|
|
61
|
+
|
|
62
|
+
SELECT string_agg(name, ', ' ORDER BY sort_order) INTO v_summary
|
|
63
|
+
FROM public.order_items WHERE order_id = p_order_id;
|
|
64
|
+
SELECT name INTO v_contact FROM public.people WHERE id = v_order.party_id;
|
|
65
|
+
|
|
66
|
+
UPDATE public.orders SET
|
|
67
|
+
status = COALESCE(p_status, status),
|
|
68
|
+
stage = 'invoiced',
|
|
69
|
+
direction = p_direction,
|
|
70
|
+
reference_number = v_ref,
|
|
71
|
+
metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object(
|
|
72
|
+
'itemsSummary', COALESCE(v_summary, ''),
|
|
73
|
+
'contactName', v_contact,
|
|
74
|
+
'installmentCount', v_n,
|
|
75
|
+
'quoteNumber', v_order.reference_number,
|
|
76
|
+
'direction', p_direction
|
|
77
|
+
),
|
|
78
|
+
updated_at = now()
|
|
79
|
+
WHERE id = p_order_id;
|
|
80
|
+
|
|
81
|
+
v_total := COALESCE(v_order.total, 0);
|
|
82
|
+
v_base := round(v_total / v_n, 2);
|
|
83
|
+
v_due := COALESCE(p_due_date, current_date);
|
|
84
|
+
FOR i IN 1..v_n LOOP
|
|
85
|
+
IF i = v_n THEN v_amt := round(v_total - v_alloc, 2);
|
|
86
|
+
ELSE v_amt := v_base; v_alloc := v_alloc + v_base; END IF;
|
|
87
|
+
INSERT INTO public.plg_financial_movements
|
|
88
|
+
(tenant_id, invoice_id, direction, movement_kind, amount, paid_amount, status, due_date, installment_number)
|
|
89
|
+
VALUES
|
|
90
|
+
(v_order.tenant_id, p_order_id, p_direction, 'bill', v_amt, 0, 'pending',
|
|
91
|
+
(v_due + ((i - 1) || ' months')::interval)::date, i);
|
|
92
|
+
END LOOP;
|
|
93
|
+
|
|
94
|
+
RETURN p_order_id;
|
|
95
|
+
END;
|
|
96
|
+
$$;
|
|
97
|
+
|
|
98
|
+
-- Deliberately NOT granted to authenticated/anon: entry is through
|
|
99
|
+
-- fn_invoice_from_order, or from another definer function that has already
|
|
100
|
+
-- established the caller's right to create this receivable.
|
|
101
|
+
REVOKE ALL ON FUNCTION public.fn_invoice_from_order_internal(uuid, date, text, int, text) FROM PUBLIC;
|
|
102
|
+
|
|
103
|
+
-- The public entry point: unchanged signature and behaviour, now a guard around
|
|
104
|
+
-- the shared body instead of a second copy of it.
|
|
105
|
+
CREATE OR REPLACE FUNCTION public.fn_invoice_from_order(
|
|
106
|
+
p_order_id uuid,
|
|
107
|
+
p_due_date date DEFAULT NULL,
|
|
108
|
+
p_status text DEFAULT NULL,
|
|
109
|
+
p_installments int DEFAULT 1,
|
|
110
|
+
p_direction text DEFAULT 'credit'
|
|
111
|
+
) RETURNS uuid
|
|
112
|
+
LANGUAGE plpgsql
|
|
113
|
+
SECURITY DEFINER
|
|
114
|
+
SET search_path = public
|
|
115
|
+
AS $$
|
|
116
|
+
DECLARE v_tenant uuid;
|
|
117
|
+
BEGIN
|
|
118
|
+
SELECT tenant_id INTO v_tenant FROM public.orders WHERE id = p_order_id;
|
|
119
|
+
IF v_tenant IS NULL THEN
|
|
120
|
+
RAISE EXCEPTION 'fn_invoice_from_order: order % not found', p_order_id;
|
|
121
|
+
END IF;
|
|
122
|
+
IF v_tenant NOT IN (SELECT public.user_tenant_ids()) THEN
|
|
123
|
+
RAISE EXCEPTION 'fn_invoice_from_order: forbidden';
|
|
124
|
+
END IF;
|
|
125
|
+
|
|
126
|
+
RETURN public.fn_invoice_from_order_internal(
|
|
127
|
+
p_order_id, p_due_date, p_status, p_installments, p_direction);
|
|
128
|
+
END;
|
|
129
|
+
$$;
|
|
130
|
+
|
|
131
|
+
GRANT EXECUTE ON FUNCTION public.fn_invoice_from_order(uuid, date, text, int, text) TO authenticated;
|
|
132
|
+
|
|
133
|
+
NOTIFY pgrst, 'reload schema';
|
package/src/migrations/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// AUTO-GENERATED from 000_plg_rename.sql, 001_financial_base.sql, 002_chart_of_accounts.sql, 003_card_brands.sql, 004_order_to_cash.sql, 005_seed_defaults.sql, 006_rls_policies.sql, 006b_extract_fee_amount.sql, 007_reconciliation.sql, 007b_movement_payment_method_type.sql, 008_split_payment_movements.sql — regenerate with scripts/embed-migrations.mjs
|
|
1
|
+
// AUTO-GENERATED from 000_plg_rename.sql, 001_financial_base.sql, 002_chart_of_accounts.sql, 003_card_brands.sql, 004_order_to_cash.sql, 005_seed_defaults.sql, 006_rls_policies.sql, 006b_extract_fee_amount.sql, 007_reconciliation.sql, 007b_movement_payment_method_type.sql, 008_split_payment_movements.sql, 009_agent_rpcs.sql, 010_invoice_internal.sql — regenerate with scripts/embed-migrations.mjs
|
|
2
2
|
// SQL files are the source of truth; this inline copy lets the manifest declare
|
|
3
3
|
// migrations as data. Do not edit by hand — run the embed script instead.
|
|
4
4
|
|
|
@@ -203,8 +203,22 @@ CREATE TABLE IF NOT EXISTS public.sequences (
|
|
|
203
203
|
current_value bigint NOT NULL DEFAULT 0,
|
|
204
204
|
PRIMARY KEY (tenant_id, kind)
|
|
205
205
|
);
|
|
206
|
+
-- Baseline: RLS ON, deny-all (no policies), like the salon pool. The counter is
|
|
207
|
+
-- an internal, non-API table written ONLY through next_sequence() below, which is
|
|
208
|
+
-- SECURITY DEFINER and so bypasses RLS as the table owner. Legacy pools that
|
|
209
|
+
-- shipped this table without RLS are also remediated by packages/db 013_sequences_rls.sql.
|
|
210
|
+
ALTER TABLE public.sequences ENABLE ROW LEVEL SECURITY;
|
|
211
|
+
|
|
212
|
+
-- SECURITY DEFINER: next_sequence is called directly via PostgREST RPC by
|
|
213
|
+
-- authenticated users (src/data/supabase.ts → createInvoice), so with deny-all
|
|
214
|
+
-- RLS on public.sequences it MUST run as the table owner to write the counter.
|
|
215
|
+
-- SET search_path pins schema resolution for the definer context.
|
|
206
216
|
CREATE OR REPLACE FUNCTION public.next_sequence(p_tenant_id uuid, p_kind text)
|
|
207
|
-
RETURNS bigint
|
|
217
|
+
RETURNS bigint
|
|
218
|
+
LANGUAGE plpgsql
|
|
219
|
+
SECURITY DEFINER
|
|
220
|
+
SET search_path = public
|
|
221
|
+
AS $$
|
|
208
222
|
DECLARE v_next bigint;
|
|
209
223
|
BEGIN
|
|
210
224
|
INSERT INTO public.sequences (tenant_id, kind, current_value)
|
|
@@ -529,6 +543,308 @@ SET payment_date = NULL, bank_account_id = NULL, payment_method_id = NULL,
|
|
|
529
543
|
WHERE movement_kind = 'bill';
|
|
530
544
|
`
|
|
531
545
|
|
|
546
|
+
export const MIGRATION_009_AGENT_RPCS = `-- ============================================================================
|
|
547
|
+
-- plugin-financial 009: server-plane agent write RPC.
|
|
548
|
+
--
|
|
549
|
+
-- public.agent_financial_mark_payment_received — "marca o atendimento como
|
|
550
|
+
-- pago em cartão 2x". Resolves the order (directly or via a booking id),
|
|
551
|
+
-- guard-checks the actor (agent_guard, spine 015), promotes the order into an
|
|
552
|
+
-- invoice + installment ledger through the EXISTING invariant function
|
|
553
|
+
-- fn_invoice_from_order (004) — never re-implementing its numbering/allocation
|
|
554
|
+
-- logic — then settles the installments following 008's split model (bill rows
|
|
555
|
+
-- flip to paid; each cash event is its own movement_kind='payment' row).
|
|
556
|
+
--
|
|
557
|
+
-- fn_invoice_from_order authorizes via user_tenant_ids() (auth.uid()). This
|
|
558
|
+
-- RPC runs under the broker's service key, so it impersonates the ALREADY
|
|
559
|
+
-- GUARD-VERIFIED actor for the duration of the transaction by setting
|
|
560
|
+
-- request.jwt.claims locally — the invariant function keeps working unchanged
|
|
561
|
+
-- and the audit trail carries the real acting user.
|
|
562
|
+
--
|
|
563
|
+
-- Contract: (p_tenant_id, p_actor_user_id, p_payload) → jsonb
|
|
564
|
+
-- payload {order_id? uuid, booking_id? uuid, method text
|
|
565
|
+
-- ('cash'|'pix'|'credit_card'|'debit_card'|'transfer'),
|
|
566
|
+
-- installments? int (credit_card only, 1–12), amount? numeric}
|
|
567
|
+
-- ============================================================================
|
|
568
|
+
|
|
569
|
+
CREATE OR REPLACE FUNCTION public.agent_financial_mark_payment_received(
|
|
570
|
+
p_tenant_id uuid,
|
|
571
|
+
p_actor_user_id uuid,
|
|
572
|
+
p_payload jsonb
|
|
573
|
+
) RETURNS jsonb
|
|
574
|
+
LANGUAGE plpgsql SECURITY DEFINER
|
|
575
|
+
SET search_path = public
|
|
576
|
+
AS $$
|
|
577
|
+
DECLARE
|
|
578
|
+
v_denial jsonb;
|
|
579
|
+
v_order_id uuid;
|
|
580
|
+
v_booking_id uuid;
|
|
581
|
+
v_method text;
|
|
582
|
+
v_installments int;
|
|
583
|
+
v_amount numeric;
|
|
584
|
+
v_order record;
|
|
585
|
+
v_bill record;
|
|
586
|
+
v_paid_count int := 0;
|
|
587
|
+
v_total_paid numeric := 0;
|
|
588
|
+
BEGIN
|
|
589
|
+
-- ── payload ──────────────────────────────────────────────────────────────
|
|
590
|
+
BEGIN
|
|
591
|
+
v_order_id := (p_payload->>'order_id')::uuid;
|
|
592
|
+
v_booking_id := (p_payload->>'booking_id')::uuid;
|
|
593
|
+
v_amount := (p_payload->>'amount')::numeric;
|
|
594
|
+
v_installments := COALESCE((p_payload->>'installments')::int, 1);
|
|
595
|
+
EXCEPTION WHEN OTHERS THEN
|
|
596
|
+
RETURN jsonb_build_object('ok', false, 'error', 'invalid payload: ' || SQLERRM);
|
|
597
|
+
END;
|
|
598
|
+
v_method := lower(COALESCE(p_payload->>'method', 'cash'));
|
|
599
|
+
IF v_method NOT IN ('cash', 'pix', 'credit_card', 'debit_card', 'transfer') THEN
|
|
600
|
+
RETURN jsonb_build_object('ok', false, 'error', 'unknown payment method');
|
|
601
|
+
END IF;
|
|
602
|
+
IF v_installments NOT BETWEEN 1 AND 12
|
|
603
|
+
OR (v_installments > 1 AND v_method <> 'credit_card') THEN
|
|
604
|
+
RETURN jsonb_build_object('ok', false, 'error', 'installments require credit_card (1-12)');
|
|
605
|
+
END IF;
|
|
606
|
+
|
|
607
|
+
-- booking → order
|
|
608
|
+
IF v_order_id IS NULL AND v_booking_id IS NOT NULL THEN
|
|
609
|
+
SELECT b.order_id INTO v_order_id
|
|
610
|
+
FROM appointments b
|
|
611
|
+
WHERE b.id = v_booking_id AND b.tenant_id = p_tenant_id;
|
|
612
|
+
END IF;
|
|
613
|
+
IF v_order_id IS NULL THEN
|
|
614
|
+
RETURN jsonb_build_object('ok', false, 'error', 'order_id or booking_id required (booking must have an order)');
|
|
615
|
+
END IF;
|
|
616
|
+
|
|
617
|
+
SELECT o.id, o.status, o.total INTO v_order
|
|
618
|
+
FROM orders o
|
|
619
|
+
WHERE o.id = v_order_id AND o.tenant_id = p_tenant_id;
|
|
620
|
+
IF NOT FOUND THEN
|
|
621
|
+
RETURN jsonb_build_object('ok', false, 'error', 'unknown order for this tenant');
|
|
622
|
+
END IF;
|
|
623
|
+
IF v_order.status = 'cancelled' THEN
|
|
624
|
+
RETURN jsonb_build_object('ok', false, 'error', 'cannot receive payment on a cancelled order');
|
|
625
|
+
END IF;
|
|
626
|
+
|
|
627
|
+
-- ── authorization ────────────────────────────────────────────────────────
|
|
628
|
+
v_denial := agent_guard(p_tenant_id, p_actor_user_id, 'financial.receivables', 'update');
|
|
629
|
+
IF v_denial IS NOT NULL THEN
|
|
630
|
+
RETURN jsonb_build_object('ok', false, 'denial', v_denial);
|
|
631
|
+
END IF;
|
|
632
|
+
|
|
633
|
+
-- already fully paid?
|
|
634
|
+
IF EXISTS (
|
|
635
|
+
SELECT 1 FROM plg_financial_movements m
|
|
636
|
+
WHERE m.invoice_id = v_order_id AND m.movement_kind = 'bill'
|
|
637
|
+
) AND NOT EXISTS (
|
|
638
|
+
SELECT 1 FROM plg_financial_movements m
|
|
639
|
+
WHERE m.invoice_id = v_order_id AND m.movement_kind = 'bill' AND m.status <> 'paid'
|
|
640
|
+
) THEN
|
|
641
|
+
RETURN jsonb_build_object('ok', false, 'error', 'order is already fully paid');
|
|
642
|
+
END IF;
|
|
643
|
+
|
|
644
|
+
-- ── invoice via the existing invariant function (impersonate the verified
|
|
645
|
+
-- actor so fn_invoice_from_order's user_tenant_ids() check passes) ─────
|
|
646
|
+
PERFORM set_config('request.jwt.claims',
|
|
647
|
+
jsonb_build_object('sub', p_actor_user_id, 'role', 'authenticated')::text,
|
|
648
|
+
true);
|
|
649
|
+
PERFORM fn_invoice_from_order(v_order_id, current_date, NULL, v_installments, 'credit');
|
|
650
|
+
|
|
651
|
+
-- ── settle: flip pending bill installments to paid + emit payment rows ───
|
|
652
|
+
FOR v_bill IN
|
|
653
|
+
SELECT m.* FROM plg_financial_movements m
|
|
654
|
+
WHERE m.invoice_id = v_order_id
|
|
655
|
+
AND m.tenant_id = p_tenant_id
|
|
656
|
+
AND m.movement_kind = 'bill'
|
|
657
|
+
AND m.status <> 'paid'
|
|
658
|
+
ORDER BY m.installment_number NULLS FIRST
|
|
659
|
+
LOOP
|
|
660
|
+
UPDATE plg_financial_movements SET
|
|
661
|
+
status = 'paid',
|
|
662
|
+
paid_amount = v_bill.amount,
|
|
663
|
+
payment_date = current_date,
|
|
664
|
+
card_installments = CASE WHEN v_method = 'credit_card' THEN v_installments ELSE NULL END,
|
|
665
|
+
metadata = COALESCE(metadata, '{}'::jsonb)
|
|
666
|
+
|| jsonb_build_object('paidVia', 'agent', 'method', v_method),
|
|
667
|
+
updated_at = now()
|
|
668
|
+
WHERE id = v_bill.id;
|
|
669
|
+
|
|
670
|
+
INSERT INTO plg_financial_movements
|
|
671
|
+
(tenant_id, invoice_id, direction, movement_kind, amount, paid_amount, status,
|
|
672
|
+
due_date, payment_date, installment_number, card_installments, metadata)
|
|
673
|
+
VALUES
|
|
674
|
+
(p_tenant_id, v_order_id, v_bill.direction, 'payment', v_bill.amount, v_bill.amount,
|
|
675
|
+
'paid', v_bill.due_date, current_date, v_bill.installment_number,
|
|
676
|
+
CASE WHEN v_method = 'credit_card' THEN v_installments ELSE NULL END,
|
|
677
|
+
jsonb_build_object('source', 'agent', 'method', v_method));
|
|
678
|
+
|
|
679
|
+
v_paid_count := v_paid_count + 1;
|
|
680
|
+
v_total_paid := v_total_paid + v_bill.amount;
|
|
681
|
+
END LOOP;
|
|
682
|
+
|
|
683
|
+
IF v_paid_count = 0 THEN
|
|
684
|
+
RETURN jsonb_build_object('ok', false, 'error', 'no open installments found to settle');
|
|
685
|
+
END IF;
|
|
686
|
+
|
|
687
|
+
INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
|
|
688
|
+
VALUES (p_tenant_id, p_actor_user_id, 'agent.markPaymentReceived', 'order', v_order_id::text,
|
|
689
|
+
jsonb_build_object('payload', p_payload, 'installmentsSettled', v_paid_count,
|
|
690
|
+
'totalPaid', v_total_paid));
|
|
691
|
+
|
|
692
|
+
RETURN jsonb_build_object(
|
|
693
|
+
'ok', true,
|
|
694
|
+
'id', v_order_id,
|
|
695
|
+
'record', jsonb_build_object(
|
|
696
|
+
'ref', jsonb_build_object('id', v_order_id, 'resource', 'orders',
|
|
697
|
+
'archetype', 'order:service_order'),
|
|
698
|
+
'method', v_method,
|
|
699
|
+
'installments', v_installments,
|
|
700
|
+
'installments_settled', v_paid_count,
|
|
701
|
+
'total_paid', v_total_paid
|
|
702
|
+
)
|
|
703
|
+
);
|
|
704
|
+
END;
|
|
705
|
+
$$;
|
|
706
|
+
|
|
707
|
+
REVOKE ALL ON FUNCTION public.agent_financial_mark_payment_received(uuid, uuid, jsonb) FROM public;
|
|
708
|
+
REVOKE EXECUTE ON FUNCTION public.agent_financial_mark_payment_received(uuid, uuid, jsonb) FROM anon;
|
|
709
|
+
GRANT EXECUTE ON FUNCTION public.agent_financial_mark_payment_received(uuid, uuid, jsonb)
|
|
710
|
+
TO authenticated, service_role;
|
|
711
|
+
`
|
|
712
|
+
|
|
713
|
+
export const MIGRATION_010_INVOICE_INTERNAL = `-- Financial plugin — separate WHO MAY invoice from HOW an invoice is built.
|
|
714
|
+
--
|
|
715
|
+
-- fn_invoice_from_order (004) mixes the two: it checks that the caller is a
|
|
716
|
+
-- member of the order's tenant, then does the work. That check is right for a
|
|
717
|
+
-- human clicking "Faturar" in the admin, and impossible for anything else — a
|
|
718
|
+
-- storefront checkout runs as the SHOPPER, whose user_tenant_ids() is empty by
|
|
719
|
+
-- definition, so an order that should raise a receivable the moment it is placed
|
|
720
|
+
-- cannot raise one at all. SECURITY DEFINER does not help: auth.uid() still
|
|
721
|
+
-- belongs to the caller inside the definer context.
|
|
722
|
+
--
|
|
723
|
+
-- So the body moves to an internal function with no authorization of its own,
|
|
724
|
+
-- and fn_invoice_from_order keeps its signature, keeps its check, and delegates.
|
|
725
|
+
-- Callers that exist today (CRM approveQuote, the agenda bridge) are untouched.
|
|
726
|
+
-- The internal one is granted to NOBODY: it is reachable only from other
|
|
727
|
+
-- SECURITY DEFINER functions and triggers that have already decided the caller
|
|
728
|
+
-- is entitled — the shop's order-to-cash bridge being the first.
|
|
729
|
+
--
|
|
730
|
+
-- Idempotent.
|
|
731
|
+
|
|
732
|
+
CREATE OR REPLACE FUNCTION public.fn_invoice_from_order_internal(
|
|
733
|
+
p_order_id uuid,
|
|
734
|
+
p_due_date date DEFAULT NULL,
|
|
735
|
+
p_status text DEFAULT NULL,
|
|
736
|
+
p_installments int DEFAULT 1,
|
|
737
|
+
p_direction text DEFAULT 'credit'
|
|
738
|
+
) RETURNS uuid
|
|
739
|
+
LANGUAGE plpgsql
|
|
740
|
+
SECURITY DEFINER
|
|
741
|
+
SET search_path = public
|
|
742
|
+
AS $$
|
|
743
|
+
DECLARE
|
|
744
|
+
v_order public.orders%ROWTYPE;
|
|
745
|
+
v_ref text;
|
|
746
|
+
v_seq bigint;
|
|
747
|
+
v_summary text;
|
|
748
|
+
v_contact text;
|
|
749
|
+
v_existing uuid;
|
|
750
|
+
v_kind text;
|
|
751
|
+
v_prefix text;
|
|
752
|
+
v_n int := GREATEST(COALESCE(p_installments, 1), 1);
|
|
753
|
+
v_total numeric;
|
|
754
|
+
v_base numeric;
|
|
755
|
+
v_amt numeric;
|
|
756
|
+
v_alloc numeric := 0;
|
|
757
|
+
v_due date;
|
|
758
|
+
i int;
|
|
759
|
+
BEGIN
|
|
760
|
+
SELECT * INTO v_order FROM public.orders WHERE id = p_order_id;
|
|
761
|
+
IF NOT FOUND THEN RAISE EXCEPTION 'fn_invoice_from_order: order % not found', p_order_id; END IF;
|
|
762
|
+
|
|
763
|
+
-- Idempotent: an order already carrying a bill is already invoiced.
|
|
764
|
+
SELECT id INTO v_existing FROM public.plg_financial_movements
|
|
765
|
+
WHERE invoice_id = p_order_id AND movement_kind = 'bill' LIMIT 1;
|
|
766
|
+
IF v_existing IS NOT NULL THEN RETURN p_order_id; END IF;
|
|
767
|
+
|
|
768
|
+
IF p_direction = 'debit' THEN v_kind := 'invoice_payable'; v_prefix := 'PAG';
|
|
769
|
+
ELSE v_kind := 'invoice_receivable'; v_prefix := 'REC'; END IF;
|
|
770
|
+
|
|
771
|
+
SELECT public.next_sequence(v_order.tenant_id, v_kind) INTO v_seq;
|
|
772
|
+
v_ref := v_prefix || '-' || lpad(v_seq::text, 5, '0');
|
|
773
|
+
|
|
774
|
+
SELECT string_agg(name, ', ' ORDER BY sort_order) INTO v_summary
|
|
775
|
+
FROM public.order_items WHERE order_id = p_order_id;
|
|
776
|
+
SELECT name INTO v_contact FROM public.people WHERE id = v_order.party_id;
|
|
777
|
+
|
|
778
|
+
UPDATE public.orders SET
|
|
779
|
+
status = COALESCE(p_status, status),
|
|
780
|
+
stage = 'invoiced',
|
|
781
|
+
direction = p_direction,
|
|
782
|
+
reference_number = v_ref,
|
|
783
|
+
metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object(
|
|
784
|
+
'itemsSummary', COALESCE(v_summary, ''),
|
|
785
|
+
'contactName', v_contact,
|
|
786
|
+
'installmentCount', v_n,
|
|
787
|
+
'quoteNumber', v_order.reference_number,
|
|
788
|
+
'direction', p_direction
|
|
789
|
+
),
|
|
790
|
+
updated_at = now()
|
|
791
|
+
WHERE id = p_order_id;
|
|
792
|
+
|
|
793
|
+
v_total := COALESCE(v_order.total, 0);
|
|
794
|
+
v_base := round(v_total / v_n, 2);
|
|
795
|
+
v_due := COALESCE(p_due_date, current_date);
|
|
796
|
+
FOR i IN 1..v_n LOOP
|
|
797
|
+
IF i = v_n THEN v_amt := round(v_total - v_alloc, 2);
|
|
798
|
+
ELSE v_amt := v_base; v_alloc := v_alloc + v_base; END IF;
|
|
799
|
+
INSERT INTO public.plg_financial_movements
|
|
800
|
+
(tenant_id, invoice_id, direction, movement_kind, amount, paid_amount, status, due_date, installment_number)
|
|
801
|
+
VALUES
|
|
802
|
+
(v_order.tenant_id, p_order_id, p_direction, 'bill', v_amt, 0, 'pending',
|
|
803
|
+
(v_due + ((i - 1) || ' months')::interval)::date, i);
|
|
804
|
+
END LOOP;
|
|
805
|
+
|
|
806
|
+
RETURN p_order_id;
|
|
807
|
+
END;
|
|
808
|
+
$$;
|
|
809
|
+
|
|
810
|
+
-- Deliberately NOT granted to authenticated/anon: entry is through
|
|
811
|
+
-- fn_invoice_from_order, or from another definer function that has already
|
|
812
|
+
-- established the caller's right to create this receivable.
|
|
813
|
+
REVOKE ALL ON FUNCTION public.fn_invoice_from_order_internal(uuid, date, text, int, text) FROM PUBLIC;
|
|
814
|
+
|
|
815
|
+
-- The public entry point: unchanged signature and behaviour, now a guard around
|
|
816
|
+
-- the shared body instead of a second copy of it.
|
|
817
|
+
CREATE OR REPLACE FUNCTION public.fn_invoice_from_order(
|
|
818
|
+
p_order_id uuid,
|
|
819
|
+
p_due_date date DEFAULT NULL,
|
|
820
|
+
p_status text DEFAULT NULL,
|
|
821
|
+
p_installments int DEFAULT 1,
|
|
822
|
+
p_direction text DEFAULT 'credit'
|
|
823
|
+
) RETURNS uuid
|
|
824
|
+
LANGUAGE plpgsql
|
|
825
|
+
SECURITY DEFINER
|
|
826
|
+
SET search_path = public
|
|
827
|
+
AS $$
|
|
828
|
+
DECLARE v_tenant uuid;
|
|
829
|
+
BEGIN
|
|
830
|
+
SELECT tenant_id INTO v_tenant FROM public.orders WHERE id = p_order_id;
|
|
831
|
+
IF v_tenant IS NULL THEN
|
|
832
|
+
RAISE EXCEPTION 'fn_invoice_from_order: order % not found', p_order_id;
|
|
833
|
+
END IF;
|
|
834
|
+
IF v_tenant NOT IN (SELECT public.user_tenant_ids()) THEN
|
|
835
|
+
RAISE EXCEPTION 'fn_invoice_from_order: forbidden';
|
|
836
|
+
END IF;
|
|
837
|
+
|
|
838
|
+
RETURN public.fn_invoice_from_order_internal(
|
|
839
|
+
p_order_id, p_due_date, p_status, p_installments, p_direction);
|
|
840
|
+
END;
|
|
841
|
+
$$;
|
|
842
|
+
|
|
843
|
+
GRANT EXECUTE ON FUNCTION public.fn_invoice_from_order(uuid, date, text, int, text) TO authenticated;
|
|
844
|
+
|
|
845
|
+
NOTIFY pgrst, 'reload schema';
|
|
846
|
+
`
|
|
847
|
+
|
|
532
848
|
export const MIGRATIONS: Array<{ id: string; sql: string }> = [
|
|
533
849
|
{ id: "000_plg_rename", sql: MIGRATION_000_PLG_RENAME },
|
|
534
850
|
{ id: "001_financial_base", sql: MIGRATION_001_FINANCIAL_BASE },
|
|
@@ -541,4 +857,6 @@ export const MIGRATIONS: Array<{ id: string; sql: string }> = [
|
|
|
541
857
|
{ id: "007_reconciliation", sql: MIGRATION_007_RECONCILIATION },
|
|
542
858
|
{ id: "007b_movement_payment_method_type", sql: MIGRATION_007B_MOVEMENT_PAYMENT_METHOD_TYPE },
|
|
543
859
|
{ id: "008_split_payment_movements", sql: MIGRATION_008_SPLIT_PAYMENT_MOVEMENTS },
|
|
860
|
+
{ id: "009_agent_rpcs", sql: MIGRATION_009_AGENT_RPCS },
|
|
861
|
+
{ id: "010_invoice_internal", sql: MIGRATION_010_INVOICE_INTERNAL },
|
|
544
862
|
]
|
package/src/views/CardsView.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react'
|
|
|
2
2
|
import { CreditCard, Plus, CalendarClock } from 'lucide-react'
|
|
3
3
|
import { useFinancialConfig, useFinancialStore, formatCurrency } from '../FinancialContext'
|
|
4
4
|
import { SubpageHeader } from '@fayz-ai/ui'
|
|
5
|
+
import { PermissionGate } from '@fayz-ai/saas'
|
|
5
6
|
import { useTranslation } from '@fayz-ai/core'
|
|
6
7
|
import type { BankAccount } from '../types'
|
|
7
8
|
import { QuickTransactionForm } from './QuickTransactionForm'
|
|
@@ -182,15 +183,17 @@ export function CardsView() {
|
|
|
182
183
|
)}
|
|
183
184
|
</div>
|
|
184
185
|
|
|
185
|
-
{/* Add expense against this card */}
|
|
186
|
-
<
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
186
|
+
{/* Add expense against this card — gated on the role's create action */}
|
|
187
|
+
<PermissionGate feature="financial" action="create">
|
|
188
|
+
<button
|
|
189
|
+
type="button"
|
|
190
|
+
onClick={() => openAddExpense(card.id)}
|
|
191
|
+
className="flex w-full items-center justify-center gap-1.5 rounded-input border border-input bg-card px-3 py-2 text-xs font-medium text-foreground transition-colors hover:bg-muted/40"
|
|
192
|
+
>
|
|
193
|
+
<Plus className="h-3.5 w-3.5" />
|
|
194
|
+
{t('financial.cards.addExpense')}
|
|
195
|
+
</button>
|
|
196
|
+
</PermissionGate>
|
|
194
197
|
</div>
|
|
195
198
|
</div>
|
|
196
199
|
)
|