Rebounder Tech Blog

Written by the people who actually run these systems in production.

term_months Display Drifts From Amount After Term Extension

Published About 4 min readBy the Rebounder engineering team — the people who operate these systems

This article may contain affiliate links. Its content is not affected by advertising.

In short

The invoice's period text read first.term_months (the plan's fixed unit), while the amount used period_start/period_end (the chosen period), so extending the term made them disagree.

Conclusion

The contract amount is calculated from the effective period chosen at application (period_start/period_end), but the “contract period” text on the application form and invoice still referenced a different, separate value: the unit months fixed on the plan (term_months). Once contracts could extend that unit month count at application time, the amount reflected the extended period while the displayed period kept showing the original unit — a contradiction that showed up on the document itself.

Symptom

The system bills in fixed units, and each listing plan has a base unit month count (term_months) set in advance. Once a feature let customers extend that count at application time, the contract table stored the actually-chosen period as period_start/period_end, and the amount was calculated from that effective period.

But the display logic that assembles invoices and quotes in InvoiceDoc.tsx was left over from before that feature existed (before the fix, at 736a3be):

const period =
  first.start_month && first.term_months
    ? `${monthJp(first.start_month)}〜${monthJp(endMonthStr(first.start_month, first.term_months))}(${first.term_months}ヶ月一括)`
    : "";

first.term_months is the plan’s fixed value, not the contract’s actual extended period. application/page.tsx, which assembles the application form, referenced the same kind of value separately (same point in time):

const termMonths = first.term_months ?? 1;
if (first.start_month && first.term_months) {
  const endMon = endMonthStr(first.start_month, first.term_months);
  displayPeriod = `${monthJp(first.start_month)} 〜 ${monthJp(endMon)}(契約期間 ${first.term_months}ヶ月)`;
}

Before extension existed, term_months and the effective period were always identical, so this was never a problem. Once the extension feature shipped, that assumption broke: the amount field showed the price for the extended months, while the period field kept showing the original unit — both on the same document, at the same time.

Cause

The root cause is that the calculation that builds the “contract period” string referenced the plan template’s fixed values (start_month/term_months) directly, instead of the contract’s effective period (period_start/period_end), and this was written separately in at least two places: InvoiceDoc.tsx and application/page.tsx. The term-extension feature itself didn’t touch either of those two spots, which made the drift hard to see during that feature’s review — it was actually flagged as a review issue and fixed 12 minutes after the feature commit (736a3be → 66977ce, both on 2026-06-18).

The fix

The fix added two new functions, monthSpan/docPeriod, to InvoiceDoc.tsx, consolidating how the period is decided into one place (after the fix, at 66977ce):

export function monthSpan(
  startDate: string | null | undefined,
  endDate: string | null | undefined
): number | null {
  if (!startDate || !endDate) return null;
  const [sy, sm] = startDate.slice(0, 7).split("-").map(Number);
  const [ey, em] = endDate.slice(0, 7).split("-").map(Number);
  if (!sy || !sm || !ey || !em) return null;
  const months = (ey - sy) * 12 + (em - sm) + 1;
  return months >= 1 ? months : null;
}

export function docPeriod(
  periodStart: string | null | undefined,
  periodEnd: string | null | undefined,
  fallbackStartMonth: string | null | undefined,
  fallbackTermMonths: number | null | undefined
): { startYm: string; term: number } | null {
  const term = monthSpan(periodStart, periodEnd) ?? fallbackTermMonths ?? null;
  const startYm = (periodStart ? periodStart.slice(0, 7) : fallbackStartMonth) ?? null;
  if (!startYm || !term) return null;
  return { startYm, term };
}

docPeriod prioritizes the contract’s period_start/period_end (the actually-chosen effective period), and only falls back to the plan’s start_month/term_months for contracts that have neither (i.e. older contracts that were never extended). The reference to first.term_months itself wasn’t removed — removing it would break display for contracts that were never extended. Only the priority order was reversed.

application/page.tsx was changed to call the same function:

const dp = docPeriod(
  contract.period_start,
  contract.period_end,
  first.start_month,
  first.term_months
);
const termMonths = dp?.term ?? first.term_months ?? 1;
displayPeriod = dp
  ? `${monthJp(dp.startYm)} 〜 ${monthJp(endMonthStr(dp.startYm, dp.term))}(契約期間 ${dp.term}ヶ月)`
  : `契約期間 ${termMonths}ヶ月`;

According to the commit message, the same docPeriod was also applied to the archived-document display, the consent page, and the contract list.

Preventing a repeat

This inconsistency happened because the same calculation — how to turn a contract’s period into a string — was written independently in as many files as assembled a document. The amount calculation was already correctly built on the effective period, but the display side, in multiple places, was still left reading the plan template’s fixed value. When adding a feature like term extension, where a value can drift between “the actual effective value” and “the plan’s template value” for only some contracts, it isn’t enough to search one file for references to that value — you also need to check whether some other file has independently rebuilt the same calculation.

Frequently asked questions

Q1Why did only the amount and the displayed contract period disagree?

The amount is calculated from the contract's period_start/period_end (the effective period chosen at application), but the document's "contract period" text still read first.term_months (the unit months fixed on the plan) directly. The two values came from different places.

Q2Did this only affect invoices?

No. The same calculation was written separately in both InvoiceDoc.tsx (invoices and quotes) and application/page.tsx (the application form), and both drifted for the same reason. The commit message says the consent page and contract list display got the same kind of fix.

Q3Did customers actually receive contradictory documents in production?

Within what the sources show, the commit that added term-extension and the commit that fixed this drift (caught in review) are both dated 2026-06-18, 12 minutes apart. Whether a contradictory document actually reached a customer in production isn't stated in the sources, so it isn't judged here.

Q4Did the fix mean removing the reference to first.term_months?

No, it wasn't removed. The new docPeriod() prioritizes period_start/period_end, and only falls back to first.start_month/first.term_months for contracts with no such values (i.e. older contracts that were never extended). Display for existing contracts didn't change.

Q5Why did the fix require changing multiple files instead of one?

Because the same calculation that builds the "contract period" string was written separately in both InvoiceDoc.tsx and application/page.tsx. It was consolidated into a single function, docPeriod(), and both files were changed to call it.

Environment verified

  • Next.js 16.2.7 / @supabase/supabase-js ^2.106.2 / TypeScript ^5
  • The term-extension feature and its fix both shipped on 2026-06-18

What this article is based on

  • TypeScript file lines 93-99commit 736a3be
  • TypeScript file lines 31-60commit 66977ce
  • TypeScript file lines 137-142commit 736a3be
  • TypeScript file lines 137-146commit 66977ce

Every claim in this article comes from the records above. The repositories we operate are private so we cannot link to them, but which file, which lines, and at which commit we read them is recorded for every article. Nothing here is written from guesswork.