@koolbase/core 11.4.0 → 12.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.
@@ -421,6 +421,70 @@ export class KoolbaseDatabase {
421
421
  const { pending } = await readOfflineState(userId);
422
422
  return pending.map(toPendingWrite);
423
423
  }
424
+ /**
425
+ * Count and total over a whole collection, with the read rule applied
426
+ * inside the query.
427
+ *
428
+ * Not a paged query you add up yourself: the server aggregates the entire
429
+ * authorized set. And never a bare number — collections are schemaless, so
430
+ * a sum that quietly skipped three malformed rows would be a wrong number
431
+ * that looks right. Check `accounting` for what contributed.
432
+ *
433
+ * ```ts
434
+ * const r = await db.aggregate({
435
+ * collection: 'orders',
436
+ * groupBy: { field: 'created_at', bucket: 'month', timezone: 'Africa/Accra' },
437
+ * measures: [{ aggregate: 'sum', field: 'total', as: 'revenue' }],
438
+ * });
439
+ * ```
440
+ *
441
+ * A calendar bucket requires a timezone and the types enforce it: midnight
442
+ * means nothing without one, and Accra and UTC disagree about which day a
443
+ * 23:30 sale belongs to.
444
+ */
445
+ async aggregate(request) {
446
+ const raw = await this.request('POST', '/v1/sdk/db/aggregate', {
447
+ collection: request.collection,
448
+ ...(request.where?.length ? { where: request.where } : {}),
449
+ ...(request.groupBy
450
+ ? {
451
+ group_by: {
452
+ field: request.groupBy.field,
453
+ ...(request.groupBy.bucket
454
+ ? {
455
+ bucket: request.groupBy.bucket,
456
+ timezone: request.groupBy.timezone,
457
+ }
458
+ : {}),
459
+ },
460
+ }
461
+ : {}),
462
+ measures: request.measures.map((m) => ({
463
+ aggregate: m.aggregate,
464
+ ...(m.field ? { field: m.field } : {}),
465
+ as: m.as,
466
+ })),
467
+ });
468
+ const accounting = {};
469
+ for (const [name, a] of Object.entries(raw.accounting ?? {})) {
470
+ accounting[name] = {
471
+ counted: Number(a.counted ?? 0),
472
+ skipped: Number(a.skipped ?? 0),
473
+ skippedReason: a.skipped_reason,
474
+ };
475
+ }
476
+ return {
477
+ groups: (raw.groups ?? []).map((g) => ({
478
+ category: String(g.category ?? ''),
479
+ values: g.values ?? {},
480
+ })),
481
+ matched: Number(raw.matched ?? 0),
482
+ accounting,
483
+ // Explicitly boolean: a missing field must read as false, not
484
+ // undefined, or `if (!r.tooManyGroups)` passes on a refused request.
485
+ tooManyGroups: raw.too_many_groups === true,
486
+ };
487
+ }
424
488
  async conflicts() {
425
489
  const userId = this.requireUserId('the conflict list');
426
490
  const { conflicts } = await readOfflineState(userId);
@@ -49,7 +49,13 @@ export class DeviceMetadata {
49
49
  if (this.cached)
50
50
  return this.cached;
51
51
  const platform = getPlatform().info.os;
52
- const platformVersion = getPlatform().info.version;
52
+ // 'unknown' rather than empty, matching appVersion above. A browser
53
+ // outside the handful browserVersion() recognises — Brave, Opera,
54
+ // Samsung Internet, an in-app webview — reports nothing, and an empty
55
+ // header stored against a session is indistinguishable from an SDK that
56
+ // never reported itself at all. 'unknown' says the SDK spoke and could
57
+ // not identify the host, which is a different and more useful fact.
58
+ const platformVersion = getPlatform().info.version || 'unknown';
53
59
  const deviceLabel = await this.getOrCreateDeviceLabel();
54
60
  // No User-Agent: a browser forbids setting it, and the same header set
55
61
  // now goes out from both hosts. The platform and version below carry
@@ -0,0 +1,114 @@
1
+ import { KoolbaseConfig } from './types.js';
2
+ import { KoolbaseError } from './errors.js';
3
+ /**
4
+ * The lifecycle status of a fiscal intent.
5
+ *
6
+ * A sale is durably recorded the moment `submit` returns; fiscal numbers and
7
+ * the authority's certification follow this state machine:
8
+ *
9
+ * - `queued` / `submitting` / `retrying` — sealed with allocated numbers;
10
+ * delivery to the authority is in progress.
11
+ * - `fiscalized` — the authority answered; for certifying document kinds the
12
+ * `certification` is present.
13
+ * - `blocked` — a precondition failed BEFORE any fiscal number was consumed
14
+ * (see `blockedReason`). Resubmitting the same `clientRef` after fixing the
15
+ * cause resumes it.
16
+ * - `attention` — the authority rejected the document, or its credential died
17
+ * after sealing. An operator resolves it; the register's chain waits behind
18
+ * it.
19
+ * - `voided` — terminally closed by an operator without fiscalization.
20
+ *
21
+ * `unknown` is the forward-compatible fallback: a status this SDK version has
22
+ * not heard of parses as unknown rather than throwing, so a server that grows
23
+ * a state does not break older clients.
24
+ */
25
+ export type FiscalStatus = 'created' | 'blocked' | 'queued' | 'submitting' | 'retrying' | 'fiscalized' | 'attention' | 'voided' | 'unknown';
26
+ /**
27
+ * The state of one fiscal intent.
28
+ *
29
+ * Jurisdiction-neutral by design: this SDK never learns a jurisdiction's
30
+ * vocabulary. Payloads travel as objects shaped by the device's adapter, and
31
+ * `certification` comes back as the authority granted it — for Ghana that
32
+ * means `ysdcregsig`, `ysdcrecnum`, `qr_code` and so on, which the SDK
33
+ * passes through without naming.
34
+ */
35
+ export interface FiscalIntentResult {
36
+ intentId: string;
37
+ status: FiscalStatus;
38
+ /** Why a `blocked` intent stopped, when the server said. */
39
+ blockedReason?: string;
40
+ /** Fiscal numbers allocated at sealing. */
41
+ numbers?: Record<string, number>;
42
+ /** When the intent was sealed and its numbers allocated. */
43
+ sealedAt?: string;
44
+ /**
45
+ * What the authority granted, once `status` is `fiscalized`. This is the
46
+ * receipt-rendering payload: for Ghana, the signature, receipt number and
47
+ * QR verification URL.
48
+ */
49
+ certification?: Record<string, unknown>;
50
+ }
51
+ /** True once the authority has certified the document. */
52
+ export declare function isFiscalized(r: FiscalIntentResult): boolean;
53
+ /** True while delivery to the authority is still in progress. */
54
+ export declare function isPending(r: FiscalIntentResult): boolean;
55
+ /** A fiscal request that failed. Carries the HTTP status where there was one. */
56
+ export declare class KoolbaseFiscalError extends KoolbaseError {
57
+ readonly statusCode?: number;
58
+ constructor(message: string, statusCode?: number);
59
+ }
60
+ /**
61
+ * Classify a response. Status and body in, result or error out.
62
+ *
63
+ * Exported for testability, and because auth handling belongs with the
64
+ * session hook rather than here — the same split the Flutter SDK makes.
65
+ */
66
+ export declare function decodeFiscalResponse(status: number, body: string): Promise<FiscalIntentResult>;
67
+ /**
68
+ * Koolbase Fiscal — submitting sales to a tax authority.
69
+ *
70
+ * Jurisdiction-neutral: the SDK moves payloads and reads back whatever the
71
+ * authority certified. Ghana's GRA E-VAT is the first jurisdiction; nothing
72
+ * here names it.
73
+ */
74
+ export declare class KoolbaseFiscal {
75
+ private config;
76
+ private getToken;
77
+ private onSessionExpired?;
78
+ constructor(config: KoolbaseConfig, getToken: () => Promise<string | null>, onSessionExpired?: () => Promise<void>);
79
+ private buildHeaders;
80
+ private decode;
81
+ /**
82
+ * Submit a sale for fiscalization.
83
+ *
84
+ * The sale is durably recorded the moment this returns; fiscal numbers and
85
+ * the authority's certification follow asynchronously — poll `status()`
86
+ * with the same `clientRef` to watch it.
87
+ *
88
+ * **On timeout, do not resubmit blindly.** The sale may have fiscalized
89
+ * anyway: the request reached the server and the response was lost. Poll
90
+ * `status()` with the same `clientRef` instead, which is idempotent and
91
+ * tells you what actually happened.
92
+ *
93
+ * @param clientRef your own reference for this sale; the idempotency key
94
+ * and the handle you poll and render receipts by.
95
+ */
96
+ submit(params: {
97
+ deviceId: string;
98
+ clientRef: string;
99
+ payload: Record<string, unknown>;
100
+ timeoutMs?: number;
101
+ }): Promise<FiscalIntentResult>;
102
+ /**
103
+ * Read an intent's current state by the reference it was submitted under.
104
+ *
105
+ * This is the receipt-rendering read: once the status is `fiscalized`, the
106
+ * `certification` carries what the receipt must show — for Ghana, the
107
+ * signature, receipt number and QR verification URL.
108
+ */
109
+ status(params: {
110
+ deviceId: string;
111
+ clientRef: string;
112
+ timeoutMs?: number;
113
+ }): Promise<FiscalIntentResult>;
114
+ }
@@ -0,0 +1,159 @@
1
+ import { KoolbaseError, KoolbaseUnauthenticatedError } from './errors.js';
2
+ const KNOWN_STATUSES = [
3
+ 'created', 'blocked', 'queued', 'submitting',
4
+ 'retrying', 'fiscalized', 'attention', 'voided',
5
+ ];
6
+ function parseStatus(raw) {
7
+ return typeof raw === 'string' && KNOWN_STATUSES.includes(raw)
8
+ ? raw
9
+ : 'unknown';
10
+ }
11
+ /** True once the authority has certified the document. */
12
+ export function isFiscalized(r) {
13
+ return r.status === 'fiscalized';
14
+ }
15
+ /** True while delivery to the authority is still in progress. */
16
+ export function isPending(r) {
17
+ return r.status === 'queued'
18
+ || r.status === 'submitting'
19
+ || r.status === 'retrying';
20
+ }
21
+ /** A fiscal request that failed. Carries the HTTP status where there was one. */
22
+ export class KoolbaseFiscalError extends KoolbaseError {
23
+ constructor(message, statusCode) {
24
+ super(message, 'fiscal_failed');
25
+ this.statusCode = statusCode;
26
+ this.name = 'KoolbaseFiscalError';
27
+ Object.setPrototypeOf(this, KoolbaseFiscalError.prototype);
28
+ }
29
+ }
30
+ function resultFromJson(json) {
31
+ const numbers = json.numbers;
32
+ return {
33
+ intentId: String(json.intent_id ?? ''),
34
+ status: parseStatus(json.status),
35
+ blockedReason: json.blocked_reason,
36
+ numbers: numbers ?? undefined,
37
+ sealedAt: json.sealed_at,
38
+ certification: json.certification,
39
+ };
40
+ }
41
+ /**
42
+ * Classify a response. Status and body in, result or error out.
43
+ *
44
+ * Exported for testability, and because auth handling belongs with the
45
+ * session hook rather than here — the same split the Flutter SDK makes.
46
+ */
47
+ export async function decodeFiscalResponse(status, body) {
48
+ let parsed;
49
+ try {
50
+ parsed = JSON.parse(body);
51
+ }
52
+ catch {
53
+ throw new KoolbaseFiscalError(`fiscal: unreadable response (${status})`, status);
54
+ }
55
+ if ((status >= 200 && status < 300) || status === 202) {
56
+ return resultFromJson(parsed);
57
+ }
58
+ throw new KoolbaseFiscalError(parsed.error ?? 'fiscal: request failed', status);
59
+ }
60
+ // ─── KoolbaseFiscal ──────────────────────────────────────────────────────────
61
+ /**
62
+ * Koolbase Fiscal — submitting sales to a tax authority.
63
+ *
64
+ * Jurisdiction-neutral: the SDK moves payloads and reads back whatever the
65
+ * authority certified. Ghana's GRA E-VAT is the first jurisdiction; nothing
66
+ * here names it.
67
+ */
68
+ export class KoolbaseFiscal {
69
+ constructor(config, getToken, onSessionExpired) {
70
+ this.config = config;
71
+ this.getToken = getToken;
72
+ this.onSessionExpired = onSessionExpired;
73
+ }
74
+ async buildHeaders() {
75
+ const token = await this.getToken();
76
+ return {
77
+ 'Content-Type': 'application/json',
78
+ 'x-api-key': this.config.publicKey,
79
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
80
+ };
81
+ }
82
+ async decode(res) {
83
+ if (res.status === 401 || res.status === 403) {
84
+ await this.onSessionExpired?.();
85
+ throw new KoolbaseUnauthenticatedError(`fiscal: unauthenticated (${res.status})`);
86
+ }
87
+ return decodeFiscalResponse(res.status, await res.text());
88
+ }
89
+ /**
90
+ * Submit a sale for fiscalization.
91
+ *
92
+ * The sale is durably recorded the moment this returns; fiscal numbers and
93
+ * the authority's certification follow asynchronously — poll `status()`
94
+ * with the same `clientRef` to watch it.
95
+ *
96
+ * **On timeout, do not resubmit blindly.** The sale may have fiscalized
97
+ * anyway: the request reached the server and the response was lost. Poll
98
+ * `status()` with the same `clientRef` instead, which is idempotent and
99
+ * tells you what actually happened.
100
+ *
101
+ * @param clientRef your own reference for this sale; the idempotency key
102
+ * and the handle you poll and render receipts by.
103
+ */
104
+ async submit(params) {
105
+ const controller = new AbortController();
106
+ const timer = setTimeout(() => controller.abort(), params.timeoutMs ?? 30000);
107
+ try {
108
+ const res = await fetch(`${this.config.baseUrl}/v1/sdk/fiscal/submit`, {
109
+ method: 'POST',
110
+ headers: await this.buildHeaders(),
111
+ signal: controller.signal,
112
+ body: JSON.stringify({
113
+ device_id: params.deviceId,
114
+ client_ref: params.clientRef,
115
+ payload: params.payload,
116
+ }),
117
+ });
118
+ return await this.decode(res);
119
+ }
120
+ catch (e) {
121
+ if (e instanceof Error && e.name === 'AbortError') {
122
+ throw new KoolbaseFiscalError('fiscal submit timed out — the sale may still fiscalize; '
123
+ + 'poll status with the same clientRef');
124
+ }
125
+ throw e;
126
+ }
127
+ finally {
128
+ clearTimeout(timer);
129
+ }
130
+ }
131
+ /**
132
+ * Read an intent's current state by the reference it was submitted under.
133
+ *
134
+ * This is the receipt-rendering read: once the status is `fiscalized`, the
135
+ * `certification` carries what the receipt must show — for Ghana, the
136
+ * signature, receipt number and QR verification URL.
137
+ */
138
+ async status(params) {
139
+ const controller = new AbortController();
140
+ const timer = setTimeout(() => controller.abort(), params.timeoutMs ?? 15000);
141
+ try {
142
+ const q = new URLSearchParams({
143
+ device_id: params.deviceId,
144
+ client_ref: params.clientRef,
145
+ });
146
+ const res = await fetch(`${this.config.baseUrl}/v1/sdk/fiscal/status?${q.toString()}`, { method: 'GET', headers: await this.buildHeaders(), signal: controller.signal });
147
+ return await this.decode(res);
148
+ }
149
+ catch (e) {
150
+ if (e instanceof Error && e.name === 'AbortError') {
151
+ throw new KoolbaseFiscalError('fiscal status timed out');
152
+ }
153
+ throw e;
154
+ }
155
+ finally {
156
+ clearTimeout(timer);
157
+ }
158
+ }
159
+ }
@@ -1,5 +1,6 @@
1
1
  export * from './types.js';
2
2
  export * from './errors.js';
3
+ export * from './aggregate.js';
3
4
  export * from './conflict.js';
4
5
  export * from './pending-write.js';
5
6
  export * from './function-errors.js';
@@ -13,6 +14,8 @@ export { KoolbaseFunctions } from './functions.js';
13
14
  export { KoolbaseRealtime } from './realtime.js';
14
15
  export { KoolbaseStorage } from './storage.js';
15
16
  export { KoolbaseAnalytics } from './analytics.js';
17
+ export { KoolbaseFiscal, KoolbaseFiscalError, decodeFiscalResponse, isFiscalized, isPending } from './fiscal.js';
18
+ export type { FiscalIntentResult, FiscalStatus } from './fiscal.js';
16
19
  export { KoolbaseMessaging } from './messaging.js';
17
20
  export type { RegisterTokenOptions } from './messaging.js';
18
21
  export { getOrCreateDeviceId } from './device-id.js';
package/dist/esm/index.js CHANGED
@@ -5,6 +5,7 @@
5
5
  // code installs the platform package, never this one directly.
6
6
  export * from './types.js';
7
7
  export * from './errors.js';
8
+ export * from './aggregate.js';
8
9
  export * from './conflict.js';
9
10
  export * from './pending-write.js';
10
11
  export * from './function-errors.js';
@@ -18,6 +19,7 @@ export { KoolbaseFunctions } from './functions.js';
18
19
  export { KoolbaseRealtime } from './realtime.js';
19
20
  export { KoolbaseStorage } from './storage.js';
20
21
  export { KoolbaseAnalytics } from './analytics.js';
22
+ export { KoolbaseFiscal, KoolbaseFiscalError, decodeFiscalResponse, isFiscalized, isPending } from './fiscal.js';
21
23
  export { KoolbaseMessaging } from './messaging.js';
22
24
  export { getOrCreateDeviceId } from './device-id.js';
23
25
  export { koolbaseSdkVersion } from './device-metadata.js';
@@ -88,6 +88,64 @@ export type SignUpResult = {
88
88
  user: KoolbaseUser;
89
89
  session: null;
90
90
  };
91
+ /**
92
+ * One active session — somewhere the user is signed in.
93
+ *
94
+ * Token hashes are never included; the server excludes them deliberately.
95
+ */
96
+ export interface KoolbaseSessionInfo {
97
+ id: string;
98
+ /** Where it signed in from, when the server captured it. */
99
+ ip?: string;
100
+ /** What signed in. A browser sets this; native SDKs send their own. */
101
+ userAgent?: string;
102
+ /** A per-install label, where the SDK supplied one. */
103
+ deviceLabel?: string;
104
+ createdAt: string;
105
+ expiresAt: string;
106
+ /**
107
+ * True for the session making the request.
108
+ *
109
+ * A device list needs this: without it a user cannot tell which row is the
110
+ * phone in their hand, and "sign out everywhere else" has nothing to
111
+ * exclude.
112
+ */
113
+ isCurrent: boolean;
114
+ }
115
+ /**
116
+ * One thing that happened to this account.
117
+ *
118
+ * The server sanitizes these: `eventData` carries only the fields allowed
119
+ * for that event type, so a lockout can say which address was attempted
120
+ * while a successful login carries nothing extra.
121
+ */
122
+ export interface KoolbaseAuditEvent {
123
+ id: string;
124
+ /**
125
+ * What happened. One of: auth.login.success, auth.login.failed,
126
+ * auth.account.locked, auth.account.unlocked, auth.session.created,
127
+ * auth.session.refreshed, auth.session.revoked, auth.password.changed,
128
+ * auth.user.verified.
129
+ *
130
+ * A string rather than a union: the server may add one, and an app that
131
+ * stops compiling because it did would be worse than one that shows an
132
+ * event it does not recognise.
133
+ */
134
+ eventType: string;
135
+ occurredAt: string;
136
+ ip?: string;
137
+ userAgent?: string;
138
+ /** Whatever that event type is allowed to carry. Often empty. */
139
+ eventData: Record<string, unknown>;
140
+ }
141
+ /** A page of audit events, and how many there are in total. */
142
+ export interface KoolbaseAuditPage {
143
+ events: KoolbaseAuditEvent[];
144
+ /** Across all pages, so an app knows whether to offer "show more". */
145
+ total: number;
146
+ limit: number;
147
+ offset: number;
148
+ }
91
149
  export interface ResendVerificationResult {
92
150
  alreadyVerified: boolean;
93
151
  /** When the link in the email stops working. Null when nothing was sent. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koolbase/core",
3
- "version": "11.4.0",
3
+ "version": "12.1.0",
4
4
  "description": "Koolbase SDK core \u2014 shared behaviour behind @koolbase/react-native and @koolbase/js. Install one of those, not this.",
5
5
  "main": "./dist/cjs/index.js",
6
6
  "types": "./dist/esm/index.d.ts",