@forgezero/runtime 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 +89 -0
- package/contracts/foundry.toml +9 -0
- package/contracts/src/ColdVault.sol +206 -0
- package/contracts/src/DepositFactory.sol +202 -0
- package/contracts/src/DepositProxy.sol +72 -0
- package/contracts/src/IERC20.sol +7 -0
- package/contracts/src/MockTokens.sol +32 -0
- package/contracts/src/SafeTransferLib.sol +31 -0
- package/contracts/test/Custody.t.sol +361 -0
- package/contracts/test/Vectors.t.sol +45 -0
- package/dist/audit.d.ts +265 -0
- package/dist/audit.js +291 -0
- package/dist/backup.d.ts +243 -0
- package/dist/backup.js +302 -0
- package/dist/calendar.d.ts +136 -0
- package/dist/calendar.js +129 -0
- package/dist/compliance.d.ts +172 -0
- package/dist/compliance.js +168 -0
- package/dist/finance/binance.d.ts +27 -0
- package/dist/finance/binance.js +452 -0
- package/dist/finance/chain-addresses.d.ts +130 -0
- package/dist/finance/chain-addresses.js +462 -0
- package/dist/finance/chain-deposits.d.ts +193 -0
- package/dist/finance/chain-deposits.js +596 -0
- package/dist/finance/chain-reconcile.d.ts +112 -0
- package/dist/finance/chain-reconcile.js +76 -0
- package/dist/finance/chain-withdrawals.d.ts +223 -0
- package/dist/finance/chain-withdrawals.js +631 -0
- package/dist/finance/chain.d.ts +116 -0
- package/dist/finance/chain.js +316 -0
- package/dist/finance/commission.d.ts +155 -0
- package/dist/finance/commission.js +419 -0
- package/dist/finance/custody.d.ts +68 -0
- package/dist/finance/custody.js +107 -0
- package/dist/finance/derive.d.ts +115 -0
- package/dist/finance/derive.js +116 -0
- package/dist/finance/discounts.d.ts +98 -0
- package/dist/finance/discounts.js +90 -0
- package/dist/finance/ledger.d.ts +221 -0
- package/dist/finance/ledger.js +308 -0
- package/dist/finance/market.d.ts +209 -0
- package/dist/finance/market.js +112 -0
- package/dist/finance/money.d.ts +118 -0
- package/dist/finance/money.js +176 -0
- package/dist/finance/rates.d.ts +178 -0
- package/dist/finance/rates.js +292 -0
- package/dist/finance/storage.d.ts +113 -0
- package/dist/finance/storage.js +226 -0
- package/dist/finance/tax.d.ts +132 -0
- package/dist/finance/tax.js +291 -0
- package/dist/finance/transfers.d.ts +153 -0
- package/dist/finance/transfers.js +292 -0
- package/dist/finance/venues.d.ts +190 -0
- package/dist/finance/venues.js +251 -0
- package/dist/identity.d.ts +115 -0
- package/dist/identity.js +111 -0
- package/dist/importers.d.ts +87 -0
- package/dist/importers.js +250 -0
- package/dist/jobs.d.ts +171 -0
- package/dist/jobs.js +250 -0
- package/dist/notify-templates.d.ts +11 -0
- package/dist/notify-templates.js +254 -0
- package/dist/notify.d.ts +172 -0
- package/dist/notify.js +122 -0
- package/dist/openssh.d.ts +36 -0
- package/dist/openssh.js +106 -0
- package/dist/otpauth.d.ts +57 -0
- package/dist/otpauth.js +223 -0
- package/dist/outbox.d.ts +234 -0
- package/dist/outbox.js +236 -0
- package/dist/passkey.d.ts +120 -0
- package/dist/passkey.js +105 -0
- package/dist/phrase.d.ts +87 -0
- package/dist/phrase.js +87 -0
- package/dist/pipeline.d.ts +137 -0
- package/dist/pipeline.js +121 -0
- package/dist/queue.d.ts +243 -0
- package/dist/queue.js +246 -0
- package/dist/schema-typebox.d.ts +24 -0
- package/dist/schema-typebox.js +201 -0
- package/dist/schema.d.ts +134 -0
- package/dist/schema.js +169 -0
- package/dist/serial.d.ts +54 -0
- package/dist/serial.js +40 -0
- package/dist/slip10.d.ts +37 -0
- package/dist/slip10.js +74 -0
- package/dist/snp.d.ts +115 -0
- package/dist/snp.js +109 -0
- package/dist/ssh-agent.d.ts +70 -0
- package/dist/ssh-agent.js +141 -0
- package/dist/ssh-cert.d.ts +73 -0
- package/dist/ssh-cert.js +111 -0
- package/dist/totp.d.ts +104 -0
- package/dist/totp.js +143 -0
- package/package.json +248 -0
package/dist/backup.js
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
2
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
3
|
+
}) : x)(function(x) {
|
|
4
|
+
if (typeof require !== "undefined")
|
|
5
|
+
return require.apply(this, arguments);
|
|
6
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
// src/backup.ts
|
|
10
|
+
import { hkdf, seal, open, sha256, timingSafeEqual } from "@forgezero/access/security";
|
|
11
|
+
|
|
12
|
+
class BackupError extends Error {
|
|
13
|
+
code;
|
|
14
|
+
constructor(code, message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.name = "BackupError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
var SNAPSHOT_FORMAT = 1;
|
|
21
|
+
var keyFor = (masterSeed, realm, snapshotId) => hkdf(masterSeed, `forgezero:backup:v1:${realm}:${snapshotId}`, 32);
|
|
22
|
+
var aadFor = (realm, snapshotId, index) => `backup:${realm}:${snapshotId}:${index}`;
|
|
23
|
+
var objectKey = (prefix, realm, id, part) => `${prefix}${realm}/${id}/${part}`;
|
|
24
|
+
async function snapshot(options) {
|
|
25
|
+
const now = options.now ?? Date.now;
|
|
26
|
+
const chunkRows = options.chunkRows ?? 1000;
|
|
27
|
+
const prefix = options.prefix ?? "snapshots/";
|
|
28
|
+
const id = options.id ?? `snap_${new Date(now()).toISOString().replace(/[-:.]/g, "").slice(0, 15)}`;
|
|
29
|
+
const key = await keyFor(options.masterSeed, options.realm, id);
|
|
30
|
+
const chunks = [];
|
|
31
|
+
const collections = {};
|
|
32
|
+
let totalRows = 0;
|
|
33
|
+
const flush = async (collection, batch) => {
|
|
34
|
+
if (batch.length === 0)
|
|
35
|
+
return;
|
|
36
|
+
const plaintext = JSON.stringify({ collection, rows: batch });
|
|
37
|
+
const index = chunks.length;
|
|
38
|
+
const sealed = await seal(key, plaintext, aadFor(options.realm, id, index));
|
|
39
|
+
const object = objectKey(prefix, options.realm, id, `chunk-${String(index).padStart(5, "0")}.json`);
|
|
40
|
+
const body = JSON.stringify(sealed);
|
|
41
|
+
await options.store.putObject({ key: object, body, contentType: "application/json" });
|
|
42
|
+
chunks.push({
|
|
43
|
+
index,
|
|
44
|
+
object,
|
|
45
|
+
digest: await sha256(plaintext),
|
|
46
|
+
rows: batch.length,
|
|
47
|
+
bytes: body.length
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
for (const collection of await options.source.collections()) {
|
|
51
|
+
let batch = [];
|
|
52
|
+
let count = 0;
|
|
53
|
+
for await (const row of options.source.rows(collection)) {
|
|
54
|
+
batch.push(row);
|
|
55
|
+
count += 1;
|
|
56
|
+
if (batch.length >= chunkRows) {
|
|
57
|
+
await flush(collection, batch);
|
|
58
|
+
batch = [];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
await flush(collection, batch);
|
|
62
|
+
collections[collection] = count;
|
|
63
|
+
totalRows += count;
|
|
64
|
+
options.onProgress?.({ collection, rows: count, chunks: chunks.length });
|
|
65
|
+
}
|
|
66
|
+
if (totalRows === 0) {
|
|
67
|
+
throw new BackupError("EMPTY_SNAPSHOT", "The source produced no rows. Refusing to write an empty snapshot.");
|
|
68
|
+
}
|
|
69
|
+
const manifest = {
|
|
70
|
+
format: SNAPSHOT_FORMAT,
|
|
71
|
+
id,
|
|
72
|
+
realm: options.realm,
|
|
73
|
+
createdAtMs: now(),
|
|
74
|
+
collections,
|
|
75
|
+
chunks,
|
|
76
|
+
totalRows,
|
|
77
|
+
sealDigest: await sha256(chunks.map((chunk) => chunk.digest).join("")),
|
|
78
|
+
...options.labels ? { labels: options.labels } : {}
|
|
79
|
+
};
|
|
80
|
+
await options.store.putObject({
|
|
81
|
+
key: objectKey(prefix, options.realm, id, "manifest.json"),
|
|
82
|
+
body: JSON.stringify(manifest, null, 2),
|
|
83
|
+
contentType: "application/json"
|
|
84
|
+
});
|
|
85
|
+
return manifest;
|
|
86
|
+
}
|
|
87
|
+
async function verifySnapshot(args) {
|
|
88
|
+
const problems = [];
|
|
89
|
+
const key = await keyFor(args.masterSeed, args.manifest.realm, args.manifest.id);
|
|
90
|
+
let rowsChecked = 0;
|
|
91
|
+
if (args.manifest.format > SNAPSHOT_FORMAT) {
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
id: args.manifest.id,
|
|
95
|
+
chunksChecked: 0,
|
|
96
|
+
rowsChecked: 0,
|
|
97
|
+
problems: [
|
|
98
|
+
{
|
|
99
|
+
chunk: -1,
|
|
100
|
+
code: "UNSUPPORTED_FORMAT",
|
|
101
|
+
message: `Snapshot format ${args.manifest.format} is newer than this reader (${SNAPSHOT_FORMAT}).`
|
|
102
|
+
}
|
|
103
|
+
]
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
for (const chunk of args.manifest.chunks) {
|
|
107
|
+
let raw;
|
|
108
|
+
try {
|
|
109
|
+
raw = await args.store.getObject(chunk.object);
|
|
110
|
+
} catch (cause) {
|
|
111
|
+
problems.push({
|
|
112
|
+
chunk: chunk.index,
|
|
113
|
+
code: "CHUNK_MISSING",
|
|
114
|
+
message: `${chunk.object}: ${cause instanceof Error ? cause.message : "unreadable"}`
|
|
115
|
+
});
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
let plaintext;
|
|
119
|
+
try {
|
|
120
|
+
const sealed = JSON.parse(new TextDecoder().decode(raw));
|
|
121
|
+
plaintext = await open(key, sealed, aadFor(args.manifest.realm, args.manifest.id, chunk.index));
|
|
122
|
+
} catch {
|
|
123
|
+
problems.push({
|
|
124
|
+
chunk: chunk.index,
|
|
125
|
+
code: "DECRYPT_FAILED",
|
|
126
|
+
message: `${chunk.object} did not authenticate. Wrong realm seed, or the chunk was altered or swapped.`
|
|
127
|
+
});
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (!timingSafeEqual(await sha256(plaintext), chunk.digest)) {
|
|
131
|
+
problems.push({
|
|
132
|
+
chunk: chunk.index,
|
|
133
|
+
code: "DIGEST_MISMATCH",
|
|
134
|
+
message: `${chunk.object} decrypts but does not match its manifest digest.`
|
|
135
|
+
});
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
rowsChecked += JSON.parse(plaintext).rows.length;
|
|
139
|
+
}
|
|
140
|
+
const sealDigest = await sha256(args.manifest.chunks.map((chunk) => chunk.digest).join(""));
|
|
141
|
+
if (!timingSafeEqual(sealDigest, args.manifest.sealDigest)) {
|
|
142
|
+
problems.push({
|
|
143
|
+
chunk: -1,
|
|
144
|
+
code: "BAD_MANIFEST",
|
|
145
|
+
message: "The manifest does not match its own seal — a chunk was added to or removed from the list."
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
ok: problems.length === 0,
|
|
150
|
+
id: args.manifest.id,
|
|
151
|
+
chunksChecked: args.manifest.chunks.length,
|
|
152
|
+
rowsChecked,
|
|
153
|
+
problems
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
async function restore(options) {
|
|
157
|
+
const { manifest } = options;
|
|
158
|
+
if (manifest.realm !== options.realm) {
|
|
159
|
+
throw new BackupError("REALM_MISMATCH", `This snapshot belongs to realm "${manifest.realm}" and cannot be restored into "${options.realm}".`);
|
|
160
|
+
}
|
|
161
|
+
if (manifest.format > SNAPSHOT_FORMAT) {
|
|
162
|
+
throw new BackupError("UNSUPPORTED_FORMAT", `Snapshot format ${manifest.format} is newer than this reader (${SNAPSHOT_FORMAT}). Restoring it would silently drop whatever the newer format added.`);
|
|
163
|
+
}
|
|
164
|
+
if (!options.skipVerify) {
|
|
165
|
+
const report = await verifySnapshot({
|
|
166
|
+
store: options.store,
|
|
167
|
+
manifest,
|
|
168
|
+
masterSeed: options.masterSeed
|
|
169
|
+
});
|
|
170
|
+
if (!report.ok) {
|
|
171
|
+
const [first] = report.problems;
|
|
172
|
+
throw new BackupError(first.code, `Refusing to restore: ${report.problems.length} problem(s), first at chunk ${first.chunk} — ${first.message}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
await options.sink.begin?.(manifest);
|
|
176
|
+
const key = await keyFor(options.masterSeed, manifest.realm, manifest.id);
|
|
177
|
+
const collections = {};
|
|
178
|
+
let rowsRestored = 0;
|
|
179
|
+
for (const chunk of manifest.chunks) {
|
|
180
|
+
const raw = await options.store.getObject(chunk.object);
|
|
181
|
+
const sealed = JSON.parse(new TextDecoder().decode(raw));
|
|
182
|
+
const plaintext = await open(key, sealed, aadFor(manifest.realm, manifest.id, chunk.index));
|
|
183
|
+
const { collection, rows } = JSON.parse(plaintext);
|
|
184
|
+
await options.sink.write(collection, rows);
|
|
185
|
+
collections[collection] = (collections[collection] ?? 0) + rows.length;
|
|
186
|
+
rowsRestored += rows.length;
|
|
187
|
+
options.onProgress?.({ chunk: chunk.index + 1, of: manifest.chunks.length, rows: rowsRestored });
|
|
188
|
+
}
|
|
189
|
+
await options.sink.finish?.(manifest);
|
|
190
|
+
return { id: manifest.id, rowsRestored, collections };
|
|
191
|
+
}
|
|
192
|
+
async function listSnapshots(args) {
|
|
193
|
+
const prefix = args.prefix ?? "snapshots/";
|
|
194
|
+
const objects = await args.store.listObjects(`${prefix}${args.realm}/`, 1e4);
|
|
195
|
+
const manifests = [];
|
|
196
|
+
for (const object of objects) {
|
|
197
|
+
if (!object.key.endsWith("/manifest.json"))
|
|
198
|
+
continue;
|
|
199
|
+
try {
|
|
200
|
+
manifests.push(JSON.parse(new TextDecoder().decode(await args.store.getObject(object.key))));
|
|
201
|
+
} catch {}
|
|
202
|
+
}
|
|
203
|
+
return manifests.sort((a, b) => b.createdAtMs - a.createdAtMs);
|
|
204
|
+
}
|
|
205
|
+
var DEFAULT_RETENTION = {
|
|
206
|
+
keepLast: 7,
|
|
207
|
+
keepDaily: 7,
|
|
208
|
+
keepWeekly: 4,
|
|
209
|
+
keepMonthly: 6
|
|
210
|
+
};
|
|
211
|
+
var periodOf = (atMs, unit) => {
|
|
212
|
+
const date = new Date(atMs);
|
|
213
|
+
if (unit === "month")
|
|
214
|
+
return `${date.getUTCFullYear()}-${date.getUTCMonth()}`;
|
|
215
|
+
if (unit === "day")
|
|
216
|
+
return date.toISOString().slice(0, 10);
|
|
217
|
+
return String(Math.floor(atMs / (7 * 86400000)));
|
|
218
|
+
};
|
|
219
|
+
function selectForDeletion(manifests, policy = DEFAULT_RETENTION) {
|
|
220
|
+
if (manifests.length === 0)
|
|
221
|
+
return [];
|
|
222
|
+
const newestFirst = [...manifests].sort((a, b) => b.createdAtMs - a.createdAtMs);
|
|
223
|
+
const keep = new Set;
|
|
224
|
+
for (const manifest of newestFirst.slice(0, Math.max(1, policy.keepLast)))
|
|
225
|
+
keep.add(manifest.id);
|
|
226
|
+
for (const [unit, limit] of [
|
|
227
|
+
["day", policy.keepDaily],
|
|
228
|
+
["week", policy.keepWeekly],
|
|
229
|
+
["month", policy.keepMonthly]
|
|
230
|
+
]) {
|
|
231
|
+
if (!limit)
|
|
232
|
+
continue;
|
|
233
|
+
const seen = new Set;
|
|
234
|
+
for (const manifest of newestFirst) {
|
|
235
|
+
const period = periodOf(manifest.createdAtMs, unit);
|
|
236
|
+
if (seen.has(period))
|
|
237
|
+
continue;
|
|
238
|
+
seen.add(period);
|
|
239
|
+
keep.add(manifest.id);
|
|
240
|
+
if (seen.size >= limit)
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
keep.add(newestFirst[0].id);
|
|
245
|
+
return newestFirst.filter((manifest) => !keep.has(manifest.id));
|
|
246
|
+
}
|
|
247
|
+
async function prune(args) {
|
|
248
|
+
const manifests = await listSnapshots({ store: args.store, realm: args.realm, prefix: args.prefix });
|
|
249
|
+
const doomed = selectForDeletion(manifests, args.policy);
|
|
250
|
+
if (doomed.length >= manifests.length) {
|
|
251
|
+
throw new BackupError("RETENTION_WOULD_EMPTY", "This policy would delete every snapshot. Refusing.");
|
|
252
|
+
}
|
|
253
|
+
if (!args.dryRun) {
|
|
254
|
+
for (const manifest of doomed) {
|
|
255
|
+
for (const chunk of manifest.chunks)
|
|
256
|
+
await args.store.deleteObject(chunk.object);
|
|
257
|
+
await args.store.deleteObject(`${args.prefix ?? "snapshots/"}${args.realm}/${manifest.id}/manifest.json`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return { deleted: doomed.map((manifest) => manifest.id), kept: manifests.length - doomed.length };
|
|
261
|
+
}
|
|
262
|
+
function backupJob(options) {
|
|
263
|
+
return {
|
|
264
|
+
key: options.key ?? "backup.snapshot",
|
|
265
|
+
everyMs: options.everyMs ?? 86400000,
|
|
266
|
+
run: async () => {
|
|
267
|
+
const manifest = await snapshot(options);
|
|
268
|
+
const report = await verifySnapshot({
|
|
269
|
+
store: options.store,
|
|
270
|
+
manifest,
|
|
271
|
+
masterSeed: options.masterSeed
|
|
272
|
+
});
|
|
273
|
+
if (!report.ok) {
|
|
274
|
+
return { ok: false, detail: { id: manifest.id, problems: report.problems.length } };
|
|
275
|
+
}
|
|
276
|
+
const pruned = await prune({
|
|
277
|
+
store: options.store,
|
|
278
|
+
realm: options.realm,
|
|
279
|
+
policy: options.policy,
|
|
280
|
+
prefix: options.prefix
|
|
281
|
+
});
|
|
282
|
+
return {
|
|
283
|
+
ok: true,
|
|
284
|
+
detail: { id: manifest.id, rows: manifest.totalRows, pruned: pruned.deleted.length }
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
var VERSION = "0.1.0";
|
|
290
|
+
export {
|
|
291
|
+
verifySnapshot,
|
|
292
|
+
snapshot,
|
|
293
|
+
selectForDeletion,
|
|
294
|
+
restore,
|
|
295
|
+
prune,
|
|
296
|
+
listSnapshots,
|
|
297
|
+
backupJob,
|
|
298
|
+
VERSION,
|
|
299
|
+
SNAPSHOT_FORMAT,
|
|
300
|
+
DEFAULT_RETENTION,
|
|
301
|
+
BackupError
|
|
302
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Billing periods, working days and exclusions.
|
|
3
|
+
*
|
|
4
|
+
* Pure arithmetic over dates, no I/O. That is exactly the kind of code that
|
|
5
|
+
* gets written inline in three places and disagrees about whether a period is
|
|
6
|
+
* inclusive at both ends — and a billing period that is off by one day either
|
|
7
|
+
* charges twice for a day or never charges for it, both of which surface as a
|
|
8
|
+
* customer complaint rather than as an exception.
|
|
9
|
+
*
|
|
10
|
+
* ## Periods are computed from an anchor, never from "now"
|
|
11
|
+
*
|
|
12
|
+
* `now minus seven days` gives a different answer every time it runs. A billing
|
|
13
|
+
* job that retries after a failure would then invoice a shifted window, so the
|
|
14
|
+
* same day is billed twice or skipped entirely. Every period here derives from
|
|
15
|
+
* a fixed anchor and an index, so period 34 is the same seven days whoever asks
|
|
16
|
+
* and whenever they ask.
|
|
17
|
+
*
|
|
18
|
+
* That property is what makes the invoice idempotency key meaningful: the key
|
|
19
|
+
* is the period index, and a retry recomputes the same one.
|
|
20
|
+
*
|
|
21
|
+
* ## UTC throughout
|
|
22
|
+
*
|
|
23
|
+
* No local time, no daylight saving. A billing period that shifts by an hour
|
|
24
|
+
* twice a year eventually straddles a boundary and either double-counts or
|
|
25
|
+
* loses an hour of usage. If a business wants periods to start at 09:00 in
|
|
26
|
+
* their own zone, that is an offset applied at presentation — not a different
|
|
27
|
+
* clock underneath.
|
|
28
|
+
*/
|
|
29
|
+
export declare const DAY_MS = 86400000;
|
|
30
|
+
export declare class CalendarError extends Error {
|
|
31
|
+
readonly code: 'BAD_ANCHOR' | 'BAD_LENGTH' | 'NO_WORKING_DAY' | 'BAD_DATE';
|
|
32
|
+
constructor(code: 'BAD_ANCHOR' | 'BAD_LENGTH' | 'NO_WORKING_DAY' | 'BAD_DATE', message: string);
|
|
33
|
+
}
|
|
34
|
+
/** `2026-08-01`. The only date format this module accepts or returns. */
|
|
35
|
+
export type IsoDate = string;
|
|
36
|
+
export declare function toDay(date: IsoDate): number;
|
|
37
|
+
export declare const fromDay: (day: number) => IsoDate;
|
|
38
|
+
export declare const dayOfWeek: (date: IsoDate) => number;
|
|
39
|
+
/**
|
|
40
|
+
* Which days count.
|
|
41
|
+
*
|
|
42
|
+
* `workingDays` is the positive statement — the days a business operates —
|
|
43
|
+
* rather than a list of weekend days. Stating it positively is what makes a
|
|
44
|
+
* Sunday-to-Thursday week expressible without special-casing; a `weekend` field
|
|
45
|
+
* quietly assumes Saturday and Sunday and is wrong in a good part of the world.
|
|
46
|
+
*/
|
|
47
|
+
export interface Calendar {
|
|
48
|
+
key: string;
|
|
49
|
+
label: string;
|
|
50
|
+
/** 0 = Sunday. Defaults to Monday–Friday. */
|
|
51
|
+
workingDays?: readonly number[];
|
|
52
|
+
/** Specific dates that are not working days, whatever the weekday says. */
|
|
53
|
+
holidays?: readonly IsoDate[];
|
|
54
|
+
/**
|
|
55
|
+
* Dates that ARE working days even though the rules above say otherwise.
|
|
56
|
+
*
|
|
57
|
+
* For the Saturday a business opens to clear a backlog. Exceptions win over
|
|
58
|
+
* holidays, because a list of exceptions with no way to override is a list
|
|
59
|
+
* somebody works around by deleting the holiday — and then it is gone for
|
|
60
|
+
* every year after.
|
|
61
|
+
*/
|
|
62
|
+
exceptions?: readonly IsoDate[];
|
|
63
|
+
}
|
|
64
|
+
export declare const DEFAULT_WORKING_DAYS: readonly [1, 2, 3, 4, 5];
|
|
65
|
+
export declare function isWorkingDay(date: IsoDate, calendar: Calendar): boolean;
|
|
66
|
+
/**
|
|
67
|
+
* The next working day on or after a date.
|
|
68
|
+
*
|
|
69
|
+
* Bounded, because a calendar with no working days at all — an empty
|
|
70
|
+
* `workingDays`, or a typo — would otherwise loop forever inside a billing job
|
|
71
|
+
* and present as a hung process rather than as a configuration error.
|
|
72
|
+
*/
|
|
73
|
+
export declare function nextWorkingDay(date: IsoDate, calendar: Calendar, forward?: boolean): IsoDate;
|
|
74
|
+
export declare function workingDaysBetween(from: IsoDate, to: IsoDate, calendar: Calendar): number;
|
|
75
|
+
/**
|
|
76
|
+
* How often a bill is raised.
|
|
77
|
+
*
|
|
78
|
+
* `anchor` is the first day of period 0 and never moves. `lengthDays` is the
|
|
79
|
+
* whole configuration — 7, 30, or anything else — because "monthly" is not a
|
|
80
|
+
* fixed number of days and a system that pretends otherwise drifts by five days
|
|
81
|
+
* a year.
|
|
82
|
+
*/
|
|
83
|
+
export interface BillingSchedule {
|
|
84
|
+
anchor: IsoDate;
|
|
85
|
+
lengthDays: number;
|
|
86
|
+
/** Calendar used to move a due date off a non-working day. */
|
|
87
|
+
calendar?: Calendar;
|
|
88
|
+
/**
|
|
89
|
+
* Days after a period ends before payment is due.
|
|
90
|
+
*
|
|
91
|
+
* Counted in WORKING days when a calendar is given. A seven-day term that
|
|
92
|
+
* lands on a public holiday is a term nobody could have met, and suspending
|
|
93
|
+
* an account for it is a support ticket with a refund at the end.
|
|
94
|
+
*/
|
|
95
|
+
dueAfterDays?: number;
|
|
96
|
+
}
|
|
97
|
+
export interface Period {
|
|
98
|
+
/** Stable, and the natural idempotency key for the invoice that covers it. */
|
|
99
|
+
index: number;
|
|
100
|
+
/** Inclusive. */
|
|
101
|
+
start: IsoDate;
|
|
102
|
+
/** Inclusive — the last day covered, NOT the next period's first day. */
|
|
103
|
+
end: IsoDate;
|
|
104
|
+
/** Exclusive, for a half-open range query. */
|
|
105
|
+
endExclusive: IsoDate;
|
|
106
|
+
dueOn: IsoDate;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Which period a date falls in.
|
|
110
|
+
*
|
|
111
|
+
* Floor division, so a date before the anchor gives a negative index rather
|
|
112
|
+
* than zero. Clamping to zero would silently place a backdated record in the
|
|
113
|
+
* first period and bill somebody for a week they had not signed up for.
|
|
114
|
+
*/
|
|
115
|
+
export declare function periodIndexOn(date: IsoDate, schedule: BillingSchedule): number;
|
|
116
|
+
export declare function periodAt(index: number, schedule: BillingSchedule): Period;
|
|
117
|
+
export declare const periodOn: (date: IsoDate, schedule: BillingSchedule) => Period;
|
|
118
|
+
/**
|
|
119
|
+
* Every period that has fully ended by `asOf` and is therefore billable.
|
|
120
|
+
*
|
|
121
|
+
* The current period is deliberately excluded: invoicing a period that is still
|
|
122
|
+
* accruing produces a figure that changes after it was sent.
|
|
123
|
+
*
|
|
124
|
+
* `since` makes a catch-up run possible — a billing job that was down for three
|
|
125
|
+
* weeks must raise three invoices, not one covering three weeks. A run that
|
|
126
|
+
* silently collapses missed periods is a run that undercharges and leaves no
|
|
127
|
+
* trace of having done so.
|
|
128
|
+
*/
|
|
129
|
+
export declare function closedPeriods(schedule: BillingSchedule, args: {
|
|
130
|
+
asOf: IsoDate;
|
|
131
|
+
since?: number;
|
|
132
|
+
limit?: number;
|
|
133
|
+
}): Period[];
|
|
134
|
+
/** Whether a due date has passed — the trigger for suspending an account. */
|
|
135
|
+
export declare const isOverdue: (period: Period, asOf: IsoDate) => boolean;
|
|
136
|
+
export declare const daysOverdue: (period: Period, asOf: IsoDate) => number;
|
package/dist/calendar.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
2
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
3
|
+
}) : x)(function(x) {
|
|
4
|
+
if (typeof require !== "undefined")
|
|
5
|
+
return require.apply(this, arguments);
|
|
6
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
// src/calendar.ts
|
|
10
|
+
var DAY_MS = 86400000;
|
|
11
|
+
|
|
12
|
+
class CalendarError extends Error {
|
|
13
|
+
code;
|
|
14
|
+
constructor(code, message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.name = "CalendarError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
var ISO = /^\d{4}-\d{2}-\d{2}$/;
|
|
21
|
+
function toDay(date) {
|
|
22
|
+
if (!ISO.test(date)) {
|
|
23
|
+
throw new CalendarError("BAD_DATE", `"${date}" is not an ISO date (YYYY-MM-DD).`);
|
|
24
|
+
}
|
|
25
|
+
const ms = Date.parse(`${date}T00:00:00.000Z`);
|
|
26
|
+
if (Number.isNaN(ms))
|
|
27
|
+
throw new CalendarError("BAD_DATE", `"${date}" is not a real date.`);
|
|
28
|
+
return Math.floor(ms / DAY_MS);
|
|
29
|
+
}
|
|
30
|
+
var fromDay = (day) => new Date(day * DAY_MS).toISOString().slice(0, 10);
|
|
31
|
+
var dayOfWeek = (date) => new Date(`${date}T00:00:00.000Z`).getUTCDay();
|
|
32
|
+
var DEFAULT_WORKING_DAYS = [1, 2, 3, 4, 5];
|
|
33
|
+
function isWorkingDay(date, calendar) {
|
|
34
|
+
if (calendar.exceptions?.includes(date))
|
|
35
|
+
return true;
|
|
36
|
+
if (calendar.holidays?.includes(date))
|
|
37
|
+
return false;
|
|
38
|
+
return (calendar.workingDays ?? DEFAULT_WORKING_DAYS).includes(dayOfWeek(date));
|
|
39
|
+
}
|
|
40
|
+
function nextWorkingDay(date, calendar, forward = true) {
|
|
41
|
+
const step = forward ? 1 : -1;
|
|
42
|
+
let day = toDay(date);
|
|
43
|
+
for (let tried = 0;tried <= 366; tried += 1) {
|
|
44
|
+
const candidate = fromDay(day);
|
|
45
|
+
if (isWorkingDay(candidate, calendar))
|
|
46
|
+
return candidate;
|
|
47
|
+
day += step;
|
|
48
|
+
}
|
|
49
|
+
throw new CalendarError("NO_WORKING_DAY", `"${calendar.key}" has no working day within a year of ${date}. Check workingDays and holidays.`);
|
|
50
|
+
}
|
|
51
|
+
function workingDaysBetween(from, to, calendar) {
|
|
52
|
+
const start = toDay(from);
|
|
53
|
+
const end = toDay(to);
|
|
54
|
+
if (end < start)
|
|
55
|
+
return 0;
|
|
56
|
+
let count = 0;
|
|
57
|
+
for (let day = start;day <= end; day += 1) {
|
|
58
|
+
if (isWorkingDay(fromDay(day), calendar))
|
|
59
|
+
count += 1;
|
|
60
|
+
}
|
|
61
|
+
return count;
|
|
62
|
+
}
|
|
63
|
+
function assertSchedule(schedule) {
|
|
64
|
+
if (!ISO.test(schedule.anchor)) {
|
|
65
|
+
throw new CalendarError("BAD_ANCHOR", `Anchor "${schedule.anchor}" is not an ISO date.`);
|
|
66
|
+
}
|
|
67
|
+
if (!Number.isInteger(schedule.lengthDays) || schedule.lengthDays < 1) {
|
|
68
|
+
throw new CalendarError("BAD_LENGTH", "A billing period must be a whole number of days, at least one.");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function periodIndexOn(date, schedule) {
|
|
72
|
+
assertSchedule(schedule);
|
|
73
|
+
return Math.floor((toDay(date) - toDay(schedule.anchor)) / schedule.lengthDays);
|
|
74
|
+
}
|
|
75
|
+
function periodAt(index, schedule) {
|
|
76
|
+
assertSchedule(schedule);
|
|
77
|
+
const startDay = toDay(schedule.anchor) + index * schedule.lengthDays;
|
|
78
|
+
const start = fromDay(startDay);
|
|
79
|
+
const end = fromDay(startDay + schedule.lengthDays - 1);
|
|
80
|
+
const endExclusive = fromDay(startDay + schedule.lengthDays);
|
|
81
|
+
let dueOn = fromDay(startDay + schedule.lengthDays + (schedule.dueAfterDays ?? 0));
|
|
82
|
+
if (schedule.calendar && schedule.dueAfterDays) {
|
|
83
|
+
let day = toDay(endExclusive);
|
|
84
|
+
let remaining = schedule.dueAfterDays;
|
|
85
|
+
while (remaining > 0) {
|
|
86
|
+
day += 1;
|
|
87
|
+
if (isWorkingDay(fromDay(day), schedule.calendar))
|
|
88
|
+
remaining -= 1;
|
|
89
|
+
}
|
|
90
|
+
dueOn = fromDay(day);
|
|
91
|
+
} else if (schedule.calendar) {
|
|
92
|
+
dueOn = nextWorkingDay(dueOn, schedule.calendar);
|
|
93
|
+
}
|
|
94
|
+
return { index, start, end, endExclusive, dueOn };
|
|
95
|
+
}
|
|
96
|
+
var periodOn = (date, schedule) => periodAt(periodIndexOn(date, schedule), schedule);
|
|
97
|
+
function closedPeriods(schedule, args) {
|
|
98
|
+
assertSchedule(schedule);
|
|
99
|
+
const current = periodIndexOn(args.asOf, schedule);
|
|
100
|
+
const from = args.since ?? current - 1;
|
|
101
|
+
const periods = [];
|
|
102
|
+
for (let index = from;index < current; index += 1) {
|
|
103
|
+
if (periods.length >= (args.limit ?? 52))
|
|
104
|
+
break;
|
|
105
|
+
if (index < 0)
|
|
106
|
+
continue;
|
|
107
|
+
periods.push(periodAt(index, schedule));
|
|
108
|
+
}
|
|
109
|
+
return periods;
|
|
110
|
+
}
|
|
111
|
+
var isOverdue = (period, asOf) => toDay(asOf) > toDay(period.dueOn);
|
|
112
|
+
var daysOverdue = (period, asOf) => Math.max(0, toDay(asOf) - toDay(period.dueOn));
|
|
113
|
+
export {
|
|
114
|
+
workingDaysBetween,
|
|
115
|
+
toDay,
|
|
116
|
+
periodOn,
|
|
117
|
+
periodIndexOn,
|
|
118
|
+
periodAt,
|
|
119
|
+
nextWorkingDay,
|
|
120
|
+
isWorkingDay,
|
|
121
|
+
isOverdue,
|
|
122
|
+
fromDay,
|
|
123
|
+
daysOverdue,
|
|
124
|
+
dayOfWeek,
|
|
125
|
+
closedPeriods,
|
|
126
|
+
DEFAULT_WORKING_DAYS,
|
|
127
|
+
DAY_MS,
|
|
128
|
+
CalendarError
|
|
129
|
+
};
|