Skip to content
Merged
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
19 changes: 13 additions & 6 deletions src/schema/pay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ function codeFrom(raw: string | undefined): string | null {
return null;
}

function readMoney(text: string, at: number): Money | null {
function readMoney(text: string, at: number, allowSuffixWords = false): Money | null {
MONEY.lastIndex = at;
const match = MONEY.exec(text);
if (match === null) return null;
Expand All @@ -225,7 +225,12 @@ function readMoney(text: string, at: number): Money | null {
currency ??= code;
}
if (codeAfter !== undefined) {
const code = codeFrom(codeAfter);
// Only retry ambiguous uppercase words as prose after the ordinary
// currency interpretation failed, and only for a complete pay suffix.
const suffix = text.slice(end - codeAfter.length).trim();
const suffixWord = allowSuffixWords &&
(FLAT.test(suffix) || PERIOD_ONLY.test(suffix) || PER.test(suffix));
const code = suffixWord ? null : codeFrom(codeAfter);
if (code !== null) {
// Every symbol or code attached to one amount must agree.
// Otherwise "USD 100 EUR" or "$100 EUR" silently loses a currency.
Expand Down Expand Up @@ -342,7 +347,9 @@ export function readPayLine(input: unknown): { line: PayLine; method: string | n
text = raw.slice(0, settled.index).trim();
}
}
const line = parseLine(text);
const ordinary = parseLine(text);
const retry = typeof ordinary === 'string' ? parseLine(text, true) : ordinary;
const line = typeof retry === 'string' ? ordinary : retry;
return typeof line === 'string' ? line : { line, method };
}

Expand All @@ -354,7 +361,7 @@ function isRail(value: string): boolean {
);
}

function parseLine(text: string): PayLine | string {
function parseLine(text: string, allowSuffixWords = false): PayLine | string {
const share = parseRevenueShare(text);
if (share !== null) return share;

Expand All @@ -367,7 +374,7 @@ function parseLine(text: string): PayLine | string {
cursor = leadMatch[0].length;
}

const first = readMoney(text, cursor);
const first = readMoney(text, cursor, allowSuffixWords);
if (first === null) {
return `Could not find an amount in "${text}". Write it like ${PAY_LINE_EXAMPLES}.`;
}
Expand All @@ -378,7 +385,7 @@ function parseLine(text: string): PayLine | string {
dash.lastIndex = cursor;
const dashMatch = dash.exec(text);
if (dashMatch !== null) {
second = readMoney(text, cursor + dashMatch[0].length);
second = readMoney(text, cursor + dashMatch[0].length, allowSuffixWords);
if (second !== null) cursor = second.end;
}

Expand Down
61 changes: 61 additions & 0 deletions test/pay-uppercase-period.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { normalisePay, parsePayLine } from '../src/schema/pay.ts';

test('uppercase pay-period words are not mistaken for currency tickers', () => {
for (const text of [
'$100 per task', '$100 per hour', '$100 for each task', '$100 each task',
'$100 every week', '$100 fixed', '$100 flat', '$100 total',
'$100 one-off', '$100 lump sum', '100 daily', '$100 - $200 per task',
]) {
const expected = parsePayLine(text);
assert.equal(typeof expected, 'object', text);
assert.deepEqual(parsePayLine(text.toUpperCase()), expected, text);
assert.deepEqual(normalisePay({ pay: text.toUpperCase() }), normalisePay({ pay: text }), text);
}
});

test('uppercase periods retain real currencies and reject contradictory currencies', () => {
for (const text of ['100 EUR PER TASK', '0.01 SOL PER TASK', '100 XYZ PER TASK']) {
const parsed = parsePayLine(text);
assert.equal(typeof parsed, 'object', text);
if (typeof parsed === 'string') throw new Error(parsed);
assert.equal(parsed.currency, text.split(' ')[1]);
}
for (const text of ['$100 EUR PER TASK', 'USD 100 EUR FIXED']) {
assert.equal(typeof parsePayLine(text), 'string', text);
}
});


test('currency tickers that overlap prose retain their successful interpretation', () => {
for (const currency of ['PER', 'FOR', 'EACH', 'EVERY', 'FIXED', 'FLAT', 'TOTAL', 'ONE', 'LUMP', 'DAILY']) {
const parsed = parsePayLine(`100 ${currency} per task`);
assert.equal(typeof parsed, 'object', currency);
if (typeof parsed === 'string') throw new Error(parsed);
assert.equal(parsed.currency, currency);
assert.equal(parsed.type, 'per_task');
}
});

test('incomplete suffixes are not accepted as pay periods', () => {
for (const suffix of ['PER', 'FOR', 'EACH', 'EVERY', 'ONE', 'LUMP', 'FOR EACH', 'FOR EVERY']) {
assert.equal(typeof parsePayLine(`$100 ${suffix}`), 'string', suffix);
}
});


test('existing ambiguous ticker parses and ticker ranges keep their denomination', () => {
for (const [text, currency] of [
['100 FOR EACH TASK', 'FOR'],
['100 PER PER TASK', 'PER'],
['100 ONE - 200 ONE per task', 'ONE'],
['ONE 100 per task', 'ONE'],
]) {
const parsed = parsePayLine(text);
assert.equal(typeof parsed, 'object', text);
if (typeof parsed === 'string') throw new Error(parsed);
assert.equal(parsed.currency, currency, text);
assert.equal(parsed.type, 'per_task', text);
}
});