@happyvertical/smrt-timesheets 0.53.0 → 0.53.2

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 (46) hide show
  1. package/AGENTS.md +32 -21
  2. package/README.md +124 -58
  3. package/agents/period-rollup.md +130 -0
  4. package/dist/attendance/models.d.ts +59 -0
  5. package/dist/attendance/models.d.ts.map +1 -0
  6. package/dist/attendance/service.d.ts +64 -0
  7. package/dist/attendance/service.d.ts.map +1 -0
  8. package/dist/attendance.d.ts +5 -0
  9. package/dist/attendance.d.ts.map +1 -0
  10. package/dist/attendance.js +323 -0
  11. package/dist/attendance.js.map +1 -0
  12. package/dist/chunks/attendance-Bk8o96dt.js +60 -0
  13. package/dist/chunks/attendance-Bk8o96dt.js.map +1 -0
  14. package/dist/chunks/models-D9n8HKXH.js +166 -0
  15. package/dist/chunks/models-D9n8HKXH.js.map +1 -0
  16. package/dist/chunks/rollup-yE0TFLJe.js +425 -0
  17. package/dist/chunks/rollup-yE0TFLJe.js.map +1 -0
  18. package/dist/chunks/service-time-entry-B2gHrJYI.js +190 -0
  19. package/dist/chunks/service-time-entry-B2gHrJYI.js.map +1 -0
  20. package/dist/index.d.ts +2 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +25 -205
  23. package/dist/index.js.map +1 -1
  24. package/dist/manifest.json +2553 -652
  25. package/dist/rollup/attendance.d.ts +8 -0
  26. package/dist/rollup/attendance.d.ts.map +1 -0
  27. package/dist/rollup/calculation.d.ts +7 -0
  28. package/dist/rollup/calculation.d.ts.map +1 -0
  29. package/dist/rollup/models.d.ts +47 -0
  30. package/dist/rollup/models.d.ts.map +1 -0
  31. package/dist/rollup/service.d.ts +37 -0
  32. package/dist/rollup/service.d.ts.map +1 -0
  33. package/dist/rollup/types.d.ts +38 -0
  34. package/dist/rollup/types.d.ts.map +1 -0
  35. package/dist/rollup/write.d.ts +4 -0
  36. package/dist/rollup/write.d.ts.map +1 -0
  37. package/dist/rollup.d.ts +5 -0
  38. package/dist/rollup.d.ts.map +1 -0
  39. package/dist/rollup.js +2 -0
  40. package/dist/smrt-knowledge.json +1319 -433
  41. package/package.json +19 -10
  42. package/dist/svelte/components/__tests__/DurationDisplay.test.js +0 -57
  43. package/dist/svelte/components/__tests__/TimeEntryApprovalQueue.test.js +0 -114
  44. package/dist/svelte/components/__tests__/TimeEntryCard.test.js +0 -45
  45. package/dist/svelte/components/__tests__/TimeEntryList.test.js +0 -50
  46. package/dist/svelte/components/__tests__/TimeSummary.test.js +0 -39
package/AGENTS.md CHANGED
@@ -6,9 +6,15 @@ immutable charge / compensation snapshots taken at approval. Moved out of
6
6
  `smrt-projects` / `smrt-support` in #3288 so shops, builders, and service
7
7
  desks share one model instead of re-implementing it.
8
8
 
9
- This package is **layer 1 (entry)**. Attendance (punches, breaks, offline
10
- replay) and period rollup (per-person timecards) are planned as later modules
11
- of this same package; do not add them to projects or support.
9
+ This package contains **layer 1 (entry)**, **layer 2 (attendance)**, and
10
+ **layer 3 (period rollup)**.
11
+ `src/attendance/` owns punches, breaks, atomic transitions and durable offline
12
+ receipts; `./attendance` is the public subpath. Period rollup belongs in this
13
+ package too, never projects or support. See the README attendance contract.
14
+ Attendance services accept trusted session actors, serialize mutations per
15
+ actor, and bind every write to one executor. The new tables use non-null unique
16
+ open slots, not nullable unique keys. A linked entry is draft evidence, and
17
+ rollups must deduplicate `AttendancePunch.serviceTimeEntryId`.
12
18
 
13
19
  ## Models
14
20
 
@@ -57,24 +63,21 @@ through a `ServiceCommercialResolver`), and corrects. `smrt-projects` supplies
57
63
 
58
64
  ## One table family
59
65
 
60
- Classes may share a table only as one inheritance chain, and the registry
61
- resolves a chain of same-named classes by "child wins" on the simple name.
62
- That works for exactly one subtype level per table (base → subtype); a third
63
- same-named level makes the middle class look like it extends its own child on
64
- the manifest path (`CONFIG_TABLE_NAME_COLLISION`). Therefore:
65
-
66
- - `smrt-projects` **re-exports** these classes (no projects subclass).
67
- - `smrt-support` subclasses `ServiceTimeEntry` directly from this package.
68
- - A consumer that needs its own surface (e.g. teamworks-os closing every
69
- generated route with `api: { include: [] }` and
70
- `@TenantScoped({ mode: 'required' })`) declares a same-named subclass over
71
- `service_time_entries`; its manifest entry replaces the package's. It cannot
72
- also install smrt-support's subtype on the same table.
73
- The same applies to both snapshots;
74
- `src/__tests__/consumer-closed-surface.test.ts` holds that contract for all
75
- three models. The snapshots' explicit `time_entry_id` conflict key is not
76
- rewritten when a consumer requires tenancy; widening it is the consumer's
77
- schema change, never a package default.
66
+ Classes may share a table only as one inheritance chain. Core resolves
67
+ same-named subclasses by constructor ancestry at runtime and qualified manifest
68
+ ancestry at build registration; the deepest subtype wins across N levels.
69
+ Sibling or unrelated types cannot share the table. `smrt-projects` re-exports
70
+ the base; `smrt-support` subclasses it directly.
71
+
72
+ Consumers closing package routes should call
73
+ `ObjectRegistry.registerOverride(qualifiedName, { api: false, mcp: false,
74
+ cli: false, tenancy: { mode: 'required' } })` after importing the model and before
75
+ constructing transports. This tightens the existing registration without a new
76
+ subtype. See core's README for startup order, static route generation, and reset
77
+ semantics. `src/__tests__/consumer-closed-surface.test.ts` also covers existing
78
+ manifest subclass consumers. The snapshots' explicit `time_entry_id` conflict
79
+ key is not rewritten when a consumer requires tenancy; widening it remains a
80
+ consumer schema change.
78
81
 
79
82
  Subclasses restate their fields: package-isolated manifest scanning does not
80
83
  read dependency sources, so an unrestated subclass manifest has no columns.
@@ -103,3 +106,11 @@ pnpm --filter @happyvertical/smrt-support test
103
106
  Schema-affecting edits must keep `service-time-entry-compat.test.ts` (projects,
104
107
  support) and `service-time-entry-migration-plan.test.ts` (support) green: a
105
108
  pre-move table must plan no DDL.
109
+
110
+ ## Period rollup
111
+
112
+ Layer 3 lives under `src/rollup/` and the `./rollup` subpath. See
113
+ [period-rollup.md](agents/period-rollup.md) for resolver, source clipping,
114
+ confirmation, attributed adjustments, isolation, and transaction contracts.
115
+ Timecards and adjustments have closed generated surfaces and service-only
116
+ model writes; never bypass the instance capability with generic persistence.
package/README.md CHANGED
@@ -88,68 +88,45 @@ view objects (`TimeEntry`, `TimeEntryApprovalView`).
88
88
 
89
89
  The entry and both snapshots expose generated `list` / `get` on REST, CLI,
90
90
  and MCP, with optional tenant scoping. An application that routes all time
91
- and money through its own permission-gated services declares a same-named
92
- subclass over each table and closes it:
91
+ and money through its own permission-gated services can close those surfaces
92
+ without declaring replacement models:
93
93
 
94
94
  ```ts
95
- import {
96
- ServiceChargeSnapshot as SharedServiceChargeSnapshot,
97
- ServiceCompensationSnapshot as SharedServiceCompensationSnapshot,
98
- ServiceTimeEntry as SharedServiceTimeEntry,
99
- } from '@happyvertical/smrt-timesheets';
100
-
101
- const CLOSED = { include: [] };
102
-
103
- @TenantScoped({ mode: 'required' })
104
- @smrt({
105
- tableName: 'service_time_entries',
106
- api: CLOSED,
107
- cli: CLOSED,
108
- mcp: CLOSED,
109
- })
110
- export class ServiceTimeEntry extends SharedServiceTimeEntry {
111
- // restate the fields — see AGENTS.md
112
- }
113
-
114
- @TenantScoped({ mode: 'required' })
115
- @smrt({
116
- tableName: 'service_charge_snapshots',
117
- conflictColumns: ['time_entry_id'],
118
- api: CLOSED,
119
- cli: CLOSED,
120
- mcp: CLOSED,
121
- })
122
- export class ServiceChargeSnapshot extends SharedServiceChargeSnapshot {
123
- // restate the fields — see AGENTS.md
124
- }
125
-
126
- @TenantScoped({ mode: 'required' })
127
- @smrt({
128
- tableName: 'service_compensation_snapshots',
129
- conflictColumns: ['time_entry_id'],
130
- api: CLOSED,
131
- cli: CLOSED,
132
- mcp: CLOSED,
133
- })
134
- export class ServiceCompensationSnapshot extends SharedServiceCompensationSnapshot {
135
- // restate the fields — see AGENTS.md
95
+ import { ObjectRegistry } from '@happyvertical/smrt-core';
96
+ import '@happyvertical/smrt-timesheets';
97
+
98
+ for (const name of [
99
+ 'ServiceTimeEntry',
100
+ 'ServiceChargeSnapshot',
101
+ 'ServiceCompensationSnapshot',
102
+ ]) {
103
+ ObjectRegistry.registerOverride(`@happyvertical/smrt-timesheets:${name}`, {
104
+ api: false,
105
+ cli: false,
106
+ mcp: false,
107
+ tenancy: { mode: 'required' },
108
+ });
136
109
  }
137
110
  ```
138
111
 
139
- Do not copy `previousQualifiedNames` into these subclasses: an old name may
140
- have only one claimant. Replacing the timesheets classes drops their
141
- smrt-projects aliases (see the known gap under "Stored and declared class
142
- names").
143
-
144
- Restate `conflictColumns` exactly as shown unless you mean to change it. The
145
- snapshots' conflict key is `time_entry_id` alone — one snapshot per entry —
146
- and, being an explicit key, it is never rewritten to lead with `tenant_id`
147
- when you require tenancy. That is safe as long as entry ids are unique across
148
- tenants (they are UUIDs). Widening it to `['tenant_id', 'time_entry_id']` is
149
- your decision, and it is a schema change for your database (a new unique
150
- index replacing `service_*_snapshots_time_entry_id_idx`), so it goes through
151
- `smrt db:migrate` like any other key change; the package default stays
152
- `['time_entry_id']`.
112
+ Register overrides after importing the models and before constructing or
113
+ emitting generated transports. Use the currently registered qualified name:
114
+ when smrt-support has replaced `ServiceTimeEntry`, target
115
+ `@happyvertical/smrt-support:ServiceTimeEntry` instead. Overrides accept only
116
+ closed surfaces and required tenancy; they cannot reopen a surface or weaken
117
+ tenancy. They survive registration replay and are reset by
118
+ `ObjectRegistry.clear()`. Existing static route files must be regenerated with
119
+ the closed configuration; changing the registry does not rewrite deployed files.
120
+
121
+ This preserves the model's fields, indexes, conflict keys, and aliases. Same-named
122
+ subclasses remain available when an application needs to change the model itself;
123
+ see AGENTS.md for field restatement and the alias gap below.
124
+
125
+ The snapshots' conflict key remains `time_entry_id` alone — one snapshot per
126
+ entry — and is never rewritten to lead with `tenant_id` when tenancy becomes
127
+ required. Entry ids are UUIDs unique across tenants. Widening that key to
128
+ `['tenant_id', 'time_entry_id']` is a consumer schema change requiring a new
129
+ unique index through `smrt db:migrate`; runtime overrides do not change schema.
153
130
 
154
131
  ## Migrating from smrt-projects / smrt-support
155
132
 
@@ -223,7 +200,7 @@ and are unchanged.
223
200
 
224
201
  > **Known gap: same-named subtypes drop the alias.** A same-named subclass
225
202
  > over a moved table — smrt-support's `ServiceTimeEntry`, or an
226
- > application's closing subclass (see "Closing the generated surface") —
203
+ > application's replacement subclass —
227
204
  > replaces the timesheets class in the registry, and that class's alias does
228
205
  > not carry over to it yet. With smrt-support installed,
229
206
  > `@happyvertical/smrt-projects:ServiceTimeEntry` therefore does not resolve
@@ -241,3 +218,92 @@ and are unchanged.
241
218
  `SubscriptionServiceCommercialResolver` pricing for these entries.
242
219
  - [`smrt-support`](../support/README.md) — support-case time on the same table.
243
220
  - [`smrt-profiles`](../profiles/README.md) — the participants.
221
+
222
+ ## Attendance
223
+
224
+ The `@happyvertical/smrt-timesheets/attendance` subpath adds `AttendancePunch`,
225
+ `AttendanceBreak`, and `AttendanceService`. Apply migrations for the new tables
226
+ before enabling the module. Existing entry and snapshot tables are unchanged.
227
+ Attendance supports SQLite and PostgreSQL. DuckDB cannot enforce the required
228
+ `ON UPDATE CASCADE` foreign-key contract used by attendance and existing entry
229
+ corrections; schema creation deliberately fails rather than omitting constraints.
230
+
231
+ Construct a service with your database and the **authorized session's**
232
+ `{ tenantId, profileId }`. The application resolves Profile membership and
233
+ operation permissions before this boundary; never pass request-supplied actor
234
+ identifiers. A conflicting active tenant is refused. The service cannot read or
235
+ link another profile's punch. Generated punch/break surfaces default to reads
236
+ only; the replay ledger has no generated operations and is excluded from change
237
+ feeds. Before constructing generated transports, consumers can close the remaining
238
+ reads without replacing the models or changing their schema:
239
+
240
+ ```ts
241
+ import { ObjectRegistry } from '@happyvertical/smrt-core';
242
+ import '@happyvertical/smrt-timesheets/attendance';
243
+
244
+ for (const model of ['AttendancePunch', 'AttendanceBreak']) {
245
+ ObjectRegistry.registerOverride(`@happyvertical/smrt-timesheets:${model}`, {
246
+ api: false, cli: false, mcp: false,
247
+ });
248
+ }
249
+ ```
250
+
251
+ Required tenant scoping remains in effect after closing these surfaces.
252
+
253
+ ```ts
254
+ import { AttendanceService } from '@happyvertical/smrt-timesheets/attendance';
255
+
256
+ const attendance = new AttendanceService(db, { tenantId, profileId });
257
+ await attendance.punchIn(new Date('2026-10-01T08:00:00Z'));
258
+ await attendance.startBreak(new Date('2026-10-01T12:00:00Z')); // unpaid
259
+ await attendance.endBreak(new Date('2026-10-01T12:30:00Z'));
260
+ const punch = await attendance.punchOut(new Date('2026-10-01T16:00:00Z'), {
261
+ workRefType: '@acme/jobs:WorkPackage', workRefId: 'wp-7', description: 'Framing',
262
+ });
263
+ ```
264
+
265
+ Timestamps retain device milliseconds. `durationSeconds` is elapsed time minus
266
+ unpaid breaks, rounded once to the nearest whole second; paid breaks remain
267
+ included. Local time zone and payroll rounding policy belong to the consumer. Closing also ends an active break. New punches have the
268
+ non-null `openSlot` value `open`, with a unique database index on tenant, profile
269
+ and slot. Closed punches use a slot derived from their id, so PostgreSQL and
270
+ SQLite enforce one open punch without differing nullable-unique semantics.
271
+ The index compares native UUID owner columns directly, including PostgreSQL
272
+ UUID normalization. Breaks use the same
273
+ pattern per punch. Persisted ownership/start and closed intervals are immutable.
274
+ Direct ORM saves serialize against the parent punch and reject a close while a
275
+ break remains open or ends outside the punch.
276
+
277
+ A work reference is optional: attendance alone is not billable evidence. Passing
278
+ one on close, or calling `linkServiceTimeEntry(punch.id, work)` later, creates
279
+ one draft `ServiceTimeEntry` atomically and records `serviceTimeEntryId` on the
280
+ punch. Only the three declared work fields are copied; extra request fields cannot
281
+ replace the trusted tenant or participant. Repeating the same link returns that entry; changing attribution is
282
+ refused. Approval still uses the entry layer. Rollups must deduplicate this link,
283
+ not count both attendance and its entry. Zero-net-duration punches cannot create
284
+ service evidence.
285
+
286
+ `autoClose(now, maxOpenSeconds)` closes a due open punch at its configured
287
+ maximum duration and sets `reviewRequired`. It returns null before the deadline
288
+ or when already closed. If later committed attendance would be truncated it
289
+ refuses the operation for manual review. A scheduler calls this per authorized
290
+ actor; the package does not choose overtime, payroll, or shop-time-zone rules.
291
+
292
+ `replay(taps)` accepts `{ clientId, action, at, paid?, work? }`, where `action` is
293
+ `punchIn`, `punchOut`, `startBreak`, or `endBreak`. It sorts each batch by device
294
+ time, then client id. Client ids are durable and scoped to tenant/profile.
295
+ Successful taps set `recordedOffline` and `reviewRequired`. The conflict policy
296
+ is **committed attendance wins**: an event predating committed activity, or
297
+ invalid for the current state, gets a durable error outcome. Identical retries
298
+ return that outcome even after later shifts; changed payloads using the same id
299
+ are rejected. Database failures roll back the tap and receipt together and can
300
+ be retried. Earlier committed taps in the batch remain committed. Device clocks
301
+ are treated as supplied evidence, not server authentication; consumers should
302
+ apply device drift policy before replay. Replay receipts must be retained for as
303
+ long as clients can retry their ids.
304
+
305
+ ## Period timecards
306
+
307
+ `./rollup` provides per-person integer-second regular/overtime rollups, immutable
308
+ confirmation, and attributed append-only adjustments. Consumer resolvers own
309
+ calendar/timezone, holiday and overtime rules. See the [contract and example](agents/period-rollup.md).
@@ -0,0 +1,130 @@
1
+ # Period timecards
2
+
3
+ `@happyvertical/smrt-timesheets/rollup` exports `PeriodRollupService`,
4
+ `PeriodTimecard`, `TimecardAdjustment`, their collections, and resolver types.
5
+ The package root also exports them. SQLite and PostgreSQL are supported.
6
+ Create application tables through normal migrations; neither service provisions
7
+ schema. The attendance option additionally requires the attendance tables.
8
+
9
+ Construct a service only after authenticating and authorizing its trusted
10
+ `{ tenantId, profileId }` scope. A conflicting ambient tenant is rejected.
11
+ `options.actorProfileId` records a separately authorized supervisor; it defaults
12
+ to the subject profile for self confirmation/correction. Never accept this
13
+ trusted scope or attribution unchecked from request input. Profile authorization
14
+ is consumer-owned; the service filters every source/card/adjustment read by its
15
+ trusted subject and tenant. Generic API, CLI and MCP surfaces are closed by
16
+ default; consumer registry overrides may further restrict them (#3318).
17
+
18
+ ## Resolver contract
19
+
20
+ `periodFor(at, actor)` supplies half-open UTC `startsAt`/`endsAt`, an IANA
21
+ `timezone`, a nonempty policy `version`, and a JSON `rules` snapshot.
22
+ The consumer owns period length, calendar start day, timezone and DST, overtime
23
+ thresholds, holidays and classification. The requested instant must be inside
24
+ the window. Overlapping windows for existing cards are rejected: introduce a new
25
+ calendar at a boundary after existing windows, and use adjustments for already
26
+ confirmed periods.
27
+
28
+ `classify(period, sources, actor)` returns nonnegative safe integer
29
+ `regularSeconds` and `overtimeSeconds`, whose sum must equal source seconds.
30
+ Inputs are detached so resolver mutation cannot rewrite evidence. Resolver
31
+ exceptions, malformed windows, invalid timezone, fractional/unsafe totals and
32
+ nonconserving classification abort the transaction. No Alberta or other
33
+ jurisdiction rule is assumed, and no pay rate or money is stored.
34
+
35
+ A configurable UTC calendar example (illustrative policy, not payroll advice):
36
+
37
+ ```ts
38
+ import { PeriodRollupService, type PeriodRulesResolver } from
39
+ '@happyvertical/smrt-timesheets/rollup';
40
+
41
+ const settings = {
42
+ lengthDays: 14, startDay: 1, timezone: 'UTC', // 0 = Sunday
43
+ anchor: '2026-01-05T00:00:00Z', // aligned Monday; anchors fortnight phase
44
+ thresholdSeconds: 80 * 3600,
45
+ holidays: ['2026-12-25'],
46
+ };
47
+ const resolver: PeriodRulesResolver = {
48
+ async periodFor(at) {
49
+ const anchor = new Date(settings.anchor);
50
+ if (anchor.getUTCDay() !== settings.startDay || settings.timezone !== 'UTC')
51
+ throw new Error('Use a timezone-aware calendar resolver for local periods');
52
+ const length = settings.lengthDays * 86400000;
53
+ const start = anchor.getTime() +
54
+ Math.floor((at.getTime() - anchor.getTime()) / length) * length;
55
+ return {
56
+ startsAt: new Date(start), endsAt: new Date(start + length),
57
+ timezone: settings.timezone, version: 'shop-policy-v1', rules: settings,
58
+ };
59
+ },
60
+ async classify(period, sources) {
61
+ const rules = period.rules as typeof settings;
62
+ // This example assigns a contribution to its clipped start's UTC date.
63
+ // Local daily rules should split contributions at their own local midnight.
64
+ let regular = 0;
65
+ let holiday = 0;
66
+ for (const source of sources) {
67
+ if (rules.holidays.includes(source.startsAt.slice(0, 10))) holiday += source.seconds;
68
+ else regular += source.seconds;
69
+ }
70
+ return {
71
+ regularSeconds: Math.min(regular, rules.thresholdSeconds),
72
+ overtimeSeconds: holiday + Math.max(0, regular - rules.thresholdSeconds),
73
+ };
74
+ },
75
+ };
76
+ const rollups = new PeriodRollupService(db, authorizedSubject, resolver,
77
+ { attendance: true, actorProfileId: authenticatedSupervisorProfileId });
78
+ const card = await rollups.rollup(new Date());
79
+ await rollups.confirm(card.id!);
80
+ await rollups.adjust(card.id!, {
81
+ operationId: requestId, regularSeconds: 900, overtimeSeconds: 0,
82
+ reason: 'Approved correction after confirmation',
83
+ });
84
+ const effectiveSeconds = await rollups.totals(card.id!);
85
+ ```
86
+
87
+ A timezone-aware resolver must compute each local boundary separately: a local
88
+ day crossing DST is not necessarily 86400 seconds. Resolvers can use an app's
89
+ existing calendar library; no new timezone dependency or implicit default is
90
+ introduced here.
91
+
92
+ ## Evidence and immutability
93
+
94
+ Only approved human `ServiceTimeEntry` rows for the subject count; corrected
95
+ ancestors do not. Correction chains must remain within that subject/tenant and
96
+ point to corrected parents. Multiple approved descendants of one root are
97
+ rejected as ambiguous rather than double-counted. Entries with both `startedAt`
98
+ and `endedAt` allocate their declared seconds
99
+ proportionally across the period using exact integer prefix division, preserving
100
+ seconds over adjacent periods. Unbounded entries count at `startedAt`, or, when
101
+ absent, `approvedAt`; an end-only timestamp does not create an interval. Missing
102
+ both attribution timestamps is an error. This is time attribution, not a
103
+ claim that a manual entry has a continuous wall-clock duration.
104
+
105
+ With `attendance: true`, closed punches without `reviewRequired` count, excluding
106
+ unpaid breaks and retaining paid breaks. Open/review-required punches do not
107
+ count. Linked attendance is authoritative: its evidence entry and any approved
108
+ correction descendants are excluded, including when the punch is still under
109
+ review or outside the requested period. Unlinked approved work entries and
110
+ attendance both count; consumers must link overlapping evidence explicitly.
111
+ Source snapshots preserve each paid attendance segment and entry identity.
112
+
113
+ Repeated rollup updates one open card per tenant/person/start; a confirmed card
114
+ cannot be recomputed. Confirmation is attributed and idempotent. Model save,
115
+ collection create/update/upsert and object/collection delete cannot bypass the
116
+ service. Model mutation capabilities are per-instance and not exported.
117
+ Direct SQL is a trusted administrative boundary, not an exposed application API.
118
+ Profile/card foreign keys restrict deletion to preserve history.
119
+
120
+ Adjustments contain signed integer regular/overtime deltas, reason, operation
121
+ key and actor attribution. A retry reuses its row only for the exact same
122
+ payload and actor. Effective totals may never become negative or unsafe.
123
+ Adjustments never alter the confirmed row or source snapshot. Undo an adjustment
124
+ with a separately attributed compensating adjustment, never deletion.
125
+
126
+ All service mutations use a root transaction, transaction-bound collections,
127
+ embedded writer serialization or a PostgreSQL tenant/person advisory lock.
128
+ Outputs are reloaded on the root executor after commit. Rollup reads are an
129
+ application snapshot: subsequent source approvals/corrections appear on the
130
+ next open rollup, and confirmed cards require explicit adjustments.
@@ -0,0 +1,59 @@
1
+ import { SmrtCollection, SmrtObject } from '@happyvertical/smrt-core';
2
+ /** A tenant-scoped interval of attendance, independent of billable work. */
3
+ export declare class AttendancePunch extends SmrtObject {
4
+ tenantId: string;
5
+ profileId: string;
6
+ startedAt: Date;
7
+ endedAt: Date | null;
8
+ durationSeconds: number;
9
+ unpaidBreakSeconds: number;
10
+ reviewRequired: boolean;
11
+ recordedOffline: boolean;
12
+ serviceTimeEntryId: string | null;
13
+ /** Unique while open; closed rows retain independent non-null slots on every dialect. */
14
+ openSlot: string;
15
+ save(): Promise<this>;
16
+ private saveConsistent;
17
+ }
18
+ /** A paid or unpaid break inside one attendance punch. */
19
+ export declare class AttendanceBreak extends SmrtObject {
20
+ tenantId: string;
21
+ punchId: string;
22
+ startedAt: Date;
23
+ endedAt: Date | null;
24
+ paid: boolean;
25
+ openSlot: string;
26
+ save(): Promise<this>;
27
+ private saveConsistent;
28
+ }
29
+ /** Durable success or conflict; conflicts do not mutate attendance. */
30
+ export interface AttendanceReplayOutcome {
31
+ clientId: string;
32
+ punchId?: string;
33
+ error?: string;
34
+ }
35
+ /** Durable offline-tap outcome; never exposed through generated operations. */
36
+ export declare class AttendanceReplay extends SmrtObject {
37
+ tenantId: string;
38
+ replayKey: string;
39
+ content: string;
40
+ outcome: string;
41
+ /** Read a durable receipt, failing closed rather than re-executing a corrupt tap. */
42
+ getOutcome(): AttendanceReplayOutcome;
43
+ /** Store the JSON representation of a validated success or refusal. */
44
+ setOutcome(value: AttendanceReplayOutcome): void;
45
+ save(): Promise<this>;
46
+ }
47
+ /** Punches for attendance and period rollup. */
48
+ export declare class AttendancePunchCollection extends SmrtCollection<AttendancePunch> {
49
+ static readonly _itemClass: typeof AttendancePunch;
50
+ }
51
+ /** Break intervals for net duration and period clipping. */
52
+ export declare class AttendanceBreakCollection extends SmrtCollection<AttendanceBreak> {
53
+ static readonly _itemClass: typeof AttendanceBreak;
54
+ }
55
+ /** Internal durable replay ledger. */
56
+ export declare class AttendanceReplayCollection extends SmrtCollection<AttendanceReplay> {
57
+ static readonly _itemClass: typeof AttendanceReplay;
58
+ }
59
+ //# sourceMappingURL=models.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"models.d.ts","sourceRoot":"","sources":["../../src/attendance/models.ts"],"names":[],"mappings":"AAAA,OAAO,EAML,cAAc,EACd,UAAU,EAGX,MAAM,0BAA0B,CAAC;AA4BlC,4EAA4E;AAC5E,qBAca,eAAgB,SAAQ,UAAU;IACjC,QAAQ,SAAM;IAC+B,SAAS,SAAM;IACxE,SAAS,EAAE,IAAI,CAAc;IAC7B,OAAO,EAAE,IAAI,GAAG,IAAI,CAAQ;IAC5B,eAAe,SAAK;IACpB,kBAAkB,SAAK;IACvB,cAAc,UAAS;IACvB,eAAe,UAAS;IAE4B,kBAAkB,EAClE,MAAM,GACN,IAAI,CAAQ;IAChB,yFAAyF;IAC9D,QAAQ,SAAM;IAE1B,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;YAMtB,cAAc;CA0D7B;AAED,0DAA0D;AAC1D,qBAca,eAAgB,SAAQ,UAAU;IACjC,QAAQ,SAAM;IACG,OAAO,SAAM;IAC1C,SAAS,EAAE,IAAI,CAAc;IAC7B,OAAO,EAAE,IAAI,GAAG,IAAI,CAAQ;IAC5B,IAAI,UAAS;IACc,QAAQ,SAAM;IAE1B,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;YAMtB,cAAc;CA6C7B;AAED,uEAAuE;AACvE,MAAM,WAAW,uBAAuB;IACtC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAiBD,+EAA+E;AAC/E,qBAQa,gBAAiB,SAAQ,UAAU;IAClC,QAAQ,SAAM;IACe,SAAS,SAAM;IAC/B,OAAO,SAAM;IACb,OAAO,SAAQ;IAExC,qFAAqF;IACrF,UAAU,IAAI,uBAAuB;IAOrC,uEAAuE;IACvE,UAAU,CAAC,KAAK,EAAE,uBAAuB,GAAG,IAAI;IAMjC,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;CAIrC;AAED,gDAAgD;AAChD,qBAAa,yBAA0B,SAAQ,cAAc,CAAC,eAAe,CAAC;IAC5E,MAAM,CAAC,QAAQ,CAAC,UAAU,yBAAmB;CAC9C;AACD,4DAA4D;AAC5D,qBAAa,yBAA0B,SAAQ,cAAc,CAAC,eAAe,CAAC;IAC5E,MAAM,CAAC,QAAQ,CAAC,UAAU,yBAAmB;CAC9C;AACD,sCAAsC;AACtC,qBAAa,0BAA2B,SAAQ,cAAc,CAAC,gBAAgB,CAAC;IAC9E,MAAM,CAAC,QAAQ,CAAC,UAAU,0BAAoB;CAC/C"}
@@ -0,0 +1,64 @@
1
+ import type { DatabaseInterface } from '@happyvertical/sql';
2
+ import { AttendancePunch, type AttendanceReplayOutcome } from './models.js';
3
+ export type { AttendanceReplayOutcome } from './models.js';
4
+ /** Resolve these identifiers from the authenticated session, never from tap input. */
5
+ export interface AttendanceActor {
6
+ tenantId: string;
7
+ profileId: string;
8
+ }
9
+ /** Explicit work attribution for a closed punch's draft evidence entry. */
10
+ export interface AttendanceWork {
11
+ workRefType: string;
12
+ workRefId: string;
13
+ description: string;
14
+ }
15
+ /** Offline command with a durable client-generated identifier and device timestamp. */
16
+ export interface AttendanceTap {
17
+ clientId: string;
18
+ at: string;
19
+ action: 'punchIn' | 'punchOut' | 'startBreak' | 'endBreak';
20
+ paid?: boolean;
21
+ work?: AttendanceWork;
22
+ }
23
+ /** Expected state conflict, distinct from retryable database failures. */
24
+ export declare class AttendanceConflict extends Error {
25
+ }
26
+ /**
27
+ * Attendance mutations bound to a trusted tenant/profile. Applications authorize
28
+ * the actor before constructing this service. Each transition is atomic; all
29
+ * participating collections use the transaction executor. PostgreSQL serializes
30
+ * this actor with an advisory lock; embedded adapters serialize writers.
31
+ */
32
+ export declare class AttendanceService {
33
+ private readonly db;
34
+ private readonly actor;
35
+ constructor(db: DatabaseInterface, actor: AttendanceActor);
36
+ private identity;
37
+ private transact;
38
+ private date;
39
+ private open;
40
+ private chronological;
41
+ /** Open one punch; the database unique slot also protects direct model writers. */
42
+ punchIn(at?: Date): Promise<AttendancePunch>;
43
+ /** Close the punch and any active break; optionally create linked draft evidence. */
44
+ punchOut(at: Date, work?: AttendanceWork): Promise<AttendancePunch>;
45
+ /** Begin a break; paid defaults false and must be explicitly configured by the caller. */
46
+ startBreak(at: Date, paid?: boolean): Promise<AttendancePunch>;
47
+ /** End the current break. */
48
+ endBreak(at: Date): Promise<AttendancePunch>;
49
+ private transition;
50
+ private validateWork;
51
+ private link;
52
+ /** Idempotently link a closed, owned punch to draft service evidence. */
53
+ linkServiceTimeEntry(punchId: string, work: AttendanceWork): Promise<AttendancePunch>;
54
+ /** Close at startedAt + maxOpenSeconds once due, flagging the punch for review. */
55
+ autoClose(now: Date, maxOpenSeconds: number): Promise<AttendancePunch | null>;
56
+ /**
57
+ * Replay device timestamps in order (ties use clientId). Committed attendance
58
+ * wins: stale/state-conflicting taps get durable refusals; identical retries
59
+ * return the same outcome, changed payloads using a key are rejected. A
60
+ * database failure rolls back its tap and receipt and remains retryable.
61
+ */
62
+ replay(taps: readonly AttendanceTap[]): Promise<AttendanceReplayOutcome[]>;
63
+ }
64
+ //# sourceMappingURL=service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../../src/attendance/service.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAE5D,OAAO,EAEL,eAAe,EAGf,KAAK,uBAAuB,EAC7B,MAAM,aAAa,CAAC;AAErB,YAAY,EAAE,uBAAuB,EAAE,MAAM,aAAa,CAAC;AAE3D,sFAAsF;AACtF,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB;AACD,2EAA2E;AAC3E,MAAM,WAAW,cAAc;IAC7B,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;CACrB;AACD,uFAAuF;AACvF,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,SAAS,GAAG,UAAU,GAAG,YAAY,GAAG,UAAU,CAAC;IAC3D,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,IAAI,CAAC,EAAE,cAAc,CAAC;CACvB;AAED,0EAA0E;AAC1E,qBAAa,kBAAmB,SAAQ,KAAK;CAAG;AAShD;;;;;GAKG;AACH,qBAAa,iBAAiB;IAE1B,OAAO,CAAC,QAAQ,CAAC,EAAE;IACnB,OAAO,CAAC,QAAQ,CAAC,KAAK;gBADL,EAAE,EAAE,iBAAiB,EACrB,KAAK,EAAE,eAAe;IAUzC,OAAO,CAAC,QAAQ;YAQF,QAAQ;IA0CtB,OAAO,CAAC,IAAI;YAQE,IAAI;YASJ,aAAa;IA+B3B,mFAAmF;IAC7E,OAAO,CAAC,EAAE,OAAa,GAAG,OAAO,CAAC,eAAe,CAAC;IAGxD,qFAAqF;IAC/E,QAAQ,CAAC,EAAE,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,eAAe,CAAC;IAKzE,0FAA0F;IACpF,UAAU,CAAC,EAAE,EAAE,IAAI,EAAE,IAAI,UAAQ,GAAG,OAAO,CAAC,eAAe,CAAC;IAKlE,6BAA6B;IACvB,QAAQ,CAAC,EAAE,EAAE,IAAI,GAAG,OAAO,CAAC,eAAe,CAAC;YAMpC,UAAU;IAqFxB,OAAO,CAAC,YAAY;YAcN,IAAI;IA6ClB,yEAAyE;IACnE,oBAAoB,CACxB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,cAAc,GACnB,OAAO,CAAC,eAAe,CAAC;IAY3B,mFAAmF;IAC7E,SAAS,CACb,GAAG,EAAE,IAAI,EACT,cAAc,EAAE,MAAM,GACrB,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC;IAwBlC;;;;;OAKG;IACG,MAAM,CACV,IAAI,EAAE,SAAS,aAAa,EAAE,GAC7B,OAAO,CAAC,uBAAuB,EAAE,CAAC;CAkGtC"}
@@ -0,0 +1,5 @@
1
+ /** Tenant-scoped punches, breaks, automatic close and offline replay. */
2
+ import './__smrt-register__.js';
3
+ export * from './attendance/models.js';
4
+ export * from './attendance/service.js';
5
+ //# sourceMappingURL=attendance.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attendance.d.ts","sourceRoot":"","sources":["../src/attendance.ts"],"names":[],"mappings":"AAAA,yEAAyE;AACzE,OAAO,wBAAwB,CAAC;AAEhC,cAAc,wBAAwB,CAAC;AACvC,cAAc,yBAAyB,CAAC"}