DDS: agent collection entry screen, per-agent GL account, maturity auto close

The Daily Transaction Entry screen no longer composes a Party Tran. An agent records their own round
elsewhere; this screen reads back what they wrote down, shows what it adds up to, and settles the
day once a clerk has counted the cash against it. Nothing is typed here and nothing is edited - what
settles is what the entries say, to the rupee.

Agent Master now asks which ledger account the agent's collections pass through, one agent to an
account, and offers only the accounts that can carry them. The DDS scheme product is kept out of the
agent's own savings list for the same reason the server refuses it: debits are blocked on those
accounts, so commission paid into one would go in and never come out.

The DDS product carries a Maturity Auto Close setting, asked as Yes/No to match the Auto Interest
Post question directly above it, and Batch Processing offers the run that applies it.

Also: the report viewer stops printing two money columns ungrouped and stops totalling a column that
holds a word, and an agent's birth date cannot be picked later than eighteen years ago.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
tarunmalviya
2026-09-23 11:54:49 +05:30
co-authored by Claude Opus 5
parent 57a61b3057
commit e2eff9914f
114 changed files with 10259 additions and 15955 deletions
@@ -11,7 +11,7 @@
<button mat-stroked-button [routerLink]="['../']" class="bp-back">
<i class="fas fa-arrow-left"></i> {{ 'labels.heading.Organization' | translate }}
</button>
<h2 class="bp-header__title">DDS Interest &amp; Commission Batch Processing</h2>
<h2 class="bp-header__title">DDS Interest &amp; Commission</h2>
</div>
<div class="bp-card">
@@ -20,8 +20,8 @@
<mat-form-field appearance="outline" class="bp-field bp-field--wide">
<mat-label>Job Type</mat-label>
<mat-select required formControlName="jobType">
@for (type of jobTypes; track type) {
<mat-option [value]="type">{{ type }}</mat-option>
@for (type of jobTypes; track type.code) {
<mat-option [value]="type.code">{{ type.label }}</mat-option>
}
</mat-select>
</mat-form-field>
@@ -49,7 +49,7 @@
}
<mat-form-field appearance="outline" class="bp-field">
<mat-label>{{ isPostingJob ? 'Posting Date' : 'As-On Date' }}</mat-label>
<mat-label>{{ isPostingJob ? 'Posting Date' : 'Interest Date' }}</mat-label>
<input matInput required [matDatepicker]="jobDatePicker" formControlName="jobDate" />
<mat-datepicker-toggle matIconSuffix [for]="jobDatePicker"></mat-datepicker-toggle>
<mat-datepicker #jobDatePicker></mat-datepicker>
@@ -65,69 +65,234 @@
<input matInput required type="number" formControlName="agentTo" />
</mat-form-field>
<mat-form-field appearance="outline" class="bp-field">
<mat-label>Party From</mat-label>
<input matInput required type="number" formControlName="partyFrom" />
</mat-form-field>
<!-- Commission is worked out per agent on their whole collection, so a party range would
imply a filter that has no effect. -->
@if (!isCommissionJob) {
<mat-form-field appearance="outline" class="bp-field">
<mat-label>Party From</mat-label>
<input matInput required type="number" formControlName="partyFrom" />
</mat-form-field>
<mat-form-field appearance="outline" class="bp-field">
<mat-label>Party To</mat-label>
<input matInput required type="number" formControlName="partyTo" />
</mat-form-field>
<mat-form-field appearance="outline" class="bp-field">
<mat-label>Party To</mat-label>
<input matInput required type="number" formControlName="partyTo" />
</mat-form-field>
}
</div>
<div class="bp-callout">
<i class="fas fa-info-circle"></i>
<span>
<strong>Generate</strong> runs a preview only — nothing is applied to any account. <strong>Post</strong> is a
separate, explicit step that commits the previewed amounts.
@if (isPostingJob) {
<strong>{{ jobTypeLabel }}</strong> moves what has already been calculated to where it belongs — into the
customer's balance, or out to the agent. <strong>Generate</strong> lists it first; nothing moves until you
press <strong>Post</strong>.
} @else {
<strong>{{ jobTypeLabel }}</strong> works out what has been earned since the last run and records it against
each account. It does not become spendable until the matching Posting job runs.
<strong>Generate</strong> previews it; nothing is recorded until you press <strong>Post</strong>.
}
</span>
</div>
@if (hasGenerated) {
@if (generatedRun) {
<div class="bp-section">
<div class="bp-section__header">
<i class="fas fa-table"></i>
<h3>Preview</h3>
<h3>{{ labelOf(generatedRun.jobType) }} — preview</h3>
<span class="bp-section__note">
Run #{{ generatedRun.id }} · nothing applied yet
</span>
</div>
@if (previewRows.length === 0) {
<p class="bp-empty">
No calculation preview available yet — this appears once the backend batch-job endpoint for "{{
batchForm.get('jobType').value
}}" is connected.
Nothing to apply. Either this period has already been processed, or there was no activity in it.
</p>
} @else {
<table class="bp-table">
<thead>
<tr>
<th>Agent</th>
<th>Party</th>
<th>Amount</th>
</tr>
</thead>
<tbody>
@for (row of previewRows; track row) {
<tr>
<td>{{ row.agent }}</td>
<td>{{ row.party }}</td>
<td>{{ row.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
<div class="bp-table-scroll">
@if (showsCommissionBreakdown) {
<table class="bp-table bp-table--breakdown">
<thead>
<tr>
<th>Agent</th>
<th>Period</th>
<th class="bp-num">Collection</th>
<th class="bp-num">Rate</th>
<th class="bp-num">Commission</th>
<th class="bp-num">TDS</th>
<th class="bp-num">Deductions</th>
<th class="bp-num">Payable</th>
</tr>
</thead>
<tbody>
@for (row of previewRows; track row.id) {
<tr>
<td>
<span class="bp-code">{{ row.agentCode }}</span>
<span class="bp-sub">{{ row.agentName }}</span>
</td>
<td class="bp-period">{{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}</td>
<td class="bp-num">{{ money(row.baseAmount) }}</td>
<td class="bp-num">{{ row.ratePercent }}%</td>
<td class="bp-num">{{ money(row.grossAmount) }}</td>
<td class="bp-num bp-minus">{{ money(row.tdsAmount) }}</td>
<td class="bp-num bp-minus">{{ money(row.deductionAmount) }}</td>
<td class="bp-num bp-total">{{ money(row.amount) }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="7">Total payable</td>
<td class="bp-num bp-total">{{ money(previewTotal) }}</td>
</tr>
</tfoot>
</table>
} @else {
<table class="bp-table">
<thead>
<tr>
<th>{{ isCommissionJob ? 'Agent' : 'Party' }}</th>
@if (!isCommissionJob) {
<th>Agent</th>
}
<th>Period</th>
<th class="bp-num">Amount</th>
</tr>
</thead>
<tbody>
@for (row of previewRows; track row.id) {
<tr>
<td>
@if (isCommissionJob) {
<span class="bp-code">{{ row.agentCode }}</span>
<span class="bp-sub">{{ row.agentName }}</span>
} @else {
<span class="bp-code">{{ row.partyCode }}</span>
<span class="bp-sub">{{ row.partyName }}</span>
}
</td>
@if (!isCommissionJob) {
<td>{{ row.agentCode }}</td>
}
<td class="bp-period">{{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}</td>
<td class="bp-num bp-total">{{ money(row.amount) }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td [attr.colspan]="isCommissionJob ? 2 : 3">Total</td>
<td class="bp-num bp-total">{{ money(previewTotal) }}</td>
</tr>
</tfoot>
</table>
}
</div>
}
</div>
}
<div class="bp-action-bar">
<button mat-raised-button type="button" (click)="exit()">Exit</button>
@if (generatedRun) {
<button mat-stroked-button type="button" (click)="discard()">Discard</button>
}
<button mat-stroked-button type="button" [disabled]="batchForm.invalid" (click)="generate()">
<i class="fas fa-play" style="margin-right: 6px"></i> Generate
<i class="fas fa-calculator"></i> Generate
</button>
<button mat-raised-button color="primary" type="button" [disabled]="!hasGenerated" (click)="post()">
<i class="fas fa-check" style="margin-right: 6px"></i> Post
<button
mat-raised-button
color="primary"
type="button"
[disabled]="!generatedRun || previewRows.length === 0"
(click)="post()"
>
<i class="fas fa-check"></i> Post
</button>
</div>
</form>
</div>
<!--
A crediting run costs what a savings deposit costs on every account it touches, so the server does
it in the background and answers at once. Without this the screen would report a run as posted
seconds after it began, and the clerk would go looking for credits still being written.
-->
@if (postingRunId) {
<div class="bp-running">
<i class="fas fa-circle-notch fa-spin"></i>
<span>
<strong>Posting is running.</strong>
It continues even if you leave this screen — the list below updates when it finishes.
</span>
</div>
}
<!-- Without this a generated-but-unposted run vanishes the moment the page is left, and there is
no way to tell whether a month's interest was ever actually posted. -->
<div class="bp-card">
<div class="bp-section__header">
<i class="fas fa-history"></i>
<h3>Previous runs</h3>
</div>
@if (recentRuns.length === 0) {
<p class="bp-empty">No batch has been run yet.</p>
} @else {
<div class="bp-table-scroll">
<table class="bp-table bp-table--runs">
<thead>
<tr>
<th>#</th>
<th>Job</th>
<th>Date</th>
<th class="bp-num">Entries</th>
<th class="bp-num">Amount</th>
<th>Status</th>
<th>Posted On</th>
</tr>
</thead>
<tbody>
@for (run of recentRuns; track run.id) {
<tr>
<td>{{ run.id }}</td>
<td>{{ labelOf(run.jobType) }}</td>
<td>{{ run.jobDate | dateFormat }}</td>
<td class="bp-num">{{ run.totalRows }}</td>
<td class="bp-num">{{ money(run.totalAmount) }}</td>
<!--
Four states, not two. A run that is still crediting is not "not posted" — that
reads as a run somebody forgot, and a clerk would run it again on top of the one
already going. One that failed says so, with the reason the server recorded.
-->
<td>
@switch (run.status) {
@case ('POSTED') {
<span class="bp-status bp-status--posted">Posted</span>
}
@case ('POSTING') {
<span class="bp-status bp-status--running">
<i class="fas fa-circle-notch fa-spin"></i> Running
</span>
}
@case ('FAILED') {
<span class="bp-status bp-status--failed" [matTooltip]="run.failureReason || 'No reason recorded'">
Failed
</span>
}
@default {
<span class="bp-status">Not posted</span>
}
}
</td>
<td>{{ run.postedOnDate ? (run.postedOnDate | dateFormat) : '—' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
</div>