VERIFIED → * (VERIFIED is terminal, no changes allowed)
REJECTED → VERIFIED (must resubmit first)
Immutability Rules
VERIFIED Payments
// WRONG - Never allow this
async updatePayment(id: string, data: UpdatePaymentDto) {
const payment = await this.findById(id);
if (payment.state === PaymentState.VERIFIED) {
throw new ForbiddenException('Verified payments cannot be modified');
}
}
Expected Amount
expectedAmount is frozen at payment creation
Cannot be changed after creation, even for UNPAID payments
If household override changes, only affects NEW payments
Late Status Calculation (Dynamic)
Never store late/overdue status in database. Always derive.
// CORRECT - Calculate dynamically
function getLateStatus(payment: Payment): 'ON_TIME' | 'LATE' | 'PENDING' {
if (payment.state !== PaymentState.VERIFIED) {
return 'PENDING'; // Not yet verified
}
const verifiedAt = payment.verification.verifiedAt;
const billingPeriodEnd = payment.billingPeriodEnd; // 22nd of month
return verifiedAt <= billingPeriodEnd ? 'ON_TIME' : 'LATE';
}
// WRONG - Don't store this
payment.lateStatus = 'LATE'; // Never do this
Overdue (For Display Only)
// For UNPAID/SUBMITTED payments, show "overdue" if past billing period
function isOverdue(payment: Payment): boolean {
if (payment.state === PaymentState.VERIFIED) return false;
return new Date() > payment.billingPeriodEnd;
}
Submission Versioning (Append-Only)
Creating Submissions
// Each submission is a new record, not an update
async createSubmission(data: CreateSubmissionDto): Promise<PaymentSubmission> {
// Mark any existing active submission as superseded
await this.markPreviousSubmissionsSuperseded(data.paymentId);
// Create new submission with incremented version
const latestVersion = await this.getLatestVersion(data.paymentId);
return this.create({
...data,
version: latestVersion + 1,
state: SubmissionState.ACTIVE,
});
}
Editing Rules
Payment State
Can Edit Submission?
SUBMITTED
Yes (creates new version)
REJECTED
Yes (creates new version)
VERIFIED
No (submission is locked)
Verification Rules
Auto-Verification
// Moderators submitting for their OWN household auto-verify
if (user.role === UserRole.MODERATOR && payment.householdId === user.householdId) {
// Auto-verify the payment
await this.verifyPayment(payment.id, user.id);
}
Conflict Prevention
// Moderators cannot verify payments from their own household (unless auto-verified)
if (verifier.householdId === payment.householdId) {
throw new ForbiddenException('Cannot verify your own household payment');
}
Repository Method Patterns
Finding Payments by State
// Always include communityId (multi-tenancy)
async findByHouseholdAndStates(
householdId: string,
communityId: string,
states: PaymentState[],
): Promise<Payment[]> {
return this.model.find({
householdId: new Types.ObjectId(householdId),
communityId: new Types.ObjectId(communityId),
state: { $in: states },
});
}