Use this skill when modifying payment states, implementing verification flows, or calculating late statuses in the Aegis platform.
UNPAID ──submit──> SUBMITTED ──verify──> VERIFIED (terminal)
│
└──reject──> REJECTED ──resubmit──> SUBMITTED
| From | To | Trigger |
|---|---|---|
| UNPAID | SUBMITTED | Resident submits proof |
| SUBMITTED | VERIFIED | Moderator verifies |
| SUBMITTED | REJECTED | Moderator rejects |
| REJECTED | SUBMITTED | Resident resubmits |
UNPAID → VERIFIED (must go through SUBMITTED)VERIFIED → * (VERIFIED is terminal, no changes allowed)REJECTED → VERIFIED (must resubmit first)// 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');
}
}
expectedAmount is frozen at payment creationNever 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
// 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;
}
// 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,
});
}
| Payment State | Can Edit Submission? |
|---|---|
| SUBMITTED | Yes (creates new version) |
| REJECTED | Yes (creates new version) |
| VERIFIED | No (submission is locked) |
// 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);
}
// 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');
}
// 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 },
});
}
// Explicit transition methods, not generic update
async markAsSubmitted(paymentId: string, communityId: string): Promise<Payment> {
return this.model.findOneAndUpdate(
{
_id: new Types.ObjectId(paymentId),
communityId: new Types.ObjectId(communityId),
state: PaymentState.UNPAID, // Enforce valid source state
},
{ $set: { state: PaymentState.SUBMITTED } },
{ new: true },
);
}