@butinapp/sdk 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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +63 -0
  3. package/dist/data/builders.d.ts +111 -0
  4. package/dist/data/builders.js +103 -0
  5. package/dist/data/currency.d.ts +3 -0
  6. package/dist/data/currency.js +11 -0
  7. package/dist/data/dataset.d.ts +229 -0
  8. package/dist/data/dataset.js +70 -0
  9. package/dist/data/index.d.ts +13 -0
  10. package/dist/data/index.js +11 -0
  11. package/dist/data/result.d.ts +163 -0
  12. package/dist/data/result.js +171 -0
  13. package/dist/data/roles.d.ts +17 -0
  14. package/dist/data/roles.js +3 -0
  15. package/dist/data/series.d.ts +6 -0
  16. package/dist/data/series.js +5 -0
  17. package/dist/data/summary.d.ts +36 -0
  18. package/dist/data/summary.js +18 -0
  19. package/dist/data/view.d.ts +93 -0
  20. package/dist/data/view.js +74 -0
  21. package/dist/index.d.ts +12 -0
  22. package/dist/index.js +14 -0
  23. package/dist/integrations/index.d.ts +1 -0
  24. package/dist/integrations/index.js +4 -0
  25. package/dist/integrations/stripe.d.ts +45 -0
  26. package/dist/integrations/stripe.js +102 -0
  27. package/dist/libs.d.ts +2 -0
  28. package/dist/libs.js +6 -0
  29. package/dist/plugin/auth.d.ts +49 -0
  30. package/dist/plugin/auth.js +1 -0
  31. package/dist/plugin/browser.d.ts +26 -0
  32. package/dist/plugin/browser.js +8 -0
  33. package/dist/plugin/capability.d.ts +54 -0
  34. package/dist/plugin/capability.js +19 -0
  35. package/dist/plugin/config.d.ts +61 -0
  36. package/dist/plugin/config.js +22 -0
  37. package/dist/plugin/documents.d.ts +5 -0
  38. package/dist/plugin/documents.js +1 -0
  39. package/dist/plugin/meta.d.ts +21 -0
  40. package/dist/plugin/meta.js +1 -0
  41. package/dist/plugin/plugin.d.ts +26 -0
  42. package/dist/plugin/plugin.js +1 -0
  43. package/dist/plugin/session.d.ts +33 -0
  44. package/dist/plugin/session.js +5 -0
  45. package/dist/plugin/transport.d.ts +42 -0
  46. package/dist/plugin/transport.js +1 -0
  47. package/dist/presets/apikeys.d.ts +13 -0
  48. package/dist/presets/apikeys.js +24 -0
  49. package/dist/presets/billing.d.ts +56 -0
  50. package/dist/presets/billing.js +209 -0
  51. package/dist/presets/blocks.d.ts +43 -0
  52. package/dist/presets/blocks.js +99 -0
  53. package/dist/presets/index.d.ts +38 -0
  54. package/dist/presets/index.js +22 -0
  55. package/dist/presets/members.d.ts +11 -0
  56. package/dist/presets/members.js +15 -0
  57. package/dist/presets/mtd-basis.d.ts +11 -0
  58. package/dist/presets/mtd-basis.js +16 -0
  59. package/dist/presets/usage.d.ts +17 -0
  60. package/dist/presets/usage.js +61 -0
  61. package/dist/schema.d.ts +2 -0
  62. package/dist/schema.js +6 -0
  63. package/dist/testing/index.d.ts +2 -0
  64. package/dist/testing/index.js +6 -0
  65. package/dist/testing/synthetic.d.ts +65 -0
  66. package/dist/testing/synthetic.js +259 -0
  67. package/dist/util/date.d.ts +14 -0
  68. package/dist/util/date.js +49 -0
  69. package/dist/util/fx.d.ts +2 -0
  70. package/dist/util/fx.js +7 -0
  71. package/dist/util/index.d.ts +6 -0
  72. package/dist/util/index.js +9 -0
  73. package/dist/util/money.d.ts +6 -0
  74. package/dist/util/money.js +35 -0
  75. package/dist/util/object.d.ts +2 -0
  76. package/dist/util/object.js +7 -0
  77. package/dist/util/text.d.ts +2 -0
  78. package/dist/util/text.js +16 -0
  79. package/package.json +70 -0
@@ -0,0 +1,259 @@
1
+ // Seeded, zero-dependency synthetic data for the demo seed + sample contract tests. A plugin's `sample`
2
+ // generator builds its raw payload ONLY from this toolkit, so the demo can never carry a real name, email,
3
+ // address, or account number — there is no literal to paste one into. Deterministic (no Math.random/Date.now):
4
+ // a constant reference instant + a per-call seeded PRNG → byte-identical re-runs.
5
+ import { MS_PER_DAY } from '../util/date.js';
6
+ // xfnv1a → mulberry32: the same tiny PRNG core's seed uses, kept private here (the SDK can't import core).
7
+ const hashSeed = (s) => {
8
+ let h = 2166136261 >>> 0;
9
+ for (let i = 0; i < s.length; i++) {
10
+ h ^= s.charCodeAt(i);
11
+ h = Math.imul(h, 16777619);
12
+ }
13
+ return h >>> 0;
14
+ };
15
+ const mulberry32 = (seed) => {
16
+ let a = seed >>> 0;
17
+ return () => {
18
+ a = (a + 0x6d2b79f5) | 0;
19
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
20
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
21
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
22
+ };
23
+ };
24
+ const FIRST_NAMES = [
25
+ 'Liam',
26
+ 'Emma',
27
+ 'Owen',
28
+ 'Chloe',
29
+ 'Connor',
30
+ 'Hannah',
31
+ 'Ethan',
32
+ 'Olivia',
33
+ 'Nathan',
34
+ 'Sophie',
35
+ 'Lucas',
36
+ 'Grace',
37
+ 'Ryan',
38
+ 'Megan',
39
+ 'Aiden',
40
+ 'Ella'
41
+ ];
42
+ const LAST_NAMES = [
43
+ 'MacDonald',
44
+ 'Brennan',
45
+ 'Murphy',
46
+ 'Walsh',
47
+ 'Kelly',
48
+ 'Campbell',
49
+ 'Thompson',
50
+ 'Clarke',
51
+ 'Sullivan',
52
+ 'Fitzgerald',
53
+ 'Cohen',
54
+ 'Goldberg',
55
+ 'Russo',
56
+ 'Bianchi',
57
+ 'Pappas',
58
+ 'Ryan'
59
+ ];
60
+ const COMPANY_A = ['Northwind', 'Globex', 'Initech', 'Umbrella', 'Acme', 'Hooli', 'Vandelay', 'Soylent'];
61
+ const COMPANY_B = ['Systems', 'Labs', 'Industries', 'Group', 'Cloud', 'Works'];
62
+ const SLUGS = ['acme', 'northwind', 'globex', 'initech', 'umbrella', 'hooli', 'vandelay'];
63
+ const LOREM = [
64
+ 'lorem',
65
+ 'ipsum',
66
+ 'dolor',
67
+ 'sit',
68
+ 'amet',
69
+ 'consectetur',
70
+ 'adipiscing',
71
+ 'elit',
72
+ 'sed',
73
+ 'tempor',
74
+ 'incididunt',
75
+ 'labore',
76
+ 'magna',
77
+ 'aliqua',
78
+ 'veniam',
79
+ 'nostrud',
80
+ 'aliquip',
81
+ 'commodo',
82
+ 'dataset',
83
+ 'metric',
84
+ 'report',
85
+ 'usage',
86
+ 'invoice',
87
+ 'service'
88
+ ];
89
+ const STREETS = ['Maple', 'Cedar', 'Oak', 'Exemple', 'River', 'Hill', 'Birch', 'Pine'];
90
+ const CITIES = ['Montréal', 'Québec', 'Laval', 'Gatineau', 'Sherbrooke'];
91
+ // Canada Post's valid letter set (excludes D F I O Q U / W Z). Postals are RANDOMIZED per call, never a fixed
92
+ // pool — a constant code would be a real FSA tied to a real neighbourhood.
93
+ const POSTAL_LETTERS = [
94
+ 'A',
95
+ 'B',
96
+ 'C',
97
+ 'E',
98
+ 'G',
99
+ 'H',
100
+ 'J',
101
+ 'K',
102
+ 'L',
103
+ 'M',
104
+ 'N',
105
+ 'P',
106
+ 'R',
107
+ 'S',
108
+ 'T',
109
+ 'V',
110
+ 'X',
111
+ 'Y'
112
+ ];
113
+ const MONTH_NAMES = [
114
+ 'January',
115
+ 'February',
116
+ 'March',
117
+ 'April',
118
+ 'May',
119
+ 'June',
120
+ 'July',
121
+ 'August',
122
+ 'September',
123
+ 'October',
124
+ 'November',
125
+ 'December'
126
+ ];
127
+ // A fixed-size cast built once from a CONSTANT root seed — identical across every plugin + every run, so the
128
+ // same fabricated person recurs across services. Index emails to guarantee uniqueness; all `@example.invalid`.
129
+ const CAST_SIZE = 64;
130
+ let castMemo;
131
+ const buildCast = () => {
132
+ if (castMemo) {
133
+ return castMemo;
134
+ }
135
+ const r = mulberry32(hashSeed('butin-sample-cast'));
136
+ castMemo = Array.from({ length: CAST_SIZE }, (_, i) => {
137
+ const firstName = FIRST_NAMES[Math.floor(r() * FIRST_NAMES.length)];
138
+ const lastName = LAST_NAMES[Math.floor(r() * LAST_NAMES.length)];
139
+ return {
140
+ id: `usr_${String(i + 1).padStart(4, '0')}`,
141
+ firstName,
142
+ lastName,
143
+ name: `${firstName} ${lastName}`,
144
+ email: `${firstName}.${lastName}.${i}@example.invalid`.toLowerCase()
145
+ };
146
+ });
147
+ return castMemo;
148
+ };
149
+ // A deterministic unique person for an index beyond the shared cast — lets a large roster (xlarge) hold more
150
+ // than CAST_SIZE distinct members without repeating. Indices < CAST_SIZE stay the stable shared cast.
151
+ const personBeyondCast = (i) => {
152
+ const firstName = FIRST_NAMES[i % FIRST_NAMES.length];
153
+ const lastName = LAST_NAMES[(Math.floor(i / FIRST_NAMES.length) + i) % LAST_NAMES.length];
154
+ return {
155
+ id: `usr_${String(i + 1).padStart(4, '0')}`,
156
+ firstName,
157
+ lastName,
158
+ name: `${firstName} ${lastName}`,
159
+ email: `${firstName}.${lastName}.${i}@example.invalid`.toLowerCase()
160
+ };
161
+ };
162
+ // A fixed instant the relative dates are measured from — never Date.now (which would break reproducibility).
163
+ const REFERENCE_MS = Date.UTC(2026, 5, 17, 12, 0, 0);
164
+ const MIDNIGHT_REF_MS = Date.UTC(2026, 5, 17, 0, 0, 0); // midnight of the reference day, for epoch/day helpers
165
+ const REF_YEAR = 2026;
166
+ const REF_MONTH0 = 5; // June (0-based), the reference month for monthsAgo
167
+ export const createSampleGen = (seed) => {
168
+ const rng = mulberry32(hashSeed(seed));
169
+ const cast = buildCast();
170
+ const int = (min, max) => Math.floor(min + rng() * (max - min + 1));
171
+ const pick = (items) => items[Math.floor(rng() * items.length)];
172
+ const last4 = () => String(int(1000, 9999));
173
+ const pastDate = (maxDaysAgo) => new Date(REFERENCE_MS - int(0, Math.max(0, maxDaysAgo)) * MS_PER_DAY).toISOString();
174
+ const dayMs = (maxDaysAgo) => MIDNIGHT_REF_MS - int(0, Math.max(0, maxDaysAgo)) * MS_PER_DAY;
175
+ const day = (maxDaysAgo) => {
176
+ const ms = dayMs(maxDaysAgo);
177
+ const iso = new Date(ms).toISOString();
178
+ return { date: iso.slice(0, 10), iso, epochMs: ms, epochSec: Math.floor(ms / 1000), yearMonth: iso.slice(0, 7) };
179
+ };
180
+ const onePerson = () => {
181
+ const firstName = pick(FIRST_NAMES);
182
+ const lastName = pick(LAST_NAMES);
183
+ return {
184
+ id: `usr_${Math.floor(rng() * 0xffffffff).toString(36)}`,
185
+ firstName,
186
+ lastName,
187
+ name: `${firstName} ${lastName}`,
188
+ email: `${firstName}.${lastName}.${int(100, 999)}@example.invalid`.toLowerCase()
189
+ };
190
+ };
191
+ const gen = {
192
+ person: (i) => (i === undefined ? onePerson() : cast[((i % CAST_SIZE) + CAST_SIZE) % CAST_SIZE]),
193
+ people: (n) => Array.from({ length: Math.max(0, n) }, (_, i) => (i < CAST_SIZE ? cast[i] : personBeyondCast(i))),
194
+ company: () => `${pick(COMPANY_A)} ${pick(COMPANY_B)}`,
195
+ orgSlug: () => pick(SLUGS),
196
+ id: (prefix = 'id') => `${prefix}_${Math.floor(rng() * 0xffffffff).toString(36)}`,
197
+ int,
198
+ float: (min, max, decimals = 2) => {
199
+ const p = 10 ** decimals;
200
+ return Math.round((min + rng() * (max - min)) * p) / p;
201
+ },
202
+ bool: (p = 0.5) => rng() < p,
203
+ pick,
204
+ maybe: (value, p = 0.5) => (rng() < p ? value : undefined),
205
+ money: (min, max) => Math.round((min + rng() * (max - min)) * 100) / 100,
206
+ amountCents: (min, max) => Math.floor(min + rng() * (max - min)),
207
+ pastDate,
208
+ dayString: (maxDaysAgo) => pastDate(maxDaysAgo).slice(0, 10),
209
+ words: (n) => Array.from({ length: Math.max(0, n) }, () => pick(LOREM)).join(' '),
210
+ sentence: () => {
211
+ const w = Array.from({ length: int(4, 9) }, () => pick(LOREM));
212
+ return `${w[0].charAt(0).toUpperCase()}${w[0].slice(1)} ${w.slice(1).join(' ')}.`;
213
+ },
214
+ maskedKey: () => `…${last4()}`,
215
+ last4,
216
+ phone: () => `555-01${int(10, 99)}`,
217
+ postal: () => `${pick(POSTAL_LETTERS)}${int(0, 9)}${pick(POSTAL_LETTERS)} ${int(0, 9)}${pick(POSTAL_LETTERS)}${int(0, 9)}`,
218
+ address: () => `${int(1, 9999)} rue ${pick(STREETS)}, ${pick(CITIES)} (Québec) ${gen.postal()}`,
219
+ day,
220
+ midnightIso: (maxDaysAgo) => day(maxDaysAgo).iso,
221
+ pastEpochMs: (maxDaysAgo) => dayMs(maxDaysAgo),
222
+ pastEpochSec: (maxDaysAgo) => Math.floor(dayMs(maxDaysAgo) / 1000),
223
+ monthsAgo: (n) => {
224
+ const total = REF_YEAR * 12 + REF_MONTH0 - n;
225
+ const y = Math.floor(total / 12);
226
+ const m0 = ((total % 12) + 12) % 12;
227
+ const startEpochMs = Date.UTC(y, m0, 1, 0, 0, 0);
228
+ const mm = String(m0 + 1).padStart(2, '0');
229
+ return {
230
+ yearMonth: `${y}-${mm}`,
231
+ ym: `${y}${mm}`,
232
+ startEpochMs,
233
+ startEpochSec: Math.floor(startEpochMs / 1000),
234
+ label: `${MONTH_NAMES[m0]} ${y}`
235
+ };
236
+ },
237
+ url: (segment, id) => `https://example.invalid/${segment}${id === undefined ? '' : `/${id}`}`,
238
+ moneyStr: (min, max, decimals = 2) => (min + rng() * (max - min)).toFixed(decimals),
239
+ seqId: (prefix, n, width = 4) => `${prefix}${String(n).padStart(width, '0')}`,
240
+ repeat: (n, fn) => Array.from({ length: Math.max(0, n) }, (_, i) => fn(i, gen))
241
+ };
242
+ return gen;
243
+ };
244
+ const SAMPLE_PRESETS = {
245
+ small: { users: 3, documents: 5, days: 14, window: 60 },
246
+ medium: { users: 12, documents: 20, days: 30, window: 180 },
247
+ large: { users: 40, documents: 60, days: 120, window: 365 },
248
+ xlarge: { users: 400, documents: 130, days: 250, window: 365 }
249
+ };
250
+ // Preset (default medium) then apply only the explicitly-provided knobs. medium === today's seed defaults.
251
+ export const resolveSampleConfig = (input) => {
252
+ const base = SAMPLE_PRESETS[input?.size ?? 'medium'];
253
+ return {
254
+ users: input?.users ?? base.users,
255
+ documents: input?.documents ?? base.documents,
256
+ days: input?.days ?? base.days,
257
+ window: input?.window ?? base.window
258
+ };
259
+ };
@@ -0,0 +1,14 @@
1
+ export declare const MS_PER_DAY = 86400000;
2
+ export declare const MONTH_ABBR: string[];
3
+ export declare const setReportingZone: (zone: string) => void;
4
+ export declare const getReportingZone: () => string;
5
+ export declare const isoDay: (value?: string | null) => string | undefined;
6
+ export declare const epochMsDay: (ms?: number | null) => string | undefined;
7
+ export declare const epochSecDay: (seconds?: number | null) => string | undefined;
8
+ export declare const dayOf: (value?: string | null) => string | undefined;
9
+ export declare const monthKey: (year: number, month: number) => string;
10
+ export declare const currentMonthKey: (now?: Date) => string;
11
+ export declare const dayMinus: (day: string, days: number) => string;
12
+ export declare const utcDaysAgo: (days: number) => string;
13
+ export declare const isoDaysAgo: (days: number, now?: Date) => string;
14
+ export declare const monthStart: (now?: Date) => string;
@@ -0,0 +1,49 @@
1
+ import { DateTime } from '../libs.js';
2
+ // Date→string helpers shared across plugins. Vendors hand us ISO datetimes, epoch ms, or epoch seconds;
3
+ // the data-view contract wants plain 'YYYY-MM-DD' day keys. Normalize at the edge here so each plugin's
4
+ // build*() stays free of ad-hoc date slicing.
5
+ // Milliseconds in a calendar day — the shared unit for day-arithmetic across plugins, core, and the UI.
6
+ export const MS_PER_DAY = 86_400_000;
7
+ // English short month names, indexed by 0-based month — the compact 'Jan'…'Dec' labels for month-keyed series.
8
+ export const MONTH_ABBR = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
9
+ // The single timezone every day/month BUCKET is computed in — so "this month", a per-day series, and the values
10
+ // each plugin books all agree on where the boundary sits. Defaults to 'utc' so the pure helpers (and every plugin
11
+ // fixture that asserts a day) stay deterministic regardless of the test runner's OS zone; the running app calls
12
+ // setReportingZone with the OS zone at boot, so buckets follow the user's wall clock and roll at their local
13
+ // midnight. An IANA name ('America/New_York'), 'system', or 'utc'.
14
+ let reportingZone = 'utc';
15
+ export const setReportingZone = (zone) => {
16
+ reportingZone = zone;
17
+ };
18
+ export const getReportingZone = () => reportingZone;
19
+ // Trim an ISO datetime string to its 'YYYY-MM-DD' day, keeping the day AS WRITTEN (no zone conversion) — for a
20
+ // vendor field that is already the intended calendar day. To bucket a real instant into the reporting zone, use
21
+ // `dayOf`/`epochMsDay`. Empty/missing → undefined.
22
+ export const isoDay = (value) => (value ? value.slice(0, 10) : undefined);
23
+ // Epoch milliseconds → 'YYYY-MM-DD' in the reporting zone. 0/missing/invalid → undefined.
24
+ export const epochMsDay = (ms) => {
25
+ if (!ms) {
26
+ return undefined;
27
+ }
28
+ const d = DateTime.fromMillis(ms, { zone: reportingZone });
29
+ return d.isValid ? d.toISODate() : undefined;
30
+ };
31
+ // Epoch seconds → reporting-zone 'YYYY-MM-DD' (most token/activity APIs report seconds). 0/missing/invalid → undefined.
32
+ export const epochSecDay = (seconds) => epochMsDay(seconds == null ? undefined : seconds * 1000);
33
+ // ISO datetime (Z or tz-offset) → its 'YYYY-MM-DD' day in the reporting zone. Reparses through the instant so an
34
+ // offset datetime lands on the correct zoned day, where the slice-based `isoDay` would keep the day as written.
35
+ // Missing/unparseable → undefined.
36
+ export const dayOf = (value) => epochMsDay(value ? Date.parse(value) : undefined);
37
+ // 'YYYY-MM' month key from a 1-based month.
38
+ export const monthKey = (year, month) => `${year}-${String(month).padStart(2, '0')}`;
39
+ // 'YYYY-MM' month key of `now` in the reporting zone — the current calendar month.
40
+ export const currentMonthKey = (now = new Date()) => DateTime.fromJSDate(now, { zone: reportingZone }).toFormat('yyyy-MM');
41
+ // 'YYYY-MM-DD' for `days` before `day` (a UTC 'YYYY-MM-DD'). Day-string arithmetic goes through luxon, so callers
42
+ // never re-roll millisecond math; invalid input returns `day` unchanged.
43
+ export const dayMinus = (day, days) => DateTime.fromISO(day, { zone: 'utc' }).minus({ days }).toISODate() ?? day;
44
+ // UTC 'YYYY-MM-DD' for N days before now.
45
+ export const utcDaysAgo = (days) => new Date(Date.now() - days * MS_PER_DAY).toISOString().slice(0, 10);
46
+ // Full UTC ISO datetime N days before `now` — the start of a since-window passed to usage/event APIs.
47
+ export const isoDaysAgo = (days, now = new Date()) => new Date(now.getTime() - days * MS_PER_DAY).toISOString();
48
+ // First day of `now`'s month in the reporting zone, as 'YYYY-MM-DD'.
49
+ export const monthStart = (now = new Date()) => DateTime.fromJSDate(now, { zone: reportingZone }).startOf('month').toISODate();
@@ -0,0 +1,2 @@
1
+ export type FxRates = Record<string, number>;
2
+ export declare const convert: (amount: number, from: string, to: string, rates: FxRates) => number | null;
@@ -0,0 +1,7 @@
1
+ export const convert = (amount, from, to, rates) => {
2
+ if (from === to) {
3
+ return amount;
4
+ }
5
+ const rate = rates[from];
6
+ return rate === undefined ? null : amount * rate;
7
+ };
@@ -0,0 +1,6 @@
1
+ export { centsToMajor, centsStringToMajor, millicentsToMajor, parseDecimalAmount, round2, parseFrAmount } from './money.js';
2
+ export { isoDay, dayOf, epochMsDay, epochSecDay, monthKey, currentMonthKey, dayMinus, utcDaysAgo, isoDaysAgo, monthStart, getReportingZone, setReportingZone, MS_PER_DAY, MONTH_ABBR } from './date.js';
3
+ export { startCase, parseDollarAmount } from './text.js';
4
+ export { convert } from './fx.js';
5
+ export type { FxRates } from './fx.js';
6
+ export { omitUndef, asArray } from './object.js';
@@ -0,0 +1,9 @@
1
+ // @butinapp/sdk/util — Butin's own edge normalizers: unit conversion (cents/millicents/decimal → major
2
+ // units), reporting-zone day/month helpers, title-casing + amount parsing, currency conversion. Flat named exports —
3
+ // their leaf names already read clearly, and a namespace prefix would shadow common locals (`date`, `fx`).
4
+ // (Generic third-party utilities — lodash + luxon — live on @butinapp/sdk/libs.)
5
+ export { centsToMajor, centsStringToMajor, millicentsToMajor, parseDecimalAmount, round2, parseFrAmount } from './money.js';
6
+ export { isoDay, dayOf, epochMsDay, epochSecDay, monthKey, currentMonthKey, dayMinus, utcDaysAgo, isoDaysAgo, monthStart, getReportingZone, setReportingZone, MS_PER_DAY, MONTH_ABBR } from './date.js';
7
+ export { startCase, parseDollarAmount } from './text.js';
8
+ export { convert } from './fx.js';
9
+ export { omitUndef, asArray } from './object.js';
@@ -0,0 +1,6 @@
1
+ export declare const centsToMajor: (value?: number | null) => number;
2
+ export declare const centsStringToMajor: (value?: string) => number;
3
+ export declare const millicentsToMajor: (value?: string) => number;
4
+ export declare const parseDecimalAmount: (value?: string) => number;
5
+ export declare const round2: (n: number) => number;
6
+ export declare const parseFrAmount: (raw?: string | null) => number;
@@ -0,0 +1,35 @@
1
+ // Money-unit converters — vendors report in cents / cent-strings / millicents / decimal-strings.
2
+ // Normalize everything to MAJOR UNITS of the value's own currency at the edge; the currency itself is
3
+ // named separately by `reportingCurrency` / a Column's `currency`. Unit conversion, not currency conversion.
4
+ const CENTS_PER_MAJOR = 100;
5
+ const MILLICENTS_PER_MAJOR = 100_000;
6
+ export const centsToMajor = (value) => (value ?? 0) / CENTS_PER_MAJOR;
7
+ export const centsStringToMajor = (value) => (value ? parseInt(value, 10) / CENTS_PER_MAJOR : 0);
8
+ export const millicentsToMajor = (value) => {
9
+ const n = value ? parseInt(value, 10) : 0;
10
+ return Number.isFinite(n) ? n / MILLICENTS_PER_MAJOR : 0;
11
+ };
12
+ export const parseDecimalAmount = (value) => {
13
+ if (!value) {
14
+ return 0;
15
+ }
16
+ const n = parseFloat(value.replace(/[^0-9.-]/g, ''));
17
+ return Number.isFinite(n) ? n : 0;
18
+ };
19
+ // Round to 2 decimals — sheds the float noise a vendor's already-major-unit amount carries (69.90000001 → 69.9).
20
+ export const round2 = (n) => Math.round(n * 100) / 100;
21
+ // French-Canadian money string → major units, rounded to cents. Handles space thousands-seps, a `$` suffix, and a
22
+ // comma decimal ("24 716,80 $" | "1 234,56 $" → 24716.8 | 1234.56), while still parsing dot-decimal ("1,234.56").
23
+ // A bare comma-decimal (no dot present) is treated as the decimal point; otherwise commas are thousands-seps and
24
+ // dropped. 0 when empty or unparseable. (parseDecimalAmount drops commas wholesale, so it can't read the fr form.)
25
+ export const parseFrAmount = (raw) => {
26
+ if (!raw) {
27
+ return 0;
28
+ }
29
+ const cleaned = raw.replace(/\s/g, '').replace(/\$/g, '');
30
+ const normalized = cleaned.includes(',') && !cleaned.includes('.')
31
+ ? cleaned.replace(/\./g, '').replace(',', '.')
32
+ : cleaned.replace(/,/g, '');
33
+ const n = parseFloat(normalized);
34
+ return Number.isFinite(n) ? round2(n) : 0;
35
+ };
@@ -0,0 +1,2 @@
1
+ export declare const omitUndef: <T extends object>(o: T) => T;
2
+ export declare const asArray: <T>(value: unknown) => T[];
@@ -0,0 +1,7 @@
1
+ // Drop keys whose value is `undefined` (keeps null/0/'' /false). Used when assembling wire objects so an
2
+ // absent optional field is omitted rather than serialized as `undefined`.
3
+ export const omitUndef = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
4
+ // A parsed wire value as a typed array, or [] when it isn't one — the guard every `build*()` repeats against a
5
+ // JSON payload whose "list" field can arrive missing, null, or (the classic trap) a name-keyed object. The
6
+ // element type is the caller's claim about the rows; pass it explicitly: `asArray<RawFoo>(payload.items)`.
7
+ export const asArray = (value) => (Array.isArray(value) ? value : []);
@@ -0,0 +1,2 @@
1
+ export declare const startCase: (value: string) => string;
2
+ export declare const parseDollarAmount: (formatted?: string) => number;
@@ -0,0 +1,16 @@
1
+ import { upperFirst, words } from '../libs.js';
2
+ // Title-case a machine value for display, lowercasing each word's tail so SCREAMING_SNAKE and acronyms read as
3
+ // Title Case (`team_tier_1` → 'Team Tier 1', `monthlyCredit` → 'Monthly Credit', `UNCOLLECTIBLE` → 'Uncollectible').
4
+ // Built on lodash word-splitting (camelCase + snake/kebab/space boundaries); empty/whitespace → ''.
5
+ export const startCase = (value) => words(value)
6
+ .map((w) => upperFirst(w.toLowerCase()))
7
+ .join(' ');
8
+ // Pull the first signed decimal out of a formatted currency string (`$1,204.55` → 1204.55, `—` → 0). Commas
9
+ // are stripped before matching. Empty/missing/unparseable → 0.
10
+ export const parseDollarAmount = (formatted) => {
11
+ if (!formatted) {
12
+ return 0;
13
+ }
14
+ const match = formatted.replace(/,/g, '').match(/-?\d+(\.\d+)?/);
15
+ return match ? parseFloat(match[0]) : 0;
16
+ };
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@butinapp/sdk",
3
+ "version": "0.1.0",
4
+ "description": "The Butin plugin-authoring contract: descriptor types, the data-view result contract, presets, and edge utilities.",
5
+ "homepage": "https://butin.app",
6
+ "bugs": "https://github.com/butinapp/butin/issues",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/butinapp/butin.git",
11
+ "directory": "packages/sdk"
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "LICENSE",
16
+ "README.md"
17
+ ],
18
+ "type": "module",
19
+ "sideEffects": false,
20
+ "types": "./dist/index.d.ts",
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "import": "./dist/index.js"
25
+ },
26
+ "./data": {
27
+ "types": "./dist/data/index.d.ts",
28
+ "import": "./dist/data/index.js"
29
+ },
30
+ "./presets": {
31
+ "types": "./dist/presets/index.d.ts",
32
+ "import": "./dist/presets/index.js"
33
+ },
34
+ "./integrations": {
35
+ "types": "./dist/integrations/index.d.ts",
36
+ "import": "./dist/integrations/index.js"
37
+ },
38
+ "./util": {
39
+ "types": "./dist/util/index.d.ts",
40
+ "import": "./dist/util/index.js"
41
+ },
42
+ "./testing": {
43
+ "types": "./dist/testing/index.d.ts",
44
+ "import": "./dist/testing/index.js"
45
+ },
46
+ "./libs": {
47
+ "types": "./dist/libs.d.ts",
48
+ "import": "./dist/libs.js"
49
+ },
50
+ "./schema": {
51
+ "types": "./dist/schema.d.ts",
52
+ "import": "./dist/schema.js"
53
+ }
54
+ },
55
+ "publishConfig": {
56
+ "access": "public"
57
+ },
58
+ "dependencies": {
59
+ "@types/lodash-es": "^4.17.12",
60
+ "@types/luxon": "^3.7.1",
61
+ "lodash-es": "^4.18.1",
62
+ "luxon": "^3.7.2",
63
+ "zod": "^4.3.6"
64
+ },
65
+ "scripts": {
66
+ "build": "tsgo -p tsconfig.build.json",
67
+ "typecheck": "tsgo --noEmit -p tsconfig.json",
68
+ "test": "vitest run"
69
+ }
70
+ }