@bananasplitapp/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +188 -0
- package/package.json +32 -0
- package/src/browser.ts +360 -0
- package/src/cli.ts +289 -0
- package/src/commands/balance.ts +128 -0
- package/src/commands/currencies.ts +49 -0
- package/src/commands/expenses.ts +543 -0
- package/src/commands/friends.ts +156 -0
- package/src/commands/groups.ts +627 -0
- package/src/commands/me.ts +45 -0
- package/src/commands/payments.ts +167 -0
- package/src/index.ts +11 -0
- package/src/request.ts +108 -0
- package/src/shared.ts +183 -0
- package/src/types.ts +101 -0
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_LIST_LIMIT,
|
|
3
|
+
appendQuery,
|
|
4
|
+
asArray,
|
|
5
|
+
asRecord,
|
|
6
|
+
cleanUserSummary,
|
|
7
|
+
display,
|
|
8
|
+
encodedDetailId,
|
|
9
|
+
enumValue,
|
|
10
|
+
formatCard,
|
|
11
|
+
humanAmount,
|
|
12
|
+
isoDate,
|
|
13
|
+
namedEntity,
|
|
14
|
+
numeric,
|
|
15
|
+
parseJsonBody,
|
|
16
|
+
parseOptions,
|
|
17
|
+
positiveInteger,
|
|
18
|
+
repeatedStrings,
|
|
19
|
+
requiredString,
|
|
20
|
+
requirePositionals,
|
|
21
|
+
wantsHelp,
|
|
22
|
+
yesNo,
|
|
23
|
+
} from "../shared";
|
|
24
|
+
import { CliFailure, type ParsedCommand, type Presenter } from "../types";
|
|
25
|
+
|
|
26
|
+
const HELP = `Usage: banana expenses list [--sort date|amount] [--direction asc|desc]
|
|
27
|
+
[--limit N] [--cursor CURSOR]
|
|
28
|
+
[--recurring | --no-recurring]
|
|
29
|
+
or: banana expenses add JSON
|
|
30
|
+
or: banana expenses add --title TEXT --amount AMOUNT
|
|
31
|
+
--currency-id ID --paid-by-id ID
|
|
32
|
+
--date YYYY-MM-DD|DD-MM-YYYY
|
|
33
|
+
[--group-id ID] [--description TEXT]
|
|
34
|
+
[--split-type equal|custom|percentage|shares]
|
|
35
|
+
[--split USER_ID=AMOUNT]...
|
|
36
|
+
or: banana expenses get <expense-id>
|
|
37
|
+
or: banana expenses edit <expense-id> JSON
|
|
38
|
+
or: banana expenses edit <expense-id> [--title TEXT] [--amount AMOUNT]
|
|
39
|
+
[--currency-id ID] [--paid-by-id ID] [--date DATE]
|
|
40
|
+
[--description TEXT] [--group-id ID | --no-group]
|
|
41
|
+
[--split-type equal|custom|percentage|shares]
|
|
42
|
+
[--split USER_ID=AMOUNT]...`;
|
|
43
|
+
const GET_HELP = "Usage: banana expenses get <expense-id>";
|
|
44
|
+
const LIST_HELP = `Usage: banana expenses list [options]
|
|
45
|
+
|
|
46
|
+
Options:
|
|
47
|
+
--sort date|amount
|
|
48
|
+
--direction asc|desc
|
|
49
|
+
--limit N (default: ${DEFAULT_LIST_LIMIT}; API default in browser)
|
|
50
|
+
--cursor CURSOR
|
|
51
|
+
--recurring Only recurring expenses
|
|
52
|
+
--no-recurring Only non-recurring expenses
|
|
53
|
+
|
|
54
|
+
Omit both recurring flags to include all expenses.
|
|
55
|
+
In the browser, reaching the last item loads the next page automatically.
|
|
56
|
+
Reuse the same options when requesting the next cursor.`;
|
|
57
|
+
const EDIT_HELP = `Usage: banana expenses edit <expense-id> JSON
|
|
58
|
+
or: banana expenses edit <expense-id> [--title TEXT] [--amount AMOUNT]
|
|
59
|
+
[--currency-id ID] [--paid-by-id ID] [--date DATE]
|
|
60
|
+
[--description TEXT] [--group-id ID | --no-group]
|
|
61
|
+
[--split-type equal|custom|percentage|shares]
|
|
62
|
+
[--split USER_ID=AMOUNT]...
|
|
63
|
+
|
|
64
|
+
Only the fields you pass change; everything else keeps its current value.`;
|
|
65
|
+
|
|
66
|
+
function parseSplits(value: unknown) {
|
|
67
|
+
return repeatedStrings(value).map((split) => {
|
|
68
|
+
const separator = split.indexOf("=");
|
|
69
|
+
if (separator < 1 || separator === split.length - 1) {
|
|
70
|
+
throw new CliFailure(
|
|
71
|
+
"usage",
|
|
72
|
+
`--split must use USER_ID=AMOUNT\n${HELP}`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
userId: split.slice(0, separator),
|
|
77
|
+
amount: split.slice(separator + 1),
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function parseExpenses(args: string[]): ParsedCommand {
|
|
83
|
+
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
|
|
84
|
+
return { kind: "help", text: HELP };
|
|
85
|
+
}
|
|
86
|
+
const [command, ...rest] = args;
|
|
87
|
+
if (command === "list") return parseExpensesList(rest);
|
|
88
|
+
if (command === "get") {
|
|
89
|
+
if (wantsHelp(rest)) return { kind: "help", text: GET_HELP };
|
|
90
|
+
const { positionals } = parseOptions(rest);
|
|
91
|
+
requirePositionals(positionals, 1, GET_HELP);
|
|
92
|
+
return {
|
|
93
|
+
kind: "request",
|
|
94
|
+
path: `/expenses/${encodeURIComponent(positionals[0])}`,
|
|
95
|
+
presentation: "expense",
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (command === "edit") return parseExpensesEdit(rest);
|
|
99
|
+
if (command !== "add") throw new CliFailure("usage", HELP);
|
|
100
|
+
if (wantsHelp(rest)) return { kind: "help", text: HELP };
|
|
101
|
+
|
|
102
|
+
const { positionals, values } = parseOptions(rest, {
|
|
103
|
+
amount: { type: "string" },
|
|
104
|
+
"currency-id": { type: "string" },
|
|
105
|
+
date: { type: "string" },
|
|
106
|
+
description: { type: "string" },
|
|
107
|
+
"group-id": { type: "string" },
|
|
108
|
+
"paid-by-id": { type: "string" },
|
|
109
|
+
split: { type: "string", multiple: true },
|
|
110
|
+
"split-type": { type: "string" },
|
|
111
|
+
title: { type: "string" },
|
|
112
|
+
});
|
|
113
|
+
const jsonBody = parseJsonBody(positionals, values, HELP);
|
|
114
|
+
if (jsonBody !== undefined) {
|
|
115
|
+
return {
|
|
116
|
+
kind: "request",
|
|
117
|
+
method: "POST",
|
|
118
|
+
path: "/expenses",
|
|
119
|
+
presentation: "expense-created",
|
|
120
|
+
body: jsonBody,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
requirePositionals(positionals, 0, HELP);
|
|
124
|
+
|
|
125
|
+
const groupId = values["group-id"] as string | undefined;
|
|
126
|
+
const description = values.description as string | undefined;
|
|
127
|
+
const splits = parseSplits(values.split);
|
|
128
|
+
const splitType = enumValue(values["split-type"], "--split-type", [
|
|
129
|
+
"equal",
|
|
130
|
+
"custom",
|
|
131
|
+
"percentage",
|
|
132
|
+
"shares",
|
|
133
|
+
] as const);
|
|
134
|
+
if (splits.length === 0 && groupId === undefined) {
|
|
135
|
+
throw new CliFailure(
|
|
136
|
+
"usage",
|
|
137
|
+
`At least one --split is required unless --group-id is provided\n${HELP}`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
if (splits.length === 0 && splitType !== undefined) {
|
|
141
|
+
throw new CliFailure(
|
|
142
|
+
"usage",
|
|
143
|
+
`At least one --split is required with --split-type\n${HELP}`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
kind: "request",
|
|
149
|
+
method: "POST",
|
|
150
|
+
path: "/expenses",
|
|
151
|
+
presentation: "expense-created",
|
|
152
|
+
body: {
|
|
153
|
+
title: requiredString(values.title, "--title", HELP),
|
|
154
|
+
amount: requiredString(values.amount, "--amount", HELP),
|
|
155
|
+
currencyId: requiredString(values["currency-id"], "--currency-id", HELP),
|
|
156
|
+
paidById: requiredString(values["paid-by-id"], "--paid-by-id", HELP),
|
|
157
|
+
date: isoDate(values.date, HELP),
|
|
158
|
+
splits,
|
|
159
|
+
...(groupId === undefined ? {} : { groupId }),
|
|
160
|
+
...(description === undefined ? {} : { description }),
|
|
161
|
+
...(splitType === undefined ? {} : { splitType }),
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function parseExpensesList(args: string[]): ParsedCommand {
|
|
167
|
+
if (wantsHelp(args)) return { kind: "help", text: LIST_HELP };
|
|
168
|
+
const { positionals, values } = parseOptions(args, {
|
|
169
|
+
cursor: { type: "string" },
|
|
170
|
+
direction: { type: "string" },
|
|
171
|
+
limit: { type: "string" },
|
|
172
|
+
"no-recurring": { type: "boolean" },
|
|
173
|
+
recurring: { type: "boolean" },
|
|
174
|
+
sort: { type: "string" },
|
|
175
|
+
});
|
|
176
|
+
requirePositionals(positionals, 0, LIST_HELP);
|
|
177
|
+
if (values.recurring && values["no-recurring"]) {
|
|
178
|
+
throw new CliFailure(
|
|
179
|
+
"usage",
|
|
180
|
+
`--recurring and --no-recurring cannot be used together\n${LIST_HELP}`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const query = new URLSearchParams();
|
|
185
|
+
appendQuery(
|
|
186
|
+
query,
|
|
187
|
+
"l",
|
|
188
|
+
positiveInteger(values.limit, "--limit") ?? String(DEFAULT_LIST_LIMIT),
|
|
189
|
+
);
|
|
190
|
+
appendQuery(query, "cursor", values.cursor as string | undefined);
|
|
191
|
+
appendQuery(
|
|
192
|
+
query,
|
|
193
|
+
"sort",
|
|
194
|
+
enumValue(values.sort, "--sort", ["date", "amount"] as const),
|
|
195
|
+
);
|
|
196
|
+
appendQuery(
|
|
197
|
+
query,
|
|
198
|
+
"direction",
|
|
199
|
+
enumValue(values.direction, "--direction", ["asc", "desc"] as const),
|
|
200
|
+
);
|
|
201
|
+
appendQuery(
|
|
202
|
+
query,
|
|
203
|
+
"recurring",
|
|
204
|
+
values["no-recurring"] ? false : (values.recurring as boolean | undefined),
|
|
205
|
+
);
|
|
206
|
+
return {
|
|
207
|
+
kind: "request",
|
|
208
|
+
path: "/expenses",
|
|
209
|
+
presentation: "expense-list",
|
|
210
|
+
query,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function parseExpensesEdit(args: string[]): ParsedCommand {
|
|
215
|
+
if (wantsHelp(args)) return { kind: "help", text: EDIT_HELP };
|
|
216
|
+
const { positionals, values } = parseOptions(args, {
|
|
217
|
+
amount: { type: "string" },
|
|
218
|
+
"currency-id": { type: "string" },
|
|
219
|
+
date: { type: "string" },
|
|
220
|
+
description: { type: "string" },
|
|
221
|
+
"group-id": { type: "string" },
|
|
222
|
+
"no-group": { type: "boolean" },
|
|
223
|
+
"paid-by-id": { type: "string" },
|
|
224
|
+
split: { type: "string", multiple: true },
|
|
225
|
+
"split-type": { type: "string" },
|
|
226
|
+
title: { type: "string" },
|
|
227
|
+
});
|
|
228
|
+
if (positionals.length === 0) {
|
|
229
|
+
throw new CliFailure("usage", EDIT_HELP);
|
|
230
|
+
}
|
|
231
|
+
const [id, ...rest] = positionals;
|
|
232
|
+
const path = `/expenses/${encodeURIComponent(id)}`;
|
|
233
|
+
|
|
234
|
+
const jsonBody = parseJsonBody(rest, values, EDIT_HELP);
|
|
235
|
+
if (jsonBody !== undefined) {
|
|
236
|
+
return {
|
|
237
|
+
kind: "request",
|
|
238
|
+
method: "PUT",
|
|
239
|
+
path,
|
|
240
|
+
presentation: "expense-updated",
|
|
241
|
+
mergeExpense: path,
|
|
242
|
+
body: jsonBody,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
requirePositionals(positionals, 1, EDIT_HELP);
|
|
246
|
+
|
|
247
|
+
const groupId = values["group-id"] as string | undefined;
|
|
248
|
+
if (groupId !== undefined && values["no-group"] === true) {
|
|
249
|
+
throw new CliFailure(
|
|
250
|
+
"usage",
|
|
251
|
+
`--group-id and --no-group cannot be used together\n${EDIT_HELP}`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
const splits = parseSplits(values.split);
|
|
255
|
+
const splitType = enumValue(values["split-type"], "--split-type", [
|
|
256
|
+
"equal",
|
|
257
|
+
"custom",
|
|
258
|
+
"percentage",
|
|
259
|
+
"shares",
|
|
260
|
+
] as const);
|
|
261
|
+
const patch: Record<string, unknown> = {
|
|
262
|
+
...(values.title === undefined ? {} : { title: values.title }),
|
|
263
|
+
...(values.amount === undefined ? {} : { amount: values.amount }),
|
|
264
|
+
...(values["currency-id"] === undefined
|
|
265
|
+
? {}
|
|
266
|
+
: { currencyId: values["currency-id"] }),
|
|
267
|
+
...(values["paid-by-id"] === undefined
|
|
268
|
+
? {}
|
|
269
|
+
: { paidById: values["paid-by-id"] }),
|
|
270
|
+
...(values.date === undefined
|
|
271
|
+
? {}
|
|
272
|
+
: { date: isoDate(values.date, EDIT_HELP) }),
|
|
273
|
+
...(values.description === undefined
|
|
274
|
+
? {}
|
|
275
|
+
: { description: values.description }),
|
|
276
|
+
...(groupId === undefined ? {} : { groupId }),
|
|
277
|
+
...(values["no-group"] === true ? { groupId: null } : {}),
|
|
278
|
+
...(splitType === undefined ? {} : { splitType }),
|
|
279
|
+
...(splits.length === 0 ? {} : { splits }),
|
|
280
|
+
};
|
|
281
|
+
if (Object.keys(patch).length === 0) {
|
|
282
|
+
throw new CliFailure(
|
|
283
|
+
"usage",
|
|
284
|
+
`At least one field to change is required\n${EDIT_HELP}`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
kind: "request",
|
|
290
|
+
method: "PUT",
|
|
291
|
+
path,
|
|
292
|
+
presentation: "expense-updated",
|
|
293
|
+
mergeExpense: path,
|
|
294
|
+
body: patch,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function cleanCreatedExpense(body: unknown) {
|
|
299
|
+
const expense = asRecord(body);
|
|
300
|
+
return {
|
|
301
|
+
id: expense.id ?? null,
|
|
302
|
+
title: expense.title ?? null,
|
|
303
|
+
description: expense.description ?? null,
|
|
304
|
+
amount: numeric(expense.amount),
|
|
305
|
+
currencyId: expense.currencyId ?? null,
|
|
306
|
+
paidById: expense.paidById ?? null,
|
|
307
|
+
groupId: expense.groupId ?? null,
|
|
308
|
+
date: expense.date ?? null,
|
|
309
|
+
timezone: expense.timezone ?? null,
|
|
310
|
+
splitType: expense.splitType ?? null,
|
|
311
|
+
splits: asArray(expense.shares).map((value) => {
|
|
312
|
+
const share = asRecord(value);
|
|
313
|
+
return {
|
|
314
|
+
userId: share.userId ?? null,
|
|
315
|
+
amount: numeric(share.amount),
|
|
316
|
+
};
|
|
317
|
+
}),
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function splitEvenly(total: string, userIds: string[]) {
|
|
322
|
+
const cents = Math.round(Number(total) * 100);
|
|
323
|
+
if (!Number.isFinite(cents) || userIds.length === 0) return undefined;
|
|
324
|
+
const base = Math.floor(cents / userIds.length);
|
|
325
|
+
let remainder = cents - base * userIds.length;
|
|
326
|
+
return userIds.map((userId) => {
|
|
327
|
+
const extra = remainder > 0 ? 1 : 0;
|
|
328
|
+
remainder -= extra;
|
|
329
|
+
return { userId, amount: ((base + extra) / 100).toFixed(2) };
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function mergeExpenseBody(current: unknown, patch: Record<string, unknown>) {
|
|
334
|
+
const expense = asRecord(current);
|
|
335
|
+
const shares = asArray(expense.shares).map((value) => {
|
|
336
|
+
const share = asRecord(value);
|
|
337
|
+
return {
|
|
338
|
+
userId: String(share.userId ?? ""),
|
|
339
|
+
amount: String(share.amount ?? ""),
|
|
340
|
+
};
|
|
341
|
+
});
|
|
342
|
+
const merged: Record<string, unknown> = {
|
|
343
|
+
title: expense.title,
|
|
344
|
+
description: expense.description ?? null,
|
|
345
|
+
amount: String(expense.amount ?? ""),
|
|
346
|
+
currencyId: expense.currencyId,
|
|
347
|
+
paidById: expense.paidById,
|
|
348
|
+
groupId: expense.groupId ?? null,
|
|
349
|
+
friendshipId: expense.friendshipId ?? null,
|
|
350
|
+
date: expense.date,
|
|
351
|
+
timezone: expense.timezone ?? "UTC",
|
|
352
|
+
splitType: expense.splitType,
|
|
353
|
+
categoryId: expense.categoryId ?? null,
|
|
354
|
+
splits: shares,
|
|
355
|
+
...patch,
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
// A group expense and a direct (friendship) expense are mutually exclusive.
|
|
359
|
+
if ("groupId" in patch) {
|
|
360
|
+
if (patch.groupId === null) {
|
|
361
|
+
merged.groupId = null;
|
|
362
|
+
} else {
|
|
363
|
+
merged.friendshipId = null;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const amountChanged =
|
|
368
|
+
"amount" in patch && String(patch.amount) !== String(expense.amount);
|
|
369
|
+
if (amountChanged && !("splits" in patch)) {
|
|
370
|
+
const evenly =
|
|
371
|
+
merged.splitType === "equal"
|
|
372
|
+
? splitEvenly(
|
|
373
|
+
String(merged.amount),
|
|
374
|
+
shares.map((share) => share.userId),
|
|
375
|
+
)
|
|
376
|
+
: undefined;
|
|
377
|
+
if (evenly === undefined) {
|
|
378
|
+
throw new CliFailure(
|
|
379
|
+
"usage",
|
|
380
|
+
`Changing --amount on a ${display(merged.splitType)} split needs matching --split values\n${EDIT_HELP}`,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
merged.splits = evenly;
|
|
384
|
+
}
|
|
385
|
+
return merged;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function cleanExpense(body: unknown) {
|
|
389
|
+
const expense = asRecord(body);
|
|
390
|
+
return {
|
|
391
|
+
id: expense.id ?? null,
|
|
392
|
+
title: expense.title ?? null,
|
|
393
|
+
description: expense.description ?? null,
|
|
394
|
+
amount: numeric(expense.amount),
|
|
395
|
+
currency: asRecord(expense.currency).code ?? expense.currencyId ?? null,
|
|
396
|
+
paidBy: cleanUserSummary(expense.paidByUser),
|
|
397
|
+
group: expense.groupId
|
|
398
|
+
? {
|
|
399
|
+
id: expense.groupId,
|
|
400
|
+
name: asRecord(expense.group).name ?? null,
|
|
401
|
+
}
|
|
402
|
+
: null,
|
|
403
|
+
category: asRecord(expense.category).name ?? null,
|
|
404
|
+
date: expense.date ?? null,
|
|
405
|
+
splitType: expense.splitType ?? null,
|
|
406
|
+
createdAt: expense.createdAt ?? null,
|
|
407
|
+
splits: asArray(expense.shares).map((value) => {
|
|
408
|
+
const share = asRecord(value);
|
|
409
|
+
return {
|
|
410
|
+
user: cleanUserSummary(share.user),
|
|
411
|
+
amount: numeric(share.amount),
|
|
412
|
+
};
|
|
413
|
+
}),
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function formatExpense(body: unknown, title: string) {
|
|
418
|
+
const response = asRecord(body);
|
|
419
|
+
const splits = asArray(response.splits);
|
|
420
|
+
const group = asRecord(response.group);
|
|
421
|
+
return [
|
|
422
|
+
title,
|
|
423
|
+
`Title: ${display(response.title)}`,
|
|
424
|
+
`Amount: ${humanAmount(response.amount, response.currency)}`,
|
|
425
|
+
`Paid by: ${namedEntity(response.paidBy)}`,
|
|
426
|
+
`Group: ${response.group ? namedEntity(group) : "—"}`,
|
|
427
|
+
`Category: ${display(response.category)}`,
|
|
428
|
+
`Description: ${display(response.description)}`,
|
|
429
|
+
`Date: ${display(response.date)}`,
|
|
430
|
+
`Split type: ${display(response.splitType)}`,
|
|
431
|
+
`ID: ${display(response.id)}`,
|
|
432
|
+
"",
|
|
433
|
+
"Splits",
|
|
434
|
+
...(splits.length
|
|
435
|
+
? splits.map((value) => {
|
|
436
|
+
const split = asRecord(value);
|
|
437
|
+
return `${namedEntity(split.user)}: ${humanAmount(split.amount, response.currency)}`;
|
|
438
|
+
})
|
|
439
|
+
: ["—"]),
|
|
440
|
+
].join("\n");
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export const expensePresenters = {
|
|
444
|
+
"expense-list": {
|
|
445
|
+
clean(body) {
|
|
446
|
+
const response = asRecord(body);
|
|
447
|
+
return {
|
|
448
|
+
items: asArray(response.items).map((value) => {
|
|
449
|
+
const expense = asRecord(value);
|
|
450
|
+
const { splits, ...summary } = cleanExpense(expense);
|
|
451
|
+
return {
|
|
452
|
+
...summary,
|
|
453
|
+
isRecurring:
|
|
454
|
+
expense.recurringExpenseRuleId != null || expense.recurrence != null,
|
|
455
|
+
share: numeric(asRecord(expense.share).amount),
|
|
456
|
+
};
|
|
457
|
+
}),
|
|
458
|
+
hasMore: response.hasMore === true,
|
|
459
|
+
nextCursor: response.nextCursor ?? null,
|
|
460
|
+
};
|
|
461
|
+
},
|
|
462
|
+
format(body) {
|
|
463
|
+
const response = asRecord(body);
|
|
464
|
+
const items = asArray(response.items);
|
|
465
|
+
const lines = items.length
|
|
466
|
+
? [
|
|
467
|
+
"Expenses",
|
|
468
|
+
...items.map((value, index) => {
|
|
469
|
+
const expense = asRecord(value);
|
|
470
|
+
return formatCard(index, expense.title, [
|
|
471
|
+
`ID: ${display(expense.id)}`,
|
|
472
|
+
`Amount: ${humanAmount(expense.amount, expense.currency)}`,
|
|
473
|
+
`Your share: ${humanAmount(expense.share, expense.currency)}`,
|
|
474
|
+
`Paid by: ${namedEntity(expense.paidBy)}`,
|
|
475
|
+
`Group: ${expense.group ? namedEntity(expense.group) : "—"}`,
|
|
476
|
+
`Category: ${display(expense.category)}`,
|
|
477
|
+
`Date: ${display(expense.date)}`,
|
|
478
|
+
`Recurring: ${yesNo(expense.isRecurring)}`,
|
|
479
|
+
]);
|
|
480
|
+
}),
|
|
481
|
+
]
|
|
482
|
+
: ["No expenses."];
|
|
483
|
+
lines.push(
|
|
484
|
+
response.hasMore
|
|
485
|
+
? [
|
|
486
|
+
"More expenses available.",
|
|
487
|
+
...(response.nextCursor
|
|
488
|
+
? [
|
|
489
|
+
`Next cursor: ${JSON.stringify(response.nextCursor)}`,
|
|
490
|
+
"Run banana expenses list with --cursor and the same options.",
|
|
491
|
+
]
|
|
492
|
+
: []),
|
|
493
|
+
].join("\n")
|
|
494
|
+
: "End of expenses.",
|
|
495
|
+
);
|
|
496
|
+
return lines.join("\n\n");
|
|
497
|
+
},
|
|
498
|
+
browser: {
|
|
499
|
+
detailPath(_command, item) {
|
|
500
|
+
return `/expenses/${encodedDetailId(item.id)}`;
|
|
501
|
+
},
|
|
502
|
+
formatDetail(_item, body) {
|
|
503
|
+
return formatExpense(cleanExpense(body), "Expense");
|
|
504
|
+
},
|
|
505
|
+
},
|
|
506
|
+
},
|
|
507
|
+
expense: {
|
|
508
|
+
clean: cleanExpense,
|
|
509
|
+
format: (body) => formatExpense(body, "Expense"),
|
|
510
|
+
},
|
|
511
|
+
"expense-updated": {
|
|
512
|
+
clean: cleanExpense,
|
|
513
|
+
format: (body) => formatExpense(body, "Expense updated"),
|
|
514
|
+
},
|
|
515
|
+
"expense-created": {
|
|
516
|
+
clean: cleanCreatedExpense,
|
|
517
|
+
format(body) {
|
|
518
|
+
const response = asRecord(body);
|
|
519
|
+
const splits = asArray(response.splits);
|
|
520
|
+
return [
|
|
521
|
+
"Expense created",
|
|
522
|
+
`Title: ${display(response.title)}`,
|
|
523
|
+
`Amount: ${humanAmount(response.amount, response.currencyId)}`,
|
|
524
|
+
`Paid by: ${display(response.paidById)}`,
|
|
525
|
+
`Group ID: ${display(response.groupId)}`,
|
|
526
|
+
`Date: ${display(response.date)}`,
|
|
527
|
+
`Split type: ${display(response.splitType)}`,
|
|
528
|
+
`ID: ${display(response.id)}`,
|
|
529
|
+
"",
|
|
530
|
+
"Splits",
|
|
531
|
+
...(splits.length
|
|
532
|
+
? splits.map((value) => {
|
|
533
|
+
const split = asRecord(value);
|
|
534
|
+
return `${display(split.userId)}: ${humanAmount(split.amount, response.currencyId)}`;
|
|
535
|
+
})
|
|
536
|
+
: ["—"]),
|
|
537
|
+
].join("\n");
|
|
538
|
+
},
|
|
539
|
+
},
|
|
540
|
+
} satisfies Record<
|
|
541
|
+
"expense-list" | "expense" | "expense-updated" | "expense-created",
|
|
542
|
+
Presenter
|
|
543
|
+
>;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_LIST_LIMIT,
|
|
3
|
+
appendQuery,
|
|
4
|
+
asArray,
|
|
5
|
+
asRecord,
|
|
6
|
+
cleanUserSummary,
|
|
7
|
+
currencyCode,
|
|
8
|
+
display,
|
|
9
|
+
encodedDetailId,
|
|
10
|
+
enumValue,
|
|
11
|
+
formatCard,
|
|
12
|
+
humanAmount,
|
|
13
|
+
numeric,
|
|
14
|
+
parseOptions,
|
|
15
|
+
positiveInteger,
|
|
16
|
+
requirePositionals,
|
|
17
|
+
wantsHelp,
|
|
18
|
+
yesNo,
|
|
19
|
+
} from "../shared";
|
|
20
|
+
import { CliFailure, type ParsedCommand, type Presenter } from "../types";
|
|
21
|
+
|
|
22
|
+
const HELP = `Usage: banana friends <command>
|
|
23
|
+
|
|
24
|
+
Commands:
|
|
25
|
+
list [--limit N] [--cursor CURSOR] [--sort balance|lastActivity]
|
|
26
|
+
[--filter all|guests]`;
|
|
27
|
+
const LIST_HELP = `Usage: banana friends list [options]
|
|
28
|
+
|
|
29
|
+
Options:
|
|
30
|
+
--limit N (default: ${DEFAULT_LIST_LIMIT})
|
|
31
|
+
--cursor CURSOR
|
|
32
|
+
--sort balance|lastActivity
|
|
33
|
+
--filter all|guests`;
|
|
34
|
+
|
|
35
|
+
function parseFriendsList(args: string[]): ParsedCommand {
|
|
36
|
+
if (wantsHelp(args)) return { kind: "help", text: LIST_HELP };
|
|
37
|
+
const { positionals, values } = parseOptions(args, {
|
|
38
|
+
cursor: { type: "string" },
|
|
39
|
+
filter: { type: "string" },
|
|
40
|
+
limit: { type: "string" },
|
|
41
|
+
sort: { type: "string" },
|
|
42
|
+
});
|
|
43
|
+
requirePositionals(positionals, 0, LIST_HELP);
|
|
44
|
+
|
|
45
|
+
const query = new URLSearchParams();
|
|
46
|
+
appendQuery(
|
|
47
|
+
query,
|
|
48
|
+
"l",
|
|
49
|
+
positiveInteger(values.limit, "--limit") ?? String(DEFAULT_LIST_LIMIT),
|
|
50
|
+
);
|
|
51
|
+
appendQuery(query, "cursor", values.cursor as string | undefined);
|
|
52
|
+
appendQuery(
|
|
53
|
+
query,
|
|
54
|
+
"sort",
|
|
55
|
+
enumValue(values.sort, "--sort", ["balance", "lastActivity"] as const),
|
|
56
|
+
);
|
|
57
|
+
appendQuery(
|
|
58
|
+
query,
|
|
59
|
+
"filter",
|
|
60
|
+
enumValue(values.filter, "--filter", ["all", "guests"] as const),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
kind: "request",
|
|
65
|
+
path: "/friends",
|
|
66
|
+
presentation: "friend-list",
|
|
67
|
+
query,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function parseFriends(args: string[]): ParsedCommand {
|
|
72
|
+
if (args.length === 0) return parseFriendsList(args);
|
|
73
|
+
if (args[0] === "--help" || args[0] === "-h") {
|
|
74
|
+
return { kind: "help", text: HELP };
|
|
75
|
+
}
|
|
76
|
+
const [command, ...rest] = args;
|
|
77
|
+
if (command === "list") return parseFriendsList(rest);
|
|
78
|
+
throw new CliFailure("usage", HELP);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function cleanFriendList(body: unknown) {
|
|
82
|
+
const response = asRecord(body);
|
|
83
|
+
return {
|
|
84
|
+
items: asArray(response.items).map((value) => {
|
|
85
|
+
const friendship = asRecord(value);
|
|
86
|
+
const user = asRecord(friendship.user);
|
|
87
|
+
return {
|
|
88
|
+
id: friendship.id ?? null,
|
|
89
|
+
user: cleanUserSummary(user),
|
|
90
|
+
balance: numeric(friendship.balance),
|
|
91
|
+
currency: currencyCode(friendship.currency),
|
|
92
|
+
isGuest: user.isGuest === true,
|
|
93
|
+
isGold: user.isGold === true,
|
|
94
|
+
mostRecentActivity: friendship.mostRecentActivity ?? null,
|
|
95
|
+
};
|
|
96
|
+
}),
|
|
97
|
+
hasMore: response.hasMore === true,
|
|
98
|
+
nextCursor: response.nextCursor ?? null,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const friendPresenters = {
|
|
103
|
+
"friend-list": {
|
|
104
|
+
clean: cleanFriendList,
|
|
105
|
+
format(body) {
|
|
106
|
+
const response = asRecord(body);
|
|
107
|
+
const items = asArray(response.items);
|
|
108
|
+
const lines = items.length
|
|
109
|
+
? [
|
|
110
|
+
"Friends",
|
|
111
|
+
...items.map((value, index) => {
|
|
112
|
+
const friendship = asRecord(value);
|
|
113
|
+
const user = asRecord(friendship.user);
|
|
114
|
+
return formatCard(index, user.name, [
|
|
115
|
+
`Friendship ID: ${display(friendship.id)}`,
|
|
116
|
+
`User ID: ${display(user.id)}`,
|
|
117
|
+
`Balance: ${humanAmount(friendship.balance, friendship.currency)}`,
|
|
118
|
+
`Guest: ${yesNo(friendship.isGuest)}`,
|
|
119
|
+
`Gold: ${yesNo(friendship.isGold)}`,
|
|
120
|
+
`Last activity: ${display(friendship.mostRecentActivity)}`,
|
|
121
|
+
]);
|
|
122
|
+
}),
|
|
123
|
+
]
|
|
124
|
+
: ["No friends."];
|
|
125
|
+
lines.push(
|
|
126
|
+
response.hasMore && response.nextCursor
|
|
127
|
+
? [
|
|
128
|
+
"More friends available.",
|
|
129
|
+
`Next page: banana friends list --cursor ${JSON.stringify(response.nextCursor)}`,
|
|
130
|
+
].join("\n")
|
|
131
|
+
: "End of friends.",
|
|
132
|
+
);
|
|
133
|
+
return lines.join("\n\n");
|
|
134
|
+
},
|
|
135
|
+
browser: {
|
|
136
|
+
detailPath(_command, item) {
|
|
137
|
+
return `/friends/${encodedDetailId(item.id)}`;
|
|
138
|
+
},
|
|
139
|
+
formatDetail(item, body) {
|
|
140
|
+
const friendship = asRecord(body);
|
|
141
|
+
const user = asRecord(friendship.user);
|
|
142
|
+
return [
|
|
143
|
+
`Name: ${display(user.name)}`,
|
|
144
|
+
`Balance: ${humanAmount(item.balance, item.currency)}`,
|
|
145
|
+
`Guest: ${yesNo(user.isGuest)}`,
|
|
146
|
+
`Gold: ${yesNo(user.isGold)}`,
|
|
147
|
+
`Status: ${display(friendship.status)}`,
|
|
148
|
+
`Accepted: ${display(friendship.acceptedAt)}`,
|
|
149
|
+
`Last activity: ${display(item.mostRecentActivity)}`,
|
|
150
|
+
`User ID: ${display(user.id)}`,
|
|
151
|
+
`Friendship ID: ${display(friendship.id ?? item.id)}`,
|
|
152
|
+
].join("\n");
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
} satisfies Record<"friend-list", Presenter>;
|