From de1704a39600830f1197805c890bbb9d48622a96 Mon Sep 17 00:00:00 2001 From: Shubham Seervi Date: Sun, 28 Jun 2026 13:25:30 +0530 Subject: [PATCH 1/4] feat: add 10-year long-tenure loyalty discount bracket Adds a 15% loyalty bracket for members of 10+ years on top of the existing 1/3/5-year tiers. This rewards the lowest-churn, lowest-claims-frequency cohort and, combined with the no-claims cap, now exercises the regulatory premium floor for the cheapest policies. Closes PRU-123 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/discount.ts | 7 +++++-- test/discount.test.ts | 9 ++++++++- test/quote.test.ts | 10 +++++----- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/discount.ts b/src/discount.ts index db7775f..37eb3d8 100644 --- a/src/discount.ts +++ b/src/discount.ts @@ -12,10 +12,13 @@ export function noClaimsDiscount(req: QuoteRequest): number { /** * Multi-tier loyalty bonus (added by the loyalty-discount feature). - * Brackets, not linear: the value of retention steps up at the 3- and 5-year - * marks rather than accruing smoothly, matching how the retention team models it. + * Brackets, not linear: the value of retention steps up at the 3-, 5- and + * 10-year marks rather than accruing smoothly, matching how the retention + * team models it. The 10-year "long-tenure" bracket rewards the customers + * with the lowest churn risk and the lowest claims frequency. */ export function loyaltyDiscountRate(loyaltyYears: number): number { + if (loyaltyYears >= 10) return 0.15; if (loyaltyYears >= 5) return 0.1; if (loyaltyYears >= 3) return 0.06; if (loyaltyYears >= 1) return 0.03; diff --git a/test/discount.test.ts b/test/discount.test.ts index bacb9e4..5f0f982 100644 --- a/test/discount.test.ts +++ b/test/discount.test.ts @@ -32,7 +32,9 @@ describe("loyaltyDiscountRate", () => { [3, 0.06], [4, 0.06], [5, 0.1], - [12, 0.1] + [9, 0.1], + [10, 0.15], + [12, 0.15] ])("years=%i -> rate=%f", (years, rate) => { expect(loyaltyDiscountRate(years)).toBe(rate); }); @@ -52,4 +54,9 @@ describe("loyaltyDiscount", () => { // 10% of 950 = 95 expect(loyaltyDiscount({ ...baseReq, loyaltyYears: 7 })).toBe(95); }); + + it("applies the 10-year long-tenure bracket", () => { + // 15% of 950 = 142.5 + expect(loyaltyDiscount({ ...baseReq, loyaltyYears: 11 })).toBe(142.5); + }); }); diff --git a/test/quote.test.ts b/test/quote.test.ts index de5440f..ece727b 100644 --- a/test/quote.test.ts +++ b/test/quote.test.ts @@ -33,10 +33,10 @@ describe("calculateQuote", () => { ]); }); - it("stays at or above the regulatory floor under the deepest discount", () => { + it("clamps to the regulatory floor under the deepest discount", () => { // Cheapest base (basic/compact = 540) with both discounts maxed: - // no-claims caps at 50% (270) + loyalty 10% (54) -> gross 216, which is still - // above the 200 floor. So the floor does not fire, but the result is guarded. + // no-claims caps at 50% (270) + the 10-year loyalty bracket 15% (81) -> gross 189, + // which falls below the 200 floor, so the floor adjustment brings it back up to 200. const q = calculateQuote({ ...baseReq, coverageTier: "basic", @@ -44,8 +44,8 @@ describe("calculateQuote", () => { yearsClaimFree: 30, loyaltyYears: 10 }); - expect(q.finalPremium).toBe(216); - expect(q.finalPremium).toBeGreaterThanOrEqual(PREMIUM_FLOOR); + expect(q.finalPremium).toBe(PREMIUM_FLOOR); + expect(q.lineItems.some((li) => li.label === "Regulatory floor adjustment")).toBe(true); }); it("propagates validation errors", () => { From e991df8c1145c58f80253ac8cd701c448dc17063 Mon Sep 17 00:00:00 2001 From: Shubham Seervi Date: Sun, 28 Jun 2026 13:34:42 +0530 Subject: [PATCH 2/4] ci: run gitleaks via the pinned OSS binary instead of the action gitleaks/gitleaks-action@v2 now requires a paid licence for organisation repos, which we do not have. gitleaks core is MIT/OSS, so run the pinned v8.30.1 release binary directly. Keeps the `gitleaks` workflow and job/check name (the required status-check context) and produces a real secret scan. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/gitleaks.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml index a570208..a412129 100644 --- a/.github/workflows/gitleaks.yml +++ b/.github/workflows/gitleaks.yml @@ -6,4 +6,15 @@ jobs: steps: - uses: actions/checkout@v4 with: { fetch-depth: 0 } - - uses: gitleaks/gitleaks-action@v2 + # gitleaks/gitleaks-action@v2 now requires a paid licence for organisation + # repos. gitleaks itself is MIT/OSS, so we run the pinned release binary + # directly to keep the secret-scan free while preserving the `gitleaks` + # workflow + job/check name the governance scanner matches on. + - name: Run gitleaks + run: | + set -euo pipefail + VERSION=8.30.1 + curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/gitleaks_${VERSION}_linux_x64.tar.gz" \ + | tar -xz gitleaks + ./gitleaks version + ./gitleaks detect --source . --redact --verbose --exit-code 1 From 41ba04a1d71a095565c5154d26b88764f4483493 Mon Sep 17 00:00:00 2001 From: Shubham Seervi Date: Sun, 28 Jun 2026 13:38:54 +0530 Subject: [PATCH 3/4] ci(gitleaks): verify release tarball checksum before extracting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses a CodeRabbit review comment: the gitleaks install step pulled the release tarball without integrity verification. Now download the published checksums file and verify the tarball's SHA-256 (sha256sum -c) before extraction — supply-chain hardening, fitting for an SDLC demo repo. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/gitleaks.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml index a412129..b66a214 100644 --- a/.github/workflows/gitleaks.yml +++ b/.github/workflows/gitleaks.yml @@ -14,7 +14,13 @@ jobs: run: | set -euo pipefail VERSION=8.30.1 - curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/gitleaks_${VERSION}_linux_x64.tar.gz" \ - | tar -xz gitleaks + TARBALL="gitleaks_${VERSION}_linux_x64.tar.gz" + BASE="https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}" + # Download the release tarball and its published checksums, then verify + # the tarball's SHA-256 before extracting (supply-chain integrity). + curl -sSfL "${BASE}/${TARBALL}" -o "${TARBALL}" + curl -sSfL "${BASE}/gitleaks_${VERSION}_checksums.txt" -o checksums.txt + grep " ${TARBALL}\$" checksums.txt | sha256sum -c - + tar -xzf "${TARBALL}" gitleaks ./gitleaks version ./gitleaks detect --source . --redact --verbose --exit-code 1 From 330281196be0af9d69c54de942cc22424cacbf25 Mon Sep 17 00:00:00 2001 From: Shubham Seervi Date: Sun, 28 Jun 2026 13:47:38 +0530 Subject: [PATCH 4/4] chore: drop cc-shekher from CODEOWNERS (read-only, not a valid owner) cc-shekher has read-only access, so GitHub flagged them as an unknown owner ("needs write access") and the codeowners/errors API reported the error. Removing them leaves the 5 valid Code Owners and clears repos.codeownersErrors once this PR merges to the protected main branch. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d2b87ca..46c302c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,3 @@ # Code Owners for quote-engine-svc. # Every change to main requires review from a Code Owner (enforced via branch protection). -* @NikhilVerma @pranaySinghDev @shubhamseervi @ssghait007 @cc-shekher @ankitpawarcldcvr +* @NikhilVerma @pranaySinghDev @shubhamseervi @ssghait007 @ankitpawarcldcvr