@voyant-travel/commerce 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 +201 -0
- package/README.md +145 -0
- package/dist/accepted-quote-version-reservation-golden-flow.test.d.ts +2 -0
- package/dist/accepted-quote-version-reservation-golden-flow.test.d.ts.map +1 -0
- package/dist/accepted-quote-version-reservation-golden-flow.test.js +398 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/interface.d.ts +18 -0
- package/dist/interface.d.ts.map +1 -0
- package/dist/interface.js +246 -0
- package/dist/interface.test.d.ts +2 -0
- package/dist/interface.test.d.ts.map +1 -0
- package/dist/interface.test.js +357 -0
- package/dist/markets/index.d.ts +11 -0
- package/dist/markets/index.d.ts.map +1 -0
- package/dist/markets/index.js +12 -0
- package/dist/markets/routes.d.ts +1182 -0
- package/dist/markets/routes.d.ts.map +1 -0
- package/dist/markets/routes.js +209 -0
- package/dist/markets/schema.d.ts +1527 -0
- package/dist/markets/schema.d.ts.map +1 -0
- package/dist/markets/schema.js +240 -0
- package/dist/markets/service-core.d.ts +253 -0
- package/dist/markets/service-core.d.ts.map +1 -0
- package/dist/markets/service-core.js +242 -0
- package/dist/markets/service-rules.d.ts +191 -0
- package/dist/markets/service-rules.d.ts.map +1 -0
- package/dist/markets/service-rules.js +155 -0
- package/dist/markets/service-shared.d.ts +36 -0
- package/dist/markets/service-shared.d.ts.map +1 -0
- package/dist/markets/service-shared.js +7 -0
- package/dist/markets/service.d.ts +43 -0
- package/dist/markets/service.d.ts.map +1 -0
- package/dist/markets/service.js +42 -0
- package/dist/markets/validation.d.ts +451 -0
- package/dist/markets/validation.d.ts.map +1 -0
- package/dist/markets/validation.js +160 -0
- package/dist/pricing/events.d.ts +53 -0
- package/dist/pricing/events.d.ts.map +1 -0
- package/dist/pricing/events.js +28 -0
- package/dist/pricing/index.d.ts +15 -0
- package/dist/pricing/index.d.ts.map +1 -0
- package/dist/pricing/index.js +18 -0
- package/dist/pricing/routes-core.d.ts +981 -0
- package/dist/pricing/routes-core.d.ts.map +1 -0
- package/dist/pricing/routes-core.js +102 -0
- package/dist/pricing/routes-public.d.ts +136 -0
- package/dist/pricing/routes-public.d.ts.map +1 -0
- package/dist/pricing/routes-public.js +14 -0
- package/dist/pricing/routes-rules.d.ts +1339 -0
- package/dist/pricing/routes-rules.d.ts.map +1 -0
- package/dist/pricing/routes-rules.js +138 -0
- package/dist/pricing/routes-shared.d.ts +14 -0
- package/dist/pricing/routes-shared.d.ts.map +1 -0
- package/dist/pricing/routes-shared.js +3 -0
- package/dist/pricing/routes.d.ts +7 -0
- package/dist/pricing/routes.d.ts.map +1 -0
- package/dist/pricing/routes.js +6 -0
- package/dist/pricing/schema-catalogs.d.ts +467 -0
- package/dist/pricing/schema-catalogs.d.ts.map +1 -0
- package/dist/pricing/schema-catalogs.js +47 -0
- package/dist/pricing/schema-categories.d.ts +497 -0
- package/dist/pricing/schema-categories.d.ts.map +1 -0
- package/dist/pricing/schema-categories.js +54 -0
- package/dist/pricing/schema-departure-overrides.d.ts +228 -0
- package/dist/pricing/schema-departure-overrides.d.ts.map +1 -0
- package/dist/pricing/schema-departure-overrides.js +36 -0
- package/dist/pricing/schema-option-rules.d.ts +1770 -0
- package/dist/pricing/schema-option-rules.d.ts.map +1 -0
- package/dist/pricing/schema-option-rules.js +181 -0
- package/dist/pricing/schema-policies.d.ts +395 -0
- package/dist/pricing/schema-policies.d.ts.map +1 -0
- package/dist/pricing/schema-policies.js +41 -0
- package/dist/pricing/schema-relations.d.ts +59 -0
- package/dist/pricing/schema-relations.d.ts.map +1 -0
- package/dist/pricing/schema-relations.js +111 -0
- package/dist/pricing/schema-shared.d.ts +11 -0
- package/dist/pricing/schema-shared.d.ts.map +1 -0
- package/dist/pricing/schema-shared.js +67 -0
- package/dist/pricing/schema.d.ts +8 -0
- package/dist/pricing/schema.d.ts.map +1 -0
- package/dist/pricing/schema.js +7 -0
- package/dist/pricing/service-catalog-plane-pricing.d.ts +95 -0
- package/dist/pricing/service-catalog-plane-pricing.d.ts.map +1 -0
- package/dist/pricing/service-catalog-plane-pricing.js +382 -0
- package/dist/pricing/service-catalogs.d.ts +139 -0
- package/dist/pricing/service-catalogs.d.ts.map +1 -0
- package/dist/pricing/service-catalogs.js +89 -0
- package/dist/pricing/service-categories.d.ts +147 -0
- package/dist/pricing/service-categories.d.ts.map +1 -0
- package/dist/pricing/service-categories.js +105 -0
- package/dist/pricing/service-departure-overrides.d.ts +67 -0
- package/dist/pricing/service-departure-overrides.d.ts.map +1 -0
- package/dist/pricing/service-departure-overrides.js +54 -0
- package/dist/pricing/service-option-rules.d.ts +321 -0
- package/dist/pricing/service-option-rules.d.ts.map +1 -0
- package/dist/pricing/service-option-rules.js +340 -0
- package/dist/pricing/service-policies.d.ts +123 -0
- package/dist/pricing/service-policies.d.ts.map +1 -0
- package/dist/pricing/service-policies.js +95 -0
- package/dist/pricing/service-public.d.ts +89 -0
- package/dist/pricing/service-public.d.ts.map +1 -0
- package/dist/pricing/service-public.js +473 -0
- package/dist/pricing/service-rule-resolver.d.ts +67 -0
- package/dist/pricing/service-rule-resolver.d.ts.map +1 -0
- package/dist/pricing/service-rule-resolver.js +204 -0
- package/dist/pricing/service-shared.d.ts +53 -0
- package/dist/pricing/service-shared.d.ts.map +1 -0
- package/dist/pricing/service-shared.js +4 -0
- package/dist/pricing/service-transfer-rules.d.ts +211 -0
- package/dist/pricing/service-transfer-rules.d.ts.map +1 -0
- package/dist/pricing/service-transfer-rules.js +139 -0
- package/dist/pricing/service.d.ts +79 -0
- package/dist/pricing/service.d.ts.map +1 -0
- package/dist/pricing/service.js +78 -0
- package/dist/pricing/validation-public.d.ts +412 -0
- package/dist/pricing/validation-public.d.ts.map +1 -0
- package/dist/pricing/validation-public.js +111 -0
- package/dist/pricing/validation-shared.d.ts +71 -0
- package/dist/pricing/validation-shared.d.ts.map +1 -0
- package/dist/pricing/validation-shared.js +63 -0
- package/dist/pricing/validation.d.ts +987 -0
- package/dist/pricing/validation.d.ts.map +1 -0
- package/dist/pricing/validation.js +307 -0
- package/dist/promotions/events.d.ts +38 -0
- package/dist/promotions/events.d.ts.map +1 -0
- package/dist/promotions/events.js +25 -0
- package/dist/promotions/index.d.ts +12 -0
- package/dist/promotions/index.d.ts.map +1 -0
- package/dist/promotions/index.js +17 -0
- package/dist/promotions/routes-shared.d.ts +14 -0
- package/dist/promotions/routes-shared.d.ts.map +1 -0
- package/dist/promotions/routes-shared.js +3 -0
- package/dist/promotions/routes.d.ts +395 -0
- package/dist/promotions/routes.d.ts.map +1 -0
- package/dist/promotions/routes.js +55 -0
- package/dist/promotions/schema.d.ts +675 -0
- package/dist/promotions/schema.d.ts.map +1 -0
- package/dist/promotions/schema.js +126 -0
- package/dist/promotions/service-booking-confirmed.d.ts +77 -0
- package/dist/promotions/service-booking-confirmed.d.ts.map +1 -0
- package/dist/promotions/service-booking-confirmed.js +134 -0
- package/dist/promotions/service-boundary-scheduler.d.ts +85 -0
- package/dist/promotions/service-boundary-scheduler.d.ts.map +1 -0
- package/dist/promotions/service-boundary-scheduler.js +141 -0
- package/dist/promotions/service-catalog-evaluator.d.ts +22 -0
- package/dist/promotions/service-catalog-evaluator.d.ts.map +1 -0
- package/dist/promotions/service-catalog-evaluator.js +33 -0
- package/dist/promotions/service-catalog-plane-promotions.d.ts +73 -0
- package/dist/promotions/service-catalog-plane-promotions.d.ts.map +1 -0
- package/dist/promotions/service-catalog-plane-promotions.js +118 -0
- package/dist/promotions/service-evaluator.d.ts +134 -0
- package/dist/promotions/service-evaluator.d.ts.map +1 -0
- package/dist/promotions/service-evaluator.js +302 -0
- package/dist/promotions/service-storefront.d.ts +147 -0
- package/dist/promotions/service-storefront.d.ts.map +1 -0
- package/dist/promotions/service-storefront.js +326 -0
- package/dist/promotions/service.d.ts +143 -0
- package/dist/promotions/service.d.ts.map +1 -0
- package/dist/promotions/service.js +359 -0
- package/dist/promotions/validation.d.ts +195 -0
- package/dist/promotions/validation.d.ts.map +1 -0
- package/dist/promotions/validation.js +167 -0
- package/dist/promotions/workflow-bulk-reindex.d.ts +36 -0
- package/dist/promotions/workflow-bulk-reindex.d.ts.map +1 -0
- package/dist/promotions/workflow-bulk-reindex.js +53 -0
- package/dist/promotions/workflow-runtime.d.ts +17 -0
- package/dist/promotions/workflow-runtime.d.ts.map +1 -0
- package/dist/promotions/workflow-runtime.js +9 -0
- package/dist/runtime.d.ts +18 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +27 -0
- package/dist/runtime.test.d.ts +2 -0
- package/dist/runtime.test.d.ts.map +1 -0
- package/dist/runtime.test.js +25 -0
- package/dist/schema.d.ts +5 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +4 -0
- package/dist/sellability/index.d.ts +13 -0
- package/dist/sellability/index.d.ts.map +1 -0
- package/dist/sellability/index.js +17 -0
- package/dist/sellability/routes.d.ts +2332 -0
- package/dist/sellability/routes.d.ts.map +1 -0
- package/dist/sellability/routes.js +166 -0
- package/dist/sellability/schema.d.ts +1716 -0
- package/dist/sellability/schema.d.ts.map +1 -0
- package/dist/sellability/schema.js +278 -0
- package/dist/sellability/service-records.d.ts +316 -0
- package/dist/sellability/service-records.d.ts.map +1 -0
- package/dist/sellability/service-records.js +253 -0
- package/dist/sellability/service-resolve.d.ts +72 -0
- package/dist/sellability/service-resolve.d.ts.map +1 -0
- package/dist/sellability/service-resolve.js +580 -0
- package/dist/sellability/service-shared.d.ts +124 -0
- package/dist/sellability/service-shared.d.ts.map +1 -0
- package/dist/sellability/service-shared.js +96 -0
- package/dist/sellability/service-snapshots.d.ts +191 -0
- package/dist/sellability/service-snapshots.d.ts.map +1 -0
- package/dist/sellability/service-snapshots.js +153 -0
- package/dist/sellability/service.d.ts +1038 -0
- package/dist/sellability/service.d.ts.map +1 -0
- package/dist/sellability/service.js +17 -0
- package/dist/sellability/validation.d.ts +477 -0
- package/dist/sellability/validation.d.ts.map +1 -0
- package/dist/sellability/validation.js +192 -0
- package/dist/types.d.ts +239 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +62 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storefront resolvers — populate the previously-empty
|
|
3
|
+
* `/v1/public/products/:productId/offers` and `/v1/public/offers/:slug`
|
|
4
|
+
* endpoints in `@voyant-travel/storefront` with real data.
|
|
5
|
+
*
|
|
6
|
+
* Wire via:
|
|
7
|
+
*
|
|
8
|
+
* const storefrontModule = createStorefrontHonoModule({
|
|
9
|
+
* offers: createPromotionsStorefrontResolvers(),
|
|
10
|
+
* })
|
|
11
|
+
*
|
|
12
|
+
* Per docs/architecture/promotions-architecture.md §8.
|
|
13
|
+
*
|
|
14
|
+
* V1 limitations:
|
|
15
|
+
* - Storefront calls don't carry an audience / market in the request
|
|
16
|
+
* context, so the resolver only filters by `products` + `global`
|
|
17
|
+
* scopes (the catalog plane handles audience-scoped projection).
|
|
18
|
+
* `markets` and `audiences` scoped offers don't appear in this
|
|
19
|
+
* listing endpoint.
|
|
20
|
+
* - Locale-aware offer names: the schema doesn't store translations
|
|
21
|
+
* yet (single-locale offer names per §12.1); `locale` is accepted
|
|
22
|
+
* but ignored. A `promotional_offer_translations` table mirrors
|
|
23
|
+
* `destinations_translations` if/when needed.
|
|
24
|
+
* - `applicableDepartureIds`: always empty per §12.7 — departure-
|
|
25
|
+
* scoped offers aren't modelled in v1.
|
|
26
|
+
* - Only auto-applied offers (no code) appear in `listApplicableOffers`;
|
|
27
|
+
* code-gated offers are still queryable via `getOfferBySlug`.
|
|
28
|
+
*/
|
|
29
|
+
import type { AnyDrizzleDb } from "@voyant-travel/db";
|
|
30
|
+
import { type PromotionalOffer } from "./schema.js";
|
|
31
|
+
import { type CodeStatus } from "./service-evaluator.js";
|
|
32
|
+
import type { PromotionalOfferScope } from "./validation.js";
|
|
33
|
+
export interface StorefrontRequestContext {
|
|
34
|
+
db?: AnyDrizzleDb;
|
|
35
|
+
eventBus?: unknown;
|
|
36
|
+
env?: unknown;
|
|
37
|
+
context?: unknown;
|
|
38
|
+
}
|
|
39
|
+
export interface StorefrontPromotionalOffer {
|
|
40
|
+
id: string;
|
|
41
|
+
name: string;
|
|
42
|
+
slug: string | null;
|
|
43
|
+
description: string | null;
|
|
44
|
+
discountType: "percentage" | "fixed_amount";
|
|
45
|
+
discountValue: string;
|
|
46
|
+
currency: string | null;
|
|
47
|
+
applicableProductIds: string[];
|
|
48
|
+
applicableDepartureIds: string[];
|
|
49
|
+
validFrom: string | null;
|
|
50
|
+
validTo: string | null;
|
|
51
|
+
minTravelers: number | null;
|
|
52
|
+
imageMobileUrl: string | null;
|
|
53
|
+
imageDesktopUrl: string | null;
|
|
54
|
+
stackable: boolean;
|
|
55
|
+
createdAt: string;
|
|
56
|
+
updatedAt: string;
|
|
57
|
+
}
|
|
58
|
+
export interface StorefrontOfferApplyInput {
|
|
59
|
+
productId: string;
|
|
60
|
+
departureId?: string | null;
|
|
61
|
+
bookingId?: string | null;
|
|
62
|
+
sessionId?: string | null;
|
|
63
|
+
locale?: string;
|
|
64
|
+
pax: number;
|
|
65
|
+
audience: "staff" | "customer" | "partner" | "supplier";
|
|
66
|
+
market: string;
|
|
67
|
+
basePriceCents: number;
|
|
68
|
+
currency: string;
|
|
69
|
+
}
|
|
70
|
+
export interface StorefrontOfferRedeemInput extends StorefrontOfferApplyInput {
|
|
71
|
+
code: string;
|
|
72
|
+
}
|
|
73
|
+
export interface StorefrontAppliedOffer {
|
|
74
|
+
offerId: string;
|
|
75
|
+
offerName: string;
|
|
76
|
+
discountAppliedCents: number;
|
|
77
|
+
discountedPriceCents: number;
|
|
78
|
+
currency: string;
|
|
79
|
+
discountKind: "percentage" | "fixed_amount";
|
|
80
|
+
discountPercent: number | null;
|
|
81
|
+
discountAmountCents: number | null;
|
|
82
|
+
appliedCode: string | null;
|
|
83
|
+
stackable: boolean;
|
|
84
|
+
}
|
|
85
|
+
export interface StorefrontOfferMutationResult {
|
|
86
|
+
status: "applied" | "not_applicable" | "invalid" | "conflict";
|
|
87
|
+
reason: "offer_not_found" | "offer_expired" | "offer_not_yet_valid" | "code_not_found" | "code_required" | "code_expired" | "code_not_yet_valid" | "scope" | "min_pax" | "eligibility" | "currency" | "no_discount" | "booking_mismatch" | "session_mismatch" | "conflict" | null;
|
|
88
|
+
offer: StorefrontPromotionalOffer | null;
|
|
89
|
+
target: {
|
|
90
|
+
bookingId: string | null;
|
|
91
|
+
sessionId: string | null;
|
|
92
|
+
productId: string;
|
|
93
|
+
departureId: string | null;
|
|
94
|
+
};
|
|
95
|
+
pricing: {
|
|
96
|
+
basePriceCents: number;
|
|
97
|
+
currency: string;
|
|
98
|
+
discountAppliedCents: number;
|
|
99
|
+
discountedPriceCents: number;
|
|
100
|
+
};
|
|
101
|
+
appliedOffers: StorefrontAppliedOffer[];
|
|
102
|
+
conflict: {
|
|
103
|
+
policy: "best_discount_wins" | "stackable_compose";
|
|
104
|
+
autoAppliedOfferIds: string[];
|
|
105
|
+
manualOfferId: string | null;
|
|
106
|
+
selectedOfferIds: string[];
|
|
107
|
+
message: string;
|
|
108
|
+
} | null;
|
|
109
|
+
}
|
|
110
|
+
export interface StorefrontOfferResolvers {
|
|
111
|
+
listApplicableOffers?: (input: {
|
|
112
|
+
productId: string;
|
|
113
|
+
departureId?: string;
|
|
114
|
+
locale?: string;
|
|
115
|
+
} & StorefrontRequestContext) => Promise<StorefrontPromotionalOffer[]> | StorefrontPromotionalOffer[];
|
|
116
|
+
getOfferBySlug?: (input: {
|
|
117
|
+
slug: string;
|
|
118
|
+
locale?: string;
|
|
119
|
+
} & StorefrontRequestContext) => Promise<StorefrontPromotionalOffer | null> | StorefrontPromotionalOffer | null;
|
|
120
|
+
applyOffer?: (input: {
|
|
121
|
+
slug: string;
|
|
122
|
+
body: StorefrontOfferApplyInput;
|
|
123
|
+
} & StorefrontRequestContext) => Promise<StorefrontOfferMutationResult> | StorefrontOfferMutationResult;
|
|
124
|
+
redeemOffer?: (input: {
|
|
125
|
+
body: StorefrontOfferRedeemInput;
|
|
126
|
+
} & StorefrontRequestContext) => Promise<StorefrontOfferMutationResult> | StorefrontOfferMutationResult;
|
|
127
|
+
}
|
|
128
|
+
export declare function createPromotionsStorefrontResolvers(): StorefrontOfferResolvers;
|
|
129
|
+
declare function matchesProduct(scope: PromotionalOfferScope, inLinkTable: boolean): boolean;
|
|
130
|
+
declare function toStorefrontDto(offer: PromotionalOffer, applicableProductIds: string[]): StorefrontPromotionalOffer;
|
|
131
|
+
declare function currentValidityStatus(offer: PromotionalOffer, now: Date): "offer_expired" | "offer_not_yet_valid" | null;
|
|
132
|
+
declare function codeStatusToReason(status: CodeStatus): NonNullable<StorefrontOfferMutationResult["reason"]> | null;
|
|
133
|
+
declare function buildConflict(input: {
|
|
134
|
+
autoAppliedOfferIds: string[];
|
|
135
|
+
manualOfferId: string | null;
|
|
136
|
+
selectedOfferIds: string[];
|
|
137
|
+
manualSelected: boolean;
|
|
138
|
+
}): StorefrontOfferMutationResult["conflict"];
|
|
139
|
+
export declare const __test__: {
|
|
140
|
+
buildConflict: typeof buildConflict;
|
|
141
|
+
codeStatusToReason: typeof codeStatusToReason;
|
|
142
|
+
currentValidityStatus: typeof currentValidityStatus;
|
|
143
|
+
matchesProduct: typeof matchesProduct;
|
|
144
|
+
toStorefrontDto: typeof toStorefrontDto;
|
|
145
|
+
};
|
|
146
|
+
export {};
|
|
147
|
+
//# sourceMappingURL=service-storefront.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service-storefront.d.ts","sourceRoot":"","sources":["../../src/promotions/service-storefront.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storefront resolvers — populate the previously-empty
|
|
3
|
+
* `/v1/public/products/:productId/offers` and `/v1/public/offers/:slug`
|
|
4
|
+
* endpoints in `@voyant-travel/storefront` with real data.
|
|
5
|
+
*
|
|
6
|
+
* Wire via:
|
|
7
|
+
*
|
|
8
|
+
* const storefrontModule = createStorefrontHonoModule({
|
|
9
|
+
* offers: createPromotionsStorefrontResolvers(),
|
|
10
|
+
* })
|
|
11
|
+
*
|
|
12
|
+
* Per docs/architecture/promotions-architecture.md §8.
|
|
13
|
+
*
|
|
14
|
+
* V1 limitations:
|
|
15
|
+
* - Storefront calls don't carry an audience / market in the request
|
|
16
|
+
* context, so the resolver only filters by `products` + `global`
|
|
17
|
+
* scopes (the catalog plane handles audience-scoped projection).
|
|
18
|
+
* `markets` and `audiences` scoped offers don't appear in this
|
|
19
|
+
* listing endpoint.
|
|
20
|
+
* - Locale-aware offer names: the schema doesn't store translations
|
|
21
|
+
* yet (single-locale offer names per §12.1); `locale` is accepted
|
|
22
|
+
* but ignored. A `promotional_offer_translations` table mirrors
|
|
23
|
+
* `destinations_translations` if/when needed.
|
|
24
|
+
* - `applicableDepartureIds`: always empty per §12.7 — departure-
|
|
25
|
+
* scoped offers aren't modelled in v1.
|
|
26
|
+
* - Only auto-applied offers (no code) appear in `listApplicableOffers`;
|
|
27
|
+
* code-gated offers are still queryable via `getOfferBySlug`.
|
|
28
|
+
*/
|
|
29
|
+
import { and, eq, gte, inArray, isNull, lte, or } from "drizzle-orm";
|
|
30
|
+
import { promotionalOfferProducts, promotionalOffers } from "./schema.js";
|
|
31
|
+
import { createDrizzleOfferDataSource, evaluateOffersForProduct, } from "./service-evaluator.js";
|
|
32
|
+
export function createPromotionsStorefrontResolvers() {
|
|
33
|
+
return {
|
|
34
|
+
async listApplicableOffers(input) {
|
|
35
|
+
const db = resolveDb(input);
|
|
36
|
+
if (!db)
|
|
37
|
+
return [];
|
|
38
|
+
const now = new Date();
|
|
39
|
+
// Active + currently-valid + auto-applied (no code) offers.
|
|
40
|
+
const baseRows = await db
|
|
41
|
+
.select()
|
|
42
|
+
.from(promotionalOffers)
|
|
43
|
+
.where(and(eq(promotionalOffers.active, true), isNull(promotionalOffers.code), or(isNull(promotionalOffers.validFrom), lte(promotionalOffers.validFrom, now)), or(isNull(promotionalOffers.validUntil), gte(promotionalOffers.validUntil, now))));
|
|
44
|
+
if (baseRows.length === 0)
|
|
45
|
+
return [];
|
|
46
|
+
// Restrict to offers that match the product: either `global` scope
|
|
47
|
+
// (always matches) OR a product-shaped scope whose materialized
|
|
48
|
+
// link table contains this product.
|
|
49
|
+
const productLinkRows = await db
|
|
50
|
+
.select({ offerId: promotionalOfferProducts.offerId })
|
|
51
|
+
.from(promotionalOfferProducts)
|
|
52
|
+
.where(and(eq(promotionalOfferProducts.productId, input.productId), inArray(promotionalOfferProducts.offerId, baseRows.map((r) => r.id))));
|
|
53
|
+
const offersMatchingProduct = new Set(productLinkRows.map((r) => r.offerId));
|
|
54
|
+
const productLinkLookups = await loadApplicableProductIds(db, baseRows.map((r) => r.id));
|
|
55
|
+
const out = [];
|
|
56
|
+
for (const offer of baseRows) {
|
|
57
|
+
if (!matchesProduct(offer.scope, offersMatchingProduct.has(offer.id)))
|
|
58
|
+
continue;
|
|
59
|
+
out.push(toStorefrontDto(offer, productLinkLookups.get(offer.id) ?? []));
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
},
|
|
63
|
+
async getOfferBySlug(input) {
|
|
64
|
+
const db = resolveDb(input);
|
|
65
|
+
if (!db)
|
|
66
|
+
return null;
|
|
67
|
+
const offer = await findActiveOfferBySlug(db, input.slug);
|
|
68
|
+
if (!offer)
|
|
69
|
+
return null;
|
|
70
|
+
const links = await loadApplicableProductIds(db, [offer.id]);
|
|
71
|
+
return toStorefrontDto(offer, links.get(offer.id) ?? []);
|
|
72
|
+
},
|
|
73
|
+
async applyOffer(input) {
|
|
74
|
+
const db = resolveDb(input);
|
|
75
|
+
if (!db)
|
|
76
|
+
return notConfiguredResult(input.body);
|
|
77
|
+
const offer = await findActiveOfferBySlug(db, input.slug);
|
|
78
|
+
if (!offer) {
|
|
79
|
+
return emptyResult(input.body, "invalid", "offer_not_found", null);
|
|
80
|
+
}
|
|
81
|
+
if (offer.code != null) {
|
|
82
|
+
return emptyResult(input.body, "invalid", "code_required", await dtoForOffer(db, offer));
|
|
83
|
+
}
|
|
84
|
+
const validity = currentValidityStatus(offer, new Date());
|
|
85
|
+
if (validity !== null) {
|
|
86
|
+
return emptyResult(input.body, "invalid", validity, await dtoForOffer(db, offer));
|
|
87
|
+
}
|
|
88
|
+
return evaluateStorefrontMutation(db, input.body, {
|
|
89
|
+
manualOffer: offer,
|
|
90
|
+
code: undefined,
|
|
91
|
+
offer: await dtoForOffer(db, offer),
|
|
92
|
+
});
|
|
93
|
+
},
|
|
94
|
+
async redeemOffer(input) {
|
|
95
|
+
const db = resolveDb(input);
|
|
96
|
+
if (!db)
|
|
97
|
+
return notConfiguredResult(input.body);
|
|
98
|
+
const offer = await findActiveOfferByCode(db, input.body.code);
|
|
99
|
+
return evaluateStorefrontMutation(db, input.body, {
|
|
100
|
+
manualOffer: offer,
|
|
101
|
+
code: input.body.code,
|
|
102
|
+
offer: offer ? await dtoForOffer(db, offer) : null,
|
|
103
|
+
});
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function resolveDb(input) {
|
|
108
|
+
return input.db ?? undefined;
|
|
109
|
+
}
|
|
110
|
+
function matchesProduct(scope, inLinkTable) {
|
|
111
|
+
switch (scope.kind) {
|
|
112
|
+
case "global":
|
|
113
|
+
return true;
|
|
114
|
+
case "products":
|
|
115
|
+
case "categories":
|
|
116
|
+
case "destinations":
|
|
117
|
+
return inLinkTable;
|
|
118
|
+
// markets / audiences scopes don't render via this endpoint in v1
|
|
119
|
+
// (no audience / market in the request context). They're still
|
|
120
|
+
// honored by the catalog projection (PR3) when products are indexed.
|
|
121
|
+
case "markets":
|
|
122
|
+
case "audiences":
|
|
123
|
+
// Fare/cabin-grade scopes need booking-line context and are evaluated
|
|
124
|
+
// by the checkout path rather than this product-listing endpoint.
|
|
125
|
+
case "fare_codes":
|
|
126
|
+
case "cabin_grades":
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
async function loadApplicableProductIds(db, offerIds) {
|
|
131
|
+
if (offerIds.length === 0)
|
|
132
|
+
return new Map();
|
|
133
|
+
const rows = await db
|
|
134
|
+
.select({
|
|
135
|
+
offerId: promotionalOfferProducts.offerId,
|
|
136
|
+
productId: promotionalOfferProducts.productId,
|
|
137
|
+
})
|
|
138
|
+
.from(promotionalOfferProducts)
|
|
139
|
+
.where(inArray(promotionalOfferProducts.offerId, offerIds));
|
|
140
|
+
const out = new Map();
|
|
141
|
+
for (const row of rows) {
|
|
142
|
+
const list = out.get(row.offerId) ?? [];
|
|
143
|
+
list.push(row.productId);
|
|
144
|
+
out.set(row.offerId, list);
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
function toStorefrontDto(offer, applicableProductIds) {
|
|
149
|
+
return {
|
|
150
|
+
id: offer.id,
|
|
151
|
+
name: offer.name,
|
|
152
|
+
slug: offer.slug,
|
|
153
|
+
description: offer.description,
|
|
154
|
+
discountType: offer.discountType,
|
|
155
|
+
// The DTO carries `discountValue` as a string for both flavors.
|
|
156
|
+
// Percentage offers store the percent ("20"); fixed_amount offers
|
|
157
|
+
// store the cents amount as a string ("500" for $5.00).
|
|
158
|
+
discountValue: offer.discountType === "percentage"
|
|
159
|
+
? (offer.discountPercent ?? "0")
|
|
160
|
+
: String(offer.discountAmountCents ?? 0),
|
|
161
|
+
currency: offer.currency,
|
|
162
|
+
applicableProductIds,
|
|
163
|
+
// V1: departure-scoped offers aren't modelled (per §12.7).
|
|
164
|
+
applicableDepartureIds: [],
|
|
165
|
+
validFrom: offer.validFrom?.toISOString() ?? null,
|
|
166
|
+
validTo: offer.validUntil?.toISOString() ?? null,
|
|
167
|
+
minTravelers: typeof offer.conditions === "object" && offer.conditions != null
|
|
168
|
+
? (offer.conditions.minPax ?? null)
|
|
169
|
+
: null,
|
|
170
|
+
// V1: no merchandising images on offers (per §2 non-goals).
|
|
171
|
+
imageMobileUrl: null,
|
|
172
|
+
imageDesktopUrl: null,
|
|
173
|
+
stackable: offer.stackable,
|
|
174
|
+
createdAt: offer.createdAt.toISOString(),
|
|
175
|
+
updatedAt: offer.updatedAt.toISOString(),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
async function findActiveOfferBySlug(db, slug) {
|
|
179
|
+
const rows = await db
|
|
180
|
+
.select()
|
|
181
|
+
.from(promotionalOffers)
|
|
182
|
+
.where(and(eq(promotionalOffers.slug, slug), eq(promotionalOffers.active, true)))
|
|
183
|
+
.limit(1);
|
|
184
|
+
return rows[0] ?? null;
|
|
185
|
+
}
|
|
186
|
+
async function findActiveOfferByCode(db, code) {
|
|
187
|
+
const rows = await db
|
|
188
|
+
.select()
|
|
189
|
+
.from(promotionalOffers)
|
|
190
|
+
.where(and(eq(promotionalOffers.active, true), eq(promotionalOffers.code, code.toLowerCase())))
|
|
191
|
+
.limit(1);
|
|
192
|
+
return rows[0] ?? null;
|
|
193
|
+
}
|
|
194
|
+
async function dtoForOffer(db, offer) {
|
|
195
|
+
const links = await loadApplicableProductIds(db, [offer.id]);
|
|
196
|
+
return toStorefrontDto(offer, links.get(offer.id) ?? []);
|
|
197
|
+
}
|
|
198
|
+
function currentValidityStatus(offer, now) {
|
|
199
|
+
if (offer.validUntil != null && offer.validUntil < now)
|
|
200
|
+
return "offer_expired";
|
|
201
|
+
if (offer.validFrom != null && offer.validFrom > now)
|
|
202
|
+
return "offer_not_yet_valid";
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
function emptyResult(input, status, reason, offer) {
|
|
206
|
+
return {
|
|
207
|
+
status,
|
|
208
|
+
reason,
|
|
209
|
+
offer,
|
|
210
|
+
target: targetFromInput(input),
|
|
211
|
+
pricing: {
|
|
212
|
+
basePriceCents: input.basePriceCents,
|
|
213
|
+
currency: input.currency,
|
|
214
|
+
discountAppliedCents: 0,
|
|
215
|
+
discountedPriceCents: input.basePriceCents,
|
|
216
|
+
},
|
|
217
|
+
appliedOffers: [],
|
|
218
|
+
conflict: null,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
function notConfiguredResult(input) {
|
|
222
|
+
return emptyResult(input, "invalid", "offer_not_found", null);
|
|
223
|
+
}
|
|
224
|
+
async function evaluateStorefrontMutation(db, input, options) {
|
|
225
|
+
const evaluation = await evaluateOffersForProduct(createDrizzleOfferDataSource(db), {
|
|
226
|
+
productId: input.productId,
|
|
227
|
+
slice: {
|
|
228
|
+
audience: input.audience,
|
|
229
|
+
market: input.market,
|
|
230
|
+
},
|
|
231
|
+
pax: input.pax,
|
|
232
|
+
code: options.code,
|
|
233
|
+
basePriceCents: input.basePriceCents,
|
|
234
|
+
baseCurrency: input.currency,
|
|
235
|
+
});
|
|
236
|
+
const codeReason = codeStatusToReason(evaluation.codeStatus);
|
|
237
|
+
if (codeReason != null) {
|
|
238
|
+
return emptyResult(input, "invalid", codeReason, options.offer);
|
|
239
|
+
}
|
|
240
|
+
const appliedOffers = evaluation.applied.map(toStorefrontAppliedOffer);
|
|
241
|
+
const manualOfferId = options.manualOffer?.id ?? null;
|
|
242
|
+
const selectedOfferIds = appliedOffers.map((offer) => offer.offerId);
|
|
243
|
+
const manualSelected = manualOfferId ? selectedOfferIds.includes(manualOfferId) : false;
|
|
244
|
+
const autoAppliedOfferIds = appliedOffers
|
|
245
|
+
.filter((offer) => offer.appliedCode == null && offer.offerId !== manualOfferId)
|
|
246
|
+
.map((offer) => offer.offerId);
|
|
247
|
+
const conflict = manualOfferId
|
|
248
|
+
? buildConflict({
|
|
249
|
+
autoAppliedOfferIds,
|
|
250
|
+
manualOfferId,
|
|
251
|
+
selectedOfferIds,
|
|
252
|
+
manualSelected,
|
|
253
|
+
})
|
|
254
|
+
: null;
|
|
255
|
+
if (evaluation.total.discountAppliedCents <= 0) {
|
|
256
|
+
return {
|
|
257
|
+
...emptyResult(input, "not_applicable", "no_discount", options.offer),
|
|
258
|
+
conflict,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
const status = manualOfferId && !manualSelected ? "conflict" : "applied";
|
|
262
|
+
return {
|
|
263
|
+
status,
|
|
264
|
+
reason: status === "conflict" ? "conflict" : null,
|
|
265
|
+
offer: options.offer,
|
|
266
|
+
target: targetFromInput(input),
|
|
267
|
+
pricing: {
|
|
268
|
+
basePriceCents: input.basePriceCents,
|
|
269
|
+
currency: input.currency,
|
|
270
|
+
discountAppliedCents: evaluation.total.discountAppliedCents,
|
|
271
|
+
discountedPriceCents: evaluation.total.discountedPriceCents,
|
|
272
|
+
},
|
|
273
|
+
appliedOffers,
|
|
274
|
+
conflict,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
function codeStatusToReason(status) {
|
|
278
|
+
if (status == null || status.kind === "code_valid")
|
|
279
|
+
return null;
|
|
280
|
+
if (status.kind === "code_not_applicable")
|
|
281
|
+
return status.reason;
|
|
282
|
+
return status.kind;
|
|
283
|
+
}
|
|
284
|
+
function toStorefrontAppliedOffer(offer) {
|
|
285
|
+
return {
|
|
286
|
+
offerId: offer.offerId,
|
|
287
|
+
offerName: offer.offerName,
|
|
288
|
+
discountAppliedCents: offer.discountAppliedCents,
|
|
289
|
+
discountedPriceCents: offer.discountedPriceCents,
|
|
290
|
+
currency: offer.currency,
|
|
291
|
+
discountKind: offer.discountKind,
|
|
292
|
+
discountPercent: offer.discountPercent,
|
|
293
|
+
discountAmountCents: offer.discountAmountCents,
|
|
294
|
+
appliedCode: offer.appliedCode,
|
|
295
|
+
stackable: offer.stackable,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
function targetFromInput(input) {
|
|
299
|
+
return {
|
|
300
|
+
bookingId: input.bookingId ?? null,
|
|
301
|
+
sessionId: input.sessionId ?? null,
|
|
302
|
+
productId: input.productId,
|
|
303
|
+
departureId: input.departureId ?? null,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
function buildConflict(input) {
|
|
307
|
+
if (input.autoAppliedOfferIds.length === 0 && input.manualSelected)
|
|
308
|
+
return null;
|
|
309
|
+
const policy = input.manualSelected ? "stackable_compose" : "best_discount_wins";
|
|
310
|
+
return {
|
|
311
|
+
policy,
|
|
312
|
+
autoAppliedOfferIds: input.autoAppliedOfferIds,
|
|
313
|
+
manualOfferId: input.manualOfferId,
|
|
314
|
+
selectedOfferIds: input.selectedOfferIds,
|
|
315
|
+
message: policy === "stackable_compose"
|
|
316
|
+
? "The manually applied offer composes with selected auto-applied offers because every selected offer is stackable."
|
|
317
|
+
: "The best discount wins when a manually applied or code offer competes with non-stackable auto-applied offers.",
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
export const __test__ = {
|
|
321
|
+
buildConflict,
|
|
322
|
+
codeStatusToReason,
|
|
323
|
+
currentValidityStatus,
|
|
324
|
+
matchesProduct,
|
|
325
|
+
toStorefrontDto,
|
|
326
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Promotions service — CRUD over `promotional_offers` plus link-table
|
|
3
|
+
* materialization for product-shaped scopes.
|
|
4
|
+
*
|
|
5
|
+
* Per docs/architecture/promotions-architecture.md §13 (PR1 scope):
|
|
6
|
+
* - listOffers / getOfferById / createOffer / updateOffer / archiveOffer / deleteOffer
|
|
7
|
+
* - recomputeOfferLinks (rebuilds `promotional_offer_products` from current scope)
|
|
8
|
+
* - emit `promotion.changed` (when an `eventBus` is supplied via the runtime arg)
|
|
9
|
+
*
|
|
10
|
+
* The evaluator (PR2) and catalog-plane / booking-engine / scheduler wiring
|
|
11
|
+
* (PR3 + PR4 + PR3.boundary) are NOT in this PR.
|
|
12
|
+
*
|
|
13
|
+
* Cross-module reads:
|
|
14
|
+
* - `categories` scope expands via the Product-owned `product_category_products` table
|
|
15
|
+
* - `destinations` scope expands via the Product-owned `product_destinations` table
|
|
16
|
+
* through a resolver seam or raw SQL fallback. Promotions does not import
|
|
17
|
+
* Product schemas at runtime.
|
|
18
|
+
*/
|
|
19
|
+
import type { EventBus } from "@voyant-travel/core";
|
|
20
|
+
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
21
|
+
import { type PromotionChangedSource } from "./events.js";
|
|
22
|
+
import { type PromotionalOffer } from "./schema.js";
|
|
23
|
+
import type { InsertPromotionalOffer, PromotionalOfferListQuery, PromotionalOfferScope, UpdatePromotionalOffer } from "./validation.js";
|
|
24
|
+
export interface OfferMutationRuntime {
|
|
25
|
+
/**
|
|
26
|
+
* Optional event bus. When wired, every CRUD path emits
|
|
27
|
+
* `promotion.changed` so the catalog bridge reindexes affected products.
|
|
28
|
+
* Per docs/architecture/promotions-architecture.md §9.1.
|
|
29
|
+
*/
|
|
30
|
+
eventBus?: EventBus;
|
|
31
|
+
/**
|
|
32
|
+
* Override the emission `source`. `createOffer` / `deleteOffer` default
|
|
33
|
+
* to `"created"` / `"deleted"`; `updateOffer` / `archiveOffer` default
|
|
34
|
+
* to `"updated"`. The boundary scheduler (PR3) overrides with
|
|
35
|
+
* `"expired"` for `validUntil` crossings.
|
|
36
|
+
*/
|
|
37
|
+
source?: PromotionChangedSource;
|
|
38
|
+
/**
|
|
39
|
+
* Optional resolver for Product-owned category/destination scope expansion.
|
|
40
|
+
* Hosts that move Product authoring behind Inventory can inject the
|
|
41
|
+
* appropriate adapter here without making Promotions depend on Product
|
|
42
|
+
* schemas.
|
|
43
|
+
*/
|
|
44
|
+
resolveScopeProductIds?: ResolvePromotionalOfferScopeProductIds;
|
|
45
|
+
}
|
|
46
|
+
export type ResolvePromotionalOfferScopeProductIds = (db: PostgresJsDatabase, scope: Extract<PromotionalOfferScope, {
|
|
47
|
+
kind: "categories" | "destinations";
|
|
48
|
+
}>) => Promise<string[]>;
|
|
49
|
+
/**
|
|
50
|
+
* Expand an offer's scope to the product set it covers. Used by
|
|
51
|
+
* `recomputeOfferLinks` and by the event emitter to populate
|
|
52
|
+
* `affected.productIds`.
|
|
53
|
+
*
|
|
54
|
+
* Returns `null` for slice-shaped or checkout-shaped scopes (`global`,
|
|
55
|
+
* `markets`, `audiences`, `fare_codes`, `cabin_grades`)
|
|
56
|
+
* to signal that the link table should be empty AND that the event payload
|
|
57
|
+
* should fall back to `affected: { kind: "all" }` — these scopes can match
|
|
58
|
+
* an unbounded product set and we don't enumerate them at write time
|
|
59
|
+
* (per §9.1's resolution rules).
|
|
60
|
+
*/
|
|
61
|
+
export declare function resolveScopeProductIds(db: PostgresJsDatabase, scope: PromotionalOfferScope, resolver?: ResolvePromotionalOfferScopeProductIds): Promise<string[] | null>;
|
|
62
|
+
/**
|
|
63
|
+
* Rebuild the `promotional_offer_products` rows for an offer from its
|
|
64
|
+
* current scope. Idempotent: deletes any prior rows, inserts the
|
|
65
|
+
* freshly-resolved set. Slice-shaped scopes leave the table empty.
|
|
66
|
+
*/
|
|
67
|
+
export declare function recomputeOfferLinks(db: PostgresJsDatabase, offerId: string, scope: PromotionalOfferScope, runtime?: OfferMutationRuntime): Promise<{
|
|
68
|
+
productIds: string[] | null;
|
|
69
|
+
}>;
|
|
70
|
+
declare function listOffers(db: PostgresJsDatabase, query: PromotionalOfferListQuery): Promise<{
|
|
71
|
+
data: {
|
|
72
|
+
id: string;
|
|
73
|
+
name: string;
|
|
74
|
+
slug: string;
|
|
75
|
+
description: string | null;
|
|
76
|
+
discountType: "percentage" | "fixed_amount";
|
|
77
|
+
discountPercent: string | null;
|
|
78
|
+
discountAmountCents: number | null;
|
|
79
|
+
currency: string | null;
|
|
80
|
+
scope: {
|
|
81
|
+
kind: "global";
|
|
82
|
+
} | {
|
|
83
|
+
kind: "products";
|
|
84
|
+
productIds: string[];
|
|
85
|
+
} | {
|
|
86
|
+
kind: "categories";
|
|
87
|
+
categoryIds: string[];
|
|
88
|
+
} | {
|
|
89
|
+
kind: "destinations";
|
|
90
|
+
destinationIds: string[];
|
|
91
|
+
} | {
|
|
92
|
+
kind: "markets";
|
|
93
|
+
marketIds: string[];
|
|
94
|
+
} | {
|
|
95
|
+
kind: "audiences";
|
|
96
|
+
audiences: ("staff" | "customer" | "partner" | "supplier")[];
|
|
97
|
+
} | {
|
|
98
|
+
kind: "fare_codes";
|
|
99
|
+
fareCodes: string[];
|
|
100
|
+
} | {
|
|
101
|
+
kind: "cabin_grades";
|
|
102
|
+
cabinGradeCodes: string[];
|
|
103
|
+
};
|
|
104
|
+
conditions: {
|
|
105
|
+
minPax?: number | undefined;
|
|
106
|
+
pastGuestOnly?: boolean | undefined;
|
|
107
|
+
soloTravelerOnly?: boolean | undefined;
|
|
108
|
+
childTravelerOnly?: boolean | undefined;
|
|
109
|
+
familyOnly?: boolean | undefined;
|
|
110
|
+
};
|
|
111
|
+
validFrom: Date | null;
|
|
112
|
+
validUntil: Date | null;
|
|
113
|
+
code: string | null;
|
|
114
|
+
stackable: boolean;
|
|
115
|
+
active: boolean;
|
|
116
|
+
metadata: unknown;
|
|
117
|
+
createdAt: Date;
|
|
118
|
+
updatedAt: Date;
|
|
119
|
+
}[];
|
|
120
|
+
total: number;
|
|
121
|
+
limit: number;
|
|
122
|
+
offset: number;
|
|
123
|
+
}>;
|
|
124
|
+
declare function getOfferById(db: PostgresJsDatabase, id: string): Promise<PromotionalOffer | null>;
|
|
125
|
+
declare function createOffer(db: PostgresJsDatabase, input: InsertPromotionalOffer, runtime?: OfferMutationRuntime): Promise<PromotionalOffer>;
|
|
126
|
+
declare function updateOffer(db: PostgresJsDatabase, id: string, patch: UpdatePromotionalOffer, runtime?: OfferMutationRuntime): Promise<PromotionalOffer | null>;
|
|
127
|
+
declare function archiveOffer(db: PostgresJsDatabase, id: string, runtime?: OfferMutationRuntime): Promise<PromotionalOffer | null>;
|
|
128
|
+
declare function deleteOffer(db: PostgresJsDatabase, id: string, runtime?: OfferMutationRuntime): Promise<{
|
|
129
|
+
id: string;
|
|
130
|
+
} | null>;
|
|
131
|
+
export declare const promotionsService: {
|
|
132
|
+
listOffers: typeof listOffers;
|
|
133
|
+
getOfferById: typeof getOfferById;
|
|
134
|
+
createOffer: typeof createOffer;
|
|
135
|
+
updateOffer: typeof updateOffer;
|
|
136
|
+
archiveOffer: typeof archiveOffer;
|
|
137
|
+
deleteOffer: typeof deleteOffer;
|
|
138
|
+
recomputeOfferLinks: typeof recomputeOfferLinks;
|
|
139
|
+
resolveScopeProductIds: typeof resolveScopeProductIds;
|
|
140
|
+
};
|
|
141
|
+
export type PromotionsService = typeof promotionsService;
|
|
142
|
+
export {};
|
|
143
|
+
//# sourceMappingURL=service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../../src/promotions/service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAA;AAEnD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AAEjE,OAAO,EAIL,KAAK,sBAAsB,EAC5B,MAAM,aAAa,CAAA;AACpB,OAAO,EAEL,KAAK,gBAAgB,EAItB,MAAM,aAAa,CAAA;AACpB,OAAO,KAAK,EACV,sBAAsB,EACtB,yBAAyB,EACzB,qBAAqB,EACrB,sBAAsB,EACvB,MAAM,iBAAiB,CAAA;AAExB,MAAM,WAAW,oBAAoB;IACnC;;;;OAIG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAA;IACnB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,sBAAsB,CAAA;IAC/B;;;;;OAKG;IACH,sBAAsB,CAAC,EAAE,sCAAsC,CAAA;CAChE;AAED,MAAM,MAAM,sCAAsC,GAAG,CACnD,EAAE,EAAE,kBAAkB,EACtB,KAAK,EAAE,OAAO,CAAC,qBAAqB,EAAE;IAAE,IAAI,EAAE,YAAY,GAAG,cAAc,CAAA;CAAE,CAAC,KAC3E,OAAO,CAAC,MAAM,EAAE,CAAC,CAAA;AAUtB;;;;;;;;;;;GAWG;AACH,wBAAsB,sBAAsB,CAC1C,EAAE,EAAE,kBAAkB,EACtB,KAAK,EAAE,qBAAqB,EAC5B,QAAQ,CAAC,EAAE,sCAAsC,GAChD,OAAO,CAAC,MAAM,EAAE,GAAG,IAAI,CAAC,CAqB1B;AA6CD;;;;GAIG;AACH,wBAAsB,mBAAmB,CACvC,EAAE,EAAE,kBAAkB,EACtB,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,qBAAqB,EAC5B,OAAO,GAAE,oBAAyB,GACjC,OAAO,CAAC;IAAE,UAAU,EAAE,MAAM,EAAE,GAAG,IAAI,CAAA;CAAE,CAAC,CAS1C;AA8ED,iBAAe,UAAU,CAAC,EAAE,EAAE,kBAAkB,EAAE,KAAK,EAAE,yBAAyB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2EjF;AAUD,iBAAe,YAAY,CAAC,EAAE,EAAE,kBAAkB,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAOhG;AAED,iBAAe,WAAW,CACxB,EAAE,EAAE,kBAAkB,EACtB,KAAK,EAAE,sBAAsB,EAC7B,OAAO,GAAE,oBAAyB,GACjC,OAAO,CAAC,gBAAgB,CAAC,CAa3B;AAED,iBAAe,WAAW,CACxB,EAAE,EAAE,kBAAkB,EACtB,EAAE,EAAE,MAAM,EACV,KAAK,EAAE,sBAAsB,EAC7B,OAAO,GAAE,oBAAyB,GACjC,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAyClC;AAED,iBAAe,YAAY,CACzB,EAAE,EAAE,kBAAkB,EACtB,EAAE,EAAE,MAAM,EACV,OAAO,GAAE,oBAAyB,GACjC,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAgBlC;AAED,iBAAe,WAAW,CACxB,EAAE,EAAE,kBAAkB,EACtB,EAAE,EAAE,MAAM,EACV,OAAO,GAAE,oBAAyB,GACjC,OAAO,CAAC;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAC,CAqChC;AAED,eAAO,MAAM,iBAAiB;;;;;;;;;CAS7B,CAAA;AAED,MAAM,MAAM,iBAAiB,GAAG,OAAO,iBAAiB,CAAA"}
|