@spinekit/promo 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,75 @@
1
+ Classytic Source-Available License (Community & Commercial), Version 1.0
2
+
3
+ Copyright (c) 2026 Classytic LLC. All rights reserved.
4
+
5
+ This software is source-available, not open source. Please read this License
6
+ before using it.
7
+
8
+ 1. DEFINITIONS
9
+ "Software" means the contents of this package and any accompanying source
10
+ code, object code, or documentation, in any form.
11
+ "Licensor" means Classytic LLC, a limited liability company organized in the
12
+ United States.
13
+ "You" means the individual or legal entity exercising rights under this
14
+ License.
15
+ "Evaluation Use" means use for evaluation, development, testing,
16
+ experimentation, or internal non-commercial purposes only.
17
+ "Production Use" means any use of the Software that is live, commercial,
18
+ revenue-generating, or customer-facing, or any use other than Evaluation Use.
19
+ "Commercial License" means a separate written agreement executed with
20
+ Licensor that grants Production Use rights.
21
+
22
+ 2. OWNERSHIP
23
+ The Software is licensed, not sold. Licensor retains all right, title, and
24
+ interest in and to the Software, including all intellectual property rights.
25
+ All rights not expressly granted in this License are reserved by Licensor.
26
+
27
+ 3. EVALUATION GRANT
28
+ Subject to Your compliance with this License, Licensor grants You a
29
+ worldwide, royalty-free, non-exclusive, non-transferable, revocable license
30
+ to download, install, run, and modify the Software solely for Evaluation Use.
31
+
32
+ 4. PRODUCTION USE REQUIRES A COMMERCIAL LICENSE
33
+ Production Use of the Software is NOT permitted under this License and
34
+ requires a Commercial License from Licensor. A Commercial License may be
35
+ granted at Licensor's discretion, with or without a fee, and may confer
36
+ perpetual, irrevocable, and transferable Production Use rights as set out in
37
+ that agreement. Contact licensing@classytic.com to obtain one.
38
+
39
+ 5. RESTRICTIONS
40
+ You may not:
41
+ (a) make any Production Use of the Software without a Commercial License;
42
+ (b) redistribute, resell, rent, sublicense, or otherwise make the Software
43
+ (in whole or in part, original or modified) available to third parties as
44
+ a stand-alone product, framework, library, or hosted/managed service that
45
+ competes with the Software or with Licensor;
46
+ (c) remove, obscure, or alter any copyright, license, or attribution notice.
47
+
48
+ 6. FEEDBACK AND CONTRIBUTIONS
49
+ Any feedback, suggestions, or contributions You provide relating to the
50
+ Software may be used by Licensor for any purpose without obligation to You.
51
+
52
+ 7. TERMINATION
53
+ This License and the Evaluation Grant terminate automatically if You breach
54
+ any term. Upon termination You must cease all use of the Software and destroy
55
+ all copies in Your possession. Rights under a Commercial License are governed
56
+ by that agreement's own terms and survive termination of this License to the
57
+ extent stated there.
58
+
59
+ 8. DISCLAIMER OF WARRANTY
60
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
61
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
62
+ FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
63
+
64
+ 9. LIMITATION OF LIABILITY
65
+ TO THE MAXIMUM EXTENT PERMITTED BY LAW, IN NO EVENT SHALL LICENSOR BE LIABLE
66
+ FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
67
+ TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE
68
+ OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
69
+
70
+ 10. GOVERNING LAW
71
+ This License is governed by the laws of the State of Delaware, United States,
72
+ without regard to its conflict-of-laws principles. (Adjust to Your LLC's state
73
+ of formation.)
74
+
75
+ For commercial licensing, contact: licensing@classytic.com
package/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # @spinekit/promo
2
+
3
+ > Promotions — programs, vouchers, rules, rewards, and cart evaluation — as composable arc resources.
4
+
5
+ Part of **Spine**, Classytic's ERP — the arc-module layer that composes the
6
+ framework-agnostic `@classytic/promo` kernel into an [`@classytic/arc`](https://www.npmjs.com/package/@classytic/arc)
7
+ application via `createApp({ modules })`.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ npm install @spinekit/promo @classytic/promo @classytic/arc @classytic/mongokit mongoose zod
13
+ ```
14
+
15
+ Kernels and the framework are **peer dependencies** — the host installs a single
16
+ copy; this module bundles nothing.
17
+
18
+ ## Usage
19
+
20
+ ```ts
21
+ import { createApp } from '@classytic/arc/factory';
22
+ import { createPromoModule } from '@spinekit/promo';
23
+ import { createPromoEngine } from '@classytic/promo';
24
+
25
+ const engine = createPromoEngine({ mongoose: connection, tenant: false });
26
+ const app = await createApp({
27
+ modules: [
28
+ createPromoModule({
29
+ engine, // BYO — carries tenancy + event transport
30
+ permissions, // granular gates per entity (see PromoPermissions)
31
+ // resolveActor, prefixes, extraRoutes as needed
32
+ }),
33
+ ],
34
+ });
35
+ ```
36
+
37
+ Mounts `/promotions/{programs,vouchers,rules,rewards,evaluate}` — adapter CRUD,
38
+ Stripe-pattern FSM actions (activate/pause/archive, cancel, commit/rollback),
39
+ code-centric voucher routes, and preview/evaluate.
40
+
41
+ **Company-wide by default** (`tenantField: false`): in a single-tenant
42
+ multi-branch deployment, a voucher generated at one branch redeems at any
43
+ other. **Wire-error contract included**: the kernel's hierarchical codes map to
44
+ stable UPPER_SNAKE codes + HTTP statuses (`PROMO_CODE_TO_WIRE`) so every host
45
+ serves the identical public contract.
46
+
47
+ The engine is exported at `fastify.arc.modules.promo` — order-placement
48
+ choreography (reserve → commit/rollback) stays host policy over that export.
49
+
50
+ ## License
51
+
52
+ MIT © Classytic
@@ -0,0 +1,35 @@
1
+ import { ErrorMapper } from "@classytic/arc/plugins";
2
+ import { FastifyReply, FastifyRequest } from "fastify";
3
+ //#region src/errors.d.ts
4
+ declare const PROMO_CODE_TO_WIRE: Record<string, string>;
5
+ /** Fallback status for legacy/unmapped codes arriving without HttpError.status. */
6
+ declare function statusForCode(code: string | undefined): number;
7
+ /** Normalise a thrown promo error into the stable wire `{ code, statusCode }`. */
8
+ declare function toWireError(err: unknown): {
9
+ code: string;
10
+ statusCode: number;
11
+ };
12
+ /**
13
+ * Wrap a raw promo route: domain errors map to the wire contract; anything
14
+ * not already an ArcError becomes one so handleRaw's envelope stays clean.
15
+ */
16
+ declare function promoRoute<T>(fn: (req: FastifyRequest, reply: FastifyReply) => Promise<T>, statusCode?: number): (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
17
+ /** Same mapping shaped for arc's declarative `actions:` handlers. */
18
+ declare function promoAction<T>(fn: (id: string, data: Record<string, unknown>, req: FastifyRequest) => Promise<T>): (id: string, data: Record<string, unknown>, req: FastifyRequest) => Promise<T>;
19
+ /**
20
+ * The kernel→wire error contract, registered on the module so EVERY path
21
+ * serves it — not only handlers wrapped in `promoRoute`/`promoAction`.
22
+ *
23
+ * A `PromoError` raised outside a wrapper (controller override, host action,
24
+ * evaluation bridge) previously reached the wire with the kernel's RAW code
25
+ * instead of the public alias in {@link PROMO_CODE_TO_WIRE}, so the same
26
+ * failure answered with two different codes depending on the path that raised
27
+ * it. Registering the base class fixes that everywhere, and a host can no
28
+ * longer mount promo and forget to wire the table.
29
+ *
30
+ * Arc dispatches by `instanceof` and every kernel error extends `PromoError`,
31
+ * so ONE mapper covers the whole family — including classes added later.
32
+ */
33
+ declare const promoErrorMappers: ErrorMapper[];
34
+ //#endregion
35
+ export { PROMO_CODE_TO_WIRE, promoAction, promoErrorMappers, promoRoute, statusForCode, toWireError };
@@ -0,0 +1,121 @@
1
+ import { PromoError } from "@classytic/promo";
2
+ import { ArcError, defineErrorMapper, handleRaw } from "@classytic/arc/utils";
3
+ //#region src/errors.ts
4
+ /**
5
+ * Promo kernel ↔ wire error contract.
6
+ *
7
+ * `@classytic/promo` 0.4+ throws hierarchical lowercase codes
8
+ * (`promo.<entity>.<problem>`) carrying an authoritative HttpError `status`.
9
+ * The public wire contract (SDK + clients) discriminates on stable
10
+ * UPPER_SNAKE codes — this table is that mapping, owned by the MODULE so
11
+ * every host serves the identical contract (lifted at parity from be-prod).
12
+ */
13
+ const PROMO_CODE_TO_WIRE = {
14
+ "promo.program.not_found": "PROGRAM_NOT_FOUND",
15
+ "promo.voucher.not_found": "VOUCHER_NOT_FOUND",
16
+ "promo.evaluation.not_found": "EVALUATION_NOT_FOUND",
17
+ "promo.rule.not_found": "RULE_NOT_FOUND",
18
+ "promo.reward.not_found": "REWARD_NOT_FOUND",
19
+ "promo.program.invalid_transition": "INVALID_TRANSITION",
20
+ "promo.voucher.expired": "VOUCHER_EXPIRED",
21
+ "promo.voucher.exhausted": "VOUCHER_EXHAUSTED",
22
+ "promo.gift_card.insufficient_balance": "INSUFFICIENT_BALANCE",
23
+ "promo.gift_card.exhausted": "INSUFFICIENT_BALANCE",
24
+ "promo.validation.invalid_input": "VALIDATION_ERROR",
25
+ "promo.voucher.duplicate_redemption": "DUPLICATE_REDEMPTION",
26
+ "promo.voucher.duplicate_code": "DUPLICATE_CODE",
27
+ "promo.program.usage_cap_exceeded": "PROGRAM_USAGE_CAP_EXCEEDED",
28
+ "promo.evaluation.cart_hash_mismatch": "CART_HASH_MISMATCH",
29
+ "promo.tenant.missing_context": "TENANT_ISOLATION",
30
+ "promo.concurrency.write_conflict": "WRITE_CONFLICT",
31
+ "promo.engine.model_collision": "MODEL_COLLISION"
32
+ };
33
+ /** Fallback status for legacy/unmapped codes arriving without HttpError.status. */
34
+ function statusForCode(code) {
35
+ switch (code) {
36
+ case "PROGRAM_NOT_FOUND":
37
+ case "VOUCHER_NOT_FOUND":
38
+ case "EVALUATION_NOT_FOUND":
39
+ case "RULE_NOT_FOUND":
40
+ case "REWARD_NOT_FOUND": return 404;
41
+ case "INVALID_TRANSITION": return 422;
42
+ case "VOUCHER_EXPIRED":
43
+ case "VOUCHER_EXHAUSTED": return 410;
44
+ case "DUPLICATE_REDEMPTION":
45
+ case "DUPLICATE_CODE":
46
+ case "CART_HASH_MISMATCH":
47
+ case "PROGRAM_USAGE_CAP_EXCEEDED":
48
+ case "WRITE_CONFLICT": return 409;
49
+ case "TENANT_ISOLATION": return 403;
50
+ default: return 400;
51
+ }
52
+ }
53
+ /** Normalise a thrown promo error into the stable wire `{ code, statusCode }`. */
54
+ function toWireError(err) {
55
+ const e = err;
56
+ const wireCode = (e.code && PROMO_CODE_TO_WIRE[e.code]) ?? e.code ?? "PROMO_ERROR";
57
+ return {
58
+ code: wireCode,
59
+ statusCode: e.status ?? e.statusCode ?? statusForCode(wireCode)
60
+ };
61
+ }
62
+ /**
63
+ * Wrap a raw promo route: domain errors map to the wire contract; anything
64
+ * not already an ArcError becomes one so handleRaw's envelope stays clean.
65
+ */
66
+ function promoRoute(fn, statusCode = 200) {
67
+ return handleRaw(async (req, reply) => {
68
+ try {
69
+ return await fn(req, reply);
70
+ } catch (err) {
71
+ if (err instanceof ArcError) throw err;
72
+ const { code, statusCode: status } = toWireError(err);
73
+ throw new ArcError(err.message, {
74
+ code,
75
+ statusCode: status
76
+ });
77
+ }
78
+ }, statusCode);
79
+ }
80
+ /** Same mapping shaped for arc's declarative `actions:` handlers. */
81
+ function promoAction(fn) {
82
+ return async (id, data, req) => {
83
+ try {
84
+ return await fn(id, data, req);
85
+ } catch (err) {
86
+ if (err instanceof ArcError) throw err;
87
+ const { code, statusCode } = toWireError(err);
88
+ throw new ArcError(err.message, {
89
+ code,
90
+ statusCode
91
+ });
92
+ }
93
+ };
94
+ }
95
+ /**
96
+ * The kernel→wire error contract, registered on the module so EVERY path
97
+ * serves it — not only handlers wrapped in `promoRoute`/`promoAction`.
98
+ *
99
+ * A `PromoError` raised outside a wrapper (controller override, host action,
100
+ * evaluation bridge) previously reached the wire with the kernel's RAW code
101
+ * instead of the public alias in {@link PROMO_CODE_TO_WIRE}, so the same
102
+ * failure answered with two different codes depending on the path that raised
103
+ * it. Registering the base class fixes that everywhere, and a host can no
104
+ * longer mount promo and forget to wire the table.
105
+ *
106
+ * Arc dispatches by `instanceof` and every kernel error extends `PromoError`,
107
+ * so ONE mapper covers the whole family — including classes added later.
108
+ */
109
+ const promoErrorMappers = [defineErrorMapper({
110
+ type: PromoError,
111
+ toResponse: (error) => {
112
+ const { code, statusCode } = toWireError(error);
113
+ return {
114
+ status: statusCode,
115
+ code,
116
+ message: error.message
117
+ };
118
+ }
119
+ })];
120
+ //#endregion
121
+ export { PROMO_CODE_TO_WIRE, promoAction, promoErrorMappers, promoRoute, statusForCode, toWireError };
@@ -0,0 +1,6 @@
1
+ import { a as PromoPermissions, i as PromoModuleDeps, n as PromoActorCtx, r as PromoEngineLike, t as CrudGates } from "./types-D07igQha.mjs";
2
+ import { PromoFactoryCtx } from "./resources.mjs";
3
+ //#region src/module.d.ts
4
+ declare function createPromoModule<TEngine extends PromoEngineLike>(deps: PromoModuleDeps<TEngine>): import("@classytic/arc/factory").ArcModule<unknown>;
5
+ //#endregion
6
+ export { type CrudGates, type PromoActorCtx, type PromoEngineLike, type PromoFactoryCtx, type PromoModuleDeps, type PromoPermissions, createPromoModule };
package/dist/index.mjs ADDED
@@ -0,0 +1,76 @@
1
+ import { promoErrorMappers } from "./errors.mjs";
2
+ import { buildFactoryCtx, createEvaluationResource, createProgramResource, createRewardResource, createRuleResource, createVoucherResource } from "./resources.mjs";
3
+ import { definePromo } from "@classytic/promo";
4
+ import { defineEngineModule } from "@spinekit/kit/engine-module";
5
+ //#region src/module.ts
6
+ /**
7
+ * `createPromoModule` — @spinekit/promo's single composition surface.
8
+ *
9
+ * ```ts
10
+ * const promoModule = createPromoModule({
11
+ * engine: ensurePromoEngine(), // BYO — carries tenancy + transport
12
+ * permissions: appPermissions.promotions,
13
+ * });
14
+ * createApp({ modules: [promoModule] });
15
+ * ```
16
+ *
17
+ * Bootstrap RETURNS the engine → recorded at `fastify.arc.modules.promo`
18
+ * (layer-2 module export) for cross-module wiring (order placement's
19
+ * reserve/commit/rollback choreography reads it — that orchestration itself
20
+ * stays HOST policy; see be-prod's promo-placement.ts).
21
+ *
22
+ * Composed over `defineEngineModule` (`@spinekit/kit/engine-module`) — the ONE
23
+ * primitive for mounting a kernel engine as an arc module. This file used to
24
+ * spell the four-piece dance (`deferredEngine` + `defineSpineModule` +
25
+ * bootstrap/closeOwned threading) by hand; the primitive owns all of it —
26
+ * BYO-vs-owned teardown, shared-slot fill/clear, in-flight-boot race guard,
27
+ * and `owns` derivation from the resources arm — so only the promo-specific
28
+ * pieces remain here.
29
+ */
30
+ function createPromoModule(deps) {
31
+ const { module } = defineEngineModule({
32
+ name: "promo",
33
+ /**
34
+ * Module-OWNED when the host supplies `connection` + shape/runtime; BYO when it supplies
35
+ * `engine`. Both are accepted, and the difference is TEARDOWN: the primitive closes only what
36
+ * it allocated, so only the owned form can be reclaimed here. The BYO arm stays for hosts
37
+ * still mid-migration. A host passing a THUNK (`() => ensurePromoEngine()`) allocates nothing
38
+ * at compose — the five promo models register at `bootstrap` (post-connect), never while the
39
+ * graph is composed.
40
+ */
41
+ ...deps.engine !== void 0 ? { supplied: deps.engine } : {},
42
+ /**
43
+ * The host's slot, so a host can READ the engine (be-prod's promo.port.ts)
44
+ * while the module keeps ownership — filled at bootstrap, emptied at teardown.
45
+ */
46
+ ...deps.slot !== void 0 ? { slot: deps.slot } : {},
47
+ boot: () => {
48
+ const { connection, engineShape, engineRuntime } = deps;
49
+ if (!connection || !engineShape || !engineRuntime) throw new Error("createPromoModule needs either `engine` (BYO) or `connection` + `engineShape` + `engineRuntime` (module-owned). Supplying neither would allocate nothing and every promo route would read an empty slot at the first request.");
50
+ return definePromo(engineShape()).bind(connection, engineRuntime());
51
+ },
52
+ resources: (engine) => {
53
+ const fx = buildFactoryCtx(deps, engine);
54
+ return [
55
+ createProgramResource(fx),
56
+ createVoucherResource(fx),
57
+ createRuleResource(fx),
58
+ createRewardResource(fx),
59
+ createEvaluationResource(fx)
60
+ ];
61
+ },
62
+ extend: () => ({
63
+ errorMappers: promoErrorMappers,
64
+ /**
65
+ * Host teardown COMPOSES with the owned engine close — `defineSpineModule`
66
+ * runs the host's `onClose` FIRST (it may drain work that still needs the
67
+ * engine), then the owned close. Same ordering the pre-migration config
68
+ * spread produced; nothing is silently replaced.
69
+ */
70
+ ...deps.onClose !== void 0 ? { onClose: deps.onClose } : {}
71
+ })
72
+ });
73
+ return module;
74
+ }
75
+ //#endregion
76
+ export { createPromoModule };
@@ -0,0 +1,147 @@
1
+ import { r as PromoEngineLike } from "../types-D07igQha.mjs";
2
+ import { EngineRef } from "@spinekit/kit/engine-slot";
3
+ import { StructuredLogger } from "@classytic/primitives/context";
4
+ //#region src/placement/promo-placement.d.ts
5
+ /** Canonical line item shape for the promo engine. Matches `EvaluateInput.items`. */
6
+ interface PromoLineItem {
7
+ productId: string;
8
+ sku: string;
9
+ quantity: number;
10
+ /** Minor units (e.g. paisa for BDT). */
11
+ unitPrice: number;
12
+ lineTotal: number;
13
+ }
14
+ interface ReservePromoInput {
15
+ /** Live engine or a thunk — resolved per call, never at module load. */
16
+ engine: EngineRef<PromoEngineLike>;
17
+ /** Uppercased promo codes submitted by the client. */
18
+ codes: string[] | undefined;
19
+ /** Order lines AFTER server-side catalog resolution — NOT client-claimed. */
20
+ lines: PromoLineItem[];
21
+ /** Subtotal in minor units, computed from `lines`. */
22
+ subtotal: number;
23
+ /** Optional customer ID — enables per-customer usage caps. */
24
+ customerId?: string;
25
+ /** Optional customer tags — enables tag-scoped programs. */
26
+ customerTags?: string[];
27
+ /** Actor performing the placement (user, cashier, or 'system'). */
28
+ actorId: string;
29
+ /** Branch organization ID (BA org) — carried for audit only; promos are company-wide. */
30
+ organizationId?: string;
31
+ /**
32
+ * Logger for structured error + audit reporting.
33
+ *
34
+ * Typed as the KERNEL's `StructuredLogger` rather than a local union. This
35
+ * declared `FastifyBaseLogger | Pick<Console, …>` on the assumption that the
36
+ * two were interchangeable; when `@classytic/promo` narrowed its own logger
37
+ * contract the assumption stopped holding, and the port that bridges them
38
+ * could no longer pass what it receives. Deriving from the callee's type is
39
+ * what keeps that from happening again — a hand-written structural copy
40
+ * stops matching silently.
41
+ */
42
+ logger?: StructuredLogger;
43
+ }
44
+ interface PromoReservation {
45
+ /**
46
+ * Evaluation ID returned by the engine when codes were supplied and
47
+ * matched a program. `undefined` when there are no codes or when every
48
+ * code was rejected (in which case the placement proceeds without
49
+ * discount — rejectedCodes can be surfaced back to the client).
50
+ */
51
+ evaluationId: string | undefined;
52
+ /** Total discount the engine computed, in minor units. */
53
+ totalDiscount: number;
54
+ /** Codes the engine actually applied. */
55
+ appliedCodes: string[];
56
+ /** Codes the engine rejected, with reason — useful for post-placement UX. */
57
+ rejectedCodes: Array<{
58
+ code: string;
59
+ reason: string;
60
+ }>;
61
+ }
62
+ interface PromoCommitResult {
63
+ /** True when an evaluation existed AND commit succeeded. */
64
+ committed: boolean;
65
+ /** True when there was no evaluation to commit (no codes / all rejected). */
66
+ skipped: boolean;
67
+ /** Engine error message if commit failed. Reservation is already rolled back. */
68
+ error?: string;
69
+ /** The discount locked to the order, in minor units. */
70
+ totalDiscount?: number;
71
+ /** Codes the engine applied — echoed for client display. */
72
+ appliedCodes?: string[];
73
+ /** Codes the engine rejected — echoed for client display. */
74
+ rejectedCodes?: Array<{
75
+ code: string;
76
+ reason: string;
77
+ }>;
78
+ }
79
+ /** Canonical line builder for placement pipelines — see `placement.service.ts`. */
80
+ declare function buildPromoLines(resolvedLines: Array<{
81
+ skuRef: string;
82
+ quantity: number;
83
+ snapshot: {
84
+ productId?: string;
85
+ unitPrice?: number;
86
+ } | undefined;
87
+ }>): PromoLineItem[];
88
+ /** Subtotal from canonical lines — always drives the engine input. */
89
+ declare function computePromoSubtotal(lines: PromoLineItem[]): number;
90
+ /**
91
+ * Reserve a promo evaluation for an order-in-flight.
92
+ *
93
+ * Returns an empty reservation (`evaluationId: undefined`) when:
94
+ * - The client submitted no codes.
95
+ * - The client submitted codes but every one was rejected.
96
+ *
97
+ * In both cases `rejectedCodes` still lists what failed so the caller can
98
+ * echo it back to the client without any additional engine round-trip.
99
+ */
100
+ declare function reservePromo(input: ReservePromoInput): Promise<PromoReservation>;
101
+ /**
102
+ * Commit a reserved evaluation to an order. Idempotent for empty
103
+ * reservations. On commit failure (voucher exhausted between evaluate and
104
+ * commit, engine error), automatically rolls back the reservation so no
105
+ * drift is left behind.
106
+ */
107
+ declare function commitPromo(reservation: PromoReservation, orderId: string, input: {
108
+ engine: EngineRef<PromoEngineLike>;
109
+ actorId: string;
110
+ organizationId?: string;
111
+ logger?: ReservePromoInput['logger'];
112
+ }): Promise<PromoCommitResult>;
113
+ /**
114
+ * Release a reservation. Call when the surrounding pipeline fails AFTER
115
+ * `reservePromo` but BEFORE `commitPromo` (e.g. order insert throws).
116
+ * Idempotent — safe to call even if the reservation is already gone.
117
+ */
118
+ declare function rollbackPromo(reservation: PromoReservation, input: {
119
+ engine: EngineRef<PromoEngineLike>;
120
+ actorId: string;
121
+ organizationId?: string;
122
+ logger?: ReservePromoInput['logger'];
123
+ }): Promise<void>;
124
+ /**
125
+ * Bind the placement verbs to one engine.
126
+ *
127
+ * The functions above stay individually exported and engine-parameterised so they remain
128
+ * directly testable; this is the convenience a composer wants — bind once, call three
129
+ * times, no chance of two call sites disagreeing about which engine they meant.
130
+ */
131
+ declare function createPromoPlacement(deps: {
132
+ engine: EngineRef<PromoEngineLike>;
133
+ }): {
134
+ reservePromo: (input: Omit<ReservePromoInput, 'engine'>) => Promise<PromoReservation>;
135
+ commitPromo: (reservation: PromoReservation, orderId: string, input: {
136
+ actorId: string;
137
+ organizationId?: string;
138
+ logger?: ReservePromoInput['logger'];
139
+ }) => Promise<PromoCommitResult>;
140
+ rollbackPromo: (reservation: PromoReservation, input: {
141
+ actorId: string;
142
+ organizationId?: string;
143
+ logger?: ReservePromoInput['logger'];
144
+ }) => Promise<void>;
145
+ };
146
+ //#endregion
147
+ export { PromoCommitResult, PromoLineItem, PromoReservation, ReservePromoInput, buildPromoLines, commitPromo, computePromoSubtotal, createPromoPlacement, reservePromo, rollbackPromo };
@@ -0,0 +1,198 @@
1
+ import { resolveEngineRef } from "@spinekit/kit/engine-slot";
2
+ //#region src/placement/promo-placement.ts
3
+ const LOG_PREFIX = "[promo-placement]";
4
+ const SYSTEM_ACTOR_ID = "system";
5
+ const resolveActorId = (actorId) => actorId && actorId.trim().length > 0 ? actorId : SYSTEM_ACTOR_ID;
6
+ /**
7
+ * Build the promo engine context.
8
+ *
9
+ * Promos are company-wide (see `promo.resources.ts` and the `tenant: false`
10
+ * engine boot). Passing `organizationId` would make the repos inject a
11
+ * per-branch filter on reads — programs seeded at company level would
12
+ * become invisible to branch-scoped calls.
13
+ *
14
+ * The `organizationId` arriving into these helpers is carried only for
15
+ * audit logging (which branch triggered the commit), never forwarded to
16
+ * the engine itself.
17
+ */
18
+ const buildCtx = (input) => ({ actorRef: input.actorId });
19
+ /** Canonical line builder for placement pipelines — see `placement.service.ts`. */
20
+ function buildPromoLines(resolvedLines) {
21
+ return resolvedLines.map((line) => {
22
+ const unitPrice = line.snapshot?.unitPrice ?? 0;
23
+ return {
24
+ productId: line.snapshot?.productId ?? line.skuRef,
25
+ sku: line.skuRef,
26
+ quantity: line.quantity,
27
+ unitPrice,
28
+ lineTotal: unitPrice * line.quantity
29
+ };
30
+ });
31
+ }
32
+ /** Subtotal from canonical lines — always drives the engine input. */
33
+ function computePromoSubtotal(lines) {
34
+ return lines.reduce((sum, l) => sum + l.lineTotal, 0);
35
+ }
36
+ /**
37
+ * Reserve a promo evaluation for an order-in-flight.
38
+ *
39
+ * Returns an empty reservation (`evaluationId: undefined`) when:
40
+ * - The client submitted no codes.
41
+ * - The client submitted codes but every one was rejected.
42
+ *
43
+ * In both cases `rejectedCodes` still lists what failed so the caller can
44
+ * echo it back to the client without any additional engine round-trip.
45
+ */
46
+ async function reservePromo(input) {
47
+ const codes = (input.codes ?? []).map((c) => c.toUpperCase()).filter(Boolean);
48
+ if (codes.length === 0) return {
49
+ evaluationId: void 0,
50
+ totalDiscount: 0,
51
+ appliedCodes: [],
52
+ rejectedCodes: []
53
+ };
54
+ const actorId = resolveActorId(input.actorId);
55
+ const ctx = buildCtx({ actorId });
56
+ try {
57
+ const result = await (await resolveEngineRef(input.engine)).services.evaluation.evaluate({
58
+ items: input.lines,
59
+ subtotal: input.subtotal,
60
+ codes,
61
+ customerId: input.customerId,
62
+ customerTags: input.customerTags
63
+ }, ctx);
64
+ return {
65
+ evaluationId: result.appliedCodes.length > 0 && result.totalDiscount > 0 ? result.evaluationId : void 0,
66
+ totalDiscount: result.totalDiscount,
67
+ appliedCodes: result.appliedCodes,
68
+ rejectedCodes: result.rejectedCodes
69
+ };
70
+ } catch (err) {
71
+ const message = err instanceof Error ? err.message : String(err);
72
+ input.logger?.warn?.({
73
+ err,
74
+ codes,
75
+ actorId,
76
+ organizationId: input.organizationId
77
+ }, `${LOG_PREFIX} reserve failed — continuing placement without discount`);
78
+ return {
79
+ evaluationId: void 0,
80
+ totalDiscount: 0,
81
+ appliedCodes: [],
82
+ rejectedCodes: codes.map((code) => ({
83
+ code,
84
+ reason: `Engine error: ${message}`
85
+ }))
86
+ };
87
+ }
88
+ }
89
+ /**
90
+ * Commit a reserved evaluation to an order. Idempotent for empty
91
+ * reservations. On commit failure (voucher exhausted between evaluate and
92
+ * commit, engine error), automatically rolls back the reservation so no
93
+ * drift is left behind.
94
+ */
95
+ async function commitPromo(reservation, orderId, input) {
96
+ if (!reservation.evaluationId) return {
97
+ committed: false,
98
+ skipped: true,
99
+ totalDiscount: 0,
100
+ appliedCodes: reservation.appliedCodes,
101
+ rejectedCodes: reservation.rejectedCodes
102
+ };
103
+ const actorId = resolveActorId(input.actorId);
104
+ const ctx = buildCtx({ actorId });
105
+ const evaluationId = reservation.evaluationId;
106
+ try {
107
+ await (await resolveEngineRef(input.engine)).services.evaluation.commit(evaluationId, orderId, ctx);
108
+ input.logger?.info?.({
109
+ audit: true,
110
+ op: "promo.evaluation.commit",
111
+ evaluationId,
112
+ orderId,
113
+ actorId,
114
+ organizationId: input.organizationId,
115
+ totalDiscount: reservation.totalDiscount
116
+ }, `${LOG_PREFIX} committed`);
117
+ return {
118
+ committed: true,
119
+ skipped: false,
120
+ totalDiscount: reservation.totalDiscount,
121
+ appliedCodes: reservation.appliedCodes,
122
+ rejectedCodes: reservation.rejectedCodes
123
+ };
124
+ } catch (err) {
125
+ const message = err instanceof Error ? err.message : String(err);
126
+ input.logger?.error?.({
127
+ err,
128
+ orderId,
129
+ evaluationId,
130
+ actorId,
131
+ organizationId: input.organizationId
132
+ }, `${LOG_PREFIX} commit failed — rolling back reservation`);
133
+ await rollbackPromo(reservation, {
134
+ ...input,
135
+ actorId
136
+ }).catch(() => {});
137
+ return {
138
+ committed: false,
139
+ skipped: false,
140
+ error: message,
141
+ totalDiscount: 0,
142
+ appliedCodes: reservation.appliedCodes,
143
+ rejectedCodes: reservation.rejectedCodes
144
+ };
145
+ }
146
+ }
147
+ /**
148
+ * Release a reservation. Call when the surrounding pipeline fails AFTER
149
+ * `reservePromo` but BEFORE `commitPromo` (e.g. order insert throws).
150
+ * Idempotent — safe to call even if the reservation is already gone.
151
+ */
152
+ async function rollbackPromo(reservation, input) {
153
+ if (!reservation.evaluationId) return;
154
+ const actorId = resolveActorId(input.actorId);
155
+ const ctx = buildCtx({ actorId });
156
+ try {
157
+ await (await resolveEngineRef(input.engine)).services.evaluation.rollback(reservation.evaluationId, ctx);
158
+ input.logger?.info?.({
159
+ audit: true,
160
+ op: "promo.evaluation.rollback",
161
+ evaluationId: reservation.evaluationId,
162
+ actorId,
163
+ organizationId: input.organizationId
164
+ }, `${LOG_PREFIX} rolled back`);
165
+ } catch (err) {
166
+ input.logger?.warn?.({
167
+ err,
168
+ evaluationId: reservation.evaluationId,
169
+ actorId,
170
+ organizationId: input.organizationId
171
+ }, `${LOG_PREFIX} rollback failed — reservation will expire via TTL`);
172
+ }
173
+ }
174
+ /**
175
+ * Bind the placement verbs to one engine.
176
+ *
177
+ * The functions above stay individually exported and engine-parameterised so they remain
178
+ * directly testable; this is the convenience a composer wants — bind once, call three
179
+ * times, no chance of two call sites disagreeing about which engine they meant.
180
+ */
181
+ function createPromoPlacement(deps) {
182
+ return {
183
+ reservePromo: (input) => reservePromo({
184
+ ...input,
185
+ engine: deps.engine
186
+ }),
187
+ commitPromo: (reservation, orderId, input) => commitPromo(reservation, orderId, {
188
+ ...input,
189
+ engine: deps.engine
190
+ }),
191
+ rollbackPromo: (reservation, input) => rollbackPromo(reservation, {
192
+ ...input,
193
+ engine: deps.engine
194
+ })
195
+ };
196
+ }
197
+ //#endregion
198
+ export { buildPromoLines, commitPromo, computePromoSubtotal, createPromoPlacement, reservePromo, rollbackPromo };
@@ -0,0 +1,11 @@
1
+ import { r as PromoEngineLike } from "../types-D07igQha.mjs";
2
+ import { PromoPort } from "@classytic/promo";
3
+ import { EngineRef } from "@spinekit/kit/engine-slot";
4
+ //#region src/placement/promo.port.d.ts
5
+ /** Build the port for one engine. A factory, not a frozen singleton — the singleton form
6
+ * forced the placement verbs to resolve a module-global engine. */
7
+ declare function createPromoPort(deps: {
8
+ engine: EngineRef<PromoEngineLike>;
9
+ }): PromoPort;
10
+ //#endregion
11
+ export { createPromoPort };
@@ -0,0 +1,31 @@
1
+ import { createPromoPlacement } from "./promo-placement.mjs";
2
+ //#region src/placement/promo.port.ts
3
+ /** Build the port for one engine. A factory, not a frozen singleton — the singleton form
4
+ * forced the placement verbs to resolve a module-global engine. */
5
+ function createPromoPort(deps) {
6
+ const placement = createPromoPlacement(deps);
7
+ const UNATTRIBUTED_ACTOR = "unattributed";
8
+ return Object.freeze({
9
+ reserve: (input) => placement.reservePromo({
10
+ lines: input.lines,
11
+ subtotal: input.subtotal,
12
+ codes: input.codes ?? [],
13
+ ...input.customerId !== void 0 ? { customerId: input.customerId } : {},
14
+ actorId: input.actorId === void 0 ? UNATTRIBUTED_ACTOR : String(input.actorId),
15
+ ...input.organizationId !== void 0 ? { organizationId: String(input.organizationId) } : {},
16
+ ...input.logger !== void 0 ? { logger: input.logger } : {}
17
+ }),
18
+ commit: (reservation, orderId, scope) => placement.commitPromo(reservation, orderId, {
19
+ actorId: scope.actorId === void 0 ? UNATTRIBUTED_ACTOR : String(scope.actorId),
20
+ ...scope.organizationId !== void 0 ? { organizationId: String(scope.organizationId) } : {},
21
+ ...scope.logger !== void 0 ? { logger: scope.logger } : {}
22
+ }),
23
+ rollback: (reservation, scope) => placement.rollbackPromo(reservation, {
24
+ actorId: scope.actorId === void 0 ? UNATTRIBUTED_ACTOR : String(scope.actorId),
25
+ ...scope.organizationId !== void 0 ? { organizationId: String(scope.organizationId) } : {},
26
+ ...scope.logger !== void 0 ? { logger: scope.logger } : {}
27
+ })
28
+ });
29
+ }
30
+ //#endregion
31
+ export { createPromoPort };
@@ -0,0 +1,38 @@
1
+ import { i as PromoModuleDeps, n as PromoActorCtx, r as PromoEngineLike } from "./types-D07igQha.mjs";
2
+ import { PromoModels, PromoRepositories, PromoServices } from "@classytic/promo";
3
+ import { z } from "zod";
4
+ import { FastifyRequest } from "fastify";
5
+ //#region src/resources.d.ts
6
+ /** Internal wiring bundle shared by the five factories. */
7
+ interface PromoFactoryCtx {
8
+ deps: PromoModuleDeps;
9
+ engineModels: PromoModels;
10
+ engineRepos: PromoRepositories;
11
+ engineServices: PromoServices;
12
+ actor: (req: FastifyRequest) => PromoActorCtx;
13
+ }
14
+ declare function buildFactoryCtx(deps: PromoModuleDeps, engine: PromoEngineLike): PromoFactoryCtx;
15
+ declare function createProgramResource(fx: PromoFactoryCtx): import("@classytic/arc").ResourceDefinition<unknown>;
16
+ declare function createVoucherResource(fx: PromoFactoryCtx): import("@classytic/arc").ResourceDefinition<unknown>;
17
+ declare function createRuleResource(fx: PromoFactoryCtx): import("@classytic/arc").ResourceDefinition<unknown>;
18
+ declare function createRewardResource(fx: PromoFactoryCtx): import("@classytic/arc").ResourceDefinition<unknown>;
19
+ declare function createEvaluationResource(fx: PromoFactoryCtx): import("@classytic/arc").ResourceDefinition<unknown> & {
20
+ readonly actions: {
21
+ commit: {
22
+ handler: (id: string, data: Record<string, unknown>, req: FastifyRequest) => Promise<unknown>;
23
+ permissions: import("@spinekit/kit/permissions").PermissionGate;
24
+ schema: z.ZodObject<{
25
+ orderId: z.ZodString;
26
+ cartHash: z.ZodOptional<z.ZodString>;
27
+ }, z.core.$strip>;
28
+ };
29
+ rollback: {
30
+ handler: (id: string, data: Record<string, unknown>, req: FastifyRequest) => Promise<{
31
+ message: string;
32
+ }>;
33
+ permissions: import("@spinekit/kit/permissions").PermissionGate;
34
+ };
35
+ };
36
+ };
37
+ //#endregion
38
+ export { PromoFactoryCtx, buildFactoryCtx, createEvaluationResource, createProgramResource, createRewardResource, createRuleResource, createVoucherResource };
@@ -0,0 +1,266 @@
1
+ import { promoAction, promoRoute } from "./errors.mjs";
2
+ import { ArcError } from "@classytic/arc/utils";
3
+ import { defineResource, mergeResourceConfig } from "@classytic/arc";
4
+ import { getUserId } from "@classytic/arc/scope";
5
+ import { QueryParser } from "@classytic/mongokit";
6
+ import { createMongooseAdapter } from "@classytic/mongokit/adapter";
7
+ import { z } from "zod";
8
+ //#region src/resources.ts
9
+ /**
10
+ * The five promo resource factories — lifted at wire parity from be-prod's
11
+ * promotions fork. Program & Voucher: adapter CRUD + FSM actions (Stripe
12
+ * pattern) + code-centric routes. Rule & Reward: first-class top-level CRUD
13
+ * filtered by `?programId=`. Evaluation: pure service orchestration (no
14
+ * model) — id-scoped commit/rollback actions + preview/evaluate routes.
15
+ */
16
+ function defaultActor(req) {
17
+ return { actorRef: getUserId(req.scope) ?? "anonymous" };
18
+ }
19
+ function buildFactoryCtx(deps, engine) {
20
+ return {
21
+ deps,
22
+ engineModels: engine.models,
23
+ engineRepos: engine.repositories,
24
+ engineServices: engine.services,
25
+ actor: deps.resolveActor ?? defaultActor
26
+ };
27
+ }
28
+ function createProgramResource(fx) {
29
+ const { deps, engineModels, engineRepos, actor } = fx;
30
+ const gates = deps.permissions.programs;
31
+ const programRepo = engineRepos.program;
32
+ const ruleRepo = engineRepos.rule;
33
+ const rewardRepo = engineRepos.reward;
34
+ return defineResource(mergeResourceConfig({
35
+ name: "promo-program",
36
+ displayName: "Promo Programs",
37
+ tag: "Promotions",
38
+ prefix: deps.prefixes?.program ?? "/promotions/programs",
39
+ tenantField: false,
40
+ adapter: createMongooseAdapter(engineModels.Program, engineRepos.program),
41
+ permissions: {
42
+ list: gates.list,
43
+ get: gates.get,
44
+ create: gates.create,
45
+ update: gates.update,
46
+ delete: gates.delete
47
+ },
48
+ actions: {
49
+ activate: {
50
+ handler: promoAction(async (id, _d, req) => programRepo.activate?.(id, actor(req))),
51
+ permissions: gates.transition
52
+ },
53
+ pause: {
54
+ handler: promoAction(async (id, _d, req) => programRepo.pause?.(id, actor(req))),
55
+ permissions: gates.transition
56
+ },
57
+ archive: {
58
+ handler: promoAction(async (id, _d, req) => programRepo.archive?.(id, actor(req))),
59
+ permissions: gates.transition
60
+ }
61
+ },
62
+ routes: [{
63
+ method: "GET",
64
+ path: "/:id/full",
65
+ summary: "Get full program with rules + rewards",
66
+ permissions: gates.get,
67
+ rawHandler: promoRoute(async (req) => {
68
+ const { id } = req.params;
69
+ const [program, rules, rewards] = await Promise.all([
70
+ programRepo.getById(id, { lean: true }),
71
+ ruleRepo.findAll({ programId: id }),
72
+ rewardRepo.findAll({ programId: id })
73
+ ]);
74
+ if (!program) throw new ArcError("Program not found", {
75
+ code: "PROGRAM_NOT_FOUND",
76
+ statusCode: 404
77
+ });
78
+ return {
79
+ ...program,
80
+ rules,
81
+ rewards
82
+ };
83
+ })
84
+ }, ...deps.extraRoutes?.program ?? []]
85
+ }));
86
+ }
87
+ function createVoucherResource(fx) {
88
+ const { deps, engineModels, engineRepos, engineServices, actor } = fx;
89
+ const gates = deps.permissions.vouchers;
90
+ const voucherRepo = engineRepos.voucher;
91
+ const voucherService = engineServices.voucher;
92
+ return defineResource(mergeResourceConfig({
93
+ name: "promo-voucher",
94
+ displayName: "Promo Vouchers",
95
+ tag: "Promotions",
96
+ prefix: deps.prefixes?.voucher ?? "/promotions/vouchers",
97
+ tenantField: false,
98
+ adapter: createMongooseAdapter(engineModels.Voucher, engineRepos.voucher),
99
+ /**
100
+ * A voucher is STORED VALUE, and it is minted only by `/generate` and
101
+ * `/generate-single` — which allocate a unique code, stamp the batch and apply the
102
+ * program's expiry. Raw CRUD writes bypass every one of those invariants, so they are
103
+ * not a second door with a stricter lock; they are not a door at all.
104
+ *
105
+ * This is why `PromoPermissions['vouchers']` declares `list | get | cancel | generate`
106
+ * and no CRUD write gates: the gate set IS the intended surface. But the adapter mounts
107
+ * all five slots regardless, and an OMITTED permission mounts the route WITHOUT auth —
108
+ * so before this was gated, `POST /promotions/vouchers` let an unauthenticated caller
109
+ * mint arbitrary voucher codes. Nothing threw; the routes simply answered.
110
+ *
111
+ * Found the moment `ARC_STRICT_PERMISSIONS=true` was switched on for the first time.
112
+ *
113
+ * Expressed as a POSITIVE `crud` allow-list (arc's documented going-forward form) rather
114
+ * than a `disabledRoutes` deny-list: the two are equivalent (`crud: { list, get }` ⇒
115
+ * `disabledRoutes: [create, update, delete]`), but the allow-list states the intended
116
+ * surface directly, so a new write op cannot leak in by someone shortening a deny-list —
117
+ * every slot is opt-in.
118
+ */
119
+ crud: {
120
+ list: true,
121
+ get: true
122
+ },
123
+ permissions: {
124
+ list: gates.list,
125
+ get: gates.get
126
+ },
127
+ actions: { cancel: {
128
+ handler: promoAction(async (id, _d, req) => voucherRepo.cancel?.(id, actor(req))),
129
+ permissions: gates.cancel
130
+ } },
131
+ routes: [
132
+ {
133
+ method: "POST",
134
+ path: "/generate",
135
+ summary: "Generate batch voucher codes",
136
+ permissions: gates.generate,
137
+ rawHandler: promoRoute(async (req) => voucherService.generateCodes?.(req.body, actor(req)), 201)
138
+ },
139
+ {
140
+ method: "POST",
141
+ path: "/generate-single",
142
+ summary: "Generate single voucher code",
143
+ permissions: gates.generate,
144
+ rawHandler: promoRoute(async (req) => voucherService.generateSingleCode?.(req.body, actor(req)), 201)
145
+ },
146
+ {
147
+ method: "POST",
148
+ path: "/validate/:code",
149
+ summary: "Validate voucher code",
150
+ permissions: deps.permissions.evaluation.validateCode ?? deps.permissions.evaluation.preview,
151
+ rawHandler: promoRoute(async (req) => {
152
+ const { code } = req.params;
153
+ return voucherService.validateCode?.(code, actor(req));
154
+ })
155
+ },
156
+ {
157
+ method: "GET",
158
+ path: "/code/:code",
159
+ summary: "Get voucher by code",
160
+ permissions: gates.get,
161
+ rawHandler: promoRoute(async (req) => {
162
+ const { code } = req.params;
163
+ const data = await voucherRepo.getByCode?.(code);
164
+ if (!data) throw new ArcError("Voucher not found", {
165
+ code: "VOUCHER_NOT_FOUND",
166
+ statusCode: 404
167
+ });
168
+ return data;
169
+ })
170
+ }
171
+ ]
172
+ }));
173
+ }
174
+ function crudResource(fx, cfg) {
175
+ return defineResource(mergeResourceConfig({
176
+ name: cfg.name,
177
+ displayName: cfg.displayName,
178
+ tag: "Promotions",
179
+ prefix: cfg.prefix,
180
+ tenantField: false,
181
+ adapter: createMongooseAdapter(fx.engineModels[cfg.modelKey], fx.engineRepos[cfg.repoKey]),
182
+ queryParser: new QueryParser({
183
+ maxLimit: 100,
184
+ allowedFilterFields: ["programId", "code"],
185
+ allowedSortFields: ["createdAt", "updatedAt"]
186
+ }),
187
+ permissions: {
188
+ list: cfg.gates.list,
189
+ get: cfg.gates.get,
190
+ create: cfg.gates.create,
191
+ update: cfg.gates.update,
192
+ delete: cfg.gates.delete
193
+ }
194
+ }));
195
+ }
196
+ function createRuleResource(fx) {
197
+ return crudResource(fx, {
198
+ name: "promo-rule",
199
+ displayName: "Promo Rules",
200
+ prefix: fx.deps.prefixes?.rule ?? "/promotions/rules",
201
+ modelKey: "Rule",
202
+ repoKey: "rule",
203
+ gates: fx.deps.permissions.rules
204
+ });
205
+ }
206
+ function createRewardResource(fx) {
207
+ return crudResource(fx, {
208
+ name: "promo-reward",
209
+ displayName: "Promo Rewards",
210
+ prefix: fx.deps.prefixes?.reward ?? "/promotions/rewards",
211
+ modelKey: "Reward",
212
+ repoKey: "reward",
213
+ gates: fx.deps.permissions.rewards
214
+ });
215
+ }
216
+ function createEvaluationResource(fx) {
217
+ const { deps, engineServices, actor } = fx;
218
+ const gates = deps.permissions.evaluation;
219
+ const evaluation = engineServices.evaluation;
220
+ return defineResource({
221
+ name: "promo-evaluation",
222
+ displayName: "Promo Evaluation",
223
+ tag: "Promotions",
224
+ prefix: deps.prefixes?.evaluation ?? "/promotions/evaluate",
225
+ disableDefaultRoutes: true,
226
+ actions: {
227
+ commit: {
228
+ handler: promoAction(async (id, data, req) => {
229
+ const { orderId, cartHash } = data;
230
+ return evaluation.commit?.(id, orderId, actor(req), cartHash ? { cartHash } : {});
231
+ }),
232
+ permissions: gates.evaluate,
233
+ schema: z.object({
234
+ orderId: z.string(),
235
+ cartHash: z.string().optional()
236
+ })
237
+ },
238
+ rollback: {
239
+ handler: promoAction(async (id, _d, req) => {
240
+ await evaluation.rollback?.(id, actor(req));
241
+ return { message: "Rolled back" };
242
+ }),
243
+ permissions: gates.evaluate
244
+ }
245
+ },
246
+ routes: [
247
+ {
248
+ method: "POST",
249
+ path: "/preview",
250
+ summary: "Preview evaluation (no side effects)",
251
+ permissions: gates.preview,
252
+ rawHandler: promoRoute(async (req) => evaluation.preview?.(req.body, actor(req)))
253
+ },
254
+ {
255
+ method: "POST",
256
+ path: "/",
257
+ summary: "Evaluate cart (creates pending evaluation)",
258
+ permissions: gates.evaluate,
259
+ rawHandler: promoRoute(async (req) => evaluation.evaluate?.(req.body, actor(req)), 201)
260
+ },
261
+ ...deps.extraRoutes?.evaluation ?? []
262
+ ]
263
+ });
264
+ }
265
+ //#endregion
266
+ export { buildFactoryCtx, createEvaluationResource, createProgramResource, createRewardResource, createRuleResource, createVoucherResource };
@@ -0,0 +1,132 @@
1
+ import { PromoEngine, definePromo } from "@classytic/promo";
2
+ import { EngineRef, EngineSlot } from "@spinekit/kit/engine-slot";
3
+ import { FastifyRequest } from "fastify";
4
+ import { Connection } from "mongoose";
5
+ import { PermissionGate } from "@spinekit/kit/permissions";
6
+ //#region src/types.d.ts
7
+ /**
8
+ * Immutable engine configuration — the DESCRIBE half.
9
+ *
10
+ * Derived from `definePromo`'s own parameter, never restated: a hand-written copy type-checks
11
+ * against a signature that has since changed, and the drift is invisible until runtime.
12
+ */
13
+ type PromoEngineShape = Parameters<typeof definePromo>[0];
14
+ /** Live collaborators — the BIND half. */
15
+ type PromoEngineRuntime = Parameters<ReturnType<typeof definePromo>['bind']>[1];
16
+ /**
17
+ * The slice of the kernel's engine this module requires — DERIVED from
18
+ * `PromoEngine`, never restated.
19
+ *
20
+ * It exists as a narrowing (not an alias for `PromoEngine`) so a host can pass a test
21
+ * double or a superset without constructing a full engine. `Pick` rather than a
22
+ * re-declared interface is the whole point: if the kernel renames `services` or changes
23
+ * `PromoRepositories`, this breaks HERE at build time. A hand-written twin would keep
24
+ * compiling against a shape the kernel no longer has.
25
+ *
26
+ * DELIBERATELY OMITTED: `events`, `syncIndexes()` and `destroy()`. This module never
27
+ * drives engine lifecycle — the host owns it. If a future arm needs `destroy()`, widen
28
+ * this Pick rather than casting at the call site.
29
+ */
30
+ type PromoEngineLike = Pick<PromoEngine, 'models' | 'repositories' | 'services'>;
31
+ /** Per-CRUD-slot gates (arc resource permissions shape). */
32
+ interface CrudGates {
33
+ list: PermissionGate;
34
+ get: PermissionGate;
35
+ create: PermissionGate;
36
+ update: PermissionGate;
37
+ delete: PermissionGate;
38
+ }
39
+ interface PromoPermissions {
40
+ programs: CrudGates & {
41
+ transition: PermissionGate;
42
+ };
43
+ vouchers: {
44
+ list: PermissionGate;
45
+ get: PermissionGate;
46
+ cancel: PermissionGate;
47
+ generate: PermissionGate;
48
+ };
49
+ rules: CrudGates;
50
+ rewards: CrudGates;
51
+ evaluation: {
52
+ /** Read-only preview (display math — no side effects). */
53
+ preview: PermissionGate;
54
+ /** Evaluate/commit/rollback (mutates reservations). */
55
+ evaluate: PermissionGate;
56
+ /**
57
+ * Validate a voucher code at CHECKOUT (`POST /vouchers/validate/:code`).
58
+ *
59
+ * A checkout-time READ authority (a cashier/shopper confirming a code), NOT the
60
+ * voucher-admin `get` gate. Optional so existing hosts need not add it; when
61
+ * absent the route falls back to `preview` (the other checkout read authority),
62
+ * never to `vouchers.get` — validating a code is not a voucher-management action,
63
+ * and gating it as one silently locked cashiers out of the very flow the code
64
+ * exists for.
65
+ */
66
+ validateCode?: PermissionGate;
67
+ };
68
+ }
69
+ /** Actor context for promo service calls (kernel `actorRef` convention). */
70
+ interface PromoActorCtx {
71
+ actorRef: string;
72
+ }
73
+ interface PromoModuleDeps<TEngine extends PromoEngineLike = PromoEngineLike> {
74
+ /**
75
+ * The promo engine, host-initialized. Exported at `arc.modules.promo`.
76
+ *
77
+ * Accepts an {@link EngineRef}: a LIVE engine (legacy, resolved immediately) or a
78
+ * THUNK `() => ensurePromoEngine()`. The thunk keeps the five promo models out of
79
+ * the compose-time set — the module allocates nothing while the graph is composed
80
+ * and resolves the engine in `bootstrap` (post-connect), like order/accounting/pos.
81
+ */
82
+ engine?: EngineRef<TEngine>;
83
+ /**
84
+ * ── Module-OWNED construction (preferred) ──────────────────────────────
85
+ *
86
+ * Supply these INSTEAD of `engine` and this module describes, binds, publishes and CLOSES the
87
+ * engine. That is what makes ownership single: `deferredEngine` only tears down what it
88
+ * allocated (`owned = supplied === undefined`), so a host-supplied `engine` leaves teardown with
89
+ * the host — and be-prod expressed that teardown in a separate Fastify plugin, split from the
90
+ * module that mounted the resources. Two systems, one engine, and the module graph did not
91
+ * describe who owned it.
92
+ *
93
+ * The split was not theoretical: the plugin called `ensurePromoEngine()` EAGERLY at
94
+ * registration, so the five promo models registered before arc had ordered the module graph —
95
+ * exactly the allocation the deferred thunk was added to prevent. The thunk was correct and the
96
+ * plugin walked straight past it.
97
+ *
98
+ * `shape` is immutable configuration, `runtime` the live collaborators (DESCRIBE then BIND).
99
+ */
100
+ connection?: Connection;
101
+ engineShape?: () => PromoEngineShape;
102
+ engineRuntime?: () => PromoEngineRuntime;
103
+ /**
104
+ * A slot to publish the bound engine into, SHARED with the host.
105
+ *
106
+ * Lets a host READ the engine (`slot.get()`) without OWNING it — this module fills it at
107
+ * bootstrap and empties it at teardown. Without it a host that merely needs to read has to
108
+ * construct, which is the ownership inversion this arm exists to remove.
109
+ */
110
+ slot?: EngineSlot<TEngine>;
111
+ /**
112
+ * Host contribution arm — declared so a host never has to spread the returned module.
113
+ * COMPOSES with the module-owned engine close (host runs FIRST, then the engine is
114
+ * closed) — see `defineSpineModule`'s teardown-composition docblock; it never replaces it.
115
+ */
116
+ onClose?: (fastify: unknown) => void | Promise<void>;
117
+ permissions: PromoPermissions;
118
+ /**
119
+ * Derive the kernel actor context from the request. Default: arc's
120
+ * scope-first actor (`request.scope` userId/clientId) falling back to
121
+ * `'anonymous'`.
122
+ * `organizationId` is intentionally NOT part of the context — promo is
123
+ * company-wide; passing it would re-scope reads/writes per branch.
124
+ */
125
+ resolveActor?: (req: FastifyRequest) => PromoActorCtx;
126
+ /** Mount prefixes. Defaults: /promotions/{programs,vouchers,rules,rewards,evaluate}. */
127
+ prefixes?: Partial<Record<'program' | 'voucher' | 'rule' | 'reward' | 'evaluation', string>>;
128
+ /** App-policy routes appended per resource surface. */
129
+ extraRoutes?: Partial<Record<'program' | 'voucher' | 'evaluation', ReadonlyArray<import('@classytic/arc/types').RouteDefinition>>>;
130
+ }
131
+ //#endregion
132
+ export { PromoPermissions as a, PromoModuleDeps as i, PromoActorCtx as n, PromoEngineLike as r, CrudGates as t };
package/package.json ADDED
@@ -0,0 +1,89 @@
1
+ {
2
+ "name": "@spinekit/promo",
3
+ "version": "0.1.0",
4
+ "description": "Arc module for @classytic/promo — programs, vouchers, rules, rewards, and cart evaluation as composable arc resources. BYO engine; company-wide by default (tenantField: false — a voucher generated at one branch redeems at any other); ships the promo-kernel↔wire error contract (stable UPPER_SNAKE codes + HTTP statuses).",
5
+ "type": "module",
6
+ "license": "SEE LICENSE IN LICENSE",
7
+ "author": "Classytic",
8
+ "sideEffects": false,
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.mts",
12
+ "default": "./dist/index.mjs"
13
+ },
14
+ "./package.json": "./package.json",
15
+ "./placement": {
16
+ "types": "./dist/placement/promo-placement.d.mts",
17
+ "default": "./dist/placement/promo-placement.mjs"
18
+ },
19
+ "./port": {
20
+ "types": "./dist/placement/promo.port.d.mts",
21
+ "default": "./dist/placement/promo.port.mjs"
22
+ },
23
+ "./resources": {
24
+ "types": "./dist/resources.d.mts",
25
+ "default": "./dist/resources.mjs"
26
+ },
27
+ "./errors": {
28
+ "types": "./dist/errors.d.mts",
29
+ "default": "./dist/errors.mjs"
30
+ }
31
+ },
32
+ "files": [
33
+ "LICENSE",
34
+ "dist",
35
+ "README.md"
36
+ ],
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "homepage": "https://www.npmjs.com/package/@spinekit/promo",
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/classytic/spine.git",
44
+ "directory": "packages/spine-promo"
45
+ },
46
+ "keywords": [
47
+ "arc",
48
+ "classytic",
49
+ "spine",
50
+ "erp",
51
+ "promo",
52
+ "promotions",
53
+ "voucher",
54
+ "discount",
55
+ "coupon",
56
+ "evaluation"
57
+ ],
58
+ "scripts": {
59
+ "build": "tsdown",
60
+ "typecheck": "tsc --noEmit",
61
+ "test": "vitest run",
62
+ "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
63
+ "prepublishOnly": "npm run typecheck && npm run test && npm run build"
64
+ },
65
+ "peerDependencies": {
66
+ "@classytic/arc": ">=2.34.0",
67
+ "@classytic/mongokit": ">=3.34.0",
68
+ "@classytic/primitives": ">=0.23.0",
69
+ "@classytic/promo": ">=0.7.0",
70
+ "@spinekit/kit": ">=0.1.0",
71
+ "mongoose": ">=9.4.1",
72
+ "zod": ">=4.0.0"
73
+ },
74
+ "devDependencies": {
75
+ "@classytic/arc": "^2.34.0",
76
+ "@classytic/arc-testkit": "^0.4.0",
77
+ "@classytic/mongokit": ">=3.34.0",
78
+ "@classytic/primitives": ">=0.23.0",
79
+ "@classytic/promo": ">=0.7.0",
80
+ "@spinekit/kit": "workspace:*",
81
+ "@types/node": "^24.3.0",
82
+ "fastify": "^5.12.0",
83
+ "mongoose": "^9.7.2",
84
+ "tsdown": "^0.22.14",
85
+ "typescript": "^7.0.2",
86
+ "vitest": "^3.2.4",
87
+ "zod": "^4.3.6"
88
+ }
89
+ }