@stardeck-customer-apps/controls 0.1.0 → 0.4.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/SKILL.md CHANGED
@@ -3,8 +3,10 @@
3
3
  **Package**: `@stardeck-customer-apps/controls`
4
4
 
5
5
  Controls are values Stardeck owns and your app reads while it runs: feature
6
- flags, what the plan allows, and which payment provider and methods the app
7
- uses. Your app asks Stardeck about once a minute and keeps the answer in memory.
6
+ flags, what the plan allows, how many locations have POS Pro hardware, which
7
+ Accounting edition the business pays for, which payment provider and methods
8
+ the app uses, and who the business is (its name, address, tax ID, currency and
9
+ time zone). Your app asks Stardeck about once a minute and keeps the answer in memory.
8
10
  A change on Stardeck reaches the app within about a minute, with no deploy.
9
11
 
10
12
  Never invent controls or flags of your own. The list is fixed by Stardeck and
@@ -48,15 +50,124 @@ the last values stay.
48
50
  | `flag.paymentsSetupStep` | `boolean` | no |
49
51
  | `plan.locations` | `{ included: number \| null, max: number \| null, extraMonthly: { amount, currency } \| null }` | no |
50
52
  | `plan.multiEntity` | `boolean` | no |
53
+ | `pos.pro` | `{ locations: number \| null }` | no |
54
+ | `plan.accountingEdition` | `"small" \| "medium" \| null` | no |
51
55
  | `payments.provider` | `"beam" \| "stripe" \| null` | yes |
52
56
  | `payments.methods` | `{ card: boolean, promptPay: boolean }` | yes |
57
+ | `business.profile` | `BusinessProfile \| null` (see "Business details") | yes |
53
58
 
54
59
  - `plan.locations.included` is what the plan covers. `max` is a hard cap (the
55
60
  entry plan has 1); `null` means no cap, and each location past `included` is
56
61
  billed at `extraMonthly`. `included: null` means the number was agreed per deal.
62
+ - `pos.pro.locations` is how many locations the business pays for POS Pro (Edge
63
+ receipt printing, Bolt+ card terminals). `null` means no limit; `0` means none
64
+ (the entry plan never includes POS Pro). See "POS Pro hardware" below.
65
+ - `plan.accountingEdition` is the Accounting edition the business pays for, an
66
+ add-on next to its plan. `null` means it has no Accounting plan. See
67
+ "Accounting edition" below.
68
+ - `business.profile` is the business the app is for, as Stardeck keeps it for
69
+ the organization. `null` means Stardeck has no record yet. See "Business
70
+ details" below.
57
71
  - Keys are never removed in later versions, so code written against today's list
58
72
  keeps working.
59
73
 
74
+ ## POS Pro hardware
75
+
76
+ Check `pos.pro` only when someone adds POS hardware (pairs a receipt printer or
77
+ a card terminal) at a location that has none yet. Never check it on a sale, a
78
+ print or a payment: hardware that is already set up keeps working whatever the
79
+ count says, so a business is never stopped mid-service.
80
+
81
+ ```ts
82
+ const posPro = await getControl("pos.pro");
83
+ if (!posPro.ok) {
84
+ // Unknown right now: refuse the add with a retry message (answer 503), and
85
+ // leave the hardware the business already has alone.
86
+ return unavailable("POS Pro could not be checked. Try again in a minute.");
87
+ }
88
+ const { locations } = posPro.value;
89
+ const usesPosPro = await countLocationsWithPosHardware(); // locations with a printer or terminal
90
+ if (locations !== null && !locationAlreadyHasHardware && usesPosPro >= locations) {
91
+ return refuse("Every POS Pro location you pay for is in use. Add POS Pro to this location first.");
92
+ }
93
+ ```
94
+
95
+ A location that already has a printer or terminal can add more without using up
96
+ another POS Pro location.
97
+
98
+ ## Accounting edition
99
+
100
+ Accounting comes in two editions, Small and Medium, each bought as an add-on.
101
+ Finance is on every plan and never depends on this control. Read
102
+ `plan.accountingEdition` to decide which Accounting the app offers:
103
+
104
+ - `"small"` or `"medium"`: offer that edition.
105
+ - `null`: the business has no Accounting plan. Hide Accounting and refuse its
106
+ writes with a message that it needs an Accounting plan. Records it made while
107
+ it had one stay readable.
108
+ - `ok: false`: unknown right now, which is not the same as `null`. Refuse new
109
+ accounting records (journal entries, period closes, filings) with a retry
110
+ message (answer 503), and keep everything that exists readable. Never hide
111
+ Accounting or treat the business as having none because of `ok: false`.
112
+
113
+ ```ts
114
+ const edition = await getControl("plan.accountingEdition");
115
+ if (!edition.ok) {
116
+ return unavailable("Accounting could not be checked. Try again in a minute.");
117
+ }
118
+ if (edition.value === null) {
119
+ return refuse("Accounting needs an Accounting plan.");
120
+ }
121
+ // edition.value is "small" or "medium"
122
+ ```
123
+
124
+ ## Business details
125
+
126
+ Stardeck keeps one business record per organization: what the owner told
127
+ Stardeck when they signed up, edited in Stardeck's Business settings. Every app
128
+ in the organization reads the same record (needs controls 0.4.0 or later):
129
+
130
+ ```ts
131
+ type BusinessProfile = {
132
+ kind: string; // a business preset id: "restaurant", "spa", "general"…
133
+ name: { en: string; th: string | null };
134
+ headOfficeAddress: string | null;
135
+ taxId: string | null;
136
+ country: string; // ISO 3166-1 alpha-2, "TH"
137
+ currency: string; // ISO 4217, "THB"
138
+ timezone: string; // IANA, "Asia/Bangkok"
139
+ };
140
+ ```
141
+
142
+ Stardeck owns it; the app keeps a copy. Mirror it into the app's own records
143
+ (the default company, the currency and time zone settings, the business preset)
144
+ whenever the value differs from what the app holds:
145
+
146
+ - `{ ok: true, value }`: copy `value` into the app. A `kind` this app does not
147
+ have stays on the general preset; never fail on it.
148
+ - `{ ok: true, value: null }`: Stardeck has no record yet. If the app already
149
+ has its own company details, send them up once with `setControl`; otherwise
150
+ ask the owner as before.
151
+ - `ok: false`: unknown right now, which is not the same as `null`. Change
152
+ nothing and keep the company the app has. Never blank a name or address
153
+ because of `ok: false`.
154
+
155
+ When the owner edits the business inside the app, write it to Stardeck first
156
+ with `setControl("business.profile", profile, { userToken })`, then save the
157
+ app's own copy from the answer. Saving only the app's copy would be undone by
158
+ the next mirror. Only an admin of the organization on Stardeck may write it, so
159
+ a `FORBIDDEN` answer means "ask an organization admin"; show `error.message`.
160
+ `null` is refused (`INVALID_VALUE`): the app can change the details but not
161
+ remove them. Validate input with the same rules Stardeck uses:
162
+
163
+ ```ts
164
+ import { businessProfileSchema } from "@stardeck-customer-apps/controls";
165
+
166
+ const parsed = businessProfileSchema.safeParse(input);
167
+ if (!parsed.success) return badRequest(parsed.error.issues[0]?.message);
168
+ const result = await setControl("business.profile", parsed.data, { userToken });
169
+ ```
170
+
60
171
  Types and the list itself are in the root export:
61
172
 
62
173
  ```ts
@@ -65,10 +176,11 @@ import { CONTROLS, parseControls, type ControlKey } from "@stardeck-customer-app
65
176
 
66
177
  ## Changing provider or methods
67
178
 
68
- Only `payments.provider` and `payments.methods` can be changed from the app, and
69
- only on behalf of a signed-in person. Pass that person's session token. Stardeck
70
- checks that this person may change the app's settings; the app's own roles are
71
- never enough.
179
+ Only `payments.provider`, `payments.methods` and `business.profile` can be
180
+ changed from the app, and only on behalf of a signed-in person. Pass that
181
+ person's session token. Stardeck checks that this person may change the app's
182
+ settings (for `business.profile`, that they are an admin of the organization);
183
+ the app's own roles are never enough.
72
184
 
73
185
  ```ts
74
186
  import { cookies } from "next/headers";
package/dist/index.cjs CHANGED
@@ -22,6 +22,7 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  CONTROLS: () => CONTROLS,
24
24
  CONTROL_KEYS: () => CONTROL_KEYS,
25
+ businessProfileSchema: () => businessProfileSchema,
25
26
  isControlKey: () => isControlKey,
26
27
  isWritableControl: () => isWritableControl,
27
28
  parseControls: () => parseControls,
@@ -31,6 +32,33 @@ module.exports = __toCommonJS(index_exports);
31
32
 
32
33
  // src/controls.ts
33
34
  var import_zod = require("zod");
35
+ function isIanaTimeZone(value) {
36
+ try {
37
+ new Intl.DateTimeFormat("en", { timeZone: value });
38
+ return true;
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+ var businessProfileSchema = import_zod.z.object({
44
+ /** The kind of business: a business preset id the app defines (`restaurant`, `spa`, `general`…). */
45
+ kind: import_zod.z.string().regex(/^[a-z][a-z0-9-]{0,39}$/, "a business kind is a lowercase preset id"),
46
+ /** The company's name. Thai is null when the business has no Thai name. */
47
+ name: import_zod.z.object({
48
+ en: import_zod.z.string().trim().min(1, "the business needs a name").max(200),
49
+ th: import_zod.z.string().trim().min(1).max(200).nullable()
50
+ }),
51
+ /** Head-office address as one block of text; null until someone enters it. */
52
+ headOfficeAddress: import_zod.z.string().trim().min(1).max(1e3).nullable(),
53
+ /** Tax ID; null for a business with none (under the VAT threshold, say). */
54
+ taxId: import_zod.z.string().trim().min(1).max(40).nullable(),
55
+ /** ISO 3166-1 alpha-2. */
56
+ country: import_zod.z.string().regex(/^[A-Z]{2}$/, "country is an ISO 3166-1 alpha-2 code"),
57
+ /** ISO 4217. */
58
+ currency: import_zod.z.string().regex(/^[A-Z]{3}$/, "currency is an ISO 4217 code"),
59
+ /** IANA time zone. */
60
+ timezone: import_zod.z.string().refine(isIanaTimeZone, "timezone is not an IANA time zone")
61
+ });
34
62
  var CONTROLS = {
35
63
  "flag.paymentsSetupStep": { schema: import_zod.z.boolean(), appWrite: false },
36
64
  "plan.locations": {
@@ -52,7 +80,29 @@ var CONTROLS = {
52
80
  "payments.methods": {
53
81
  schema: import_zod.z.object({ card: import_zod.z.boolean(), promptPay: import_zod.z.boolean() }),
54
82
  appWrite: true
55
- }
83
+ },
84
+ "pos.pro": {
85
+ schema: import_zod.z.object({
86
+ /** POS Pro locations paid for. null = unlimited (legacy plans, internal orgs). */
87
+ locations: import_zod.z.number().int().nonnegative().nullable()
88
+ }),
89
+ appWrite: false
90
+ },
91
+ /**
92
+ * The Accounting Module edition the org pays for, each an add-on next to its plan. null means
93
+ * no Accounting plan: the app has no Accounting. `{ ok: false }` means unknown right now:
94
+ * refuse new accounting records and keep existing ones readable.
95
+ */
96
+ "plan.accountingEdition": {
97
+ schema: import_zod.z.enum(["small", "medium"]).nullable(),
98
+ appWrite: false
99
+ },
100
+ /**
101
+ * The business this app is for, owned by Stardeck. null means Stardeck has no record yet: an
102
+ * app that already has its own company details sends them up once. `{ ok: false }` means
103
+ * unknown right now: keep the company the app has.
104
+ */
105
+ "business.profile": { schema: businessProfileSchema.nullable(), appWrite: true }
56
106
  };
57
107
  var CONTROL_KEYS = Object.keys(CONTROLS);
58
108
  function isControlKey(key) {
@@ -98,6 +148,7 @@ function unavailableControls(reason) {
98
148
  0 && (module.exports = {
99
149
  CONTROLS,
100
150
  CONTROL_KEYS,
151
+ businessProfileSchema,
101
152
  isControlKey,
102
153
  isWritableControl,
103
154
  parseControls,
package/dist/index.d.cts CHANGED
@@ -1,5 +1,23 @@
1
1
  import { z } from 'zod';
2
2
 
3
+ /**
4
+ * Who the business is, as Stardeck keeps it for the org: what onboarding asks and Stardeck's
5
+ * Business settings edit. The app mirrors it into its default company and workspace settings, and
6
+ * sends an owner's edit back through this same control, so Stardeck stays the one source.
7
+ */
8
+ declare const businessProfileSchema: z.ZodObject<{
9
+ kind: z.ZodString;
10
+ name: z.ZodObject<{
11
+ en: z.ZodString;
12
+ th: z.ZodNullable<z.ZodString>;
13
+ }, z.core.$strip>;
14
+ headOfficeAddress: z.ZodNullable<z.ZodString>;
15
+ taxId: z.ZodNullable<z.ZodString>;
16
+ country: z.ZodString;
17
+ currency: z.ZodString;
18
+ timezone: z.ZodString;
19
+ }, z.core.$strip>;
20
+ type BusinessProfile = z.infer<typeof businessProfileSchema>;
3
21
  /**
4
22
  * The controls list: every value the platform owns and an app reads.
5
23
  *
@@ -48,6 +66,44 @@ declare const CONTROLS: {
48
66
  }, z.core.$strip>;
49
67
  readonly appWrite: true;
50
68
  };
69
+ readonly "pos.pro": {
70
+ readonly schema: z.ZodObject<{
71
+ locations: z.ZodNullable<z.ZodNumber>;
72
+ }, z.core.$strip>;
73
+ readonly appWrite: false;
74
+ };
75
+ /**
76
+ * The Accounting Module edition the org pays for, each an add-on next to its plan. null means
77
+ * no Accounting plan: the app has no Accounting. `{ ok: false }` means unknown right now:
78
+ * refuse new accounting records and keep existing ones readable.
79
+ */
80
+ readonly "plan.accountingEdition": {
81
+ readonly schema: z.ZodNullable<z.ZodEnum<{
82
+ small: "small";
83
+ medium: "medium";
84
+ }>>;
85
+ readonly appWrite: false;
86
+ };
87
+ /**
88
+ * The business this app is for, owned by Stardeck. null means Stardeck has no record yet: an
89
+ * app that already has its own company details sends them up once. `{ ok: false }` means
90
+ * unknown right now: keep the company the app has.
91
+ */
92
+ readonly "business.profile": {
93
+ readonly schema: z.ZodNullable<z.ZodObject<{
94
+ kind: z.ZodString;
95
+ name: z.ZodObject<{
96
+ en: z.ZodString;
97
+ th: z.ZodNullable<z.ZodString>;
98
+ }, z.core.$strip>;
99
+ headOfficeAddress: z.ZodNullable<z.ZodString>;
100
+ taxId: z.ZodNullable<z.ZodString>;
101
+ country: z.ZodString;
102
+ currency: z.ZodString;
103
+ timezone: z.ZodString;
104
+ }, z.core.$strip>>;
105
+ readonly appWrite: true;
106
+ };
51
107
  };
52
108
  type ControlKey = keyof typeof CONTROLS;
53
109
  type ControlValue<K extends ControlKey> = z.infer<(typeof CONTROLS)[K]["schema"]>;
@@ -100,4 +156,4 @@ declare function parseControls(raw: unknown): Controls;
100
156
  /** Every control unavailable for one reason (cold start with the platform down, or not configured). */
101
157
  declare function unavailableControls(reason: string): Controls;
102
158
 
103
- export { CONTROLS, CONTROL_KEYS, type ControlKey, type ControlResult, type ControlValue, type ControlWriteError, type ControlWriteErrorCode, type ControlWriteRequest, type Controls, type ControlsSyncRequest, type ControlsSyncResponse, type ControlsUsage, type WritableControlKey, isControlKey, isWritableControl, parseControls, unavailableControls };
159
+ export { type BusinessProfile, CONTROLS, CONTROL_KEYS, type ControlKey, type ControlResult, type ControlValue, type ControlWriteError, type ControlWriteErrorCode, type ControlWriteRequest, type Controls, type ControlsSyncRequest, type ControlsSyncResponse, type ControlsUsage, type WritableControlKey, businessProfileSchema, isControlKey, isWritableControl, parseControls, unavailableControls };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,23 @@
1
1
  import { z } from 'zod';
2
2
 
3
+ /**
4
+ * Who the business is, as Stardeck keeps it for the org: what onboarding asks and Stardeck's
5
+ * Business settings edit. The app mirrors it into its default company and workspace settings, and
6
+ * sends an owner's edit back through this same control, so Stardeck stays the one source.
7
+ */
8
+ declare const businessProfileSchema: z.ZodObject<{
9
+ kind: z.ZodString;
10
+ name: z.ZodObject<{
11
+ en: z.ZodString;
12
+ th: z.ZodNullable<z.ZodString>;
13
+ }, z.core.$strip>;
14
+ headOfficeAddress: z.ZodNullable<z.ZodString>;
15
+ taxId: z.ZodNullable<z.ZodString>;
16
+ country: z.ZodString;
17
+ currency: z.ZodString;
18
+ timezone: z.ZodString;
19
+ }, z.core.$strip>;
20
+ type BusinessProfile = z.infer<typeof businessProfileSchema>;
3
21
  /**
4
22
  * The controls list: every value the platform owns and an app reads.
5
23
  *
@@ -48,6 +66,44 @@ declare const CONTROLS: {
48
66
  }, z.core.$strip>;
49
67
  readonly appWrite: true;
50
68
  };
69
+ readonly "pos.pro": {
70
+ readonly schema: z.ZodObject<{
71
+ locations: z.ZodNullable<z.ZodNumber>;
72
+ }, z.core.$strip>;
73
+ readonly appWrite: false;
74
+ };
75
+ /**
76
+ * The Accounting Module edition the org pays for, each an add-on next to its plan. null means
77
+ * no Accounting plan: the app has no Accounting. `{ ok: false }` means unknown right now:
78
+ * refuse new accounting records and keep existing ones readable.
79
+ */
80
+ readonly "plan.accountingEdition": {
81
+ readonly schema: z.ZodNullable<z.ZodEnum<{
82
+ small: "small";
83
+ medium: "medium";
84
+ }>>;
85
+ readonly appWrite: false;
86
+ };
87
+ /**
88
+ * The business this app is for, owned by Stardeck. null means Stardeck has no record yet: an
89
+ * app that already has its own company details sends them up once. `{ ok: false }` means
90
+ * unknown right now: keep the company the app has.
91
+ */
92
+ readonly "business.profile": {
93
+ readonly schema: z.ZodNullable<z.ZodObject<{
94
+ kind: z.ZodString;
95
+ name: z.ZodObject<{
96
+ en: z.ZodString;
97
+ th: z.ZodNullable<z.ZodString>;
98
+ }, z.core.$strip>;
99
+ headOfficeAddress: z.ZodNullable<z.ZodString>;
100
+ taxId: z.ZodNullable<z.ZodString>;
101
+ country: z.ZodString;
102
+ currency: z.ZodString;
103
+ timezone: z.ZodString;
104
+ }, z.core.$strip>>;
105
+ readonly appWrite: true;
106
+ };
51
107
  };
52
108
  type ControlKey = keyof typeof CONTROLS;
53
109
  type ControlValue<K extends ControlKey> = z.infer<(typeof CONTROLS)[K]["schema"]>;
@@ -100,4 +156,4 @@ declare function parseControls(raw: unknown): Controls;
100
156
  /** Every control unavailable for one reason (cold start with the platform down, or not configured). */
101
157
  declare function unavailableControls(reason: string): Controls;
102
158
 
103
- export { CONTROLS, CONTROL_KEYS, type ControlKey, type ControlResult, type ControlValue, type ControlWriteError, type ControlWriteErrorCode, type ControlWriteRequest, type Controls, type ControlsSyncRequest, type ControlsSyncResponse, type ControlsUsage, type WritableControlKey, isControlKey, isWritableControl, parseControls, unavailableControls };
159
+ export { type BusinessProfile, CONTROLS, CONTROL_KEYS, type ControlKey, type ControlResult, type ControlValue, type ControlWriteError, type ControlWriteErrorCode, type ControlWriteRequest, type Controls, type ControlsSyncRequest, type ControlsSyncResponse, type ControlsUsage, type WritableControlKey, businessProfileSchema, isControlKey, isWritableControl, parseControls, unavailableControls };
package/dist/index.js CHANGED
@@ -1,5 +1,32 @@
1
1
  // src/controls.ts
2
2
  import { z } from "zod";
3
+ function isIanaTimeZone(value) {
4
+ try {
5
+ new Intl.DateTimeFormat("en", { timeZone: value });
6
+ return true;
7
+ } catch {
8
+ return false;
9
+ }
10
+ }
11
+ var businessProfileSchema = z.object({
12
+ /** The kind of business: a business preset id the app defines (`restaurant`, `spa`, `general`…). */
13
+ kind: z.string().regex(/^[a-z][a-z0-9-]{0,39}$/, "a business kind is a lowercase preset id"),
14
+ /** The company's name. Thai is null when the business has no Thai name. */
15
+ name: z.object({
16
+ en: z.string().trim().min(1, "the business needs a name").max(200),
17
+ th: z.string().trim().min(1).max(200).nullable()
18
+ }),
19
+ /** Head-office address as one block of text; null until someone enters it. */
20
+ headOfficeAddress: z.string().trim().min(1).max(1e3).nullable(),
21
+ /** Tax ID; null for a business with none (under the VAT threshold, say). */
22
+ taxId: z.string().trim().min(1).max(40).nullable(),
23
+ /** ISO 3166-1 alpha-2. */
24
+ country: z.string().regex(/^[A-Z]{2}$/, "country is an ISO 3166-1 alpha-2 code"),
25
+ /** ISO 4217. */
26
+ currency: z.string().regex(/^[A-Z]{3}$/, "currency is an ISO 4217 code"),
27
+ /** IANA time zone. */
28
+ timezone: z.string().refine(isIanaTimeZone, "timezone is not an IANA time zone")
29
+ });
3
30
  var CONTROLS = {
4
31
  "flag.paymentsSetupStep": { schema: z.boolean(), appWrite: false },
5
32
  "plan.locations": {
@@ -21,7 +48,29 @@ var CONTROLS = {
21
48
  "payments.methods": {
22
49
  schema: z.object({ card: z.boolean(), promptPay: z.boolean() }),
23
50
  appWrite: true
24
- }
51
+ },
52
+ "pos.pro": {
53
+ schema: z.object({
54
+ /** POS Pro locations paid for. null = unlimited (legacy plans, internal orgs). */
55
+ locations: z.number().int().nonnegative().nullable()
56
+ }),
57
+ appWrite: false
58
+ },
59
+ /**
60
+ * The Accounting Module edition the org pays for, each an add-on next to its plan. null means
61
+ * no Accounting plan: the app has no Accounting. `{ ok: false }` means unknown right now:
62
+ * refuse new accounting records and keep existing ones readable.
63
+ */
64
+ "plan.accountingEdition": {
65
+ schema: z.enum(["small", "medium"]).nullable(),
66
+ appWrite: false
67
+ },
68
+ /**
69
+ * The business this app is for, owned by Stardeck. null means Stardeck has no record yet: an
70
+ * app that already has its own company details sends them up once. `{ ok: false }` means
71
+ * unknown right now: keep the company the app has.
72
+ */
73
+ "business.profile": { schema: businessProfileSchema.nullable(), appWrite: true }
25
74
  };
26
75
  var CONTROL_KEYS = Object.keys(CONTROLS);
27
76
  function isControlKey(key) {
@@ -66,6 +115,7 @@ function unavailableControls(reason) {
66
115
  export {
67
116
  CONTROLS,
68
117
  CONTROL_KEYS,
118
+ businessProfileSchema,
69
119
  isControlKey,
70
120
  isWritableControl,
71
121
  parseControls,
@@ -32,6 +32,33 @@ module.exports = __toCommonJS(index_exports);
32
32
 
33
33
  // src/controls.ts
34
34
  var import_zod = require("zod");
35
+ function isIanaTimeZone(value) {
36
+ try {
37
+ new Intl.DateTimeFormat("en", { timeZone: value });
38
+ return true;
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+ var businessProfileSchema = import_zod.z.object({
44
+ /** The kind of business: a business preset id the app defines (`restaurant`, `spa`, `general`…). */
45
+ kind: import_zod.z.string().regex(/^[a-z][a-z0-9-]{0,39}$/, "a business kind is a lowercase preset id"),
46
+ /** The company's name. Thai is null when the business has no Thai name. */
47
+ name: import_zod.z.object({
48
+ en: import_zod.z.string().trim().min(1, "the business needs a name").max(200),
49
+ th: import_zod.z.string().trim().min(1).max(200).nullable()
50
+ }),
51
+ /** Head-office address as one block of text; null until someone enters it. */
52
+ headOfficeAddress: import_zod.z.string().trim().min(1).max(1e3).nullable(),
53
+ /** Tax ID; null for a business with none (under the VAT threshold, say). */
54
+ taxId: import_zod.z.string().trim().min(1).max(40).nullable(),
55
+ /** ISO 3166-1 alpha-2. */
56
+ country: import_zod.z.string().regex(/^[A-Z]{2}$/, "country is an ISO 3166-1 alpha-2 code"),
57
+ /** ISO 4217. */
58
+ currency: import_zod.z.string().regex(/^[A-Z]{3}$/, "currency is an ISO 4217 code"),
59
+ /** IANA time zone. */
60
+ timezone: import_zod.z.string().refine(isIanaTimeZone, "timezone is not an IANA time zone")
61
+ });
35
62
  var CONTROLS = {
36
63
  "flag.paymentsSetupStep": { schema: import_zod.z.boolean(), appWrite: false },
37
64
  "plan.locations": {
@@ -53,7 +80,29 @@ var CONTROLS = {
53
80
  "payments.methods": {
54
81
  schema: import_zod.z.object({ card: import_zod.z.boolean(), promptPay: import_zod.z.boolean() }),
55
82
  appWrite: true
56
- }
83
+ },
84
+ "pos.pro": {
85
+ schema: import_zod.z.object({
86
+ /** POS Pro locations paid for. null = unlimited (legacy plans, internal orgs). */
87
+ locations: import_zod.z.number().int().nonnegative().nullable()
88
+ }),
89
+ appWrite: false
90
+ },
91
+ /**
92
+ * The Accounting Module edition the org pays for, each an add-on next to its plan. null means
93
+ * no Accounting plan: the app has no Accounting. `{ ok: false }` means unknown right now:
94
+ * refuse new accounting records and keep existing ones readable.
95
+ */
96
+ "plan.accountingEdition": {
97
+ schema: import_zod.z.enum(["small", "medium"]).nullable(),
98
+ appWrite: false
99
+ },
100
+ /**
101
+ * The business this app is for, owned by Stardeck. null means Stardeck has no record yet: an
102
+ * app that already has its own company details sends them up once. `{ ok: false }` means
103
+ * unknown right now: keep the company the app has.
104
+ */
105
+ "business.profile": { schema: businessProfileSchema.nullable(), appWrite: true }
57
106
  };
58
107
  var CONTROL_KEYS = Object.keys(CONTROLS);
59
108
  function parseOne(entry, schema) {
@@ -48,6 +48,44 @@ declare const CONTROLS: {
48
48
  }, z.core.$strip>;
49
49
  readonly appWrite: true;
50
50
  };
51
+ readonly "pos.pro": {
52
+ readonly schema: z.ZodObject<{
53
+ locations: z.ZodNullable<z.ZodNumber>;
54
+ }, z.core.$strip>;
55
+ readonly appWrite: false;
56
+ };
57
+ /**
58
+ * The Accounting Module edition the org pays for, each an add-on next to its plan. null means
59
+ * no Accounting plan: the app has no Accounting. `{ ok: false }` means unknown right now:
60
+ * refuse new accounting records and keep existing ones readable.
61
+ */
62
+ readonly "plan.accountingEdition": {
63
+ readonly schema: z.ZodNullable<z.ZodEnum<{
64
+ small: "small";
65
+ medium: "medium";
66
+ }>>;
67
+ readonly appWrite: false;
68
+ };
69
+ /**
70
+ * The business this app is for, owned by Stardeck. null means Stardeck has no record yet: an
71
+ * app that already has its own company details sends them up once. `{ ok: false }` means
72
+ * unknown right now: keep the company the app has.
73
+ */
74
+ readonly "business.profile": {
75
+ readonly schema: z.ZodNullable<z.ZodObject<{
76
+ kind: z.ZodString;
77
+ name: z.ZodObject<{
78
+ en: z.ZodString;
79
+ th: z.ZodNullable<z.ZodString>;
80
+ }, z.core.$strip>;
81
+ headOfficeAddress: z.ZodNullable<z.ZodString>;
82
+ taxId: z.ZodNullable<z.ZodString>;
83
+ country: z.ZodString;
84
+ currency: z.ZodString;
85
+ timezone: z.ZodString;
86
+ }, z.core.$strip>>;
87
+ readonly appWrite: true;
88
+ };
51
89
  };
52
90
  type ControlKey = keyof typeof CONTROLS;
53
91
  type ControlValue<K extends ControlKey> = z.infer<(typeof CONTROLS)[K]["schema"]>;
@@ -48,6 +48,44 @@ declare const CONTROLS: {
48
48
  }, z.core.$strip>;
49
49
  readonly appWrite: true;
50
50
  };
51
+ readonly "pos.pro": {
52
+ readonly schema: z.ZodObject<{
53
+ locations: z.ZodNullable<z.ZodNumber>;
54
+ }, z.core.$strip>;
55
+ readonly appWrite: false;
56
+ };
57
+ /**
58
+ * The Accounting Module edition the org pays for, each an add-on next to its plan. null means
59
+ * no Accounting plan: the app has no Accounting. `{ ok: false }` means unknown right now:
60
+ * refuse new accounting records and keep existing ones readable.
61
+ */
62
+ readonly "plan.accountingEdition": {
63
+ readonly schema: z.ZodNullable<z.ZodEnum<{
64
+ small: "small";
65
+ medium: "medium";
66
+ }>>;
67
+ readonly appWrite: false;
68
+ };
69
+ /**
70
+ * The business this app is for, owned by Stardeck. null means Stardeck has no record yet: an
71
+ * app that already has its own company details sends them up once. `{ ok: false }` means
72
+ * unknown right now: keep the company the app has.
73
+ */
74
+ readonly "business.profile": {
75
+ readonly schema: z.ZodNullable<z.ZodObject<{
76
+ kind: z.ZodString;
77
+ name: z.ZodObject<{
78
+ en: z.ZodString;
79
+ th: z.ZodNullable<z.ZodString>;
80
+ }, z.core.$strip>;
81
+ headOfficeAddress: z.ZodNullable<z.ZodString>;
82
+ taxId: z.ZodNullable<z.ZodString>;
83
+ country: z.ZodString;
84
+ currency: z.ZodString;
85
+ timezone: z.ZodString;
86
+ }, z.core.$strip>>;
87
+ readonly appWrite: true;
88
+ };
51
89
  };
52
90
  type ControlKey = keyof typeof CONTROLS;
53
91
  type ControlValue<K extends ControlKey> = z.infer<(typeof CONTROLS)[K]["schema"]>;
@@ -1,5 +1,32 @@
1
1
  // src/controls.ts
2
2
  import { z } from "zod";
3
+ function isIanaTimeZone(value) {
4
+ try {
5
+ new Intl.DateTimeFormat("en", { timeZone: value });
6
+ return true;
7
+ } catch {
8
+ return false;
9
+ }
10
+ }
11
+ var businessProfileSchema = z.object({
12
+ /** The kind of business: a business preset id the app defines (`restaurant`, `spa`, `general`…). */
13
+ kind: z.string().regex(/^[a-z][a-z0-9-]{0,39}$/, "a business kind is a lowercase preset id"),
14
+ /** The company's name. Thai is null when the business has no Thai name. */
15
+ name: z.object({
16
+ en: z.string().trim().min(1, "the business needs a name").max(200),
17
+ th: z.string().trim().min(1).max(200).nullable()
18
+ }),
19
+ /** Head-office address as one block of text; null until someone enters it. */
20
+ headOfficeAddress: z.string().trim().min(1).max(1e3).nullable(),
21
+ /** Tax ID; null for a business with none (under the VAT threshold, say). */
22
+ taxId: z.string().trim().min(1).max(40).nullable(),
23
+ /** ISO 3166-1 alpha-2. */
24
+ country: z.string().regex(/^[A-Z]{2}$/, "country is an ISO 3166-1 alpha-2 code"),
25
+ /** ISO 4217. */
26
+ currency: z.string().regex(/^[A-Z]{3}$/, "currency is an ISO 4217 code"),
27
+ /** IANA time zone. */
28
+ timezone: z.string().refine(isIanaTimeZone, "timezone is not an IANA time zone")
29
+ });
3
30
  var CONTROLS = {
4
31
  "flag.paymentsSetupStep": { schema: z.boolean(), appWrite: false },
5
32
  "plan.locations": {
@@ -21,7 +48,29 @@ var CONTROLS = {
21
48
  "payments.methods": {
22
49
  schema: z.object({ card: z.boolean(), promptPay: z.boolean() }),
23
50
  appWrite: true
24
- }
51
+ },
52
+ "pos.pro": {
53
+ schema: z.object({
54
+ /** POS Pro locations paid for. null = unlimited (legacy plans, internal orgs). */
55
+ locations: z.number().int().nonnegative().nullable()
56
+ }),
57
+ appWrite: false
58
+ },
59
+ /**
60
+ * The Accounting Module edition the org pays for, each an add-on next to its plan. null means
61
+ * no Accounting plan: the app has no Accounting. `{ ok: false }` means unknown right now:
62
+ * refuse new accounting records and keep existing ones readable.
63
+ */
64
+ "plan.accountingEdition": {
65
+ schema: z.enum(["small", "medium"]).nullable(),
66
+ appWrite: false
67
+ },
68
+ /**
69
+ * The business this app is for, owned by Stardeck. null means Stardeck has no record yet: an
70
+ * app that already has its own company details sends them up once. `{ ok: false }` means
71
+ * unknown right now: keep the company the app has.
72
+ */
73
+ "business.profile": { schema: businessProfileSchema.nullable(), appWrite: true }
25
74
  };
26
75
  var CONTROL_KEYS = Object.keys(CONTROLS);
27
76
  function parseOne(entry, schema) {
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@stardeck-customer-apps/controls",
3
3
  "type": "module",
4
- "version": "0.1.0",
5
- "description": "Platform-owned app controls (feature flags, plan limits, payment provider and methods) for Stardeck customer apps: a typed list plus a cached, signed sync client",
4
+ "version": "0.4.0",
5
+ "description": "Platform-owned app controls (feature flags, plan limits, POS Pro hardware, the Accounting edition, payment provider and methods) for Stardeck customer apps: a typed list plus a cached, signed sync client",
6
6
  "main": "dist/index.cjs",
7
7
  "module": "dist/index.js",
8
8
  "types": "dist/index.d.ts",