- One entry per year, because the Finance Act fixes TDS per year and a return for an earlier year must keep quoting
- that year's figures. Add the next year before 01 April, or TDS screens stop computing on that date.
+ One entry per year, because the Finance Act fixes TDS per year and a return for an earlier year must keep
+ quoting that year's figures. Add the next year before 01 April, or TDS screens stop computing on that date.
diff --git a/src/app/organization/tds-config/tds-config.component.ts b/src/app/organization/tds-config/tds-config.component.ts
index 91de87e37..77fd5e928 100644
--- a/src/app/organization/tds-config/tds-config.component.ts
+++ b/src/app/organization/tds-config/tds-config.component.ts
@@ -37,7 +37,10 @@ interface FieldDef {
@Component({
selector: 'mifosx-tds-config',
templateUrl: './tds-config.component.html',
- styleUrls: ['../rbi-reserve-config/rbi-reserve-config.component.scss', './tds-config.component.scss'],
+ styleUrls: [
+ '../rbi-reserve-config/rbi-reserve-config.component.scss',
+ './tds-config.component.scss'
+ ],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule
@@ -67,7 +70,12 @@ export class TdsConfigComponent implements OnInit {
readonly rateFields: FieldDef[] = [
{ key: 'thresholdGeneral', label: 'Threshold – general (₹)', kind: 'amount', hint: 'Payee below 60' },
- { key: 'thresholdSenior', label: 'Threshold – senior citizen (₹)', kind: 'amount', hint: '60 at any time in the year' },
+ {
+ key: 'thresholdSenior',
+ label: 'Threshold – senior citizen (₹)',
+ kind: 'amount',
+ hint: '60 at any time in the year'
+ },
{ key: 'ratePanPercentage', label: 'Rate – valid PAN (%)', kind: 'percent' },
{ key: 'rateNoPanPercentage', label: 'Rate – PAN missing / invalid (%)', kind: 'percent' },
{ key: 'lateDeductionPercentage', label: 'Late deduction interest (% per month)', kind: 'percent' },
@@ -96,7 +104,12 @@ export class TdsConfigComponent implements OnInit {
{ value: 'QUARTERLY', label: 'Quarterly' }
]
},
- { key: 'declarationStatementDueDay', label: 'Statement due on day', kind: 'int', hint: 'Of the following month / quarter' }
+ {
+ key: 'declarationStatementDueDay',
+ label: 'Statement due on day',
+ kind: 'int',
+ hint: 'Of the following month / quarter'
+ }
];
readonly statuteFields: FieldDef[] = [
diff --git a/src/app/products/current-products/current-product-stepper/current-product-settings-step/current-product-settings-step.component.ts b/src/app/products/current-products/current-product-stepper/current-product-settings-step/current-product-settings-step.component.ts
index da3435ff5..f68d32c94 100644
--- a/src/app/products/current-products/current-product-stepper/current-product-settings-step/current-product-settings-step.component.ts
+++ b/src/app/products/current-products/current-product-stepper/current-product-settings-step/current-product-settings-step.component.ts
@@ -226,22 +226,18 @@ export class CurrentProductSettingsStepComponent implements OnInit {
const percent = form.get('odExcessLimitPercent');
const penal = form.get('odPenalRate');
percent.setValidators(
- required
- ? [
+ required ? [
Validators.required,
Validators.min(0.01),
Validators.max(25)
- ]
- : []
+ ] : []
);
penal.setValidators(
- required
- ? [
+ required ? [
Validators.required,
Validators.min(0.01),
Validators.max(50)
- ]
- : []
+ ] : []
);
percent.updateValueAndValidity({ emitEvent: false });
penal.updateValueAndValidity({ emitEvent: false });
diff --git a/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.html b/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.html
index 910e31c4a..a7d6adee9 100644
--- a/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.html
+++ b/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.html
@@ -23,7 +23,9 @@
@if (!isOverdraftAllowed) {
ℹ️
- Tick "Is Overdraft Allowed" to set the OD interest rate. The OD limits are set on the Settings tab.
+ Tick "Is Overdraft Allowed" to set the OD interest rate. The OD limits are set on the Settings tab.
}
diff --git a/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.ts b/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.ts
index 4decaae32..4f41c6e41 100644
--- a/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.ts
+++ b/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.ts
@@ -84,8 +84,12 @@ export class CurrentProductTermsStepComponent implements OnInit {
this.CurrentProductTermsForm.patchValue({
interestPostingPeriodType: storedPostingOffered ? storedPosting : defaultPosting,
interestCalculationDaysInYearType: storedDaysInYear === 360 ? 360 : 365,
- nominalAnnualInterestRateOverdraft: isEditing ? this.CurrentProductsTemplate.nominalAnnualInterestRateOverdraft : '',
- minOverdraftForInterestCalculation: isEditing ? this.CurrentProductsTemplate.minOverdraftForInterestCalculation : '',
+ nominalAnnualInterestRateOverdraft: isEditing
+ ? this.CurrentProductsTemplate.nominalAnnualInterestRateOverdraft
+ : '',
+ minOverdraftForInterestCalculation: isEditing
+ ? this.CurrentProductsTemplate.minOverdraftForInterestCalculation
+ : '',
autoPost: isEditing ? (this.CurrentProductsTemplate.autoPost ?? true) : true
});
@@ -98,13 +102,11 @@ export class CurrentProductTermsStepComponent implements OnInit {
private applyOverdraftValidators(allowed: boolean) {
const rate = this.CurrentProductTermsForm.get('nominalAnnualInterestRateOverdraft');
rate.setValidators(
- allowed
- ? [
+ allowed ? [
Validators.required,
Validators.min(0.01),
Validators.max(50)
- ]
- : [Validators.min(0)]
+ ] : [Validators.min(0)]
);
rate.updateValueAndValidity({ emitEvent: false });
}
diff --git a/src/app/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.ts b/src/app/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.ts
index 9f45429ff..c374eb301 100644
--- a/src/app/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.ts
+++ b/src/app/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.ts
@@ -88,6 +88,8 @@ export class SavingProductAccountingStepComponent implements OnInit {
liabilityAccountOptions: GLAccount[] = [];
expenseAccountOptions: GLAccount[] = [];
incomeAccountOptions: GLAccount[] = [];
+ /** Savings Interest Paid nets to nil each posting, so it may be a liability clearing GL or an expense GL. */
+ paidAccountOptions: GLAccount[] = [];
assetAccountOptions: GLAccount[] = [];
paymentTypeOptions: any[] = [];
/** A payment-type Fund Source override can legitimately be a suspense/clearing Liability account
@@ -114,6 +116,7 @@ export class SavingProductAccountingStepComponent implements OnInit {
this.liabilityAccountOptions = accountingMappingOptions.liabilityAccountOptions || [];
this.expenseAccountOptions = accountingMappingOptions.expenseAccountOptions || [];
this.incomeAccountOptions = accountingMappingOptions.incomeAccountOptions || [];
+ this.paidAccountOptions = this.liabilityAccountOptions.concat(this.expenseAccountOptions);
this.paymentTypeOptions = this.savingProductsTemplate?.paymentTypeOptions || [];
this.fundSourceAccountOptions = this.assetAccountOptions.concat(this.liabilityAccountOptions);
this.buildAccountingSectionConfigs();
@@ -201,26 +204,29 @@ export class SavingProductAccountingStepComponent implements OnInit {
// Backend serializes as 'otherGlMappingsMap'; fall back to 'otherGlMappings' for compatibility
const glMappings = mappings.otherGlMappingsMap || mappings.otherGlMappings;
this.setOtherGlMappings('assetsOtherGlMappings', glMappings?.assets || [], this.assetAccountOptions);
- this.setOtherGlMappings(
- 'liabilitiesOtherGlMappings',
+ // Payable and Paid are liability rows, saved under liabilities. A product saved while they sat
+ // under expenses still carries them there, so both buckets are read and the named entries are
+ // stripped out of either one's "Other" rows.
+ const fromLiabilities = this.extractNamedGlMapping(
this.extractNamedGlMapping(
glMappings?.liabilities || [],
SAVINGS_INTEREST_PAYABLE_ACCOUNT_NAME,
'savingsInterestPayableAccountId',
this.liabilityAccountOptions
),
- this.liabilityAccountOptions
+ SAVINGS_INTEREST_PAID_ACCOUNT_NAME,
+ 'savingsInterestPaidAccountId',
+ this.paidAccountOptions
);
- this.setOtherGlMappings(
- 'expensesOtherGlMappings',
- this.extractNamedGlMapping(
- glMappings?.expenses || [],
- SAVINGS_INTEREST_PAID_ACCOUNT_NAME,
- 'savingsInterestPaidAccountId',
- this.expenseAccountOptions
- ),
- this.expenseAccountOptions
+ this.setOtherGlMappings('liabilitiesOtherGlMappings', fromLiabilities, this.liabilityAccountOptions);
+ const fromExpenses = [
+ SAVINGS_INTEREST_PAYABLE_ACCOUNT_NAME,
+ SAVINGS_INTEREST_PAID_ACCOUNT_NAME
+ ].reduce(
+ (remaining, name) => remaining.filter((mapping: any) => mapping?.accountName !== name),
+ glMappings?.expenses || []
);
+ this.setOtherGlMappings('expensesOtherGlMappings', fromExpenses, this.expenseAccountOptions);
this.setOtherGlMappings('incomeOtherGlMappings', glMappings?.income || [], this.incomeAccountOptions);
}
}
@@ -334,16 +340,14 @@ export class SavingProductAccountingStepComponent implements OnInit {
SAVINGS_INTEREST_PAYABLE_ACCOUNT_NAME,
this.liabilityAccountOptions
),
- ...mapOtherGlMappings('liabilitiesOtherGlMappings', this.liabilityAccountOptions)
- ],
- expenses: [
...namedAsOtherGlMapping(
'savingsInterestPaidAccountId',
SAVINGS_INTEREST_PAID_ACCOUNT_NAME,
- this.expenseAccountOptions
+ this.paidAccountOptions
),
- ...mapOtherGlMappings('expensesOtherGlMappings', this.expenseAccountOptions)
+ ...mapOtherGlMappings('liabilitiesOtherGlMappings', this.liabilityAccountOptions)
],
+ expenses: mapOtherGlMappings('expensesOtherGlMappings', this.expenseAccountOptions),
income: mapOtherGlMappings('incomeOtherGlMappings', this.incomeAccountOptions)
};
@@ -378,14 +382,16 @@ export class SavingProductAccountingStepComponent implements OnInit {
if (this.accounting.isCashOrAccrualAccountingRuleId(accountingRule)) {
addControl('savingsReferenceAccountId', true);
addControl('savingsControlAccountId', true);
+ // The interest expense: every interest posting debits it for the product's total.
addControl('interestOnSavingsAccountId', true);
addControl('incomeFromFeeAccountId', true);
addControl('incomeFromPenaltyAccountId', true);
// Savings Interest Payable / Paid are offered under Cash too, but the backend only persists the
// core mappings for CASH_BASED, so these two ride along in otherGlMappings (see getOtherGlMappings)
- // rather than as named accounting params, which would be silently dropped. Optional by design.
- addControl('savingsInterestPayableAccountId', false);
- addControl('savingsInterestPaidAccountId', false);
+ // rather than as named accounting params, which would be silently dropped.
+ // Required: interest posting refuses a product without them rather than skipping their legs.
+ addControl('savingsInterestPayableAccountId', true);
+ addControl('savingsInterestPaidAccountId', true);
// A real accounting param, unlike the two above, so it rides out as itself rather than
// inside otherGlMappings. Optional: a product saved without one still closes.
addControl('roundingAdjustmentAccountId', false);
@@ -522,11 +528,24 @@ export class SavingProductAccountingStepComponent implements OnInit {
required: true
},
{
+ // Credited with the product's total interest at posting and drawn straight back down,
+ // so it closes each posting at nil.
key: 'savingsInterestPayableAccountId',
label: 'Savings Interest Payable',
inputLabel: 'Savings interest payable',
controlName: 'savingsInterestPayableAccountId',
- options: this.liabilityAccountOptions
+ options: this.liabilityAccountOptions,
+ required: true
+ },
+ {
+ // A pass-through between the payable and the members that closes each posting at nil, so a
+ // liability clearing GL and an expense GL both work; both kinds are offered.
+ key: 'savingsInterestPaidAccountId',
+ label: 'Savings Interest Paid',
+ inputLabel: 'Savings interest paid',
+ controlName: 'savingsInterestPaidAccountId',
+ options: this.paidAccountOptions,
+ required: true
},
{
key: 'interestPayableAccountId',
@@ -555,6 +574,7 @@ export class SavingProductAccountingStepComponent implements OnInit {
pillClass: 'amber',
rows: [
{
+ // The interest cost: debited for the product's total at every posting.
key: 'interestOnSavingsAccountId',
label: 'Interest On Savings Account',
inputLabel: 'Interest on savings account',
@@ -562,13 +582,6 @@ export class SavingProductAccountingStepComponent implements OnInit {
options: this.expenseAccountOptions,
required: true
},
- {
- key: 'savingsInterestPaidAccountId',
- label: 'Savings Interest Paid',
- inputLabel: 'Savings interest paid',
- controlName: 'savingsInterestPaidAccountId',
- options: this.expenseAccountOptions
- },
{
// Cash has no paise, so a closing balance of 9,883.79 is handed over as 9,883 and the
// 79 paise land here rather than on the member's own ledger.
diff --git a/src/app/rbi-circulars/form9-return/form9-return.component.html b/src/app/rbi-circulars/form9-return/form9-return.component.html
new file mode 100644
index 000000000..98878e3e0
--- /dev/null
+++ b/src/app/rbi-circulars/form9-return/form9-return.component.html
@@ -0,0 +1,261 @@
+
+
+
+
+ @if (awaitingDate) {
+
+
Form IX — Return on Assets and Liabilities
+
+ Return code R408, filed monthly. Every figure is a closing balance as at the month end, in rupees thousands.
+
+
+
+
+ Reporting date
+
+
+
+
+
+ @if (chosenMonth) {
+
+ Month: {{ chosenMonth }}
+
+ }
+
+
+ Jump to:
+
+
+
+ @if (errorMsg) {
+
{{ errorMsg }}
+ }
+
+ } @else if (loading) {
+
Computing from the ledger…
+ } @else if (errorMsg) {
+
{{ errorMsg }}
+ } @else if (data) {
+
+
+ As at {{ data.reportingDate }} · {{ data.unit }}
+
+
+
+
+
+
+
+
+
{{ data.formName }}
+
{{ data.statutoryReference }} · Return code {{ data.returnCode }}
+
As at {{ data.reportingDate }} · {{ data.unit }}
+
+
+
+
+
+ Total Liabilities
+ {{ amount(data.reconciliation.totalLiabilities) }}
+
+
+ Total Assets
+ {{ amount(data.reconciliation.totalAssets) }}
+
+
+ Difference
+
+ {{ amount(data.reconciliation.difference) }}
+
+
+ {{
+ data.reconciliation.balanced
+ ? 'The two sides agree'
+ : rupees(data.reconciliation.differenceInRupees) + ' in rupees'
+ }}
+
+
+
+
+ @if (data.caveats.length) {
+
+
Check before filing.
+
+ @for (caveat of data.caveats; track caveat) {
+ - {{ caveat }}
+ }
+
+
+ }
+
+
+
+
+
+ | A. Liabilities in India |
+ Rs. in Thousands |
+
+
+
+ @for (line of data.liabilityLines; track line.configKey) {
+
+ |
+ {{ line.label }}
+ @if (!line.configured) {
+ Not mapped
+ }
+
+ @if (line.note) {
+ {{ line.note }}
+ }
+ |
+ {{ amount(line.amount) }} |
+
+
+ @for (account of line.accounts; track $index) {
+
+ |
+ {{ account.glCode }}
+ {{ account.name }}
+ @if (account.excluded) {
+ excluded — {{ account.excluded }}
+ }
+ @if (isPartial(account)) {
+ {{ account.weightPercentage }}% share
+ }
+ |
+ {{ amount(account.balance) }} |
+
+ }
+ }
+
+ | Total Demand and Time Liabilities (4 + 5 + 6) |
+ {{ amount(total('totalDemandAndTime')) }} |
+
+
+ | TOTAL LIABILITIES |
+ {{ amount(total('totalLiabilities')) }} |
+
+
+
+
+
+
+
+
+ | B. Assets in India |
+ Rs. in Thousands |
+
+
+
+ @for (line of data.assetLines; track line.configKey) {
+
+ |
+ {{ line.label }}
+ @if (!line.configured) {
+ Not mapped
+ }
+
+ @if (line.note) {
+ {{ line.note }}
+ }
+ |
+ {{ amount(line.amount) }} |
+
+
+ @if (line.accountsMirrorHead && line.accounts.length) {
+
+ |
+ As the ledger holds them — these balances carry the opposite sign and add to the figure above
+ |
+
+ }
+ @for (account of line.accounts; track $index) {
+
+ |
+ {{ account.glCode }}
+ {{ account.name }}
+ @if (account.excluded) {
+ excluded — {{ account.excluded }}
+ }
+ @if (isPartial(account)) {
+ {{ account.weightPercentage }}% share
+ }
+ |
+ {{ amount(account.balance) }} |
+
+ }
+ }
+
+ | TOTAL ASSETS |
+ {{ amount(total('totalAssets')) }} |
+
+
+
+
+
+ @if (data.memoLines.length) {
+
+
+
+ | Stated splits and explanations — entering no total |
+ Rs. in Thousands |
+
+
+
+ @for (line of data.memoLines; track line.configKey) {
+
+ |
+ {{ line.label }}
+ @if (!line.configured) {
+ Not mapped
+ }
+ |
+ {{ amount(line.amount) }} |
+
+ }
+
+ | Percentage of overdues to total loans and advances |
+ {{ percent(total('overduePercentage')) }} |
+
+
+ | Priority sector advances as a percentage of total loans |
+ {{ percent(total('prioritySectorPercentage')) }} |
+
+
+
+ }
+
+
+
+ }
+
diff --git a/src/app/rbi-circulars/form9-return/form9-return.component.scss b/src/app/rbi-circulars/form9-return/form9-return.component.scss
new file mode 100644
index 000000000..3a3fd42e1
--- /dev/null
+++ b/src/app/rbi-circulars/form9-return/form9-return.component.scss
@@ -0,0 +1,395 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+$primary: #1353a0;
+$border: #e2e8f0;
+$gray-50: #f8f9fa;
+$gray-200: #e8eaed;
+$gray-500: #9aa0a6;
+$gray-900: #202124;
+$green: #137333;
+$amber: #b06000;
+$red: #c5221f;
+
+.f9-container {
+ max-width: 1180px;
+ margin: 0 auto;
+ padding: 20px 16px;
+}
+
+// ── Date prompt ──
+// Centred and roomy rather than a strip at the top: nothing is on screen yet, so this is the
+// screen, and a filer choosing the month the return covers should not be hunting for the field.
+.f9-prompt {
+ max-width: 640px;
+ margin: 48px auto;
+ padding: 28px 32px;
+ background: #fff;
+ border: 1px solid $border;
+ border-radius: 12px;
+ text-align: center;
+ box-shadow: 0 1px 3px rgb(60 64 67 / 12%);
+
+ &__title {
+ margin: 0 0 8px;
+ font-size: 19px;
+ font-weight: 600;
+ color: $gray-900;
+ }
+
+ &__sub {
+ margin: 0 0 22px;
+ font-size: 13px;
+ line-height: 1.6;
+ color: $gray-500;
+ }
+
+ &__row {
+ display: flex;
+ gap: 12px;
+ align-items: center;
+ justify-content: center;
+ flex-wrap: wrap;
+ }
+
+ &__date {
+ width: 200px;
+ }
+
+ &__hint {
+ margin: 16px 0 0;
+ font-size: 12.5px;
+ color: $primary;
+ }
+
+ &__quick {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ justify-content: center;
+ margin-top: 18px;
+ font-size: 12px;
+ color: $gray-500;
+ }
+}
+
+.f9-loading {
+ padding: 48px;
+ text-align: center;
+ color: $gray-500;
+ font-size: 13px;
+}
+
+.f9-alert {
+ padding: 12px 16px;
+ border-radius: 8px;
+ font-size: 12.5px;
+ margin-bottom: 16px;
+ line-height: 1.6;
+
+ &--error {
+ background: #fce8e6;
+ color: $red;
+ }
+
+ &--warn {
+ background: #fff8e1;
+ color: $amber;
+ border: 1px solid #e0c56b;
+ }
+}
+
+.f9-caveats {
+ margin: 8px 0 0 18px;
+ padding: 0;
+}
+
+// ── Toolbar ──
+.f9-bar {
+ display: flex;
+ gap: 10px;
+ align-items: center;
+ margin-bottom: 18px;
+
+ &__date {
+ flex: 1;
+ font-size: 13px;
+ color: $primary;
+ font-weight: 500;
+ }
+}
+
+// ── The form itself ──
+.f9-sheet {
+ background: #fff;
+ border: 1px solid $gray-900;
+ padding: 20px;
+
+ &__head {
+ text-align: center;
+ border-bottom: 2px solid $gray-900;
+ padding-bottom: 12px;
+ margin-bottom: 16px;
+ }
+
+ &__title {
+ font-weight: 700;
+ font-size: 16px;
+ text-transform: uppercase;
+ color: $gray-900;
+ }
+
+ &__ref {
+ font-size: 11.5px;
+ color: $gray-500;
+ margin-top: 4px;
+ }
+
+ &__unit {
+ font-size: 11.5px;
+ color: $primary;
+ font-weight: 600;
+ margin-top: 4px;
+ }
+}
+
+// ── Reconciliation ──
+// Green when the two sides agree, red when they do not. The colour is the point: a return that
+// does not balance has something unmapped or mapped twice, and that has to be visible before
+// anyone reads a figure off the statement below.
+.f9-recon {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 1px;
+ background: $gray-200;
+ border: 1px solid $green;
+ border-radius: 8px;
+ overflow: hidden;
+ margin-bottom: 18px;
+
+ &--off {
+ border-color: $red;
+ }
+
+ &__cell {
+ background: #fff;
+ padding: 12px 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ }
+
+ &__label {
+ font-size: 10.5px;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: $gray-500;
+ font-weight: 600;
+ }
+
+ &__value {
+ font-size: 20px;
+ font-weight: 600;
+ color: $gray-900;
+ font-variant-numeric: tabular-nums;
+ }
+
+ &__sub {
+ font-size: 11px;
+ color: $gray-500;
+ }
+}
+
+.f9-neg {
+ color: $red;
+}
+
+// ── Tables ──
+.f9-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12.5px;
+ margin-bottom: 20px;
+
+ th,
+ td {
+ border: 1px solid $gray-200;
+ padding: 6px 10px;
+ vertical-align: top;
+ }
+
+ th {
+ background: $gray-50;
+ font-weight: 700;
+ font-size: 11.5px;
+ color: $gray-900;
+ border-color: $gray-900;
+ text-align: left;
+ }
+
+ &__particulars {
+ width: 72%;
+ }
+
+ &__num {
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+ width: 1%;
+ min-width: 140px;
+ }
+
+ // Qualified with the element so a numeric heading is not dragged back to the left by the bare
+ // `th` rule above, which matches at the same specificity and would win on source order alone.
+ th#{&}__num {
+ text-align: right;
+ }
+}
+
+.f9-line {
+ &--unconfigured {
+ background: #fffdf8;
+ }
+
+ // Set on its own line rather than inline: it explains the figure beside it, and read as a
+ // trailing clause on the label it looks like part of the head's name.
+ &__note {
+ display: block;
+ margin-top: 3px;
+ font-size: 10.5px;
+ font-style: italic;
+ color: $amber;
+ }
+}
+
+.f9-badge {
+ display: inline-block;
+ margin-left: 8px;
+ padding: 1px 6px;
+ font-size: 9.5px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: $amber;
+ background: #fff3cd;
+ border: 1px solid #e0c56b;
+ border-radius: 3px;
+}
+
+// An account behind a head. These rows are the "specify" sheets of the RBI workbook — one row
+// per account, under its own name — so they are indented under their head rather than hidden.
+.f9-subline {
+ td {
+ padding-top: 4px;
+ padding-bottom: 4px;
+ font-size: 11.5px;
+ color: $gray-500;
+ border-bottom: none;
+ }
+
+ td:first-child {
+ padding-left: 28px;
+ }
+
+ &--excluded td {
+ background: #fffdf5;
+ }
+
+ // The line that warns the rows beneath it read the other way round. Set apart from the balances
+ // themselves, since it explains them rather than being one of them.
+ &--mirror td {
+ padding-left: 28px;
+ font-style: italic;
+ font-size: 10.5px;
+ color: $gray-500;
+ background: #fbfcfe;
+ }
+
+ &__code {
+ font-family: 'Roboto Mono', monospace;
+ font-size: 10.5px;
+ background: $gray-50;
+ border: 1px solid $border;
+ border-radius: 4px;
+ padding: 1px 5px;
+ margin-right: 6px;
+ }
+
+ &__portion {
+ margin-left: 8px;
+ padding: 0 5px;
+ font-size: 10px;
+ font-weight: 600;
+ color: $amber;
+ background: #fff3cd;
+ border-radius: 3px;
+ }
+
+ &__excluded {
+ margin-left: 8px;
+ font-size: 10px;
+ font-weight: 600;
+ color: $amber;
+ }
+}
+
+.f9-total td {
+ font-weight: 700;
+ background: #fafafa;
+ color: $gray-900;
+}
+
+.f9-total--grand td {
+ background: #e8f0fe;
+ font-size: 13px;
+}
+
+.f9-sign {
+ margin-top: 56px;
+ text-align: right;
+
+ &__line {
+ display: inline-block;
+ border-top: 1px solid $gray-900;
+ padding-top: 6px;
+ font-size: 12px;
+ min-width: 230px;
+ text-align: center;
+ }
+}
+
+@media print {
+ .f9-noprint {
+ display: none !important;
+ }
+
+ .f9-container {
+ max-width: none;
+ padding: 0;
+ }
+
+ .f9-table thead {
+ display: table-header-group;
+ }
+
+ .f9-table tr {
+ break-inside: avoid;
+ }
+
+ // Backgrounds carry meaning here — an unmapped head is tinted, a reconciliation that fails is
+ // red — so they must survive the print engine's default of dropping them to save ink.
+ .f9-sheet,
+ .f9-table,
+ .f9-table th,
+ .f9-table td,
+ .f9-recon,
+ .f9-badge,
+ .f9-alert {
+ -webkit-print-color-adjust: exact;
+ print-color-adjust: exact;
+ }
+}
diff --git a/src/app/rbi-circulars/form9-return/form9-return.component.ts b/src/app/rbi-circulars/form9-return/form9-return.component.ts
new file mode 100644
index 000000000..01608f2f9
--- /dev/null
+++ b/src/app/rbi-circulars/form9-return/form9-return.component.ts
@@ -0,0 +1,492 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+import { Component, inject } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { catchError, of } from 'rxjs';
+import * as ExcelJS from 'exceljs';
+import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+import { RbiCircularsService } from '../rbi-circulars.service';
+import { addBanner, boxAllCells, downloadWorkbook, newWorkbook, solidFill, XL } from '../oss-workbook';
+
+/** One account behind a head — a row of the form's "specify" sheets. */
+export interface Form9Account {
+ glAccountId: number;
+ glCode: string;
+ name: string;
+ /** In thousands, as the form prints it. */
+ balance: number | null;
+ /** The same balance in rupees, which is what reconciles against the ledger. */
+ balanceInRupees?: number | null;
+ weightPercentage?: number | null;
+ liabilityClass?: string | null;
+ excluded?: 'disabled' | 'header';
+}
+
+/** One head of the return. */
+export interface Form9Line {
+ configKey: string;
+ label: string;
+ usage: 'LIABILITY' | 'ASSET' | 'MEMO';
+ configured: boolean;
+ amount: number | null;
+ amountInRupees: number | null;
+ accounts: Form9Account[];
+ /** Why a line reads as it does, where that is not obvious — a loss moved to the asset side. */
+ note?: string;
+ /**
+ * Whether the accounts below carry the opposite sign to the head.
+ *
+ * True on a figure the return carries across sides: a loss shown as a positive at item 12 is made
+ * up of balances that are negative where the ledger holds them. Without saying so, three negative
+ * rows under a positive head read as an error.
+ */
+ accountsMirrorHead?: boolean;
+}
+
+export interface Form9Reconciliation {
+ totalLiabilities: number;
+ totalAssets: number;
+ difference: number;
+ differenceInRupees: number;
+ balanced: boolean;
+}
+
+export interface Form9Return {
+ formCode: string;
+ formName: string;
+ returnCode: string;
+ statutoryReference: string;
+ frequency: string;
+ unit: string;
+ reportingDate: string;
+ monthEnd: string;
+ monthComplete: boolean;
+ note: string;
+ liabilityLines: Form9Line[];
+ assetLines: Form9Line[];
+ memoLines: Form9Line[];
+ totals: Record
;
+ reconciliation: Form9Reconciliation;
+ caveats: string[];
+ computable: boolean;
+}
+
+/**
+ * Form IX — the Return on Assets and Liabilities a co-operative bank files monthly, code R408.
+ *
+ * A different return from Form 9 (SLR) despite the name, and it does not share that screen's
+ * machinery: this one reports the whole balance sheet rather than one ratio, in rupees thousands
+ * rather than rupees, and its two sides have to be shown against each other.
+ *
+ * The reconciliation is on the face of the screen whether or not it holds. The filed return this
+ * was built from balanced its sides by subtracting the difference from the side that was already
+ * short and printed "DIFF ADJUSTED" — so the one thing a reviewer needed to know was the one thing
+ * the report would not say.
+ */
+@Component({
+ selector: 'mifosx-form9-return',
+ templateUrl: './form9-return.component.html',
+ styleUrls: ['./form9-return.component.scss'],
+ imports: [
+ ...STANDALONE_SHARED_IMPORTS,
+ FormsModule
+ ]
+})
+export class Form9ReturnComponent {
+ private rbiService = inject(RbiCircularsService);
+
+ data: Form9Return | null = null;
+ loading = false;
+ errorMsg = '';
+ generating = false;
+
+ /**
+ * Nothing is fetched until a date is chosen. Which month the return covers follows from that one
+ * field, and a return that opened on today's month would be a month still running — reported as
+ * though it had closed.
+ */
+ awaitingDate = true;
+
+ /** Today, as a date input formats it — also the latest date the return may be run for. */
+ readonly today = new Date().toISOString().slice(0, 10);
+
+ reportingDate = this.today;
+
+ generate(): void {
+ if (!this.reportingDate) {
+ this.errorMsg = 'Choose a reporting date first.';
+ return;
+ }
+ // A balance is summed as everything up to a date, so a future one does not fail and does not
+ // come back empty — it returns today's figures under a heading that has not happened.
+ if (this.reportingDate > this.today) {
+ this.errorMsg = `The reporting date (${this.reportingDate}) has not happened yet — today is ${this.today}.`;
+ return;
+ }
+ this.awaitingDate = false;
+ this.load();
+ }
+
+ load(): void {
+ this.loading = true;
+ this.errorMsg = '';
+ this.rbiService
+ .getForm9Return(this.reportingDate)
+ .pipe(
+ catchError((err: any) => {
+ this.errorMsg =
+ err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not compute the return from the ledger.';
+ return of(null);
+ })
+ )
+ .subscribe((res: Form9Return | null) => {
+ this.loading = false;
+ this.data = res;
+ });
+ }
+
+ /** Back to the date prompt, so a filer can run a different month. */
+ changeDate(): void {
+ this.awaitingDate = true;
+ this.data = null;
+ this.errorMsg = '';
+ }
+
+ /** The month the chosen date falls in, shown beside the picker. */
+ get chosenMonth(): string {
+ if (!this.reportingDate) {
+ return '';
+ }
+ const d = new Date(this.reportingDate + 'T00:00:00');
+ return d.toLocaleDateString('en-IN', { month: 'long', year: 'numeric' });
+ }
+
+ /** Sets the date to the end of the month it currently falls in, capped at today. */
+ setToMonthEnd(): void {
+ const d = this.reportingDate ? new Date(this.reportingDate + 'T00:00:00') : new Date();
+ const end = new Date(d.getFullYear(), d.getMonth() + 1, 0);
+ const iso = `${end.getFullYear()}-${String(end.getMonth() + 1).padStart(2, '0')}-${String(end.getDate()).padStart(2, '0')}`;
+ // Capped: the current month's end has not arrived, and the return would be refused.
+ this.reportingDate = iso > this.today ? this.today : iso;
+ }
+
+ amount(value: number | null | undefined): string {
+ if (value === null || value === undefined) {
+ return '—';
+ }
+ return value.toLocaleString('en-IN', { maximumFractionDigits: 0 });
+ }
+
+ /** Rupee figures keep their paise — they are what reconciles against the ledger. */
+ rupees(value: number | null | undefined): string {
+ if (value === null || value === undefined) {
+ return '—';
+ }
+ return value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
+ }
+
+ percent(value: number | null | undefined): string {
+ if (value === null || value === undefined) {
+ return '—';
+ }
+ return `${value.toFixed(2)}%`;
+ }
+
+ /** A head taking only part of an account — the figure below is not that account's balance. */
+ isPartial(account: Form9Account): boolean {
+ const weight = account.weightPercentage;
+ return weight !== null && weight !== undefined && weight < 100;
+ }
+
+ /** One of the form's stated totals. */
+ total(key: string): number | null {
+ return this.data?.totals?.[key] ?? null;
+ }
+
+ printReturn(): void {
+ window.print();
+ }
+
+ /**
+ * The return as a workbook, laid out as the RBI template is: General Information, then Liabilities
+ * and Assets, then a sheet per "specify" list, then Signatory Information.
+ *
+ * A workbook that reorganised the form would have to be read back against the screen line by line
+ * before anyone could trust it.
+ */
+ async exportWorkbook(): Promise {
+ if (!this.data) {
+ return;
+ }
+ this.generating = true;
+ try {
+ const wb = newWorkbook();
+ this.addGeneralInformation(wb);
+ this.addSideSheet(wb, 'Liabilities', 'A. Liabilities in India', this.data.liabilityLines);
+ this.addSideSheet(wb, 'Assets', 'B. Assets in India', this.data.assetLines);
+ this.addSpecifySheets(wb);
+ this.addMemoSheet(wb);
+ this.addSignatorySheet(wb);
+ await downloadWorkbook(wb, `RBI_Form_IX_${this.data.reportingDate}`);
+ } finally {
+ this.generating = false;
+ }
+ }
+
+ private addGeneralInformation(wb: ExcelJS.Workbook): void {
+ const sheet = wb.addWorksheet('General Information');
+ sheet.columns = [
+ { width: 42 },
+ { width: 46 }];
+ addBanner(sheet, 'General Information', 'B');
+
+ const start = sheet.rowCount + 1;
+ this.keyValue(sheet, 'Return Name', this.data!.formName);
+ this.keyValue(sheet, 'Return Code', this.data!.returnCode);
+ this.keyValue(sheet, 'Period End Date', this.data!.reportingDate);
+ this.keyValue(sheet, 'Reporting Frequency', this.data!.frequency);
+ this.keyValue(sheet, 'Unit', this.data!.unit);
+ // Whether the return balances belongs here, at the top, not buried under the figures: a
+ // reviewer who reads it after the statement has already believed the statement.
+ this.keyValue(
+ sheet,
+ 'Validation Status',
+ this.data!.reconciliation.balanced
+ ? 'Balanced'
+ : `NOT BALANCED — liabilities exceed assets by ${this.amount(this.data!.reconciliation.difference)} thousand`
+ );
+ boxAllCells(sheet, start, sheet.rowCount, 2);
+
+ if (this.data!.caveats.length) {
+ sheet.addRow([]);
+ const head = sheet.addRow(['Check before filing:']);
+ head.font = { bold: true, color: { argb: 'FFB06000' } };
+ for (const caveat of this.data!.caveats) {
+ const row = sheet.addRow([caveat]);
+ sheet.mergeCells(row.number, 1, row.number, 2);
+ row.alignment = { wrapText: true };
+ row.font = { size: 9, color: { argb: 'FFB06000' } };
+ }
+ }
+ }
+
+ private addSideSheet(wb: ExcelJS.Workbook, name: string, title: string, lines: Form9Line[]): void {
+ const sheet = wb.addWorksheet(name);
+ sheet.columns = [
+ { width: 64 },
+ { width: 20 },
+ { width: 22 }];
+ addBanner(sheet, title, 'C');
+
+ const start = sheet.rowCount + 1;
+ const head = sheet.addRow([
+ 'Particulars',
+ 'Rs. in Thousands',
+ 'Rs. (as in ledger)'
+ ]);
+ head.font = { bold: true };
+ head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
+
+ for (const line of lines) {
+ const row = sheet.addRow([
+ line.label,
+ line.configured ? line.amount : 'Not mapped',
+ line.configured ? line.amountInRupees : ''
+ ]);
+ row.getCell(1).alignment = { wrapText: true };
+ row.getCell(2).numFmt = '#,##0';
+ row.getCell(3).numFmt = '#,##0.00';
+
+ // The accounts behind the head, indented under it, so every figure traces to the ledger.
+ for (const account of line.accounts) {
+ const portion = this.isPartial(account) ? ` (${account.weightPercentage}% share)` : '';
+ const sub = sheet.addRow([
+ ` ${account.glCode} — ${account.name}${portion}${account.excluded ? ` (excluded — ${account.excluded})` : ''}`,
+ account.excluded ? 'Excluded' : account.balance,
+ account.excluded ? '' : account.balanceInRupees
+ ]);
+ sub.font = { size: 9, color: { argb: account.excluded ? 'FFB06000' : 'FF666666' } };
+ sub.getCell(2).numFmt = '#,##0';
+ sub.getCell(3).numFmt = '#,##0.00';
+ }
+ }
+
+ sheet.addRow([]);
+ const totalKey = name === 'Liabilities' ? 'totalLiabilities' : 'totalAssets';
+ const total = sheet.addRow([
+ `TOTAL ${name.toUpperCase()}`,
+ this.total(totalKey),
+ ''
+ ]);
+ total.font = { bold: true };
+ total.getCell(2).numFmt = '#,##0';
+ boxAllCells(sheet, start, sheet.rowCount, 3);
+ }
+
+ /**
+ * One sheet per "specify" list, exactly as the RBI workbook carries them.
+ *
+ * Each account mapped to the head IS a row on its sheet, under its own name — the same accounts
+ * already listed under the head on the side sheet, gathered here because the template asks for
+ * them on their own page.
+ */
+ private addSpecifySheets(wb: ExcelJS.Workbook): void {
+ const sheets: { name: string; title: string; key: string }[] = [
+ { name: 'Other Funds And Reserves', title: 'Other Funds And Reserves - Specify', key: 'FORM9_L2_G_OTHER_FUNDS' },
+ {
+ name: 'Demand Liabilities',
+ title: 'Demand Liabilities Others Borrowing From Banks - Specify',
+ key: 'FORM9_L4_C_BORROWINGS_DEMAND'
+ },
+ { name: 'Other Demand Liabilities', title: 'Other Demand Liabilities - Specify', key: 'FORM9_L4_D_OTHER_DEMAND' },
+ {
+ name: 'Time Liabilities',
+ title: 'Time Liabilities Others Borrowing From Banks - Specify',
+ key: 'FORM9_L5_C_BORROWINGS_TIME'
+ },
+ { name: 'Other Time Liabilities', title: 'Other Time Liabilities - Specify', key: 'FORM9_L5_E_OTHER_TIME' },
+ { name: 'Others Investments', title: 'Others Investments - Specify', key: 'FORM9_A4_H_OTHER_INVESTMENTS' }
+ ];
+
+ const all = [
+ ...this.data!.liabilityLines,
+ ...this.data!.assetLines
+ ];
+ for (const spec of sheets) {
+ const line = all.find((l) => l.configKey === spec.key);
+ const sheet = wb.addWorksheet(spec.name);
+ sheet.columns = [
+ { width: 8 },
+ { width: 52 },
+ { width: 20 }];
+ addBanner(sheet, spec.title, 'C');
+
+ const start = sheet.rowCount + 1;
+ const head = sheet.addRow([
+ 'Sr No.',
+ 'Particulars',
+ 'Amount'
+ ]);
+ head.font = { bold: true };
+ head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
+
+ const accounts = (line?.accounts ?? []).filter((a) => !a.excluded);
+ accounts.forEach((account, i) => {
+ const row = sheet.addRow([
+ i + 1,
+ account.name,
+ account.balance
+ ]);
+ row.getCell(3).numFmt = '#,##0';
+ });
+ // An empty sheet still prints its heading and a nil total: the template carries the sheet
+ // whether or not the bank has anything to put on it.
+ const total = sheet.addRow([
+ '',
+ 'Total',
+ line?.configured ? line.amount : 0
+ ]);
+ total.font = { bold: true };
+ total.getCell(3).numFmt = '#,##0';
+ boxAllCells(sheet, start, sheet.rowCount, 3);
+ }
+ }
+
+ /** The stated splits and explanations — on the form's face, entering no total. */
+ private addMemoSheet(wb: ExcelJS.Workbook): void {
+ if (!this.data!.memoLines.length) {
+ return;
+ }
+ const sheet = wb.addWorksheet('Explanations');
+ sheet.columns = [
+ { width: 64 },
+ { width: 20 }];
+ addBanner(sheet, 'Stated splits and explanations', 'B');
+
+ const start = sheet.rowCount + 1;
+ const head = sheet.addRow([
+ 'Particulars',
+ 'Rs. in Thousands'
+ ]);
+ head.font = { bold: true };
+ head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
+
+ for (const line of this.data!.memoLines) {
+ const row = sheet.addRow([
+ line.label,
+ line.configured ? line.amount : 'Not mapped'
+ ]);
+ row.getCell(1).alignment = { wrapText: true };
+ row.getCell(2).numFmt = '#,##0';
+ }
+ const overdue = sheet.addRow([
+ 'Percentage of overdues to total loans and advances',
+ this.percent(this.total('overduePercentage'))
+ ]);
+ overdue.font = { bold: true };
+ const priority = sheet.addRow([
+ 'Priority sector advances as a percentage of total loans',
+ this.percent(this.total('prioritySectorPercentage'))
+ ]);
+ priority.font = { bold: true };
+ boxAllCells(sheet, start, sheet.rowCount, 2);
+ }
+
+ /**
+ * The signatory block, left blank for signing.
+ *
+ * Deliberately not filled from anywhere: who signs a filed return and on what date is a decision
+ * the bank makes when it files, and pre-filling a name would put a signature on a form nobody had
+ * agreed to sign.
+ */
+ private addSignatorySheet(wb: ExcelJS.Workbook): void {
+ const sheet = wb.addWorksheet('Signatory Information');
+ sheet.columns = [
+ { width: 24 },
+ { width: 34 },
+ { width: 34 }];
+ addBanner(sheet, 'Signatory Information', 'C');
+
+ const start = sheet.rowCount + 1;
+ const head = sheet.addRow([
+ '',
+ 'Authorised Reporting Official',
+ 'Countersigned By'
+ ]);
+ head.font = { bold: true };
+ head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
+ for (const field of [
+ 'Name',
+ 'Designation',
+ 'e-mail ID',
+ 'Tel. No. (O)',
+ 'Tel. No. (R)',
+ 'Place',
+ 'Date'
+ ]) {
+ const row = sheet.addRow([
+ field,
+ '',
+ ''
+ ]);
+ row.getCell(1).font = { bold: true };
+ row.getCell(1).fill = solidFill(XL.keyCell);
+ }
+ boxAllCells(sheet, start, sheet.rowCount, 3);
+ }
+
+ private keyValue(sheet: ExcelJS.Worksheet, label: string, value: string): void {
+ const row = sheet.addRow([
+ label,
+ value
+ ]);
+ row.getCell(1).font = { bold: true };
+ row.getCell(1).fill = solidFill(XL.keyCell);
+ }
+}
diff --git a/src/app/rbi-circulars/oss-live/oss-return.base.ts b/src/app/rbi-circulars/oss-live/oss-return.base.ts
index 3ed065606..f62312051 100644
--- a/src/app/rbi-circulars/oss-live/oss-return.base.ts
+++ b/src/app/rbi-circulars/oss-live/oss-return.base.ts
@@ -10,14 +10,7 @@ import { inject } from '@angular/core';
import { Observable, catchError, of } from 'rxjs';
import * as ExcelJS from 'exceljs';
import { RbiCircularsService } from '../rbi-circulars.service';
-import {
- addBanner,
- boxAllCells,
- downloadWorkbook,
- newWorkbook,
- solidFill,
- XL
-} from '../oss-workbook';
+import { addBanner, boxAllCells, downloadWorkbook, newWorkbook, solidFill, XL } from '../oss-workbook';
/** One GL account behind a return line. */
export interface OssAccount {
diff --git a/src/app/rbi-circulars/rbi-circulars-routing.module.ts b/src/app/rbi-circulars/rbi-circulars-routing.module.ts
index 791827a72..650354f34 100644
--- a/src/app/rbi-circulars/rbi-circulars-routing.module.ts
+++ b/src/app/rbi-circulars/rbi-circulars-routing.module.ts
@@ -69,12 +69,24 @@ const routes: Routes = [
loadComponent: () => import('./reserve-return/form1-crr.component').then((m) => m.Form1CrrComponent),
data: { title: 'Form 1 — Cash Reserve Ratio', breadcrumb: 'Form 1 (CRR)' }
},
+ // Form 9 (SLR) has no route of its own. Section 24 is settled on the statutory Form 1 at Part C,
+ // charged on the same liabilities Part B charges the cash reserve on. A second screen computing
+ // the same ratio from a month-end base disagreed with it, and both were headed Form 9 — whichever
+ // a filer read, they had no way to know the other said something else.
{
- // SLR return, filed at the end of each month.
- path: 'rbi/form9',
- loadComponent: () => import('./reserve-return/form9-slr.component').then((m) => m.Form9SlrComponent),
- data: { title: 'Form 9 — Statutory Liquidity Ratio', breadcrumb: 'Form 9 (SLR)' }
+ // Form IX — the Return on Assets and Liabilities, code R408, filed monthly.
+ //
+ // A DIFFERENT return from Form 9 (SLR) above despite the name. That one reports one ratio;
+ // this one reports the whole balance sheet, in rupees thousands, and its two sides must
+ // agree. Its own route rather than a tab on rbi/form9 for exactly that reason — they answer
+ // different questions and share no figures.
+ path: 'rbi/form9-return',
+ loadComponent: () => import('./form9-return/form9-return.component').then((m) => m.Form9ReturnComponent),
+ data: { title: 'Form IX — Return on Assets and Liabilities', breadcrumb: 'Form IX (R408)' }
}
+ // The statutory Form 1 has no route of its own: it is a tab on rbi/form1. Both are Form 1 for
+ // one bank and one period, and two routes made a filer guess which held which — one could be
+ // read while the other was believed to be on screen.
])
];
diff --git a/src/app/rbi-circulars/rbi-circulars.service.ts b/src/app/rbi-circulars/rbi-circulars.service.ts
index 38fd0c260..ed6e10638 100644
--- a/src/app/rbi-circulars/rbi-circulars.service.ts
+++ b/src/app/rbi-circulars/rbi-circulars.service.ts
@@ -71,11 +71,17 @@ export class RbiCircularsService {
* @param reportingDate yyyy-MM-dd. Defaults to the current business date on the backend.
* @param officeId Optional — scopes every figure to one office.
*/
- getForm1(reportingDate?: string, officeId?: number): Observable {
+ getForm1(reportingDate?: string, baseDate?: string, officeId?: number): Observable {
let params = new HttpParams();
if (reportingDate) {
params = params.set('reportingDate', reportingDate);
}
+ // The base date can be given outright. The 45-day lag lands where it lands, and a bank reading
+ // an earlier fortnight — or correcting a filing — needs to name the date the base is struck at
+ // rather than work backwards from it. Left blank it is derived from the lag as before.
+ if (baseDate) {
+ params = params.set('baseDate', baseDate);
+ }
if (officeId != null) {
params = params.set('officeId', String(officeId));
}
@@ -97,6 +103,44 @@ export class RbiCircularsService {
return this.http.get('/rbireservereturn/form9', { params });
}
+ /**
+ * Form 9 in the shape it is filed — the statutory statement of compliance with section 24.
+ *
+ * A different statement from `getForm9`, not a reformatting: three dated columns rather than one,
+ * and the ratio charged on the last Friday of the second preceding fortnight rather than on the
+ * month end itself.
+ */
+ getForm9Statutory(reportingDate?: string, officeId?: number): Observable {
+ let params = new HttpParams();
+ if (reportingDate) {
+ params = params.set('reportingDate', reportingDate);
+ }
+ if (officeId != null) {
+ params = params.set('officeId', String(officeId));
+ }
+ return this.http.get('/rbireservereturn/form9-statutory', { params });
+ }
+
+ /**
+ * Form IX — the Return on Assets and Liabilities a co-operative bank files monthly, code R408.
+ *
+ * A different return from `getForm9` despite the name: that one reports the SLR ratio, this one
+ * reports the whole balance sheet. Every figure comes back in rupees thousands, with the rupee
+ * figure alongside it so a line can still be reconciled against the ledger.
+ *
+ * @param reportingDate yyyy-MM-dd — any date in the month; the return reports that month's end.
+ */
+ getForm9Return(reportingDate?: string, officeId?: number): Observable {
+ let params = new HttpParams();
+ if (reportingDate) {
+ params = params.set('reportingDate', reportingDate);
+ }
+ if (officeId != null) {
+ params = params.set('officeId', String(officeId));
+ }
+ return this.http.get('/rbireservereturn/form9-return', { params });
+ }
+
/**
* OSS 1 — Return on Assets and Liabilities, computed live from the ledger.
*
@@ -121,6 +165,39 @@ export class RbiCircularsService {
return this.http.get('/ossreturn/oss2', { params: this.ossParams(quarterEndDate, officeId) });
}
+ /**
+ * Form 1 as a co-operative bank files it — Banking Regulation (Co-operative Societies) Rules
+ * 1966, Rule 5, sections 18(1) and 24(3).
+ *
+ * A different statement from `getForm1`, not a reformatting: three columns rather than one, both
+ * ratios settled on the same liabilities, and those liabilities taken from the last Friday of the
+ * second preceding fortnight rather than a 45-day lag.
+ */
+ getForm1Statutory(
+ reportingDate?: string,
+ firstColumnDate?: string,
+ secondColumnDate?: string,
+ officeId?: number
+ ): Observable {
+ let params = new HttpParams();
+ if (reportingDate) {
+ params = params.set('reportingDate', reportingDate);
+ }
+ // Both fortnight closes can be given outright. A bank filing August wants 15/08 and 31/08 on
+ // the face of the form whatever today is, and one date cannot produce a pair that spans a month
+ // boundary or skips a fortnight — which a late or corrected filing needs.
+ if (firstColumnDate) {
+ params = params.set('firstColumnDate', firstColumnDate);
+ }
+ if (secondColumnDate) {
+ params = params.set('secondColumnDate', secondColumnDate);
+ }
+ if (officeId != null) {
+ params = params.set('officeId', String(officeId));
+ }
+ return this.http.get('/rbireservereturn/form1-statutory', { params });
+ }
+
// ── Saved returns ──
/**
diff --git a/src/app/rbi-circulars/reserve-return/form1-crr.component.ts b/src/app/rbi-circulars/reserve-return/form1-crr.component.ts
index c5935ff16..a28486f4e 100644
--- a/src/app/rbi-circulars/reserve-return/form1-crr.component.ts
+++ b/src/app/rbi-circulars/reserve-return/form1-crr.component.ts
@@ -8,9 +8,11 @@
import { Component, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
-import { Observable } from 'rxjs';
+import { Observable, catchError, of } from 'rxjs';
+import * as ExcelJS from 'exceljs';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
-import { ReserveReturnBase } from './reserve-return.base';
+import { addBanner, boxAllCells, downloadWorkbook, newWorkbook, solidFill, XL } from '../oss-workbook';
+import { Form1Part, Form1Statutory, ReserveReturnBase } from './reserve-return.base';
/**
* RBI Form 1 — Cash Reserve Ratio, filed twice a month.
@@ -18,11 +20,20 @@ import { ReserveReturnBase } from './reserve-return.base';
* The return compares the CRR required on an earlier fortnight's Demand and Time Liabilities
* against the cash and bank balances held today. The two sides are deliberately read on different
* dates — see the basis panel on the screen, which states both.
+ *
+ * The statutory Form 1 — the return as a co-operative bank actually files it, three dated columns
+ * with both ratios settled on one base — is a tab on this same screen rather than a page of its
+ * own. Both are Form 1 for the same bank and the same period; splitting them across two routes
+ * meant a filer had to know which URL held which, and could read one while believing they were
+ * looking at the other.
*/
@Component({
selector: 'mifosx-form1-crr',
templateUrl: './reserve-return.component.html',
- styleUrls: ['./reserve-return.component.scss'],
+ styleUrls: [
+ './reserve-return.component.scss',
+ './form1-statutory.component.scss'
+ ],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule
@@ -30,14 +41,98 @@ import { ReserveReturnBase } from './reserve-return.base';
})
export class Form1CrrComponent extends ReserveReturnBase implements OnInit {
protected readonly exportStem = 'RBI_Form1_CRR';
- protected readonly formCode = 'FORM_1';
+ // The statutory form's code, because that is what this screen saves and therefore what its saved
+ // list must ask for. Left as FORM_1 the list filtered on a code nothing writes any more and came
+ // back empty, which reads as "nothing has ever been saved" rather than as a mismatch.
+ protected readonly formCode = 'FORM_1_STATUTORY';
+
+ /** Returns filed before the screen moved to the statutory form, so they stay in the list. */
+ protected override readonly legacyFormCodes = ['FORM_1'];
+
+ override readonly hasStatutoryForm = true;
+ override readonly hasBaseDate = true;
+
+ /**
+ * The statutory form is the only view of Form 1.
+ *
+ * There were two: a working report charging the ratio on a 45-day lag, and the statutory form
+ * charging it on the last Friday of the second preceding fortnight. Both were headed Form 1, both
+ * were computed from the same ledger, and they disagreed — because the rules they follow differ.
+ * One screen showing two different answers to "what is our CRR position" is worse than no screen:
+ * whichever a filer read, they had no way to know the other existed and said something else. What
+ * gets filed is the statutory form, so that is what the screen shows.
+ */
+ override activeTab: 'report' | 'statutory' | 'saved' = 'statutory';
+
+ protected override fetchStatutory(): Observable {
+ return this.rbiService.getForm1Statutory(
+ this.statutoryDate,
+ this.statutoryFirstColumn || undefined,
+ this.statutorySecondColumn || undefined
+ );
+ }
protected fetch(reportingDate: string): Observable {
- return this.rbiService.getForm1(reportingDate);
+ return this.rbiService.getForm1(reportingDate, this.baseDate || undefined);
}
ngOnInit(): void {
- this.load();
+ // No load() here — the screen asks for a reporting date first, because which fortnight the
+ // return covers and which base date it reads follow from that one field.
this.loadSaved();
}
+ /**
+ * The fortnight the chosen date falls in, shown beside the picker so a filer sees which period
+ * they are about to run. Fortnights close on the 15th and on the month end.
+ */
+ get statutoryFortnight(): string {
+ if (!this.statutoryDate) {
+ return '';
+ }
+ const d = new Date(this.statutoryDate + 'T00:00:00');
+ const first = d.getDate() <= 15;
+ const end = first ? 15 : new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
+ const month = d.toLocaleDateString('en-IN', { month: 'short', year: 'numeric' });
+ return `${first ? 1 : 16}–${end} ${month}`;
+ }
+
+ /**
+ * Fills both columns with a whole month's fortnight closes — 15th and month end — which is the
+ * pair a filed return almost always carries.
+ */
+ fillStatutoryMonth(): void {
+ const d = this.statutoryDate ? new Date(this.statutoryDate + 'T00:00:00') : new Date();
+ const year = d.getFullYear();
+ const month = d.getMonth() + 1;
+ const monthEnd = new Date(year, month, 0).getDate();
+ this.statutoryFirstColumn = this.asDate(year, month, 15);
+ // Filling the CURRENT month would put a month end that has not arrived into the second column,
+ // and the form would be refused the moment it was run. Capped at today, so the quick fill
+ // always produces a pair that can actually be reported.
+ const filled = this.asDate(year, month, monthEnd);
+ this.statutorySecondColumn = filled > this.today ? this.today : filled;
+ this.statutoryDate = this.statutorySecondColumn;
+ }
+
+ /** Clears both columns, so they are derived from the reporting date instead. */
+ clearStatutoryColumns(): void {
+ this.statutoryFirstColumn = '';
+ this.statutorySecondColumn = '';
+ }
+
+ private asDate(year: number, month: number, day: number): string {
+ return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
+ }
+ /**
+ * Form 1 saves the statutory form, because that is the only Form 1 this screen shows.
+ *
+ * Saved under its own form code rather than FORM_1: a saved return is rendered from its stored
+ * snapshot, and a statutory statement filed under the working report's code would be opened by
+ * the reader built for the other shape.
+ */
+ protected override saveableReturn(): { formCode: string; reportingDate: string; report: any } | null {
+ return this.statutory
+ ? { formCode: this.statutory.formCode, reportingDate: this.statutory.reportingDate, report: this.statutory }
+ : null;
+ }
}
diff --git a/src/app/rbi-circulars/reserve-return/form1-statutory.component.scss b/src/app/rbi-circulars/reserve-return/form1-statutory.component.scss
new file mode 100644
index 000000000..65c05639d
--- /dev/null
+++ b/src/app/rbi-circulars/reserve-return/form1-statutory.component.scss
@@ -0,0 +1,377 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+$primary: #1353a0;
+$border: #e2e8f0;
+$gray-50: #f8f9fa;
+$gray-200: #e8eaed;
+$gray-500: #9aa0a6;
+$gray-900: #202124;
+$green: #137333;
+$amber: #b06000;
+$red: #c5221f;
+
+.f1s-container {
+ max-width: 1180px;
+ margin: 0 auto;
+ padding: 20px 16px;
+}
+
+.f1s-loading {
+ padding: 36px;
+ text-align: center;
+ color: $gray-500;
+ font-size: 13px;
+}
+
+.f1s-alert {
+ padding: 12px 16px;
+ border-radius: 8px;
+ font-size: 12.5px;
+ margin-bottom: 16px;
+ line-height: 1.6;
+
+ &--error {
+ background: #fce8e6;
+ color: $red;
+ }
+
+ &--warn {
+ background: #fef7e0;
+ color: $amber;
+ }
+}
+
+.f1s-unmet {
+ margin: 8px 0 0 18px;
+ padding: 0;
+}
+
+// ── Date prompt ──
+// Shown before anything is fetched. Centred and roomy rather than a strip at the top, because at
+// this point choosing the date is the whole of the screen's job.
+
+.f1s-prompt {
+ max-width: 640px;
+ margin: 48px auto;
+ background: #fff;
+ border: 1px solid $border;
+ border-radius: 12px;
+ padding: 32px;
+ text-align: center;
+
+ &__title {
+ margin: 0 0 6px;
+ font-size: 18px;
+ font-weight: 700;
+ color: $gray-900;
+ }
+
+ &__sub {
+ margin: 0 0 24px;
+ font-size: 12.5px;
+ color: $gray-500;
+ line-height: 1.7;
+ }
+
+ &__row {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ flex-wrap: wrap;
+ }
+
+ &__date {
+ width: 220px;
+ }
+
+ &__hint {
+ margin: 14px 0 0;
+ font-size: 12.5px;
+ color: $primary;
+ }
+
+ &__quick {
+ margin-top: 18px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ font-size: 12px;
+ color: $gray-500;
+
+ button {
+ font-size: 12px !important;
+ min-width: 0 !important;
+ }
+ }
+}
+
+// The strip above the sheet when the statutory form is shown as a tab: says what period is on
+// screen and offers the way back to the date prompt.
+.f1s-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ flex-wrap: wrap;
+ margin-bottom: 14px;
+
+ &__date {
+ font-size: 12.5px;
+ font-weight: 600;
+ color: $primary;
+ }
+}
+
+// ── Toolbar ──
+
+.f1s-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ flex-wrap: wrap;
+ margin-bottom: 16px;
+
+ &__title {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 600;
+ color: $gray-900;
+ }
+
+ &__ref {
+ margin: 2px 0 0;
+ font-size: 12px;
+ color: $gray-500;
+ }
+
+ &__actions {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+
+ i {
+ margin-right: 6px;
+ }
+ }
+}
+
+.f1s-date {
+ width: 180px;
+}
+
+// ── The sheet ──
+// Bordered and squared off rather than card-like: this is a statutory return that gets printed and
+// signed, and it should read on screen as the same document that comes out of the printer.
+
+.f1s-sheet {
+ background: #fff;
+ border: 1px solid $gray-900;
+ padding: 20px;
+
+ &__head {
+ text-align: center;
+ border-bottom: 2px solid $gray-900;
+ padding-bottom: 12px;
+ margin-bottom: 16px;
+ }
+
+ &__title {
+ font-size: 15px;
+ font-weight: 700;
+ color: $gray-900;
+ text-transform: uppercase;
+ }
+
+ &__ref {
+ margin-top: 4px;
+ font-size: 12px;
+ color: $gray-500;
+ }
+}
+
+.f1s-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12.5px;
+
+ th,
+ td {
+ border: 1px solid $gray-200;
+ padding: 6px 10px;
+ vertical-align: top;
+ }
+
+ th {
+ background: $gray-50;
+ font-weight: 700;
+ font-size: 11.5px;
+ color: $gray-900;
+ text-align: center;
+ border-color: $gray-900;
+ }
+
+ &__particulars {
+ text-align: left !important;
+ width: 52%;
+ }
+
+ &__num {
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+ }
+
+ // A column heading is a date, not a figure, and sits centred over the column it heads. Qualified
+ // with `th` so it beats `&__num` above on specificity rather than on source order.
+ th#{&}__num {
+ text-align: center;
+ }
+
+ // The second line of a heading — what the column IS, under the date it carries. Without it the
+ // three dates read as three closes, and the base the ratios are charged on looks like one more
+ // reporting date.
+ &__colnote {
+ display: block;
+ margin-top: 2px;
+ font-weight: 400;
+ font-size: 9.5px;
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
+ color: $gray-500;
+ }
+}
+
+// Part banner — PART A / PART B / PART C.
+.f1s-part td {
+ background: $primary;
+ color: #fff;
+ font-weight: 700;
+ font-size: 12px;
+ letter-spacing: 0.03em;
+ border-color: $primary;
+}
+
+// Section caption inside a part, e.g. "(a) Demand Liabilities".
+.f1s-heading td {
+ background: $gray-50;
+ font-weight: 700;
+}
+
+.f1s-total td {
+ font-weight: 700;
+ background: #e8f0fe;
+}
+
+.f1s-total--result td {
+ background: #e6f4ea;
+}
+
+// A shortfall reads as one rather than as a number with a minus sign.
+.f1s-neg {
+ color: $red;
+}
+
+.f1s-subnote td {
+ font-size: 11px;
+ font-style: italic;
+ color: $gray-500;
+ background: #fffdf5;
+}
+
+// The form nests to three levels — (a), then (i), then the figure.
+.f1s-indent-1 {
+ padding-left: 26px !important;
+}
+
+.f1s-indent-2 {
+ padding-left: 46px !important;
+}
+
+.f1s-badge {
+ margin-left: 8px;
+ font-size: 10px;
+ font-weight: 600;
+ background: #fef7e0;
+ color: $amber;
+ border-radius: 20px;
+ padding: 1px 8px;
+ white-space: nowrap;
+}
+
+.f1s-sign {
+ margin-top: 56px;
+ text-align: right;
+
+ &__line {
+ display: inline-block;
+ border-top: 1px solid $gray-900;
+ padding-top: 6px;
+ font-size: 12px;
+ font-weight: 600;
+ min-width: 220px;
+ text-align: center;
+ }
+}
+
+.f1s-toast {
+ position: fixed;
+ bottom: 24px;
+ left: 50%;
+ transform: translateX(-50%);
+ background: $gray-900;
+ color: #fff;
+ font-size: 12.5px;
+ padding: 10px 18px;
+ border-radius: 8px;
+ z-index: 1000;
+}
+
+// ── Print ──
+// The browser's print dialog is also its save-as-PDF, so one stylesheet serves both. The sheet is
+// already laid out as the filed form, so printing only has to drop the controls and keep the
+// shading that carries meaning.
+@media print {
+ .f1s-noprint {
+ display: none !important;
+ }
+
+ .f1s-container {
+ max-width: none;
+ padding: 0;
+ }
+
+ .f1s-sheet {
+ border: none;
+ padding: 0;
+ }
+
+ .f1s-table thead {
+ display: table-header-group;
+ }
+
+ .f1s-table tr {
+ break-inside: avoid;
+ }
+
+ .f1s-toast {
+ display: none !important;
+ }
+
+ .f1s-container,
+ .f1s-sheet,
+ .f1s-table,
+ th,
+ td {
+ -webkit-print-color-adjust: exact;
+ print-color-adjust: exact;
+ }
+}
diff --git a/src/app/rbi-circulars/reserve-return/form9-slr.component.ts b/src/app/rbi-circulars/reserve-return/form9-slr.component.ts
deleted file mode 100644
index 656b1fdc3..000000000
--- a/src/app/rbi-circulars/reserve-return/form9-slr.component.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-/**
- * Copyright since 2025 Mifos Initiative
- *
- * This Source Code Form is subject to the terms of the Mozilla Public
- * License, v. 2.0. If a copy of the MPL was not distributed with this
- * file, You can obtain one at http://mozilla.org/MPL/2.0/.
- */
-
-import { Component, OnInit } from '@angular/core';
-import { FormsModule } from '@angular/forms';
-import { Observable } from 'rxjs';
-import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
-import { ReserveReturnBase } from './reserve-return.base';
-
-/**
- * RBI Form 9 — Statutory Liquidity Ratio, filed at the end of the month.
- *
- * Unlike Form 1 there is no lag: both the liability base and the SLR-eligible investments are read
- * as at month end, so the return states the position actually held at close. Any date the user
- * picks resolves to that month's end.
- */
-@Component({
- selector: 'mifosx-form9-slr',
- templateUrl: './reserve-return.component.html',
- styleUrls: ['./reserve-return.component.scss'],
- imports: [
- ...STANDALONE_SHARED_IMPORTS,
- FormsModule
- ]
-})
-export class Form9SlrComponent extends ReserveReturnBase implements OnInit {
- protected readonly exportStem = 'RBI_Form9_SLR';
- protected readonly formCode = 'FORM_9';
-
- protected fetch(reportingDate: string): Observable {
- return this.rbiService.getForm9(reportingDate);
- }
-
- ngOnInit(): void {
- this.load();
- this.loadSaved();
- }
-}
diff --git a/src/app/rbi-circulars/reserve-return/reserve-return.base.ts b/src/app/rbi-circulars/reserve-return/reserve-return.base.ts
index efa7f5962..3fdbea146 100644
--- a/src/app/rbi-circulars/reserve-return/reserve-return.base.ts
+++ b/src/app/rbi-circulars/reserve-return/reserve-return.base.ts
@@ -7,7 +7,7 @@
*/
import { inject } from '@angular/core';
-import { Observable, catchError, of } from 'rxjs';
+import { Observable, catchError, forkJoin, of } from 'rxjs';
import * as ExcelJS from 'exceljs';
import { RbiCircularsService } from '../rbi-circulars.service';
import { addBanner, boxAllCells, downloadWorkbook, newWorkbook, solidFill, XL } from '../oss-workbook';
@@ -19,6 +19,15 @@ export interface ReserveLine {
configured: boolean;
asOnDate: string;
amount: number | null;
+ /**
+ * The same head read at both of the return's dates, so a row can be compared across them. The
+ * return is built from figures struck at two different dates — the base at its lagged date, the
+ * holdings at the reporting date — and without both a reader cannot tell a real movement from the
+ * two dates simply being different.
+ */
+ baseAmount?: number | null;
+ currentAmount?: number | null;
+ movement?: number | null;
/**
* The accounts the figure was derived from. `balance` is null and `excluded` set when an account
* is still pinned to the role but no longer countable — disabled, or turned into a header whose
@@ -45,6 +54,66 @@ export interface ReserveLineAccount {
liabilityClass?: string | null;
balance: number | null;
excluded?: 'disabled' | 'header';
+ /** The mapping row this came from — one account can be mapped to a head more than once. */
+ mappingId?: number;
+ /**
+ * The same account read at each of the return's two dates, so the breakdown explains both
+ * columns rather than only the one its line is struck at. Null where the account was not mapped
+ * at the other date — it did not hold nothing, it was not on the return.
+ */
+ baseBalance?: number | null;
+ currentBalance?: number | null;
+ baseAccountBalance?: number | null;
+ currentAccountBalance?: number | null;
+ movement?: number | null;
+}
+
+// ── The statutory Form 1 ──
+//
+// Shapes for the return as a co-operative bank files it: three dated columns, Part A establishing
+// the liabilities and Parts B and C settling the two ratios on that same base. It is shown as a tab
+// on the Form 1 screen rather than as a page of its own.
+
+/** One printed row: a heading, a figure line, or a total. */
+export interface Form1Row {
+ label: string;
+ configKey?: string | null;
+ indent?: number;
+ heading?: boolean;
+ total?: boolean;
+ configured?: boolean;
+ values?: number[];
+ /** On the base-date row, the date each column's liabilities were read at. */
+ baseDates?: string[];
+}
+
+export interface Form1Part {
+ title: string;
+ lines: Form1Row[];
+ required?: number[];
+ maintained?: number[];
+ surplus?: number[];
+ baseDates?: string[];
+ baseDateLiabilities?: number[];
+}
+
+export interface Form1Statutory {
+ formCode: string;
+ formName: string;
+ statutoryReference: string;
+ reportingDate: string;
+ /** The two fortnight closes and the reporting date, in time order. */
+ columns: string[];
+ crrPercentage: number | null;
+ slrPercentage: number | null;
+ partA: Form1Part;
+ /**
+ * Part B settles section 18 — the cash reserve. Absent on a return that settles only section 24,
+ * where printing it would put a CRR compliance statement on an SLR form.
+ */
+ partB?: Form1Part;
+ partC: Form1Part;
+ unmetRequirements: string[];
}
/** One row of the Memorandum or an Annexure — a stated figure, entering no total. */
@@ -106,6 +175,9 @@ export interface ReserveReturn {
note: string;
};
lines: ReserveLine[];
+ /** The two dates the return is struck at, so the table can head its columns with them. */
+ baseDate?: string;
+ currentDate?: string;
/** Form A's Memorandum and its two Annexures — sections of the return, not lines on it. */
memorandum?: SectionRow[];
annexureA?: SectionRow[];
@@ -162,6 +234,90 @@ export abstract class ReserveReturnBase {
/** The reporting date the user is looking at. Editable, so past periods can be reviewed. */
reportingDate = new Date().toISOString().slice(0, 10);
+ /**
+ * The date the liability base is struck at, given outright.
+ *
+ * The 45-day lag lands where it lands, and a bank reading an earlier fortnight — or correcting a
+ * filing — needs to name the date rather than work backwards from it. Left blank it is derived
+ * from the lag as before, and the return says on its face which of the two it used.
+ *
+ * Form 9 ignores it: there both sides are struck at the month end, so there is no second date to
+ * choose.
+ */
+ baseDate = '';
+
+ /** Whether this return has a base date distinct from its reporting date. Form 9 does not. */
+ readonly hasBaseDate: boolean = false;
+
+ /**
+ * Nothing is fetched until a date is chosen.
+ *
+ * The return is dated: which fortnight it covers, which lagged date the base is read at and which
+ * rate is in force all follow from it. Opening straight onto today's figures invites a filer to
+ * read a return for a period they never asked for, so the screen asks first.
+ */
+ awaitingDate = true;
+
+ /**
+ * What the prompt calls this return, before any data has been fetched to name it. Once the return
+ * runs its own `formName` is used instead.
+ */
+ get promptTitle(): string {
+ return this.formCode === 'FORM_9' ? 'Form 9 — Statutory Liquidity Ratio' : 'Form 1 — Cash Reserve Ratio';
+ }
+
+ /** Runs the return for the chosen date. */
+ generate(): void {
+ if (!this.reportingDate) {
+ this.errorMsg = 'Choose a reporting date first.';
+ return;
+ }
+ // `max` on the input stops the picker, not a typed or pasted date, and a future one returns
+ // today's figures under a heading that has not happened rather than failing.
+ if (this.reportingDate > this.today) {
+ this.errorMsg = `The reporting date (${this.reportingDate}) has not happened yet — today is ${this.today}.`;
+ return;
+ }
+ if (this.baseDate && this.baseDate > this.today) {
+ this.errorMsg = `The base date (${this.baseDate}) has not happened yet — today is ${this.today}.`;
+ return;
+ }
+ this.awaitingDate = false;
+ this.load();
+ }
+
+ /**
+ * Back to the date prompt, so a filer can run a different period. Both dates are kept as entered:
+ * moving from one fortnight to the next usually means editing one field, not retyping both.
+ */
+ changeDate(): void {
+ this.awaitingDate = true;
+ this.data = null;
+ this.errorMsg = '';
+ }
+
+ /**
+ * The fortnight the chosen date falls in, shown beside the picker so a filer sees which period
+ * they are about to run. Fortnights close on the 15th and on the month end.
+ */
+ get chosenFortnight(): string {
+ if (!this.reportingDate) {
+ return '';
+ }
+ const d = new Date(this.reportingDate + 'T00:00:00');
+ const first = d.getDate() <= 15;
+ const end = first ? 15 : new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
+ const month = d.toLocaleDateString('en-IN', { month: 'short', year: 'numeric' });
+ return `${first ? 1 : 16}–${end} ${month}`;
+ }
+
+ /** Jumps the picker to a fortnight close, which is what a filed return is usually dated. */
+ setToFortnightClose(which: 'first' | 'second'): void {
+ const d = this.reportingDate ? new Date(this.reportingDate + 'T00:00:00') : new Date();
+ const day = which === 'first' ? 15 : new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
+ this.reportingDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
+ }
+
toastMsg = '';
toastVisible = false;
private toastTimer: any;
@@ -179,12 +335,138 @@ export abstract class ReserveReturnBase {
* times and for different reasons, and stacking them meant scrolling past the whole statement -
* including a month of daily rows - to reach the list at all.
*/
- activeTab: 'report' | 'saved' = 'report';
+ activeTab: 'report' | 'statutory' | 'saved' = 'report';
- showTab(tab: 'report' | 'saved'): void {
+ showTab(tab: 'report' | 'statutory' | 'saved'): void {
this.activeTab = tab;
}
+ /**
+ * Whether this screen also offers the statutory Form 1 — the return as a co-operative bank files
+ * it, three dated columns with both ratios settled on one base. Only Form 1 does; Form 9 has no
+ * statutory counterpart in these rules, so its subclass leaves this false.
+ */
+ readonly hasStatutoryForm: boolean = false;
+
+ /**
+ * Whether the statutory form asks for two fortnight closes or one reporting date.
+ *
+ * Form 1 is filed fortnightly and reports two closes side by side, so a filer names both. Form 9
+ * is filed monthly: the month it covers settles which columns it prints, and asking for two dates
+ * there would let a filer name a pair that is not a month.
+ */
+ readonly statutoryTakesTwoColumns: boolean = true;
+
+ /** What the statutory form is called on its tab, so the two returns cannot be confused. */
+ readonly statutoryTabLabel: string = 'Form 1';
+
+ /** What the statutory form is called on its own prompt and button. */
+ readonly statutoryTitle: string = 'Form 1 — Sections 18(1) and 24(3)';
+ readonly statutorySubtitle: string =
+ 'Banking Regulation (Co-operative Societies) Rules, 1966. The form reports two fortnight closes ' +
+ 'side by side — for August that is 15/08 and 31/08 — and charges both ratios on the last Friday ' +
+ 'of the second preceding fortnight.';
+
+ // ── Statutory form state ──
+ //
+ // Held here rather than on Form 1 because Form 9 now carries a statutory form too, and the
+ // template they share reads these directly.
+
+ /** The statutory return, fetched only when its tab is first opened. */
+ statutory: Form1Statutory | null = null;
+ statutoryLoading = false;
+ statutoryError = '';
+
+ /**
+ * Nothing is fetched for the statutory form until a date is chosen. Which period it covers, which
+ * columns it prints and which base date the ratios are charged on all follow from that one field.
+ */
+ statutoryAwaitingDate = true;
+ statutoryDate = new Date().toISOString().slice(0, 10);
+
+ /** The two fortnight closes the form's first two columns report. Unused on a monthly return. */
+ statutoryFirstColumn = '';
+ statutorySecondColumn = '';
+
+ /** Fetches the statutory form. Overridden by each return that has one. */
+ protected fetchStatutory(): Observable {
+ return of(null);
+ }
+
+ /** Runs the statutory return for the dates entered. */
+ generateStatutory(): void {
+ if (this.statutoryTakesTwoColumns) {
+ if (!this.statutoryFirstColumn || !this.statutorySecondColumn) {
+ this.statutoryError = 'Enter both fortnight closing dates — for August that is 15/08 and 31/08.';
+ return;
+ }
+ if (this.statutoryFirstColumn >= this.statutorySecondColumn) {
+ // The form reads left to right as the period advances. Reversed, every movement on it would
+ // carry the wrong sign while still looking like a properly filled return.
+ this.statutoryError = 'The first column must fall before the second.';
+ return;
+ }
+ if (this.statutorySecondColumn > this.today) {
+ this.statutoryError = `The second column (${this.statutorySecondColumn}) has not happened yet — today is ${this.today}.`;
+ return;
+ }
+ // The return is made up to the later of the two closes, so the reporting date follows from
+ // them rather than being a third date a filer has to keep consistent with the first two.
+ this.statutoryDate = this.statutorySecondColumn;
+ } else {
+ if (!this.statutoryDate) {
+ this.statutoryError = 'Choose a reporting date first.';
+ return;
+ }
+ if (this.statutoryDate > this.today) {
+ this.statutoryError = `The reporting date (${this.statutoryDate}) has not happened yet — today is ${this.today}.`;
+ return;
+ }
+ }
+
+ this.statutoryAwaitingDate = false;
+ this.statutoryLoading = true;
+ this.statutoryError = '';
+ this.fetchStatutory()
+ .pipe(
+ catchError((err: any) => {
+ this.statutoryError =
+ err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not compute the statutory return from the ledger.';
+ return of(null);
+ })
+ )
+ .subscribe((res: Form1Statutory | null) => {
+ this.statutoryLoading = false;
+ this.statutory = res;
+ });
+ }
+
+ /** Back to the date prompt, so a filer can run a different period. */
+ changeStatutoryDate(): void {
+ this.statutoryAwaitingDate = true;
+ this.statutory = null;
+ this.statutoryError = '';
+ }
+
+ /** All three parts in printing order, so the template walks one list. */
+ get statutoryParts(): Form1Part[] {
+ if (!this.statutory) {
+ return [];
+ }
+ // partB is absent on a return that settles only section 24, so it is skipped rather than
+ // printed empty.
+ return [
+ this.statutory.partA,
+ this.statutory.partB,
+ this.statutory.partC
+ ].filter((p): p is Form1Part => !!p);
+ }
+
+ /** A shortfall reads as one rather than as a number with a minus sign. */
+ isShort(value: number): boolean {
+ return value < 0;
+ }
+
/** The saved return currently open below the list, or null when none is. */
viewingSaved: any = null;
/**
@@ -263,23 +545,43 @@ export abstract class ReserveReturnBase {
loadSaved(): void {
this.savedLoading = true;
- this.rbiService
- .getSavedReturns(this.formCode)
- .pipe(catchError(() => of([])))
- .subscribe((rows: any[]) => {
+ // Returns saved under a code this screen no longer writes are still returns this screen filed.
+ // Form 1 moved from the working report's code to the statutory one, and fetching only the
+ // current code would hide every return saved before that — which is exactly the history a
+ // saved list exists to hold.
+ const codes = [
+ this.formCode,
+ ...this.legacyFormCodes
+ ];
+ forkJoin(codes.map((code) => this.rbiService.getSavedReturns(code).pipe(catchError(() => of([]))))).subscribe(
+ (results: any[][]) => {
this.savedLoading = false;
- this.savedReturns = rows || [];
- });
+ this.savedReturns = results
+ .flat()
+ .filter((row) => row)
+ .sort((a, b) => String(b.reportingDate ?? '').localeCompare(String(a.reportingDate ?? '')));
+ }
+ );
}
+ /**
+ * Codes this screen used to save under. Listed alongside the current one so nothing already
+ * filed drops out of the list when a form code changes.
+ */
+ protected readonly legacyFormCodes: string[] = [];
+
/** Keeps the return on screen, so it can be read again later exactly as it stands now. */
saveCurrent(): void {
- if (!this.data) {
+ // What is on screen is what gets saved. On a return whose statutory form IS the return, the
+ // working report is never fetched, so saving `data` would save nothing at all — and the return
+ // the filer was looking at could not be kept.
+ const snapshot = this.saveableReturn();
+ if (!snapshot) {
return;
}
this.saving = true;
this.rbiService
- .saveReturn(this.data.formCode, this.data.reportingDate, this.data, this.savedRemarks || undefined)
+ .saveReturn(snapshot.formCode, snapshot.reportingDate, snapshot.report, this.savedRemarks || undefined)
.pipe(
catchError((err: any) => {
this.saving = false;
@@ -415,9 +717,59 @@ export abstract class ReserveReturnBase {
return labels[status] ?? status;
}
- /** Hands the rendered return to the browser's print dialog, which also prints to PDF. */
- printReturn(): void {
- window.print();
+ /**
+ * The return currently on screen, in the shape the saved list stores.
+ *
+ * Overridden where the screen shows something other than the working report, so that Save keeps
+ * the statement the filer was actually reading rather than one they never saw.
+ */
+ protected saveableReturn(): { formCode: string; reportingDate: string; report: any } | null {
+ return this.data
+ ? { formCode: this.data.formCode, reportingDate: this.data.reportingDate, report: this.data }
+ : null;
+ }
+
+ /** Whether there is anything on screen to save — drives the Save button. */
+ get canSave(): boolean {
+ return this.saveableReturn() !== null;
+ }
+
+ /**
+ * Today, as a date input formats it — the latest date any of these returns may be run for.
+ *
+ * A balance is summed as everything up to a date, so a future one does not fail and does not come
+ * back empty: it returns today's figures under a heading that has not happened, and a period
+ * still running reads as one that has closed.
+ */
+ readonly today = new Date().toISOString().slice(0, 10);
+
+ /**
+ * Whether the opened snapshot is a statutory form rather than a working report.
+ *
+ * The two are stored in the same list but are different statements: the working report carries a
+ * `basis` block and one amount per line, the statutory form carries three dated columns and no
+ * basis at all. Rendering one through the other's markup reads every missing field as blank, so
+ * the shape is checked before anything is drawn.
+ */
+ get savedIsStatutory(): boolean {
+ return (this.savedData as any)?.formCode === 'FORM_1_STATUTORY';
+ }
+
+ /** The opened snapshot as a statutory form, for the template to walk its parts. */
+ get savedStatutory(): Form1Statutory | null {
+ return this.savedIsStatutory ? (this.savedData as any as Form1Statutory) : null;
+ }
+
+ /** The opened statutory snapshot's parts, in printing order. */
+ get savedStatutoryParts(): Form1Part[] {
+ const s = this.savedStatutory;
+ // partB is absent on a snapshot of a return that settles only section 24, so it is skipped
+ // rather than rendered as an empty part with a heading and no lines.
+ return s ? [
+ s.partA,
+ s.partB,
+ s.partC
+ ].filter((p): p is Form1Part => !!p) : [];
}
/**
@@ -429,6 +781,30 @@ export abstract class ReserveReturnBase {
return weight !== null && weight !== undefined && weight < 100 && account.accountBalance != null;
}
+ /**
+ * The balance a weighted account's share is taken of, stated for both of the return's dates
+ * where they differ.
+ *
+ * A weight applies to a different balance in each column, and printing only one of them makes
+ * the other column's figure look as though it does not follow from the share stated beside it.
+ * Where the account held the same balance on both dates — or is only on the return at one of
+ * them — one figure says everything there is to say.
+ */
+ portionOf(account: ReserveLineAccount): string {
+ const base = account.baseAccountBalance;
+ const current = account.currentAccountBalance;
+ if (base === undefined && current === undefined) {
+ return this.amount(account.accountBalance);
+ }
+ if (base == null || current == null) {
+ return this.amount(base ?? current ?? account.accountBalance);
+ }
+ if (base === current) {
+ return this.amount(base);
+ }
+ return `${this.amount(base)} / ${this.amount(current)}`;
+ }
+
amount(value: number | null | undefined): string {
if (value === null || value === undefined) {
return '—';
@@ -685,7 +1061,9 @@ export abstract class ReserveReturnBase {
}
}
- private addKeyValue(sheet: ExcelJS.Worksheet, label: string, value: string): void {
+ // Protected rather than private: a subclass exporting its own form — the statutory Form 1 — needs
+ // the same key/value block, and a second copy of it would drift from this one.
+ protected addKeyValue(sheet: ExcelJS.Worksheet, label: string, value: string): void {
const row = sheet.addRow([
label,
value
@@ -693,4 +1071,224 @@ export abstract class ReserveReturnBase {
row.getCell(1).font = { bold: true };
row.getCell(1).fill = solidFill(XL.keyCell);
}
+
+ /**
+ * Prints the form on its own, in a window carrying nothing else.
+ *
+ * `window.print()` on the page printed blank. The form sits inside the app shell — fixed nav
+ * bars, a scrolling content pane — and the print engine lays that out from the top of the
+ * document, so the sheet itself fell past the end of the first page with the shell's own chrome
+ * occupying it. Component styles could not fix it either: they are scoped to this component and
+ * cannot reach the shell that surrounds it.
+ *
+ * So the sheet is lifted into a window of its own with the styles it needs inlined, which is what
+ * the passbook screens do for the same reason.
+ */
+ printReturn(): void {
+ const sheet = document.getElementById('f1s-printable');
+ if (!sheet) {
+ // Nothing generated yet — printing an empty screen would produce the blank page this exists
+ // to prevent.
+ return;
+ }
+
+ const win = window.open('', 'RbiStatutory', 'height=900,width=1200');
+ if (!win) {
+ // Blocked by the browser. Said out loud rather than returning quietly: a Print button that
+ // does nothing reads as the blank page this replaced.
+ this.toast('Allow pop-ups for this site to print the form.');
+ return;
+ }
+ const title = `${this.statutoryTabLabel} — ${this.statutory?.reportingDate ?? ''}`;
+ win.document.write(
+ `${title}` +
+ `${sheet.outerHTML}` +
+ '' +
+ ''
+ );
+ win.document.close();
+ win.focus();
+ }
+
+ /**
+ * The form's own styles, inlined because the print window loads no stylesheet of its own.
+ *
+ * Kept to what the form needs to read correctly on paper: the rules that carry meaning — the part
+ * banners, the totals, the column alignment — rather than a copy of the screen. Backgrounds are
+ * forced to print, because a part banner dropped to save ink takes the form's structure with it.
+ */
+ private static readonly PRINT_STYLES = `
+ @page { size: A4 landscape; margin: 12mm; }
+ * { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
+ body { font-family: Roboto, Arial, sans-serif; color: #212121; margin: 0; }
+ .f1s-sheet { border: 1px solid #212121; padding: 16px; }
+ .f1s-sheet__head { text-align: center; border-bottom: 2px solid #212121; padding-bottom: 10px; margin-bottom: 12px; }
+ .f1s-sheet__title { font-weight: 700; font-size: 15px; text-transform: uppercase; }
+ .f1s-sheet__ref { font-size: 11px; color: #616161; margin-top: 3px; }
+ table { width: 100%; border-collapse: collapse; font-size: 11px; }
+ th, td { border: 1px solid #9e9e9e; padding: 5px 8px; vertical-align: top; }
+ th { background: #eee; font-weight: 700; font-size: 10.5px; text-align: center; border-color: #212121; }
+ .f1s-table__particulars { text-align: left; width: 52%; }
+ td.f1s-table__num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
+ .f1s-table__colnote { display: block; margin-top: 2px; font-weight: 400; font-size: 8.5px;
+ text-transform: uppercase; color: #616161; }
+ /* The form nests to three levels — (a), then (i), then the figure. Flattened, a line reads as
+ belonging to the wrong head and the sub-totals below stop making sense. */
+ .f1s-indent-1 { padding-left: 24px; }
+ .f1s-indent-2 { padding-left: 44px; }
+ .f1s-part td { background: #1b5e9e; color: #fff; font-weight: 700; border-color: #1b5e9e; }
+ .f1s-heading td { background: #f5f5f5; font-weight: 700; }
+ .f1s-total td { font-weight: 700; background: #fafafa; }
+ .f1s-total--result td { background: #e6f4ea; }
+ /* A shortfall reads as one rather than as a number with a minus sign — which on a filed form
+ is the difference between compliant and not. */
+ .f1s-neg { color: #c62828; }
+ .f1s-subnote td { font-size: 9.5px; font-style: italic; color: #616161; background: #fffdf5; }
+ /* The form is signed, so the space for it belongs on the print. */
+ .f1s-sign { margin-top: 48px; text-align: right; }
+ .f1s-sign__line { display: inline-block; border-top: 1px solid #212121; padding-top: 5px;
+ font-size: 11px; min-width: 210px; text-align: center; }
+ thead { display: table-header-group; }
+ tr { break-inside: avoid; }
+ /* An unmapped head reports nil, and a nil that nobody configured is a different thing from a
+ nil the bank actually holds. Kept on the printed form so a reviewer can tell them apart. */
+ .f1s-badge { display: inline-block; margin-left: 6px; padding: 0 4px; font-size: 8px;
+ font-weight: 700; text-transform: uppercase; color: #8a6100; background: #fff3cd;
+ border: 1px solid #e0c56b; border-radius: 3px; }
+ /* A form that is not ready to file says so on the paper too. Dropping this in print would hand
+ someone a statement that looks finished and carries a caveat only the screen ever showed. */
+ .f1s-alert { padding: 8px 12px; border: 1px solid #e0c56b; border-radius: 4px; font-size: 10px;
+ margin-bottom: 12px; background: #fff8e1; color: #8a6100; }
+ .f1s-unmet { margin: 6px 0 0 16px; padding: 0; }
+ /* Screen-only chrome that has no meaning on a filed form. */
+ .rr-noprint, .f1s-bar { display: none !important; }
+ `;
+
+ /**
+ * The statutory form as a workbook, laid out as the form is printed.
+ *
+ * This is the form that gets filed, so it has to leave the screen in a shape a filer can hand on:
+ * the same three dated columns, the same Roman-numbered lines, the same parts in the same order.
+ * A workbook that reorganised the form would have to be read back against the screen line by line
+ * before anyone could trust it.
+ */
+ async exportStatutory(): Promise {
+ if (!this.statutory) {
+ return;
+ }
+ this.generating = true;
+ try {
+ const wb = newWorkbook();
+ const sheet = wb.addWorksheet(this.statutoryTabLabel);
+ sheet.columns = [
+ { width: 64 },
+ { width: 20 },
+ { width: 20 },
+ { width: 20 }];
+
+ addBanner(sheet, this.statutory.formName, 'D');
+
+ const ref = sheet.addRow([this.statutory.statutoryReference]);
+ sheet.mergeCells(ref.number, 1, ref.number, 4);
+ ref.font = { size: 9, italic: true };
+ ref.alignment = { horizontal: 'center' };
+ sheet.addRow([]);
+
+ let start = sheet.rowCount + 1;
+ this.addKeyValue(sheet, 'Made up to', this.statutory.reportingDate);
+ this.addKeyValue(
+ sheet,
+ 'CRR rate',
+ this.statutory.crrPercentage === null ? 'Not set' : `${this.statutory.crrPercentage}%`
+ );
+ this.addKeyValue(
+ sheet,
+ 'SLR rate',
+ this.statutory.slrPercentage === null ? 'Not set' : `${this.statutory.slrPercentage}%`
+ );
+ boxAllCells(sheet, start, sheet.rowCount, 2);
+ sheet.addRow([]);
+
+ // Anything that would make the form wrong to file goes above the figures, not below them:
+ // a caveat under a full statement is read after the reader has already believed it.
+ if (this.statutory.unmetRequirements.length) {
+ const warn = sheet.addRow(['Check before filing:']);
+ warn.font = { bold: true, color: { argb: 'FFB06000' } };
+ for (const reason of this.statutory.unmetRequirements) {
+ const r = sheet.addRow([` ${reason}`]);
+ r.font = { size: 9, color: { argb: 'FFB06000' } };
+ }
+ sheet.addRow([]);
+ }
+
+ start = sheet.rowCount + 1;
+ // The third column is the base the ratios are charged on, not a third close — said on the
+ // header so the workbook cannot be read as carrying three reporting dates.
+ const head = sheet.addRow([
+ 'Particulars',
+ ...this.statutory.columns.map((col, i) => `${col}\n${i === 2 ? 'base for ratios' : 'fortnight close'}`)
+ ]);
+ head.font = { bold: true };
+ head.alignment = { wrapText: true, horizontal: 'center' };
+ head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
+
+ for (const part of this.statutoryParts) {
+ const banner = sheet.addRow([part.title]);
+ sheet.mergeCells(banner.number, 1, banner.number, 4);
+ banner.font = { bold: true, color: { argb: XL.bannerText } };
+ banner.eachCell((c) => (c.fill = solidFill(XL.banner)));
+
+ for (const line of part.lines) {
+ // The form's own indentation carries its numbering — (a), (i), (A) — so a flattened
+ // sheet would lose which line belongs under which head.
+ const row = sheet.addRow([
+ `${' '.repeat(line.indent ?? 0)}${line.label}`,
+ ...(line.values ?? []).map((v) => v)
+ ]);
+ row.getCell(1).alignment = { wrapText: true };
+ if (line.heading || line.total) {
+ row.font = { bold: true };
+ }
+ for (let c = 2; c <= 4; c++) {
+ row.getCell(c).numFmt = '#,##0.00';
+ row.getCell(c).alignment = { horizontal: 'right' };
+ }
+ }
+
+ this.addStatutoryTotals(sheet, 'Required', part.required);
+ this.addStatutoryTotals(sheet, 'Maintained', part.maintained);
+ this.addStatutoryTotals(sheet, 'Surplus / (shortfall)', part.surplus);
+ }
+ boxAllCells(sheet, start, sheet.rowCount, 4);
+
+ await downloadWorkbook(wb, `${this.exportStem}_${this.statutory.reportingDate}`);
+ } finally {
+ this.generating = false;
+ }
+ }
+
+ /**
+ * Form 1 saves the statutory form, because that is the only Form 1 this screen shows.
+ *
+ * Saved under its own form code rather than FORM_1: a saved return is rendered from its stored
+ * snapshot, and a statutory statement filed under the working report's code would be opened by
+ * the reader built for the other shape.
+ */
+
+ /** One of a part's computed rows, written only where the part carries it. */
+ private addStatutoryTotals(sheet: ExcelJS.Worksheet, label: string, values?: number[]): void {
+ if (!values?.length) {
+ return;
+ }
+ const row = sheet.addRow([
+ label,
+ ...values
+ ]);
+ row.font = { bold: true };
+ for (let c = 2; c <= 4; c++) {
+ row.getCell(c).numFmt = '#,##0.00';
+ row.getCell(c).alignment = { horizontal: 'right' };
+ }
+ }
}
diff --git a/src/app/rbi-circulars/reserve-return/reserve-return.component.html b/src/app/rbi-circulars/reserve-return/reserve-return.component.html
index 2f8dbc1a9..9495bf0d5 100644
--- a/src/app/rbi-circulars/reserve-return/reserve-return.component.html
+++ b/src/app/rbi-circulars/reserve-return/reserve-return.component.html
@@ -7,7 +7,88 @@
-->
- @if (loading) {
+
+
+ @if (hasStatutoryForm) {
+
+
+
+
+ }
+
+
+ @if (hasStatutoryForm) {
+
+ } @else if (awaitingDate) {
+
+
{{ promptTitle }}
+
+ Choose the date the return is made up to. The base is read at an earlier date than the balances maintained, and
+ the return states both on its face once it runs.
+
+
+
+
+ @if (hasBaseDate) {
+
+ Base date (optional)
+
+
+ }
+
+ Reporting date
+
+
+
+
+
+ @if (chosenFortnight) {
+
+ Fortnight: {{ chosenFortnight }}
+ @if (hasBaseDate && !baseDate) {
+ · base will be derived from the 45-day lag
+ }
+
+ }
+
+
+
+ Jump to:
+
+
+ @if (hasBaseDate && baseDate) {
+
+ }
+
+
+ @if (errorMsg) {
+
{{ errorMsg }}
+ }
+
+ } @else if (loading) {
Computing from the ledger…
} @else if (errorMsg) {
{{ errorMsg }}
@@ -27,11 +108,19 @@