@koolbase/core 12.0.0 → 12.2.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/dist/esm/auth.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { RestoreResult, } from './types.js';
2
- import { AccountLockedError, EmailAlreadyInUseError, InvalidCredentialsError, InvalidPhoneNumberError, KoolbaseAuthError, OtpExpiredError, OtpInvalidError, OtpMaxAttemptsError, OtpRateLimitError, PhoneAlreadyLinkedError, IdentityNotFoundError, InsufficientScopeError, MalformedSessionResponseError, ProviderIdentityAlreadyLinkedError, AccountExistsError, ContactNotVerifiedError, CurrentPasswordIncorrectError, HideRequiresVerificationError, InsufficientAuthorityError, LastCredentialError, OAuthOnlyAccountError, SessionRequiredError, SignupsDisabledError, TokenAlreadyUsedError, TokenExpiredError, UnsupportedOAuthProviderError, RateLimitError, VerificationResendCooldownError, VerificationResendDailyCapError, SessionExpiredError, SmsConfigMissingError, UnlockTokenInvalidError, UserDisabledError, WeakPasswordError, AppleEmailRequiredError, AppleSignInNotConfiguredError, InvalidAppleTokenError, OAuthEmailConflictError, GoogleEmailRequiredError, GoogleSignInNotConfiguredError, InvalidGoogleTokenError, } from './auth-errors.js';
2
+ import { AccountLockedError, EmailAlreadyInUseError, InvalidCredentialsError, InvalidPhoneNumberError, KoolbaseAuthError, OtpExpiredError, OtpInvalidError, OtpMaxAttemptsError, OtpRateLimitError, PhoneAlreadyLinkedError, IdentityNotFoundError, InsufficientScopeError, MalformedSessionResponseError, ProviderIdentityAlreadyLinkedError, AccountExistsError, ContactNotVerifiedError, CurrentPasswordIncorrectError, HideRequiresVerificationError, InsufficientAuthorityError, LastCredentialError, OAuthOnlyAccountError, SessionRequiredError, SignupsDisabledError, TokenAlreadyUsedError, TokenExpiredError, UnsupportedOAuthProviderError, RateLimitError, VerificationResendCooldownError, VerificationResendDailyCapError, SessionExpiredError, SmsConfigMissingError, UnlockTokenInvalidError, UserDisabledError, WeakPasswordError, AppleEmailRequiredError, AppleSignInNotConfiguredError, InvalidAppleTokenError, OAuthEmailConflictError, GoogleEmailRequiredError, GoogleSignInNotConfiguredError, InvalidGoogleTokenError, EmailCodeDisabledError, MfaRequiredError, RecentAuthRequiredError, RecentMfaRequiredError, MfaAlreadyEnabledError, MfaEnrollmentNotFoundError, MfaNotEnabledError, } from './auth-errors.js';
3
3
  import { getPlatform } from './platform.js';
4
4
  import { DeviceMetadata } from './device-metadata.js';
5
5
  export class KoolbaseAuth {
@@ -385,6 +385,8 @@ export class KoolbaseAuth {
385
385
  const errorMessage = body?.error ?? '';
386
386
  // ─── code-first ───
387
387
  switch (code) {
388
+ case 'mfa_required':
389
+ throw this.mfaRequired(body);
388
390
  case 'oauth_not_configured':
389
391
  throw new GoogleSignInNotConfiguredError();
390
392
  case 'invalid_oauth_token':
@@ -439,6 +441,8 @@ export class KoolbaseAuth {
439
441
  const errorMessage = body?.error ?? '';
440
442
  // ─── code-first ───
441
443
  switch (code) {
444
+ case 'mfa_required':
445
+ throw this.mfaRequired(body);
442
446
  case 'oauth_not_configured':
443
447
  throw new AppleSignInNotConfiguredError();
444
448
  case 'invalid_oauth_token':
@@ -821,20 +825,150 @@ export class KoolbaseAuth {
821
825
  }
822
826
  // ─── OAuth (DEPRECATED — see v1.10.0) ───────────────────────────────────
823
827
  /**
824
- * @deprecated v1.9.0: Server endpoint /v1/sdk/auth/oauth not yet
825
- * shipped. This method previously routed to /v1/auth/oauth (dashboard
826
- * developer OAuth) which never created project-scoped end-user
827
- * sessions. Properly implemented in v1.10.0 with provider-specific
828
- * server endpoints under /v1/sdk/auth/oauth/{apple,google,github}.
829
- * Use email/password sign-in for now.
828
+ * @deprecated Never implemented; kept only so existing code compiles.
829
+ * Google and Apple sign-in are supported — use signInWithGoogle() or
830
+ * signInWithApple(). This used to say OAuth was not yet shipped and to
831
+ * use email and password instead, which stopped being true long ago.
830
832
  *
831
833
  * @throws Always throws KoolbaseAuthError('not_implemented').
832
834
  */
833
835
  async oauthLogin(_params) {
834
- throw new KoolbaseAuthError('OAuth sign-in is not yet implemented for the Koolbase SDK. ' +
835
- 'Planned for v1.10.0 (server-side endpoints under ' +
836
- '/v1/sdk/auth/oauth/{provider}). Use email/password authentication ' +
837
- 'in the meantime.', 'not_implemented');
836
+ throw new KoolbaseAuthError('oauthLogin() was never implemented and is kept only so existing ' +
837
+ 'code compiles. Use signInWithGoogle() or signInWithApple().', 'not_implemented');
838
+ }
839
+ // ─── Email code ─────────────────────────────────────────────────────────
840
+ /**
841
+ * Emails a six-digit sign-in code.
842
+ *
843
+ * Resolves the same way whether or not the address has an account, so it
844
+ * cannot be used to check who is registered. A new address receives a code
845
+ * only while the project accepts sign-ups; a disabled account receives
846
+ * nothing.
847
+ *
848
+ * @throws EmailCodeDisabledError if the project has switched this off
849
+ * @throws RateLimitError after too many requests for one address
850
+ */
851
+ async requestEmailCode(params) {
852
+ const res = await this.authRequest('/v1/sdk/auth/email/code', {
853
+ method: 'POST',
854
+ body: { email: params.email },
855
+ });
856
+ if (!res.ok)
857
+ await this.throwTypedError(res, false);
858
+ }
859
+ /**
860
+ * Signs in with a code from requestEmailCode(), storing the session exactly
861
+ * as login() does. An unknown address becomes an account only if the
862
+ * project still accepts sign-ups at this moment. Each code works once and
863
+ * allows three attempts.
864
+ *
865
+ * @throws OtpInvalidError, OtpExpiredError, OtpMaxAttemptsError
866
+ * @throws EmailCodeDisabledError if the project has switched this off
867
+ */
868
+ async signInWithEmailCode(params) {
869
+ const res = await this.authRequest('/v1/sdk/auth/email/code/verify', {
870
+ method: 'POST',
871
+ body: { email: params.email, code: params.code },
872
+ });
873
+ const session = await this.parseSessionResponse(res, false);
874
+ await this.setSessionInternal(session);
875
+ return session;
876
+ }
877
+ // ─── Two-step sign-in (MFA) ─────────────────────────────────────────────
878
+ //
879
+ // When an account has MFA on, every sign-in method throws MfaRequiredError.
880
+ // Finish with verifyMfa() or verifyRecoveryCode().
881
+ // One builder for every parser, so the four cannot drift apart in how the
882
+ // challenge reaches the app.
883
+ mfaRequired(body) {
884
+ const d = body?.details ?? {};
885
+ return new MfaRequiredError(String(d.challenge_token ?? ''), d.expires_at);
886
+ }
887
+ /** Finishes sign-in with a code from the person's authenticator app. */
888
+ async verifyMfa(params) {
889
+ const res = await this.authRequest('/v1/sdk/auth/mfa/verify', {
890
+ method: 'POST',
891
+ body: { challenge_token: params.challengeToken, code: params.code },
892
+ });
893
+ const session = await this.parseSessionResponse(res, false);
894
+ await this.setSessionInternal(session);
895
+ return session;
896
+ }
897
+ /**
898
+ * Finishes sign-in with a recovery code. Each works once; prompt the person
899
+ * to regenerate when recoveryCodesRemaining reaches zero.
900
+ */
901
+ async verifyRecoveryCode(params) {
902
+ const res = await this.authRequest('/v1/sdk/auth/mfa/verify-recovery', {
903
+ method: 'POST',
904
+ body: { challenge_token: params.challengeToken, code: params.code },
905
+ });
906
+ if (!res.ok)
907
+ await this.throwTypedError(res, false);
908
+ // Read once: the session and the remaining count come from one body.
909
+ const data = await res.json();
910
+ if (!data?.access_token)
911
+ throw new MalformedSessionResponseError('an access token');
912
+ if (!data?.refresh_token)
913
+ throw new MalformedSessionResponseError('a refresh token');
914
+ const session = {
915
+ accessToken: data.access_token,
916
+ refreshToken: data.refresh_token,
917
+ expiresAt: data.expires_at,
918
+ user: this.mapUser(data.user),
919
+ };
920
+ await this.setSessionInternal(session);
921
+ return { session, recoveryCodesRemaining: Number(data.recovery_codes_remaining ?? 0) };
922
+ }
923
+ /**
924
+ * Starts adding an authenticator; needs a sign-in within ten minutes. Show
925
+ * otpauthUri as a QR code, then call confirmMfaEnrollment with the first
926
+ * code the app shows.
927
+ */
928
+ async enrollMfa() {
929
+ const res = await this.authRequest('/v1/sdk/auth/mfa/enroll', { method: 'POST', includeAuth: true });
930
+ await this.checkResponse(res);
931
+ const d = await res.json();
932
+ return { otpauthUri: d.otpauth_uri, secret: d.secret };
933
+ }
934
+ /** Turns MFA on and returns ten recovery codes, shown this once only. Other devices are signed out. */
935
+ async confirmMfaEnrollment(code) {
936
+ const res = await this.authRequest('/v1/sdk/auth/mfa/enroll/confirm', {
937
+ method: 'POST',
938
+ body: { code },
939
+ includeAuth: true,
940
+ });
941
+ await this.checkResponse(res);
942
+ return (await res.json()).recovery_codes;
943
+ }
944
+ async mfaStatus() {
945
+ const res = await this.authRequest('/v1/sdk/auth/mfa', { method: 'GET', includeAuth: true });
946
+ await this.checkResponse(res);
947
+ const d = await res.json();
948
+ return { enabled: !!d.enabled, recoveryCodesRemaining: Number(d.recovery_codes_remaining ?? 0) };
949
+ }
950
+ /** Re-confirms the second factor, opening ten minutes to change MFA. */
951
+ async stepUpMfa(params) {
952
+ const res = await this.authRequest('/v1/sdk/auth/mfa/step-up', {
953
+ method: 'POST',
954
+ body: {
955
+ ...(params.code ? { code: params.code } : {}),
956
+ ...(params.recoveryCode ? { recovery_code: params.recoveryCode } : {}),
957
+ },
958
+ includeAuth: true,
959
+ });
960
+ await this.checkResponse(res);
961
+ }
962
+ /** Turns MFA off. Needs stepUpMfa() within the last ten minutes. */
963
+ async disableMfa() {
964
+ const res = await this.authRequest('/v1/sdk/auth/mfa/disable', { method: 'POST', includeAuth: true });
965
+ await this.checkResponse(res);
966
+ }
967
+ /** Replaces all recovery codes; the old ones stop working. Needs stepUpMfa() within ten minutes. */
968
+ async regenerateRecoveryCodes() {
969
+ const res = await this.authRequest('/v1/sdk/auth/mfa/recovery-codes', { method: 'POST', includeAuth: true });
970
+ await this.checkResponse(res);
971
+ return (await res.json()).recovery_codes;
838
972
  }
839
973
  // ─── Phone OTP ──────────────────────────────────────────────────────────
840
974
  async sendOtp(params) {
@@ -1003,6 +1137,18 @@ export class KoolbaseAuth {
1003
1137
  const msg = body.error ?? '';
1004
1138
  // ─── code-first ───
1005
1139
  switch (code) {
1140
+ case 'mfa_required':
1141
+ throw this.mfaRequired(body);
1142
+ case 'recent_auth_required':
1143
+ throw new RecentAuthRequiredError();
1144
+ case 'recent_mfa_required':
1145
+ throw new RecentMfaRequiredError();
1146
+ case 'mfa_already_enabled':
1147
+ throw new MfaAlreadyEnabledError();
1148
+ case 'mfa_enrollment_not_found':
1149
+ throw new MfaEnrollmentNotFoundError();
1150
+ case 'mfa_not_enabled':
1151
+ throw new MfaNotEnabledError();
1006
1152
  case 'invalid_credentials':
1007
1153
  throw new InvalidCredentialsError();
1008
1154
  case 'email_in_use':
@@ -1027,6 +1173,16 @@ export class KoolbaseAuth {
1027
1173
  case 'contact_not_verified':
1028
1174
  case 'email_not_verified':
1029
1175
  throw new ContactNotVerifiedError(msg || undefined);
1176
+ // Sign-in codes. The otp_* codes are shared with phone sign-in; mapped
1177
+ // here, every path that returns them throws the class an app catches.
1178
+ case 'email_code_disabled':
1179
+ throw new EmailCodeDisabledError();
1180
+ case 'otp_expired':
1181
+ throw new OtpExpiredError();
1182
+ case 'otp_invalid':
1183
+ throw new OtpInvalidError();
1184
+ case 'otp_max_attempts':
1185
+ throw new OtpMaxAttemptsError();
1030
1186
  // Registration
1031
1187
  case 'signups_disabled':
1032
1188
  throw new SignupsDisabledError(msg || undefined);
@@ -1113,6 +1269,8 @@ export class KoolbaseAuth {
1113
1269
  const msg = body.error ?? '';
1114
1270
  // ─── code-first ───
1115
1271
  switch (code) {
1272
+ case 'mfa_required':
1273
+ throw this.mfaRequired(body);
1116
1274
  case 'invalid_phone':
1117
1275
  throw new InvalidPhoneNumberError();
1118
1276
  case 'otp_expired':
@@ -195,6 +195,52 @@ export declare class KoolbaseConflictError extends KoolbaseDataError {
195
195
  field?: string;
196
196
  constructor(message?: string, field?: string);
197
197
  }
198
+ /**
199
+ * A reference field points at a record that does not exist, is deleted, or
200
+ * lives in another collection — the server responds 400 `reference_invalid`.
201
+ *
202
+ * Checked when the write commits, so it can surface from an insert, an update,
203
+ * an upsert or a batch. In a batch the whole transaction is refused: nothing
204
+ * in it was written.
205
+ */
206
+ export declare class KoolbaseReferenceInvalidError extends KoolbaseDataError {
207
+ constructor(message?: string);
208
+ }
209
+ /**
210
+ * A record could not be deleted because live records still reference it, under
211
+ * a reference declared `on_delete: restrict` — 409 `reference_in_use`.
212
+ *
213
+ * Delete the referencing records first, or in the same batch: the check runs at
214
+ * commit, so one batch may delete a parent and its children in any order.
215
+ */
216
+ export declare class KoolbaseReferenceInUseError extends KoolbaseDataError {
217
+ constructor(message?: string);
218
+ }
219
+ /**
220
+ * A reference could not be declared because existing records already point at
221
+ * records that do not exist — 409 `dangling_references`.
222
+ *
223
+ * `dangling` lists the offending records so they can be repaired; Koolbase
224
+ * never repairs them for you. It is capped at the first 50.
225
+ */
226
+ export declare class KoolbaseDanglingReferencesError extends KoolbaseDataError {
227
+ dangling: Array<{
228
+ record_id: string;
229
+ value: string;
230
+ }>;
231
+ constructor(message?: string, dangling?: Array<{
232
+ record_id: string;
233
+ value: string;
234
+ }>);
235
+ }
236
+ /**
237
+ * A collection could not be deleted because another collection has a reference
238
+ * field pointing at it — 409 `collection_referenced`. Remove that reference
239
+ * first.
240
+ */
241
+ export declare class KoolbaseCollectionReferencedError extends KoolbaseDataError {
242
+ constructor(message?: string);
243
+ }
198
244
  /**
199
245
  * Thrown when the requested record or collection does not exist — the server
200
246
  * responds with 404 and code `not_found` / `record_not_found` /
@@ -272,6 +272,62 @@ export class KoolbaseConflictError extends KoolbaseDataError {
272
272
  Object.setPrototypeOf(this, KoolbaseConflictError.prototype);
273
273
  }
274
274
  }
275
+ /**
276
+ * A reference field points at a record that does not exist, is deleted, or
277
+ * lives in another collection — the server responds 400 `reference_invalid`.
278
+ *
279
+ * Checked when the write commits, so it can surface from an insert, an update,
280
+ * an upsert or a batch. In a batch the whole transaction is refused: nothing
281
+ * in it was written.
282
+ */
283
+ export class KoolbaseReferenceInvalidError extends KoolbaseDataError {
284
+ constructor(message) {
285
+ super(message ?? 'A reference points at a record that does not exist', 'reference_invalid');
286
+ this.name = 'KoolbaseReferenceInvalidError';
287
+ Object.setPrototypeOf(this, KoolbaseReferenceInvalidError.prototype);
288
+ }
289
+ }
290
+ /**
291
+ * A record could not be deleted because live records still reference it, under
292
+ * a reference declared `on_delete: restrict` — 409 `reference_in_use`.
293
+ *
294
+ * Delete the referencing records first, or in the same batch: the check runs at
295
+ * commit, so one batch may delete a parent and its children in any order.
296
+ */
297
+ export class KoolbaseReferenceInUseError extends KoolbaseDataError {
298
+ constructor(message) {
299
+ super(message ?? 'This record is still referenced by other records', 'reference_in_use');
300
+ this.name = 'KoolbaseReferenceInUseError';
301
+ Object.setPrototypeOf(this, KoolbaseReferenceInUseError.prototype);
302
+ }
303
+ }
304
+ /**
305
+ * A reference could not be declared because existing records already point at
306
+ * records that do not exist — 409 `dangling_references`.
307
+ *
308
+ * `dangling` lists the offending records so they can be repaired; Koolbase
309
+ * never repairs them for you. It is capped at the first 50.
310
+ */
311
+ export class KoolbaseDanglingReferencesError extends KoolbaseDataError {
312
+ constructor(message, dangling) {
313
+ super(message ?? 'Existing records point at records that do not exist', 'dangling_references');
314
+ this.dangling = dangling ?? [];
315
+ this.name = 'KoolbaseDanglingReferencesError';
316
+ Object.setPrototypeOf(this, KoolbaseDanglingReferencesError.prototype);
317
+ }
318
+ }
319
+ /**
320
+ * A collection could not be deleted because another collection has a reference
321
+ * field pointing at it — 409 `collection_referenced`. Remove that reference
322
+ * first.
323
+ */
324
+ export class KoolbaseCollectionReferencedError extends KoolbaseDataError {
325
+ constructor(message) {
326
+ super(message ?? 'Another collection references this one', 'collection_referenced');
327
+ this.name = 'KoolbaseCollectionReferencedError';
328
+ Object.setPrototypeOf(this, KoolbaseCollectionReferencedError.prototype);
329
+ }
330
+ }
275
331
  /**
276
332
  * Thrown when the requested record or collection does not exist — the server
277
333
  * responds with 404 and code `not_found` / `record_not_found` /
@@ -396,6 +452,16 @@ export function koolbaseDataError(status, body, fallbackMessage = 'Request faile
396
452
  switch (code) {
397
453
  case 'unique_violation':
398
454
  return attach(new KoolbaseConflictError(message, field));
455
+ case 'reference_invalid':
456
+ return attach(new KoolbaseReferenceInvalidError(message));
457
+ case 'reference_in_use':
458
+ return attach(new KoolbaseReferenceInUseError(message));
459
+ case 'dangling_references': {
460
+ const d = (body?.details ?? {});
461
+ return attach(new KoolbaseDanglingReferencesError(message, d.dangling));
462
+ }
463
+ case 'collection_referenced':
464
+ return attach(new KoolbaseCollectionReferencedError(message));
399
465
  case 'plan_limit_reached': {
400
466
  const d = (body?.details ?? {});
401
467
  return new KoolbasePlanLimitError(message, d.resource, d.limit, d.plan);
@@ -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
+ }
@@ -14,6 +14,8 @@ export { KoolbaseFunctions } from './functions.js';
14
14
  export { KoolbaseRealtime } from './realtime.js';
15
15
  export { KoolbaseStorage } from './storage.js';
16
16
  export { KoolbaseAnalytics } from './analytics.js';
17
+ export { KoolbaseFiscal, KoolbaseFiscalError, decodeFiscalResponse, isFiscalized, isPending } from './fiscal.js';
18
+ export type { FiscalIntentResult, FiscalStatus } from './fiscal.js';
17
19
  export { KoolbaseMessaging } from './messaging.js';
18
20
  export type { RegisterTokenOptions } from './messaging.js';
19
21
  export { getOrCreateDeviceId } from './device-id.js';
package/dist/esm/index.js CHANGED
@@ -19,6 +19,7 @@ export { KoolbaseFunctions } from './functions.js';
19
19
  export { KoolbaseRealtime } from './realtime.js';
20
20
  export { KoolbaseStorage } from './storage.js';
21
21
  export { KoolbaseAnalytics } from './analytics.js';
22
+ export { KoolbaseFiscal, KoolbaseFiscalError, decodeFiscalResponse, isFiscalized, isPending } from './fiscal.js';
22
23
  export { KoolbaseMessaging } from './messaging.js';
23
24
  export { getOrCreateDeviceId } from './device-id.js';
24
25
  export { koolbaseSdkVersion } from './device-metadata.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koolbase/core",
3
- "version": "12.0.0",
3
+ "version": "12.2.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",