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
5 changes: 5 additions & 0 deletions GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ A free-form label that can be attached to an entry.
A single financial transaction (lancamento). Has an account, a category, optional tags, a value, and a date.
_Avoid_: "transaction", "record"

**Series**:
A recurring Entry rule (agenda). Occurrences carry `agendaId` and `parcela` (e.g. `fixa`, `2/6`). On update/delete, `--scope this` (default) touches only the current occurrence; `--scope following` touches this one and the ones after it. Both map to the API flag `todos` (`false` / `true`). The web labels are "Apenas neste" / "Neste e em todos na sequência". There is no `--scope all` — the web only exposes that boolean.
_Avoid_: "recurrence" alone (prefer Series when talking about `--scope`)

**Alias**:
A user-defined short string mapped to exactly one Account, Category, or Tag ID. Stored locally in `~/.config/mdcli/mdcli.config.json`. Aliases are offline — no API call needed to resolve them.
_Avoid_: "shortcut", "nickname"
Expand All @@ -44,6 +48,7 @@ _Avoid_: "auto-upgrade", "sync with main"
- A **Category** classifies one or many **Entries**
- A **Tag** may be attached to zero or many **Entries**
- An **Alias** points to exactly one **Account**, **Category**, or **Tag**
- A **Series** groups one or many **Entry** occurrences under one agenda rule
- An **Identifier** resolves (via the resolver pipeline) to exactly one numeric ID, which is then used in the API call

## Resolver pipeline
Expand Down
4 changes: 2 additions & 2 deletions scripts/verify.sh
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,9 @@ step "cli entries update transferência --json (mock)" expect_json 'd.id === 50
# Recurring series (#65): a scheduled occurrence stays scheduled, --scope maps
# to todos (the mock keeps it in the series only with todos: true) and editing
# without --scope warns on stderr.
step "cli entries update agendado mantém status (mock)" expect_json 'd.status === "agendado" && d.agendaId === null && d.dataEfetiva === null' entries update 80 -D 2027-01-25 --json
step "cli entries update agendado mantém status (mock)" expect_json 'd.status === "agendado" && d.agendaId === null && d.dataEfetiva === null && d.scope === "this"' entries update 80 -D 2027-01-25 --json
step "cli entries update agendado avisa no stderr (mock)" expect_ok 'only this occurrence will be changed' entries update 80 -D 2027-01-25
step "cli entries update --scope following (mock)" expect_json 'd.status === "agendado" && d.agendaId === 77 && d.parcela === "fixa"' entries update 80 -D 2027-01-25 --scope following --json
step "cli entries update --scope following (mock)" expect_json 'd.status === "agendado" && d.agendaId === 77 && d.parcela === "fixa" && d.scope === "following"' entries update 80 -D 2027-01-25 --scope following --json
step "cli entries update --scope following texto (mock)" expect_ok 'Status: Scheduled' entries update 80 -D 2027-01-25 --scope following
step "cli entries update --pending confirma (mock)" expect_json 'd.status === "pendente"' entries update 80 --pending --scope this --json
step "cli entries update --scope fora de série (mock)" expect_fail 'not part of a recurring series' entries update 70 -d X --scope following
Expand Down
6 changes: 1 addition & 5 deletions src/commands/entries/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,14 @@ import { exitWithError } from '../../utils/action.js';
import { promptConfirmation } from '../../utils/prompt.js';
import { fetchEntry, deleteEntry } from '../../lib/api.js';
import { formatCurrency } from './format.js';
import { resolveSeriesScope, type SeriesScope } from './series.js';
import { describeScope, resolveSeriesScope, type SeriesScope } from './series.js';

interface DeleteOptions {
dangerouslySkipConfirmation?: boolean;
scope?: SeriesScope;
json?: boolean;
}

function describeScope(todos: boolean): string {
return todos ? 'this and the following occurrences' : 'this occurrence only';
}


export async function deleteAction(id: string, options: DeleteOptions): Promise<void> {
const entryId = Number(id);
Expand Down
2 changes: 1 addition & 1 deletion src/commands/entries/filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ export function addFilterOptions(command: Command): Command {
return command
.requiredOption('-a, --account <ids>', 'Account ID(s), alias(es), or exact name(s) (case-insensitive), comma-separated')
.option('-f, --from <date>', 'Start date (YYYY-MM-DD), defaults to first day of current month')
.option('-t, --to <date>', 'End date (YYYY-MM-DD), defaults to last day of current month')
.option('-t, --to <date>', 'End date (YYYY-MM-DD), defaults to last day of current month (API limit 2031-12-31)')
.option('-s, --status <filter>', 'Filter by status: pending, confirmed, reconciled, scheduled (or bitmask 0-15)')
.option('-T, --type <filter>', 'Filter by type: expense, income, transfer-out, transfer-in (or bitmask 0-15)')
.option('-c, --category <ids>', 'Filter by category ID(s), alias(es), or exact name(s) (case-insensitive), comma-separated')
Expand Down
5 changes: 5 additions & 0 deletions src/commands/entries/series.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import type { Entry } from '../../types/index.js';

export type SeriesScope = 'this' | 'following';

/** Human-readable label for the API's `todos` flag. */
export function describeScope(todos: boolean): string {
return todos ? 'this and the following occurrences' : 'this occurrence only';
}

/** --scope for commands that act on one occurrence of a recurring series. */
export function scopeOption(verb: string): Option {
return new Option(
Expand Down
7 changes: 4 additions & 3 deletions src/commands/entries/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { resolveId, resolveIds } from '../../lib/aliases.js';
import type { CreateEntryResponse, Entry, UpdateEntryPayload } from '../../types/index.js';
import { mapTypeToApi, parseTypeOption } from './create.js';
import { formatCurrency, formatStatus } from './format.js';
import { resolveSeriesScope, type SeriesScope } from './series.js';
import { describeScope, resolveSeriesScope, type SeriesScope } from './series.js';

interface UpdateOptions {
description?: string;
Expand Down Expand Up @@ -39,7 +39,8 @@ function describeStatus(status: unknown): string {

function reportUpdate(response: CreateEntryResponse, json?: boolean, todos?: boolean): void {
if (json) {
console.log(JSON.stringify(response, null, 2));
const scope = todos === undefined ? {} : { scope: todos ? 'following' : 'this' };
console.log(JSON.stringify({ ...response, ...scope }, null, 2));
return;
}

Expand All @@ -50,7 +51,7 @@ function reportUpdate(response: CreateEntryResponse, json?: boolean, todos?: boo
console.log(` ${chalk.gray('Date:')} ${response.data}`);
console.log(` ${chalk.gray('Status:')} ${describeStatus(response.status)}`);
if (todos !== undefined) {
console.log(` ${chalk.gray('Scope:')} ${todos ? 'this and the following occurrences' : 'this occurrence only'}`);
console.log(` ${chalk.gray('Scope:')} ${describeScope(todos)}`);
}
}

Expand Down
17 changes: 8 additions & 9 deletions src/lib/browser-session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { getChromeCookieValue, getChromeProfilePath, getEdgeProfilePath } from '
import { getFirefoxProfilePath } from './firefox.js';
import { extractUidFromJwt, fetchPublicApiKey } from './jwt.js';
import { cleanupTempDir, copyProfileToTemp } from './profile-copy.js';
import { setupAuthCapture } from '../browser-auth.js';


export {
Expand All @@ -14,7 +15,6 @@ export {
} from './chromium.js';
export { getFirefoxProfilePath, parseFirefoxInstallDefaults } from './firefox.js';

const API_URL_PATTERN = 'app.meudinheiroweb.com.br/api/';

interface LoginConfigRaw {
mdApiKey?: string;
Expand Down Expand Up @@ -85,15 +85,14 @@ export async function extractSessionFromBrowser(
// still carry the auth headers, so capture those too. Authorization is
// required: pre-login traffic can already send mdapikey/mduid, and
// accepting it would save a token-less auth from a logged-out profile.
const apiHeadersRef: { captured: Record<string, string> | null } = { captured: null };
page.on('request', (request) => {
// Context-wide, same as auth login --browser (#63): a page-level
// listener misses API traffic from another tab/popup during the wait.
const apiHeadersRef: { captured: { authorization?: string; mdapikey: string; mduid: string } | null } = {
captured: null,
};
setupAuthCapture(context, (headers) => {
if (apiHeadersRef.captured) return;
const url = request.url();
if (!url.includes(API_URL_PATTERN)) return;
const headers = request.headers();
if (headers['mdapikey'] && headers['mduid'] && headers['authorization']) {
apiHeadersRef.captured = headers;
}
apiHeadersRef.captured = headers;
});

await page.goto('https://app.meudinheiroweb.com.br/', { waitUntil: 'domcontentloaded' });
Expand Down
61 changes: 61 additions & 0 deletions test/browser-session-capture.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { chromium, type Browser } from 'playwright';
import { setupAuthCapture } from '../src/lib/browser-auth.js';

// Regression for auth --session: extractSessionFromBrowser used to listen on
// the first page only (page.on('request')). It now reuses setupAuthCapture on
// the BrowserContext, same as auth login --browser (#63). Guard the shared
// helper with the first-wins ref the session path uses.
let browser: Browser;
let server: ReturnType<typeof Bun.serve>;

beforeAll(async () => {
server = Bun.serve({
port: 0,
fetch(req) {
const { pathname } = new URL(req.url);
if (pathname.startsWith('/api/')) return Response.json({});
if (pathname === '/') {
return new Response(
`<script>fetch('/api/v1/frontend/sessao',{headers:{mdApiKey:'K'}})</script>login`,
{ headers: { 'content-type': 'text/html' } }
);
}
return new Response(
`<script>fetch('/api/v1/visaogeral',{headers:{Authorization:'Bearer SESS',mdApiKey:'K',mdUid:'7'}})</script>dash`,
{ headers: { 'content-type': 'text/html' } }
);
},
});
browser = await chromium.launch({ channel: 'chrome', headless: true });
});

afterAll(async () => {
await browser?.close();
server?.stop(true);
});

describe('session auth capture (setupAuthCapture)', () => {
test('first-wins ref captures auth headers from a later tab', async () => {
const context = await browser.newContext();
const apiHeadersRef: { captured: { authorization?: string; mdapikey: string; mduid: string } | null } = {
captured: null,
};
setupAuthCapture(context, (headers) => {
if (apiHeadersRef.captured) return;
apiHeadersRef.captured = headers;
}, `127.0.0.1:${server.port}/api/`);

const first = await context.newPage();
await first.goto(`http://127.0.0.1:${server.port}/`);
await first.waitForLoadState('networkidle');
expect(apiHeadersRef.captured).toBeNull();

const other = await context.newPage();
await other.goto(`http://127.0.0.1:${server.port}/u/7`);
await other.waitForLoadState('networkidle');

expect(apiHeadersRef.captured).toEqual({ authorization: 'Bearer SESS', mdapikey: 'K', mduid: '7' });
await context.close();
});
});
25 changes: 24 additions & 1 deletion test/entries-update-series.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,9 @@ describe('entries update on a recurring series', () => {
agenda: { frequencia: 1, intervalo: 'M', quantidade: -1, primeiraData: '2027-01-25' },
});
expect(result.stderr).toContain('only this occurrence will be changed, and it will leave the series');
expect(JSON.parse(result.stdout).status).toBe('agendado');
const json = JSON.parse(result.stdout);
expect(json.status).toBe('agendado');
expect(json.scope).toBe('this');
});

test('--scope following sends todos: true and prints the status the API answered', async () => {
Expand All @@ -92,6 +94,27 @@ describe('entries update on a recurring series', () => {
expect(result.stdout).toContain('Scope: this and the following occurrences');
});

test('--scope following --json includes scope alongside the API response', async () => {
api.on('PUT /v1/lancamentos/80', { id: 80, descricao: 'NuCel', valor: -45, data: '2027-01-25', status: 'agendado' });

const result = await runCli(
['entries', 'update', '80', '-D', '2027-01-25', '--scope', 'following', '--json'],
{ home, api }
);

expect(result.exitCode).toBe(0);
expect(JSON.parse(result.stdout)).toMatchObject({ id: 80, status: 'agendado', scope: 'following' });
});

test('an entry outside a series --json has no scope field', async () => {
api.on('PUT /v1/lancamentos/70', { ...PENDING_ENTRY, descricao: 'Café X' });

const result = await runCli(['entries', 'update', '70', '-d', 'Café X', '--json'], { home, api });

expect(result.exitCode).toBe(0);
expect(JSON.parse(result.stdout)).not.toHaveProperty('scope');
});

test('--scope this is explicit, so no warning', async () => {
api.on('PUT /v1/lancamentos/80', { id: 80, descricao: 'NuCel', valor: -45, data: '2027-01-10', status: 'agendado' });

Expand Down
Loading