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:
@@ -26,7 +26,7 @@ export const GET = withAuth(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const POST = withAuth(
|
export const POST = withAuth(
|
||||||
withPermission('invoices', 'record_payment', async (req, ctx, params) => {
|
withPermission('payments', 'record', async (req, ctx, params) => {
|
||||||
try {
|
try {
|
||||||
// Body's interestId must match the URL param — defense-in-depth against
|
// Body's interestId must match the URL param — defense-in-depth against
|
||||||
// a client that sends one ID in the URL but another in the body.
|
// a client that sends one ID in the URL but another in the body.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { updatePaymentSchema } from '@/lib/validators/payments';
|
|||||||
import { deletePayment, updatePayment } from '@/lib/services/payments.service';
|
import { deletePayment, updatePayment } from '@/lib/services/payments.service';
|
||||||
|
|
||||||
export const PATCH = withAuth(
|
export const PATCH = withAuth(
|
||||||
withPermission('invoices', 'record_payment', async (req, ctx, params) => {
|
withPermission('payments', 'record', async (req, ctx, params) => {
|
||||||
try {
|
try {
|
||||||
const body = await parseBody(req, updatePaymentSchema);
|
const body = await parseBody(req, updatePaymentSchema);
|
||||||
const payment = await updatePayment(params.id!, ctx.portId, body, {
|
const payment = await updatePayment(params.id!, ctx.portId, body, {
|
||||||
@@ -24,7 +24,7 @@ export const PATCH = withAuth(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const DELETE = withAuth(
|
export const DELETE = withAuth(
|
||||||
withPermission('invoices', 'record_payment', async (_req, ctx, params) => {
|
withPermission('payments', 'delete', async (_req, ctx, params) => {
|
||||||
try {
|
try {
|
||||||
await deletePayment(params.id!, ctx.portId, {
|
await deletePayment(params.id!, ctx.portId, {
|
||||||
userId: ctx.userId,
|
userId: ctx.userId,
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ const DEFAULT_PERMISSIONS: Record<string, Record<string, boolean>> = {
|
|||||||
record_payment: false,
|
record_payment: false,
|
||||||
export: false,
|
export: false,
|
||||||
},
|
},
|
||||||
|
payments: { view: false, record: false, delete: false },
|
||||||
files: { view: false, upload: false, edit: false, delete: false, manage_folders: false },
|
files: { view: false, upload: false, edit: false, delete: false, manage_folders: false },
|
||||||
email: { view: false, send: false, configure_account: false },
|
email: { view: false, send: false, configure_account: false },
|
||||||
reminders: {
|
reminders: {
|
||||||
@@ -105,6 +106,7 @@ const GROUP_LABELS: Record<string, string> = {
|
|||||||
documents: 'Documents',
|
documents: 'Documents',
|
||||||
expenses: 'Expenses',
|
expenses: 'Expenses',
|
||||||
invoices: 'Invoices',
|
invoices: 'Invoices',
|
||||||
|
payments: 'Payments',
|
||||||
files: 'Files',
|
files: 'Files',
|
||||||
email: 'Email',
|
email: 'Email',
|
||||||
reminders: 'Reminders',
|
reminders: 'Reminders',
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const GROUP_LABELS: Record<string, string> = {
|
|||||||
documents: 'Documents',
|
documents: 'Documents',
|
||||||
expenses: 'Expenses',
|
expenses: 'Expenses',
|
||||||
invoices: 'Invoices',
|
invoices: 'Invoices',
|
||||||
|
payments: 'Payments',
|
||||||
files: 'Files',
|
files: 'Files',
|
||||||
email: 'Email',
|
email: 'Email',
|
||||||
reminders: 'Reminders',
|
reminders: 'Reminders',
|
||||||
@@ -78,6 +79,7 @@ const PERMISSION_LEAVES: Record<string, string[]> = {
|
|||||||
],
|
],
|
||||||
expenses: ['view', 'create', 'edit', 'delete', 'export', 'scan_receipt'],
|
expenses: ['view', 'create', 'edit', 'delete', 'export', 'scan_receipt'],
|
||||||
invoices: ['view', 'create', 'edit', 'delete', 'send', 'record_payment', 'export'],
|
invoices: ['view', 'create', 'edit', 'delete', 'send', 'record_payment', 'export'],
|
||||||
|
payments: ['view', 'record', 'delete'],
|
||||||
files: ['view', 'upload', 'edit', 'delete', 'manage_folders'],
|
files: ['view', 'upload', 'edit', 'delete', 'manage_folders'],
|
||||||
email: ['view', 'send', 'configure_account'],
|
email: ['view', 'send', 'configure_account'],
|
||||||
reminders: ['view_own', 'view_all', 'create', 'edit_own', 'edit_all', 'assign_others'],
|
reminders: ['view_own', 'view_all', 'create', 'edit_own', 'edit_all', 'assign_others'],
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export function PaymentsSection({
|
|||||||
that.
|
that.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<PermissionGate resource="invoices" action="record_payment">
|
<PermissionGate resource="payments" action="record">
|
||||||
<Button size="sm" className="h-8 px-3 text-xs" onClick={() => setRecordOpen(true)}>
|
<Button size="sm" className="h-8 px-3 text-xs" onClick={() => setRecordOpen(true)}>
|
||||||
<Plus className="size-3.5" aria-hidden />
|
<Plus className="size-3.5" aria-hidden />
|
||||||
Record payment
|
Record payment
|
||||||
@@ -184,7 +184,7 @@ export function PaymentsSection({
|
|||||||
<Receipt className="size-3 text-emerald-600" aria-hidden />
|
<Receipt className="size-3 text-emerald-600" aria-hidden />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<PermissionGate resource="invoices" action="record_payment">
|
<PermissionGate resource="payments" action="delete">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Delete payment record"
|
aria-label="Delete payment record"
|
||||||
|
|||||||
32
src/lib/db/migrations/0064_payments_permission.sql
Normal file
32
src/lib/db/migrations/0064_payments_permission.sql
Normal 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');
|
||||||
@@ -58,6 +58,18 @@ export type RolePermissions = {
|
|||||||
record_payment: boolean;
|
record_payment: boolean;
|
||||||
export: 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: {
|
files: {
|
||||||
view: boolean;
|
view: boolean;
|
||||||
upload: boolean;
|
upload: boolean;
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export const ALL_PERMISSIONS: RolePermissions = {
|
|||||||
record_payment: true,
|
record_payment: true,
|
||||||
export: true,
|
export: true,
|
||||||
},
|
},
|
||||||
|
payments: { view: true, record: true, delete: true },
|
||||||
files: { view: true, upload: true, edit: true, delete: true, manage_folders: true },
|
files: { view: true, upload: true, edit: true, delete: true, manage_folders: true },
|
||||||
email: { view: true, send: true, configure_account: true },
|
email: { view: true, send: true, configure_account: true },
|
||||||
reminders: {
|
reminders: {
|
||||||
@@ -128,6 +129,7 @@ export const DIRECTOR_PERMISSIONS: RolePermissions = {
|
|||||||
record_payment: true,
|
record_payment: true,
|
||||||
export: true,
|
export: true,
|
||||||
},
|
},
|
||||||
|
payments: { view: true, record: true, delete: true },
|
||||||
files: { view: true, upload: true, edit: true, delete: true, manage_folders: true },
|
files: { view: true, upload: true, edit: true, delete: true, manage_folders: true },
|
||||||
email: { view: true, send: true, configure_account: true },
|
email: { view: true, send: true, configure_account: true },
|
||||||
reminders: {
|
reminders: {
|
||||||
@@ -206,6 +208,7 @@ export const SALES_MANAGER_PERMISSIONS: RolePermissions = {
|
|||||||
record_payment: true,
|
record_payment: true,
|
||||||
export: true,
|
export: true,
|
||||||
},
|
},
|
||||||
|
payments: { view: true, record: true, delete: true },
|
||||||
files: { view: true, upload: true, edit: true, delete: false, manage_folders: true },
|
files: { view: true, upload: true, edit: true, delete: false, manage_folders: true },
|
||||||
email: { view: true, send: true, configure_account: true },
|
email: { view: true, send: true, configure_account: true },
|
||||||
reminders: {
|
reminders: {
|
||||||
@@ -284,6 +287,7 @@ export const SALES_AGENT_PERMISSIONS: RolePermissions = {
|
|||||||
record_payment: true,
|
record_payment: true,
|
||||||
export: true,
|
export: true,
|
||||||
},
|
},
|
||||||
|
payments: { view: true, record: true, delete: true },
|
||||||
files: { view: true, upload: true, edit: false, delete: false, manage_folders: false },
|
files: { view: true, upload: true, edit: false, delete: false, manage_folders: false },
|
||||||
email: { view: true, send: true, configure_account: true },
|
email: { view: true, send: true, configure_account: true },
|
||||||
reminders: {
|
reminders: {
|
||||||
@@ -362,6 +366,7 @@ export const VIEWER_PERMISSIONS: RolePermissions = {
|
|||||||
record_payment: false,
|
record_payment: false,
|
||||||
export: false,
|
export: false,
|
||||||
},
|
},
|
||||||
|
payments: { view: true, record: false, delete: false },
|
||||||
files: { view: true, upload: false, edit: false, delete: false, manage_folders: false },
|
files: { view: true, upload: false, edit: false, delete: false, manage_folders: false },
|
||||||
email: { view: true, send: false, configure_account: false },
|
email: { view: true, send: false, configure_account: false },
|
||||||
reminders: {
|
reminders: {
|
||||||
@@ -443,6 +448,7 @@ export const RESIDENTIAL_PARTNER_PERMISSIONS: RolePermissions = {
|
|||||||
record_payment: false,
|
record_payment: false,
|
||||||
export: false,
|
export: false,
|
||||||
},
|
},
|
||||||
|
payments: { view: false, record: false, delete: false },
|
||||||
files: { view: false, upload: false, edit: false, delete: false, manage_folders: false },
|
files: { view: false, upload: false, edit: false, delete: false, manage_folders: false },
|
||||||
email: { view: false, send: false, configure_account: false },
|
email: { view: false, send: false, configure_account: false },
|
||||||
reminders: {
|
reminders: {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const rolePermissionsSchema = z.object({
|
|||||||
documents: permissionGroupSchema,
|
documents: permissionGroupSchema,
|
||||||
expenses: permissionGroupSchema,
|
expenses: permissionGroupSchema,
|
||||||
invoices: permissionGroupSchema,
|
invoices: permissionGroupSchema,
|
||||||
|
payments: permissionGroupSchema,
|
||||||
files: permissionGroupSchema,
|
files: permissionGroupSchema,
|
||||||
email: permissionGroupSchema,
|
email: permissionGroupSchema,
|
||||||
reminders: permissionGroupSchema,
|
reminders: permissionGroupSchema,
|
||||||
|
|||||||
@@ -340,6 +340,7 @@ export function makeFullPermissions(): RolePermissions {
|
|||||||
record_payment: true,
|
record_payment: true,
|
||||||
export: true,
|
export: true,
|
||||||
},
|
},
|
||||||
|
payments: { view: true, record: true, delete: true },
|
||||||
files: { view: true, upload: true, edit: true, delete: true, manage_folders: true },
|
files: { view: true, upload: true, edit: true, delete: true, manage_folders: true },
|
||||||
email: { view: true, send: true, configure_account: true },
|
email: { view: true, send: true, configure_account: true },
|
||||||
reminders: {
|
reminders: {
|
||||||
@@ -421,6 +422,7 @@ export function makeViewerPermissions(): RolePermissions {
|
|||||||
record_payment: false,
|
record_payment: false,
|
||||||
export: false,
|
export: false,
|
||||||
},
|
},
|
||||||
|
payments: { view: false, record: false, delete: false },
|
||||||
files: { view: true, upload: false, edit: false, delete: false, manage_folders: false },
|
files: { view: true, upload: false, edit: false, delete: false, manage_folders: false },
|
||||||
email: { view: true, send: false, configure_account: false },
|
email: { view: true, send: false, configure_account: false },
|
||||||
reminders: {
|
reminders: {
|
||||||
@@ -502,6 +504,7 @@ export function makeSalesAgentPermissions(): RolePermissions {
|
|||||||
record_payment: false,
|
record_payment: false,
|
||||||
export: false,
|
export: false,
|
||||||
},
|
},
|
||||||
|
payments: { view: false, record: false, delete: false },
|
||||||
files: { view: true, upload: true, edit: false, delete: false, manage_folders: false },
|
files: { view: true, upload: true, edit: false, delete: false, manage_folders: false },
|
||||||
email: { view: true, send: true, configure_account: false },
|
email: { view: true, send: true, configure_account: false },
|
||||||
reminders: {
|
reminders: {
|
||||||
@@ -583,6 +586,7 @@ export function makeSalesManagerPermissions(): RolePermissions {
|
|||||||
record_payment: true,
|
record_payment: true,
|
||||||
export: true,
|
export: true,
|
||||||
},
|
},
|
||||||
|
payments: { view: true, record: true, delete: true },
|
||||||
files: { view: true, upload: true, edit: true, delete: true, manage_folders: true },
|
files: { view: true, upload: true, edit: true, delete: true, manage_folders: true },
|
||||||
email: { view: true, send: true, configure_account: false },
|
email: { view: true, send: true, configure_account: false },
|
||||||
reminders: {
|
reminders: {
|
||||||
|
|||||||
Reference in New Issue
Block a user