feat(permissions): carve out dedicated payments resource

Payments (deposit / balance / refund records on an interest) used to
share `invoices.record_payment`, which forces a port that doesn't
issue invoices at all to still navigate the invoicing permission
group to grant its sales reps payment-recording rights. Splitting
the resource lets admins gate the two surfaces independently.

The new resource has three actions:
  - view   — gates the UI affordance (API reads still go through
             `interests.view`)
  - record — POST / PATCH a payment
  - delete — DELETE a payment record

Seed maps updated for all six system roles; existing role rows +
per-user permission overrides are backfilled by migration 0064 so
upgrades don't silently lose access. Two call sites (POST /interests/
[id]/payments, PATCH /payments/[id]) → payments.record; one
(DELETE /payments/[id]) → payments.delete. The PermissionGates on the
payments-section UI swap to the new keys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 03:46:01 +02:00
parent 6b28459c45
commit 905852b8a5
10 changed files with 64 additions and 5 deletions

View File

@@ -0,0 +1,32 @@
-- 0064_payments_permission.sql
-- ----------------------------------------------------------------------------
-- Carve out a dedicated `payments` resource from `invoices.record_payment`.
-- Existing role rows + per-user permission overrides are backfilled so the
-- new resource defaults to whatever record_payment was, preventing silent
-- access loss when the call sites switch over.
UPDATE roles
SET permissions = permissions || jsonb_build_object(
'payments', jsonb_build_object(
'view', COALESCE((permissions->'invoices'->>'view')::boolean, false),
'record', COALESCE((permissions->'invoices'->>'record_payment')::boolean, false),
'delete', COALESCE((permissions->'invoices'->>'record_payment')::boolean, false)
)
)
WHERE permissions IS NOT NULL
AND NOT (permissions ? 'payments');
-- Per-user overrides store a Partial<RolePermissions> as JSONB. Mirror an
-- explicit invoices.record_payment leaf into payments.{record, delete} so
-- "Alice can record payments even though her role can't" survives the
-- carve-out.
UPDATE user_permission_overrides
SET permission_overrides = permission_overrides || jsonb_build_object(
'payments', jsonb_build_object(
'record', (permission_overrides->'invoices'->>'record_payment')::boolean,
'delete', (permission_overrides->'invoices'->>'record_payment')::boolean
)
)
WHERE permission_overrides ? 'invoices'
AND (permission_overrides->'invoices') ? 'record_payment'
AND NOT (permission_overrides ? 'payments');

View File

@@ -58,6 +58,18 @@ export type RolePermissions = {
record_payment: boolean;
export: boolean;
};
/**
* Standalone payments resource (deposit / balance / refund records on
* an interest). Carved out from `invoices.record_payment` so a port
* that does not use the invoicing module at all can still grant
* payment-recording rights to sales reps. `view` follows interests.view
* at the route level — this gate only governs the UI affordance.
*/
payments: {
view: boolean;
record: boolean;
delete: boolean;
};
files: {
view: boolean;
upload: boolean;

View File

@@ -50,6 +50,7 @@ export const ALL_PERMISSIONS: RolePermissions = {
record_payment: true,
export: true,
},
payments: { view: true, record: true, delete: true },
files: { view: true, upload: true, edit: true, delete: true, manage_folders: true },
email: { view: true, send: true, configure_account: true },
reminders: {
@@ -128,6 +129,7 @@ export const DIRECTOR_PERMISSIONS: RolePermissions = {
record_payment: true,
export: true,
},
payments: { view: true, record: true, delete: true },
files: { view: true, upload: true, edit: true, delete: true, manage_folders: true },
email: { view: true, send: true, configure_account: true },
reminders: {
@@ -206,6 +208,7 @@ export const SALES_MANAGER_PERMISSIONS: RolePermissions = {
record_payment: true,
export: true,
},
payments: { view: true, record: true, delete: true },
files: { view: true, upload: true, edit: true, delete: false, manage_folders: true },
email: { view: true, send: true, configure_account: true },
reminders: {
@@ -284,6 +287,7 @@ export const SALES_AGENT_PERMISSIONS: RolePermissions = {
record_payment: true,
export: true,
},
payments: { view: true, record: true, delete: true },
files: { view: true, upload: true, edit: false, delete: false, manage_folders: false },
email: { view: true, send: true, configure_account: true },
reminders: {
@@ -362,6 +366,7 @@ export const VIEWER_PERMISSIONS: RolePermissions = {
record_payment: false,
export: false,
},
payments: { view: true, record: false, delete: false },
files: { view: true, upload: false, edit: false, delete: false, manage_folders: false },
email: { view: true, send: false, configure_account: false },
reminders: {
@@ -443,6 +448,7 @@ export const RESIDENTIAL_PARTNER_PERMISSIONS: RolePermissions = {
record_payment: false,
export: false,
},
payments: { view: false, record: false, delete: false },
files: { view: false, upload: false, edit: false, delete: false, manage_folders: false },
email: { view: false, send: false, configure_account: false },
reminders: {

View File

@@ -9,6 +9,7 @@ const rolePermissionsSchema = z.object({
documents: permissionGroupSchema,
expenses: permissionGroupSchema,
invoices: permissionGroupSchema,
payments: permissionGroupSchema,
files: permissionGroupSchema,
email: permissionGroupSchema,
reminders: permissionGroupSchema,