Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 130 additions & 18 deletions src/static/js/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,113 @@ const profileIsComplete = profileCompleteField?.value === "true";
const itemCounts = {};
const maxItems = 15;
let isSubmitting = false;
const preciseDecimalPlaces = 8;
const moneyDecimalPlaces = 2;

for (let i = 1; i <= 10; i++) {
itemCounts[i] = 1;
}

function decimalScale(decimalPlaces) {
return 10n ** BigInt(decimalPlaces);
}

function parseScaledAmount(value, decimalPlaces) {
const rawValue = String(value ?? '').trim();
if (!rawValue) return 0n;

const match = rawValue.match(/^(\d*)(?:\.(\d*))?$/);
if (!match || (!match[1] && !match[2])) return 0n;

const wholePart = match[1] || '0';
const fractionPart = match[2] || '';
const scale = decimalScale(decimalPlaces);
const normalizedFraction = fractionPart.padEnd(decimalPlaces + 1, '0');
const keptFraction = normalizedFraction.slice(0, decimalPlaces) || '0';
const nextDigit = Number(normalizedFraction[decimalPlaces] || '0');

let scaledValue = BigInt(wholePart) * scale + BigInt(keptFraction);
if (nextDigit >= 5) {
scaledValue += 1n;
}

return scaledValue;
}

function parseQuantity(value) {
const rawValue = String(value ?? '').trim();
if (!/^\d+$/.test(rawValue)) return 0n;
return BigInt(rawValue);
}

function formatScaledAmount(scaledValue, decimalPlaces) {
const scale = decimalScale(decimalPlaces);
const isNegative = scaledValue < 0n;
const absoluteValue = isNegative ? -scaledValue : scaledValue;
const wholePart = absoluteValue / scale;
const fractionPart = String(absoluteValue % scale).padStart(decimalPlaces, '0');
const sign = isNegative ? '-' : '';

return `${sign}${wholePart}.${fractionPart}`;
}

function roundScaledAmount(scaledValue, fromDecimalPlaces, toDecimalPlaces) {
if (fromDecimalPlaces === toDecimalPlaces) {
return scaledValue;
}

if (fromDecimalPlaces < toDecimalPlaces) {
return scaledValue * decimalScale(toDecimalPlaces - fromDecimalPlaces);
}

const divisor = decimalScale(fromDecimalPlaces - toDecimalPlaces);
const halfDivisor = divisor / 2n;

if (scaledValue < 0n) {
return (scaledValue - halfDivisor) / divisor;
}
return (scaledValue + halfDivisor) / divisor;
}

function formatPreciseAmount(scaledValue) {
return formatScaledAmount(scaledValue, preciseDecimalPlaces);
}

function formatMoneyFromPreciseAmount(scaledValue) {
const cents = roundScaledAmount(
scaledValue,
preciseDecimalPlaces,
moneyDecimalPlaces
);
return formatScaledAmount(cents, moneyDecimalPlaces);
}

function parseMoneyCents(value) {
return parseScaledAmount(value, moneyDecimalPlaces);
}

function formatMoneyCents(cents) {
return formatScaledAmount(cents, moneyDecimalPlaces);
}

function formatMoneyInput(input) {
if (!input.value.trim()) return;
input.value = formatMoneyCents(parseMoneyCents(input.value));
}

function formatMoneyFields() {
const formsToRecalculate = new Set();

document.querySelectorAll('input[data-format="money"]').forEach(input => {
formatMoneyInput(input);
if (input.dataset.action === 'recalc-total') {
formsToRecalculate.add(Number(input.dataset.form));
}
});

formsToRecalculate.forEach(formNumber => calculateFinalTotal(formNumber));
}

function toggleForm(formNumber) {
const content = document.getElementById(`form-${formNumber}`);
const toggle = content.previousElementSibling.querySelector('[data-role="accordion-toggle"]');
Expand Down Expand Up @@ -103,26 +205,25 @@ function calculateItemTotal(formNumber, itemNumber) {
const totalInput = document.querySelector(`input[name="item_total_${formNumber}_${itemNumber}"]`);

if (quantityInput && priceInput && totalInput) {
const quantity = parseFloat(quantityInput.value) || 0;
const price = parseFloat(priceInput.value) || 0;
const quantity = parseQuantity(quantityInput.value);
const price = parseScaledAmount(priceInput.value, preciseDecimalPlaces);
const total = quantity * price;

totalInput.value = total.toFixed(8);
totalInput.value = formatPreciseAmount(total);
calculateSubtotal(formNumber);
}
Comment on lines +208 to 214

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Item parsing mismatch 🐞 Bug ≡ Correctness

calculateItemTotal() now uses parseQuantity()/parseScaledAmount(), but validateSubmission() still
uses parseFloat() to decide whether an item row is complete. This can mark rows like quantity="1.0"
or price="1e-3" as complete even though the UI calculates totals as 0 and the backend will skip
invalid items (or the whole form if no valid items remain).
Agent Prompt
### Issue description
`validateSubmission()` still uses `parseFloat()` for item completeness checks, while item total/subtotal calculations were changed to strict BigInt parsers (`parseQuantity()` / `parseScaledAmount()`). This mismatch allows inputs that the validator considers “complete” (e.g. `1.0`, `1e-3`) to produce zero totals in the UI and/or be rejected/skipped by server-side Pydantic validation.

### Issue Context
- Client calculations:
  - `parseQuantity()` only accepts `^\d+$`.
  - `parseScaledAmount()` only accepts plain decimal strings (no scientific notation).
- Client validation:
  - `validateSubmission()` uses `parseFloat()` and `> 0` checks for quantity/price.
- Server validation:
  - `SubmissionLineItem.quantity` is an `int` with `gt=0`; invalid quantities raise `ValidationError` and are skipped.

### Fix Focus Areas
- src/static/js/dashboard.js[402-424]
- src/static/js/dashboard.js[40-44]
- src/static/js/dashboard.js[202-214]
- src/templates/dashboard.html[175-184]

### Suggested fix
1. Update `validateSubmission()` to use the same parsing rules as calculations:
   - `const itemQuantity = parseQuantity(quantityInput?.value);`
   - `const itemPrice = parseScaledAmount(priceInput?.value, preciseDecimalPlaces);`
   - Treat the row as complete only when `itemQuantity > 0n && itemPrice > 0n`.
2. Prevent or normalize problematic number formats at the input level:
   - Set `step="1"` on quantity inputs (and consider `inputmode="numeric"`).
   - Optionally add a blur formatter for item price/quantity similar to `formatMoneyInput()` (e.g., rewrite `1e-3` into a plain decimal string at the desired precision) so users can’t submit values the calculator treats as 0.
3. If you choose to keep rejecting scientific notation, ensure validation explicitly fails those rows with a clear alert instead of silently treating them as zero.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

}

function calculateSubtotal(formNumber) {
const container = document.getElementById(`items-container-${formNumber}`);
const totalInputs = container.querySelectorAll('input[name^="item_total_"]');
let subtotal = 0;
let subtotal = 0n;

totalInputs.forEach(input => {
const value = parseFloat(input.value) || 0;
subtotal += value;
subtotal += parseScaledAmount(input.value, preciseDecimalPlaces);
});

const formattedSubtotal = subtotal.toFixed(8);
const formattedSubtotal = formatMoneyFromPreciseAmount(subtotal);
const subtotalField = document.getElementById(`subtotal_amount_${formNumber}`);
if (subtotalField) subtotalField.value = formattedSubtotal;
const usSubtotalField = document.getElementById(`us_subtotal_${formNumber}`);
Expand All @@ -144,7 +245,7 @@ function updateHstRequirement(formNumber, subtotal) {
if (!currencySelect || !hstGstInput) return;

const isCAD = currencySelect.value === 'CAD';
const hasSubtotal = subtotal > 0;
const hasSubtotal = typeof subtotal === 'bigint' ? subtotal > 0n : subtotal > 0;
const shouldBeRequired = isCAD && hasSubtotal;

if (shouldBeRequired) {
Expand All @@ -159,13 +260,13 @@ function updateHstRequirement(formNumber, subtotal) {
}

function calculateFinalTotal(formNumber) {
const subtotal = parseFloat(document.getElementById(`subtotal_amount_${formNumber}`).value) || 0;
const discount = parseFloat(document.getElementById(`discount_amount_${formNumber}`).value) || 0;
const hstGst = parseFloat(document.getElementById(`hst_gst_amount_${formNumber}`).value) || 0;
const shipping = parseFloat(document.getElementById(`shipping_amount_${formNumber}`).value) || 0;
const subtotal = parseMoneyCents(document.getElementById(`subtotal_amount_${formNumber}`).value);
const discount = parseMoneyCents(document.getElementById(`discount_amount_${formNumber}`).value);
const hstGst = parseMoneyCents(document.getElementById(`hst_gst_amount_${formNumber}`).value);
const shipping = parseMoneyCents(document.getElementById(`shipping_amount_${formNumber}`).value);

const total = subtotal - discount + hstGst + shipping;
document.getElementById(`total_cad_amount_${formNumber}`).value = Math.max(0, total).toFixed(8);
document.getElementById(`total_cad_amount_${formNumber}`).value = formatMoneyCents(total > 0n ? total : 0n);
}

function updateCurrencyLabels(formNumber) {
Expand Down Expand Up @@ -219,7 +320,7 @@ function updateCurrencyLabels(formNumber) {
}

const subtotalInput = document.getElementById(`subtotal_amount_${formNumber}`);
const currentSubtotal = parseFloat(subtotalInput ? subtotalInput.value : 0) || 0;
const currentSubtotal = parseMoneyCents(subtotalInput ? subtotalInput.value : '');
updateHstRequirement(formNumber, currentSubtotal);

if (cadBreakdown) cadBreakdown.style.display = 'block';
Expand Down Expand Up @@ -366,19 +467,19 @@ function validateSubmission() {
return false;
}

let totalCanadianAmount = 0;
let totalCanadianCents = 0n;
for (let formNumber = 1; formNumber <= 10; formNumber++) {
const vendorName = document.getElementById(`vendor_name_${formNumber}`);
if (!vendorName || !vendorName.value.trim()) {
continue;
}
const totalField = document.getElementById(`total_cad_amount_${formNumber}`);
if (totalField && totalField.value) {
totalCanadianAmount += parseFloat(totalField.value) || 0;
totalCanadianCents += parseMoneyCents(totalField.value);
}
}
if (totalCanadianAmount < 100) {
alert(`Total Canadian amount must be greater than $100.00 CAD.\nCurrent total: $${totalCanadianAmount.toFixed(8)} CAD`);
if (totalCanadianCents < 10000n) {
alert(`Total Canadian amount must be greater than $100.00 CAD.\nCurrent total: $${formatMoneyCents(totalCanadianCents)} CAD`);
return false;
}

Expand Down Expand Up @@ -516,6 +617,15 @@ function initializeStaticHandlers() {
input.addEventListener('input', () => calculateFinalTotal(Number(input.dataset.form)));
});

document.querySelectorAll('input[data-format="money"]').forEach(input => {
input.addEventListener('blur', () => {
formatMoneyInput(input);
if (input.dataset.action === 'recalc-total') {
calculateFinalTotal(Number(input.dataset.form));
}
});
});

document.querySelectorAll('[data-role="file-upload-text"]').forEach(trigger => {
trigger.addEventListener('click', () => {
const input = document.getElementById(trigger.dataset.inputId);
Expand Down Expand Up @@ -569,6 +679,8 @@ document.addEventListener('DOMContentLoaded', function () {
return;
}

formatMoneyFields();

if (!validateSubmission()) {
e.preventDefault();
return;
Expand Down
26 changes: 13 additions & 13 deletions src/templates/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ <h4 class="m-0 text-base font-semibold text-zinc-100 mb-3">Financial Summary</h4
<label for="subtotal_amount_{{ form_num }}" class="block mb-1 text-sm font-medium text-zinc-200">Subtotal (<span
class="currency-label-{{ form_num }}">CAD</span>)</label>
<input type="number" id="subtotal_amount_{{ form_num }}"
name="subtotal_amount_{{ form_num }}" readonly min="0" step="0.00000001"
name="subtotal_amount_{{ form_num }}" readonly min="0" step="0.01"
placeholder="0.00" class="total-field w-full rounded-lg border border-zinc-700 bg-zinc-800/70 px-3 py-2 text-sm text-zinc-200">
<small class="financial-help mt-1 block text-xs text-zinc-400">Automatically calculated from item totals
above</small>
Expand All @@ -224,8 +224,8 @@ <h4 class="m-0 text-base font-semibold text-zinc-100 mb-3">Financial Summary</h4
<label for="discount_amount_{{ form_num }}" class="block mb-1 text-sm font-medium text-zinc-200">Discount (<span
class="currency-label-{{ form_num }}">CAD</span>)</label>
<input type="number" id="discount_amount_{{ form_num }}"
name="discount_amount_{{ form_num }}" min="0" step="0.00000001"
placeholder="0.00" data-action="recalc-total" data-form="{{ form_num }}" class="w-full rounded-lg border border-zinc-700 bg-zinc-900/80 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30">
name="discount_amount_{{ form_num }}" min="0" step="0.01"
placeholder="0.00" data-action="recalc-total" data-format="money" data-form="{{ form_num }}" class="w-full rounded-lg border border-zinc-700 bg-zinc-900/80 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30">
<small class="financial-help mt-1 block text-xs text-zinc-400">Coupons, promotional discounts, or
rebates</small>
</div>
Expand All @@ -238,8 +238,8 @@ <h4 class="m-0 text-base font-semibold text-zinc-100 mb-3">Financial Summary</h4
class="currency-label-{{ form_num }}">CAD</span>) <span
class="required-indicator-{{ form_num }}"></span></label>
<input type="number" id="hst_gst_amount_{{ form_num }}"
name="hst_gst_amount_{{ form_num }}" min="0" step="0.00000001"
placeholder="0.00" data-action="recalc-total" data-form="{{ form_num }}" class="w-full rounded-lg border border-zinc-700 bg-zinc-900/80 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30">
name="hst_gst_amount_{{ form_num }}" min="0" step="0.01"
placeholder="0.00" data-action="recalc-total" data-format="money" data-form="{{ form_num }}" class="w-full rounded-lg border border-zinc-700 bg-zinc-900/80 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30">
<small class="financial-help tax-help-{{ form_num }} mt-1 block text-xs text-zinc-400">Harmonized Sales
Tax / Goods and Services Tax</small>
</div>
Expand All @@ -250,8 +250,8 @@ <h4 class="m-0 text-base font-semibold text-zinc-100 mb-3">Financial Summary</h4
<label for="shipping_amount_{{ form_num }}" class="block mb-1 text-sm font-medium text-zinc-200">Shipping & Handling (<span
class="currency-label-{{ form_num }}">CAD</span>)</label>
<input type="number" id="shipping_amount_{{ form_num }}"
name="shipping_amount_{{ form_num }}" min="0" step="0.00000001"
placeholder="0.00" data-action="recalc-total" data-form="{{ form_num }}" class="w-full rounded-lg border border-zinc-700 bg-zinc-900/80 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30">
name="shipping_amount_{{ form_num }}" min="0" step="0.01"
placeholder="0.00" data-action="recalc-total" data-format="money" data-form="{{ form_num }}" class="w-full rounded-lg border border-zinc-700 bg-zinc-900/80 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30">
<small class="financial-help mt-1 block text-xs text-zinc-400">Shipping, delivery, and handling
fees</small>
</div>
Expand All @@ -266,7 +266,7 @@ <h4 class="m-0 text-base font-semibold text-zinc-100 mb-3">Financial Summary</h4
<div class="form-group">
<label for="us_subtotal_{{ form_num }}" class="block mb-1 text-sm font-medium text-zinc-200">US Subtotal (USD)</label>
<input type="number" id="us_subtotal_{{ form_num }}"
name="us_subtotal_{{ form_num }}" readonly min="0" step="0.00000001"
name="us_subtotal_{{ form_num }}" readonly min="0" step="0.01"
placeholder="0.00" class="total-field w-full rounded-lg border border-zinc-700 bg-zinc-800/70 px-3 py-2 text-sm text-zinc-200">
<small class="financial-help mt-1 block text-xs text-zinc-400">Automatically calculated from item costs above</small>
</div>
Expand All @@ -276,8 +276,8 @@ <h4 class="m-0 text-base font-semibold text-zinc-100 mb-3">Financial Summary</h4
<div class="form-group">
<label for="us_additional_fees_{{ form_num }}" class="block mb-1 text-sm font-medium text-zinc-200">Additional Fees (USD)</label>
<input type="number" id="us_additional_fees_{{ form_num }}"
name="us_additional_fees_{{ form_num }}" min="0" step="0.00000001"
placeholder="0.00" class="w-full rounded-lg border border-zinc-700 bg-zinc-900/80 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30">
name="us_additional_fees_{{ form_num }}" min="0" step="0.01"
placeholder="0.00" data-format="money" class="w-full rounded-lg border border-zinc-700 bg-zinc-900/80 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30">
<small class="financial-help mt-1 block text-xs text-zinc-400">Taxes, tariffs, shipping, or other fees not included in item costs</small>
</div>
</div>
Expand All @@ -288,8 +288,8 @@ <h4 class="m-0 text-base font-semibold text-zinc-100 mb-3">Financial Summary</h4
<label for="total_cad_amount_{{ form_num }}" class="block mb-1 text-sm font-medium text-zinc-200"><span class="total-label-{{ form_num }}">Total Reimbursement Amount</span>
(CAD)</label>
<input type="number" id="total_cad_amount_{{ form_num }}"
name="total_cad_amount_{{ form_num }}" readonly min="0" step="0.00000001"
placeholder="0.00" class="total-field w-full rounded-lg border border-zinc-700 bg-zinc-800/70 px-3 py-2 text-sm text-zinc-200">
name="total_cad_amount_{{ form_num }}" readonly min="0" step="0.01"
placeholder="0.00" data-format="money" data-form="{{ form_num }}" class="total-field w-full rounded-lg border border-zinc-700 bg-zinc-800/70 px-3 py-2 text-sm text-zinc-200">
<small class="financial-help total-help-{{ form_num }} mt-1 block text-xs text-zinc-400">Automatically calculated (Subtotal -
Discount + Tax + Shipping)</small>
</div>
Expand Down Expand Up @@ -360,4 +360,4 @@ <h4 class="m-0 text-base font-semibold text-zinc-100 mb-3">Financial Summary</h4
<script src="/static/js/dashboard.js" defer></script>
</body>

</html>
</html>
Loading