Skip to content

feat: Fiqh compliance alignment, asset amount history, and sync/auth bug fixes#295

Open
slimatic wants to merge 5 commits intomainfrom
feature/asset-amount-history
Open

feat: Fiqh compliance alignment, asset amount history, and sync/auth bug fixes#295
slimatic wants to merge 5 commits intomainfrom
feature/asset-amount-history

Conversation

@slimatic
Copy link
Owner

@slimatic slimatic commented Mar 3, 2026

Summary

This PR delivers two major bodies of work:

1. Fiqh Compliance — Aligns with Shaykh Husain Abdul Sattar's scholarship

  • Liability deductions: Added liabilities field to schema, LiabilityForm component, and wire-up in wealthCalculator.ts so deductible debts reduce nisab-threshold calculation per Hanafi fiqh
  • Hawl continuity fix: hawlTrackingService.ts now correctly handles interruptions to the lunar year tracking cycle
  • Jewelry guidance: JewelryGuidance.tsx now displays a scholarly warning that precious stones (diamonds, rubies, etc.) have no Zakat, only the gold/silver weight itself
  • Knowledge hub FAQs: Added 3 new FAQ entries covering liability deductions, jewelry valuation, and hawl continuity

2. Asset Amount History Feature

  • New AssetAmountHistory component for displaying per-asset change history
  • AssetAmountEventService and AssetAmountSnapshotService for server-side event sourcing
  • New API route GET /api/assets/:assetId/history (protected)
  • Schema migration add_asset_amount_history.sql
  • Shared types in shared/src/types/assetAmountEvent.ts

3. Sync / Auth Bug Fixes

  • Bug: double-slash URL (/api/api/user/encryption-status 404) — Fixed useMigration.ts
  • Bug: sync token 500 (CouchDB _users 404 on fresh install) — SyncService.ensureCouchDbInitialized() added
  • Bug: asset history 401AssetAmountHistory.tsx now sends Authorization: Bearer header
  • Bug: RxDB DB8 duplicate database on re-logincloseDb() now properly destroys the RxDB instance

Verification

  • TypeScript compiles clean (both client and server, zero errors)
  • All Docker images build successfully
  • Server test suite run — all failures are pre-existing
  • .env.dev NOT committed

Pre-existing Issues (not introduced here)

  • prisma/schema.prisma is missing the assetAmountEvent model — the SQL migration exists but was never integrated into Prisma's schema
  • Several server tests have pre-existing failures unrelated to this PR

ZakApp Agent added 5 commits March 2, 2026 23:57
… database error on re-login

- The closeDb() function was fetching the DB but never calling destroy()
- This left the DB instance in RxDB's internal registry, causing DB8 errors on re-login
- Now properly destroys the instance with safety checks before nulling the promise
- Preserves the 200ms delay needed for RxDB registry cleanup
- Fixes logout/re-login flow in authentication
Copilot AI review requested due to automatic review settings March 3, 2026 00:21
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR aims to (1) align Zakat/Hawl behavior with updated fiqh guidance, (2) introduce an asset amount history ledger (events + snapshots + API + UI), and (3) fix several sync/auth-related bugs across client/server.

Changes:

  • Added deductible-liability support (schema + form + wealth calculator) and adjusted Hawl interruption behavior.
  • Introduced asset amount event/snapshot services, API routes, client UI components, and unit tests for the new history feature.
  • Fixed migration URL double-prefixing, improved RxDB shutdown to prevent DB8 errors, and added a CouchDB “system DB init” guard.

Reviewed changes

Copilot reviewed 29 out of 32 changed files in this pull request and generated 16 comments.

Show a summary per file
File Description
shared/src/types/assetAmountEvent.ts Adds shared TypeScript types for asset amount history APIs.
server/src/services/AssetAmountEventService.ts Implements event creation/querying/backport + audit integration + snapshot regeneration trigger.
server/src/services/AssetAmountSnapshotService.ts Implements snapshot generation, querying, and gap-filling.
server/src/routes/asset-amount-events.ts Adds protected endpoints for history queries, amount-at-date, backport, manual event creation, and reversal.
server/src/services/AssetService.ts Emits CREATED/UPDATED amount events on asset create/update.
server/src/services/hawlTrackingService.ts Adjusts Hawl interruption logic to only break at “absolute zero”.
server/src/services/SyncService.ts Adds ensureCouchDbInitialized() to avoid fresh-install CouchDB system DB errors.
server/prisma/migrations/add_asset_amount_history.sql Adds raw SQL migration for event/snapshot tables + audit column.
server/prisma/schema.prisma Adds deductibleAmount to Liability.
client/src/components/AssetAmountHistory.tsx Displays per-asset event history (with bearer auth header).
client/src/components/BackportData.tsx Adds CSV backport UI for historical entries.
client/src/components/liabilities/LiabilityForm.tsx Adds deductible amount input and wiring.
client/src/core/calculations/wealthCalculator.ts Uses deductibleAmount override when present.
client/src/db/schema/liability.schema.ts Updates RxDB liability schema (currently problematic per comments).
client/src/hooks/useMigration.ts Fixes /api/api/... double-prefix bug.
server/tests/unit/services/*.test.ts Adds unit tests for the new event/snapshot services.
server/prisma/test/test.db-shm Adds a generated DB artifact (should not be committed).
Files not reviewed (1)
  • server/package-lock.json: Language not supported

Comment on lines +91 to +95
const response = await fetch(`${apiBaseUrl}/assets/${assetId}/backport`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fetch() call does not include an Authorization header, but the corresponding server route is protected by authenticate. As-is, imports will 401 for logged-in users. Add the bearer token header (same pattern used in AssetAmountHistory.tsx / apiService) or use the existing apiService abstraction.

Suggested change
const response = await fetch(`${apiBaseUrl}/assets/${assetId}/backport`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
const token =
typeof window !== 'undefined'
? window.localStorage.getItem('accessToken') || window.localStorage.getItem('token')
: null;
const headers: HeadersInit = {
'Content-Type': 'application/json'
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${apiBaseUrl}/assets/${assetId}/backport`, {
method: 'POST',
headers,

Copilot uses AI. Check for mistakes.
Comment on lines +172 to +173
...(options?.startDate && { effectiveDate: { gte: options.startDate } }),
...(options?.endDate && { effectiveDate: { lte: options.endDate } }),
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When both startDate and endDate are provided, the where clause spreads two separate effectiveDate objects, so the latter overrides the former and one bound is dropped. Combine them into a single effectiveDate: { gte, lte } object to ensure both filters apply.

Suggested change
...(options?.startDate && { effectiveDate: { gte: options.startDate } }),
...(options?.endDate && { effectiveDate: { lte: options.endDate } }),
...(options?.startDate || options?.endDate
? {
effectiveDate: {
...(options?.startDate && { gte: options.startDate }),
...(options?.endDate && { lte: options.endDate })
}
}
: {}),

Copilot uses AI. Check for mistakes.
Comment on lines +211 to +225
// NEW: Create initial asset amount event
try {
const eventService = new AssetAmountEventService();
await eventService.createEvent(userId, {
assetId: asset.id,
eventType: 'CREATED',
amount: assetData.value,
effectiveDate: assetData.acquisitionDate,
description: 'Asset created',
source: 'manual'
});
} catch (err) {
this.logger.error('Failed to create initial asset amount event', err);
// Don't throw - asset creation should succeed even if event creation fails
}
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Asset creation and initial amount-event creation are not atomic: the asset is created first, then the event is attempted and any failure is swallowed. This can permanently leave assets without a corresponding CREATED event (and snapshots/audit trail incomplete). Consider creating the asset + initial event in a single transaction, or at least persisting a retry/reconciliation mechanism instead of silently continuing.

Copilot uses AI. Check for mistakes.
Comment on lines +154 to +156
if (!eventType || !amount || !effectiveDate) {
throw new Error('eventType, amount, and effectiveDate are required');
}
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

POST /:assetId/events checks if (!eventType || !amount || !effectiveDate), which rejects valid amount: 0. Use amount === undefined/amount === null (and Number.isFinite) rather than a falsy check.

Copilot uses AI. Check for mistakes.
Comment on lines +305 to +310
return prisma.assetAmountEvent.findMany({
where: {
assetId: options?.assetId || { in: assetIds },
isReversed: false,
...(options?.startDate && { effectiveDate: { gte: options.startDate } }),
...(options?.endDate && { effectiveDate: { lte: options.endDate } })
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same date-filter bug as getAssetHistory: spreading { effectiveDate: { gte } } and { effectiveDate: { lte } } means one will overwrite the other if both options are set. Build a single effectiveDate filter containing both bounds.

Suggested change
return prisma.assetAmountEvent.findMany({
where: {
assetId: options?.assetId || { in: assetIds },
isReversed: false,
...(options?.startDate && { effectiveDate: { gte: options.startDate } }),
...(options?.endDate && { effectiveDate: { lte: options.endDate } })
const effectiveDateFilter: { gte?: Date; lte?: Date } = {};
if (options?.startDate) {
effectiveDateFilter.gte = options.startDate;
}
if (options?.endDate) {
effectiveDateFilter.lte = options.endDate;
}
return prisma.assetAmountEvent.findMany({
where: {
assetId: options?.assetId || { in: assetIds },
isReversed: false,
...(Object.keys(effectiveDateFilter).length && { effectiveDate: effectiveDateFilter })

Copilot uses AI. Check for mistakes.
Comment on lines +465 to +478
try {
const eventService = new AssetAmountEventService();
await eventService.createEvent(userId, {
assetId: assetId,
eventType: 'UPDATED',
amount: updateData.value,
effectiveDate: new Date(),
description: 'Asset value updated',
source: 'manual'
});
} catch (err) {
this.logger.error('Failed to create asset amount event', err);
// Don't throw - asset update should succeed even if event creation fails
}
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Asset updates write Asset.value first and then attempt to append an amount event, swallowing failures. This can desync current state vs. history (and snapshot regeneration) with no way to detect/fix it later. Prefer updating the asset and creating the UPDATED event in the same DB transaction, or record a durable “event creation failed” signal for later reconciliation.

Suggested change
try {
const eventService = new AssetAmountEventService();
await eventService.createEvent(userId, {
assetId: assetId,
eventType: 'UPDATED',
amount: updateData.value,
effectiveDate: new Date(),
description: 'Asset value updated',
source: 'manual'
});
} catch (err) {
this.logger.error('Failed to create asset amount event', err);
// Don't throw - asset update should succeed even if event creation fails
}
const eventService = new AssetAmountEventService();
await eventService.createEvent(userId, {
assetId: assetId,
eventType: 'UPDATED',
amount: updateData.value,
effectiveDate: new Date(),
description: 'Asset value updated',
source: 'manual'
});

Copilot uses AI. Check for mistakes.
Comment on lines +123 to +125
const eventService = new AssetAmountEventService();
const events = await eventService.backportHistoricalData(userId, assetId, entries);

Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

backportHistoricalData() in the service expects effectiveDate: Date, but the route forwards entries from the request body without parsing effectiveDate. Convert/validate each effectiveDate to a Date (and reject invalid dates) before passing to the service to avoid storing invalid values or relying on implicit coercion.

Copilot uses AI. Check for mistakes.
Comment on lines +6 to +13
CREATE TABLE IF NOT EXISTS "asset_amount_events" (
"id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()),
"assetId" TEXT NOT NULL,
"eventType" TEXT NOT NULL,
"amount" REAL NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'USD',
"effectiveDate" TEXT NOT NULL,
"recordedAt" TEXT NOT NULL DEFAULT (datetime('now')),
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This migration targets SQLite (per Prisma datasource), but uses DEFAULT (uuid()) for primary keys. SQLite doesn't provide a built-in uuid() function, so these CREATE TABLE statements will fail on a fresh install. Use Prisma-generated migrations / application-side UUID generation, or switch to a SQLite-compatible default (or remove the default and always supply IDs).

Copilot uses AI. Check for mistakes.
Comment on lines +33 to +88
// Get all events in the date range
const events = await prisma.assetAmountEvent.findMany({
where: {
assetId,
effectiveDate: { gte: range.startDate, lte: range.endDate },
isReversed: false
},
orderBy: { effectiveDate: 'asc' }
});

if (events.length === 0) {
return;
}

// Group events by date (start of day)
const eventsByDate = new Map<string, typeof events>();
for (const event of events) {
const dateKey = this.getStartOfDay(event.effectiveDate).toISOString();
if (!eventsByDate.has(dateKey)) {
eventsByDate.set(dateKey, []);
}
eventsByDate.get(dateKey)!.push(event);
}

// For each date, create/update snapshot with latest event
for (const [dateStr, dayEvents] of eventsByDate) {
// Get the latest event for this date (by recordedAt)
const latestEvent = dayEvents.sort(
(a, b) => new Date(b.recordedAt).getTime() - new Date(a.recordedAt).getTime()
)[0];

const date = new Date(dateStr);
const eventCount = dayEvents.length;

// Upsert snapshot
await prisma.assetAmountSnapshot.upsert({
where: {
assetId_date: {
assetId,
date
}
},
update: {
amount: latestEvent.amount,
eventCount
},
create: {
assetId,
date,
amount: latestEvent.amount,
eventCount
}
});
}

this.logger.info(`Regenerated ${eventsByDate.size} snapshots for asset ${assetId}`);
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This service calls prisma.assetAmountEvent / prisma.assetAmountSnapshot, but the Prisma schema in this repo does not define those models. Without adding them to schema.prisma (and regenerating Prisma client), this code will fail to compile and the endpoints will not work.

Suggested change
// Get all events in the date range
const events = await prisma.assetAmountEvent.findMany({
where: {
assetId,
effectiveDate: { gte: range.startDate, lte: range.endDate },
isReversed: false
},
orderBy: { effectiveDate: 'asc' }
});
if (events.length === 0) {
return;
}
// Group events by date (start of day)
const eventsByDate = new Map<string, typeof events>();
for (const event of events) {
const dateKey = this.getStartOfDay(event.effectiveDate).toISOString();
if (!eventsByDate.has(dateKey)) {
eventsByDate.set(dateKey, []);
}
eventsByDate.get(dateKey)!.push(event);
}
// For each date, create/update snapshot with latest event
for (const [dateStr, dayEvents] of eventsByDate) {
// Get the latest event for this date (by recordedAt)
const latestEvent = dayEvents.sort(
(a, b) => new Date(b.recordedAt).getTime() - new Date(a.recordedAt).getTime()
)[0];
const date = new Date(dateStr);
const eventCount = dayEvents.length;
// Upsert snapshot
await prisma.assetAmountSnapshot.upsert({
where: {
assetId_date: {
assetId,
date
}
},
update: {
amount: latestEvent.amount,
eventCount
},
create: {
assetId,
date,
amount: latestEvent.amount,
eventCount
}
});
}
this.logger.info(`Regenerated ${eventsByDate.size} snapshots for asset ${assetId}`);
// This service requires Prisma models `assetAmountEvent` and `assetAmountSnapshot`,
// which are not defined in the current Prisma schema. Until those models are added
// and the Prisma client is regenerated, snapshot regeneration cannot be performed.
const message =
'AssetAmountSnapshotService.regenerateForDateRange is not available: ' +
'missing Prisma models `assetAmountEvent` and/or `assetAmountSnapshot` in schema.prisma.';
this.logger.error(message, { assetId, range });
throw new Error(message);

Copilot uses AI. Check for mistakes.
Comment on lines +248 to +257
// Iterate through each day in the range
const currentDate = new Date(range.startDate);
while (currentDate <= range.endDate) {
const dateKey = currentDate.toISOString();

// If we don't have a snapshot for this date
if (!existingDates.has(dateKey)) {
// Check if we have events for this date
const dayEvents = eventsByDate.get(dateKey);

Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fillMissingSnapshots() groups events by getStartOfDay(...).toISOString(), but later looks them up with currentDate.toISOString() (which includes the time component from range.startDate). This makes eventsByDate.get(dateKey) miss events and existingDates.has(dateKey) miss snapshots unless the range starts exactly at midnight UTC. Normalize currentDate to start-of-day (and use that consistently for keys) before lookups/creates.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants