@pvh-afl/core 1.1.6 → 1.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/integrations/commerce/shopify-admin.service.d.ts +76 -16
- package/dist/integrations/commerce/shopify-admin.service.d.ts.map +1 -1
- package/dist/integrations/commerce/shopify-admin.service.js +585 -400
- package/dist/integrations/commerce/shopify-admin.service.js.map +1 -1
- package/package.json +1 -1
|
@@ -9,359 +9,373 @@ const logger_1 = require("../../common/logger");
|
|
|
9
9
|
* Shopify Admin API Service
|
|
10
10
|
*
|
|
11
11
|
* Handles Shopify Admin API operations for discount code management.
|
|
12
|
-
* Uses
|
|
12
|
+
* Uses GraphQL Admin API for discount operations with tags support.
|
|
13
|
+
*
|
|
14
|
+
* Migration from REST to GraphQL (2024):
|
|
15
|
+
* - REST: price_rules + discount_codes endpoints
|
|
16
|
+
* - GraphQL: discountCodeBasic mutations with tags support
|
|
17
|
+
*
|
|
18
|
+
* All Capillary-synced discounts are tagged with "CAPILLARY" for easy identification.
|
|
13
19
|
*/
|
|
14
20
|
let ShopifyAdminService = class ShopifyAdminService {
|
|
15
21
|
configService;
|
|
16
|
-
|
|
22
|
+
GRAPHQL_API_VERSION = '2026-04';
|
|
23
|
+
CAPILLARY_TAG = 'CAPILLARY';
|
|
17
24
|
constructor(configService) {
|
|
18
25
|
this.configService = configService;
|
|
19
26
|
}
|
|
20
27
|
/**
|
|
21
28
|
* Create a Shopify discount code from a validated Capillary coupon.
|
|
22
29
|
*
|
|
23
|
-
*
|
|
30
|
+
* Behavior:
|
|
24
31
|
* - Uses original Capillary code directly (e.g., "SUMMER20")
|
|
25
|
-
* - If code exists: adds customer to
|
|
26
|
-
* - If code doesn't exist: creates with customer
|
|
27
|
-
* -
|
|
32
|
+
* - If code exists: updates properties from Capillary and adds customer to eligibility
|
|
33
|
+
* - If code doesn't exist: creates with customer-specific eligibility
|
|
34
|
+
* - Tags discount with "CAPILLARY" for identification
|
|
35
|
+
* - Uses appliesOncePerCustomer: true for per-customer usage limit
|
|
28
36
|
*/
|
|
29
37
|
async createDiscountFromCapillaryCoupon(brand, capillaryValidation, shopifyCustomerId) {
|
|
30
|
-
// Use original code directly - no prefix, no suffix
|
|
31
38
|
const shopifyCode = capillaryValidation.couponCode;
|
|
32
39
|
// Check if discount code already exists
|
|
33
40
|
const existing = await this.getDiscountCodeByCode(brand, shopifyCode);
|
|
34
41
|
if (existing) {
|
|
35
|
-
// Discount code exists - add customer to
|
|
36
|
-
logger_1.logger.info('Shopify discount code exists, adding customer
|
|
42
|
+
// Discount code exists - update properties and add customer to eligibility
|
|
43
|
+
logger_1.logger.info('Shopify discount code exists, updating properties and adding customer', {
|
|
37
44
|
brand,
|
|
38
45
|
shopifyCode,
|
|
39
46
|
shopifyCustomerId,
|
|
47
|
+
discountId: existing.discountId,
|
|
40
48
|
});
|
|
41
|
-
const
|
|
42
|
-
if (!
|
|
49
|
+
const updateResult = await this.updateDiscountWithCapillaryData(brand, existing.discountId, capillaryValidation, shopifyCustomerId);
|
|
50
|
+
if (!updateResult.success) {
|
|
43
51
|
return {
|
|
44
52
|
success: false,
|
|
45
53
|
discountCode: shopifyCode,
|
|
46
|
-
|
|
47
|
-
error:
|
|
54
|
+
discountId: existing.discountId,
|
|
55
|
+
error: updateResult.error,
|
|
48
56
|
};
|
|
49
57
|
}
|
|
50
58
|
return {
|
|
51
59
|
success: true,
|
|
52
60
|
discountCode: shopifyCode,
|
|
53
|
-
|
|
54
|
-
discountCodeId: existing.discountCodeId,
|
|
61
|
+
discountId: existing.discountId,
|
|
55
62
|
alreadyExists: true,
|
|
56
63
|
};
|
|
57
64
|
}
|
|
58
|
-
// Discount code doesn't exist - create new
|
|
59
|
-
logger_1.logger.info('Creating new Shopify discount code', {
|
|
65
|
+
// Discount code doesn't exist - create new discount
|
|
66
|
+
logger_1.logger.info('Creating new Shopify discount code via GraphQL', {
|
|
60
67
|
brand,
|
|
61
68
|
shopifyCode,
|
|
62
69
|
shopifyCustomerId,
|
|
63
70
|
});
|
|
64
|
-
//
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
valueType: capillaryValidation.discountType === 'PERC' ? 'percentage' : 'fixed_amount',
|
|
69
|
-
value: -capillaryValidation.discountValue,
|
|
70
|
-
usageLimit: capillaryValidation.redemptionsLeft ?? 1,
|
|
71
|
-
oncePerCustomer: true,
|
|
72
|
-
startsAt: capillaryValidation.validFrom
|
|
73
|
-
? new Date(capillaryValidation.validFrom).toISOString()
|
|
74
|
-
: new Date().toISOString(),
|
|
75
|
-
endsAt: capillaryValidation.validTill,
|
|
76
|
-
allocationMethod: 'across',
|
|
77
|
-
targetType: 'line_item',
|
|
78
|
-
targetSelection: 'all',
|
|
79
|
-
customerSelection: 'prerequisite',
|
|
80
|
-
prerequisiteCustomerIds: [numericCustomerId],
|
|
81
|
-
};
|
|
82
|
-
const priceRule = await this.createPriceRule(brand, priceRuleConfig);
|
|
83
|
-
if (!priceRule.success) {
|
|
84
|
-
return {
|
|
85
|
-
success: false,
|
|
86
|
-
discountCode: shopifyCode,
|
|
87
|
-
error: priceRule.error,
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
const discountCode = await this.createDiscountCode(brand, priceRule.priceRuleId, shopifyCode);
|
|
91
|
-
if (!discountCode.success) {
|
|
71
|
+
// Format customer ID as GID if needed
|
|
72
|
+
const customerGid = this.formatCustomerGid(shopifyCustomerId);
|
|
73
|
+
const createResult = await this.createDiscountViaGraphQL(brand, shopifyCode, capillaryValidation, customerGid);
|
|
74
|
+
if (!createResult.success) {
|
|
92
75
|
return {
|
|
93
76
|
success: false,
|
|
94
77
|
discountCode: shopifyCode,
|
|
95
|
-
|
|
96
|
-
error: discountCode.error,
|
|
78
|
+
error: createResult.error,
|
|
97
79
|
};
|
|
98
80
|
}
|
|
99
|
-
logger_1.logger.info('Shopify discount code created', {
|
|
81
|
+
logger_1.logger.info('Shopify discount code created via GraphQL', {
|
|
100
82
|
brand,
|
|
101
83
|
shopifyCode,
|
|
102
|
-
|
|
103
|
-
discountCodeId: discountCode.discountCodeId,
|
|
84
|
+
discountId: createResult.discountId,
|
|
104
85
|
});
|
|
105
86
|
return {
|
|
106
87
|
success: true,
|
|
107
88
|
discountCode: shopifyCode,
|
|
108
|
-
|
|
109
|
-
discountCodeId: discountCode.discountCodeId,
|
|
89
|
+
discountId: createResult.discountId,
|
|
110
90
|
};
|
|
111
91
|
}
|
|
112
92
|
/**
|
|
113
|
-
*
|
|
114
|
-
* Used when multiple customers share the same Capillary coupon code.
|
|
93
|
+
* Create a new discount via GraphQL Admin API.
|
|
115
94
|
*/
|
|
116
|
-
async
|
|
95
|
+
async createDiscountViaGraphQL(brand, code, capillaryValidation, customerGid) {
|
|
117
96
|
const config = this.getAdminConfig(brand);
|
|
118
|
-
const
|
|
97
|
+
const mutation = `
|
|
98
|
+
mutation discountCodeBasicCreate($basicCodeDiscount: DiscountCodeBasicInput!) {
|
|
99
|
+
discountCodeBasicCreate(basicCodeDiscount: $basicCodeDiscount) {
|
|
100
|
+
codeDiscountNode {
|
|
101
|
+
id
|
|
102
|
+
}
|
|
103
|
+
userErrors {
|
|
104
|
+
field
|
|
105
|
+
message
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
`;
|
|
110
|
+
// Build discount value based on type
|
|
111
|
+
const discountValue = this.buildDiscountValue(capillaryValidation);
|
|
112
|
+
const variables = {
|
|
113
|
+
basicCodeDiscount: {
|
|
114
|
+
title: code,
|
|
115
|
+
code: code,
|
|
116
|
+
startsAt: capillaryValidation.validFrom
|
|
117
|
+
? new Date(capillaryValidation.validFrom).toISOString()
|
|
118
|
+
: new Date().toISOString(),
|
|
119
|
+
endsAt: capillaryValidation.validTill
|
|
120
|
+
? new Date(capillaryValidation.validTill).toISOString()
|
|
121
|
+
: null,
|
|
122
|
+
usageLimit: capillaryValidation.redemptionsLeft ?? 1,
|
|
123
|
+
appliesOncePerCustomer: true,
|
|
124
|
+
tags: [this.CAPILLARY_TAG],
|
|
125
|
+
customerGets: {
|
|
126
|
+
value: discountValue,
|
|
127
|
+
items: { all: true },
|
|
128
|
+
},
|
|
129
|
+
context: {
|
|
130
|
+
customers: {
|
|
131
|
+
add: [customerGid],
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
};
|
|
119
136
|
try {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (!getResponse.ok) {
|
|
126
|
-
const errorText = await getResponse.text();
|
|
127
|
-
logger_1.logger.error('Failed to get price rule for customer addition', {
|
|
128
|
-
brand,
|
|
129
|
-
priceRuleId,
|
|
130
|
-
error: errorText,
|
|
131
|
-
});
|
|
132
|
-
return { success: false, error: `Failed to get price rule: ${getResponse.status}` };
|
|
133
|
-
}
|
|
134
|
-
const priceRuleData = (await getResponse.json());
|
|
135
|
-
// Extract numeric customer ID
|
|
136
|
-
const numericCustomerId = parseInt(this.extractNumericCustomerId(shopifyCustomerId), 10);
|
|
137
|
-
// Get existing customer IDs
|
|
138
|
-
const existingCustomerIds = priceRuleData.price_rule.prerequisite_customer_ids ?? [];
|
|
139
|
-
// Check if customer already exists
|
|
140
|
-
if (existingCustomerIds.includes(numericCustomerId)) {
|
|
141
|
-
logger_1.logger.info('Customer already in price rule prerequisite_customer_ids', {
|
|
142
|
-
brand,
|
|
143
|
-
priceRuleId,
|
|
144
|
-
shopifyCustomerId,
|
|
145
|
-
});
|
|
146
|
-
return { success: true };
|
|
137
|
+
const response = await this.executeGraphQL(config, mutation, variables);
|
|
138
|
+
if (response.errors && response.errors.length > 0) {
|
|
139
|
+
const errorMsg = response.errors.map((e) => e.message).join(', ');
|
|
140
|
+
logger_1.logger.error('GraphQL errors creating discount', { brand, code, errors: response.errors });
|
|
141
|
+
return { success: false, error: errorMsg };
|
|
147
142
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
headers: this.getHeaders(config),
|
|
154
|
-
body: JSON.stringify({
|
|
155
|
-
price_rule: {
|
|
156
|
-
prerequisite_customer_ids: updatedCustomerIds,
|
|
157
|
-
customer_selection: 'prerequisite',
|
|
158
|
-
},
|
|
159
|
-
}),
|
|
160
|
-
});
|
|
161
|
-
if (!updateResponse.ok) {
|
|
162
|
-
const errorText = await updateResponse.text();
|
|
163
|
-
logger_1.logger.error('Failed to update price rule with new customer', {
|
|
164
|
-
brand,
|
|
165
|
-
priceRuleId,
|
|
166
|
-
shopifyCustomerId,
|
|
167
|
-
numericCustomerId,
|
|
168
|
-
status: updateResponse.status,
|
|
169
|
-
error: errorText,
|
|
170
|
-
});
|
|
171
|
-
// Include actual error details in response
|
|
172
|
-
let errorDetail = `Failed to update price rule: ${updateResponse.status}`;
|
|
173
|
-
try {
|
|
174
|
-
const errorJson = JSON.parse(errorText);
|
|
175
|
-
if (errorJson.errors) {
|
|
176
|
-
errorDetail = `Shopify error: ${JSON.stringify(errorJson.errors)}`;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
catch {
|
|
180
|
-
// errorText is not JSON
|
|
181
|
-
}
|
|
182
|
-
return { success: false, error: errorDetail };
|
|
143
|
+
const result = response.data?.discountCodeBasicCreate;
|
|
144
|
+
if (result?.userErrors && result.userErrors.length > 0) {
|
|
145
|
+
const errorMsg = result.userErrors.map((e) => e.message).join(', ');
|
|
146
|
+
logger_1.logger.error('User errors creating discount', { brand, code, userErrors: result.userErrors });
|
|
147
|
+
return { success: false, error: errorMsg };
|
|
183
148
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
totalCustomers: updatedCustomerIds.length,
|
|
189
|
-
});
|
|
190
|
-
return { success: true };
|
|
149
|
+
return {
|
|
150
|
+
success: true,
|
|
151
|
+
discountId: result?.codeDiscountNode?.id,
|
|
152
|
+
};
|
|
191
153
|
}
|
|
192
154
|
catch (error) {
|
|
193
155
|
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
|
194
|
-
logger_1.logger.error('
|
|
195
|
-
brand,
|
|
196
|
-
priceRuleId,
|
|
197
|
-
shopifyCustomerId,
|
|
198
|
-
error: errorMsg,
|
|
199
|
-
});
|
|
156
|
+
logger_1.logger.error('Failed to create discount via GraphQL', { brand, code, error: errorMsg });
|
|
200
157
|
return { success: false, error: errorMsg };
|
|
201
158
|
}
|
|
202
159
|
}
|
|
203
160
|
/**
|
|
204
|
-
*
|
|
161
|
+
* Update an existing discount with fresh Capillary data and add customer.
|
|
162
|
+
* This resolves the stale data issue by always syncing latest properties.
|
|
205
163
|
*/
|
|
206
|
-
|
|
207
|
-
if (shopifyCustomerId.startsWith('gid://shopify/Customer/')) {
|
|
208
|
-
const match = shopifyCustomerId.match(/\/(\d+)$/);
|
|
209
|
-
return match ? match[1] : shopifyCustomerId;
|
|
210
|
-
}
|
|
211
|
-
return shopifyCustomerId;
|
|
212
|
-
}
|
|
213
|
-
async createPriceRule(brand, params) {
|
|
164
|
+
async updateDiscountWithCapillaryData(brand, discountId, capillaryValidation, shopifyCustomerId) {
|
|
214
165
|
const config = this.getAdminConfig(brand);
|
|
215
|
-
const
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
166
|
+
const customerGid = this.formatCustomerGid(shopifyCustomerId);
|
|
167
|
+
const mutation = `
|
|
168
|
+
mutation discountCodeBasicUpdate($id: ID!, $basicCodeDiscount: DiscountCodeBasicInput!) {
|
|
169
|
+
discountCodeBasicUpdate(id: $id, basicCodeDiscount: $basicCodeDiscount) {
|
|
170
|
+
codeDiscountNode {
|
|
171
|
+
id
|
|
172
|
+
}
|
|
173
|
+
userErrors {
|
|
174
|
+
field
|
|
175
|
+
message
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
`;
|
|
180
|
+
// Build discount value based on type
|
|
181
|
+
const discountValue = this.buildDiscountValue(capillaryValidation);
|
|
182
|
+
const variables = {
|
|
183
|
+
id: discountId,
|
|
184
|
+
basicCodeDiscount: {
|
|
185
|
+
// Update properties from fresh Capillary validation
|
|
186
|
+
endsAt: capillaryValidation.validTill
|
|
187
|
+
? new Date(capillaryValidation.validTill).toISOString()
|
|
188
|
+
: null,
|
|
189
|
+
usageLimit: capillaryValidation.redemptionsLeft ?? 1,
|
|
190
|
+
customerGets: {
|
|
191
|
+
value: discountValue,
|
|
192
|
+
items: { all: true },
|
|
193
|
+
},
|
|
194
|
+
// Add customer to eligibility
|
|
195
|
+
context: {
|
|
196
|
+
customers: {
|
|
197
|
+
add: [customerGid],
|
|
198
|
+
},
|
|
199
|
+
},
|
|
230
200
|
},
|
|
231
201
|
};
|
|
232
202
|
try {
|
|
233
|
-
const response = await
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
});
|
|
238
|
-
if (!response.ok) {
|
|
239
|
-
const errorData = (await response.json());
|
|
240
|
-
const errorMsg = errorData.errors
|
|
241
|
-
? JSON.stringify(errorData.errors)
|
|
242
|
-
: `HTTP ${response.status}`;
|
|
243
|
-
if (response.status === 422 && errorMsg.includes('title')) {
|
|
244
|
-
logger_1.logger.warn('Price rule naming collision, looking up existing', { brand, title: params.title });
|
|
245
|
-
const existing = await this.findPriceRuleByTitle(brand, params.title);
|
|
246
|
-
if (existing) {
|
|
247
|
-
return { success: true, priceRuleId: existing };
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
logger_1.logger.error('Failed to create Shopify price rule', { brand, error: errorMsg });
|
|
203
|
+
const response = await this.executeGraphQL(config, mutation, variables);
|
|
204
|
+
if (response.errors && response.errors.length > 0) {
|
|
205
|
+
const errorMsg = response.errors.map((e) => e.message).join(', ');
|
|
206
|
+
logger_1.logger.error('GraphQL errors updating discount', { brand, discountId, errors: response.errors });
|
|
251
207
|
return { success: false, error: errorMsg };
|
|
252
208
|
}
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
209
|
+
const result = response.data?.discountCodeBasicUpdate;
|
|
210
|
+
if (result?.userErrors && result.userErrors.length > 0) {
|
|
211
|
+
const errorMsg = result.userErrors.map((e) => e.message).join(', ');
|
|
212
|
+
logger_1.logger.error('User errors updating discount', { brand, discountId, userErrors: result.userErrors });
|
|
213
|
+
return { success: false, error: errorMsg };
|
|
214
|
+
}
|
|
215
|
+
logger_1.logger.info('Discount updated with fresh Capillary data', {
|
|
216
|
+
brand,
|
|
217
|
+
discountId,
|
|
218
|
+
shopifyCustomerId,
|
|
219
|
+
endsAt: capillaryValidation.validTill,
|
|
220
|
+
discountValue: capillaryValidation.discountValue,
|
|
221
|
+
});
|
|
222
|
+
return { success: true };
|
|
258
223
|
}
|
|
259
224
|
catch (error) {
|
|
260
225
|
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
|
261
|
-
logger_1.logger.error('
|
|
226
|
+
logger_1.logger.error('Failed to update discount via GraphQL', { brand, discountId, error: errorMsg });
|
|
262
227
|
return { success: false, error: errorMsg };
|
|
263
228
|
}
|
|
264
229
|
}
|
|
265
|
-
|
|
230
|
+
/**
|
|
231
|
+
* Build discount value object based on Capillary discount type.
|
|
232
|
+
*/
|
|
233
|
+
buildDiscountValue(capillaryValidation) {
|
|
234
|
+
if (capillaryValidation.discountType === 'PERC') {
|
|
235
|
+
// GraphQL expects percentage as decimal (0.10 for 10%)
|
|
236
|
+
return { percentage: (capillaryValidation.discountValue ?? 0) / 100 };
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
// Fixed amount discount
|
|
240
|
+
return {
|
|
241
|
+
discountAmount: {
|
|
242
|
+
amount: capillaryValidation.discountValue ?? 0,
|
|
243
|
+
appliesOnEachItem: false,
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Add a customer to an existing discount's eligibility.
|
|
250
|
+
* Used when multiple customers share the same Capillary coupon code.
|
|
251
|
+
*
|
|
252
|
+
* @deprecated Use updateDiscountWithCapillaryData instead which also updates properties
|
|
253
|
+
*/
|
|
254
|
+
async addCustomerToPriceRule(brand, priceRuleId, shopifyCustomerId) {
|
|
255
|
+
// For backwards compatibility, map priceRuleId to discountId
|
|
256
|
+
// In the new GraphQL model, we use discount GIDs
|
|
257
|
+
const discountId = priceRuleId.startsWith('gid://')
|
|
258
|
+
? priceRuleId
|
|
259
|
+
: `gid://shopify/DiscountCodeNode/${priceRuleId}`;
|
|
260
|
+
return this.addCustomerToDiscount(brand, discountId, shopifyCustomerId);
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Add a customer to an existing discount's eligibility.
|
|
264
|
+
*/
|
|
265
|
+
async addCustomerToDiscount(brand, discountId, shopifyCustomerId) {
|
|
266
266
|
const config = this.getAdminConfig(brand);
|
|
267
|
-
const
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
267
|
+
const customerGid = this.formatCustomerGid(shopifyCustomerId);
|
|
268
|
+
const mutation = `
|
|
269
|
+
mutation discountCodeBasicUpdate($id: ID!, $basicCodeDiscount: DiscountCodeBasicInput!) {
|
|
270
|
+
discountCodeBasicUpdate(id: $id, basicCodeDiscount: $basicCodeDiscount) {
|
|
271
|
+
codeDiscountNode {
|
|
272
|
+
id
|
|
273
|
+
}
|
|
274
|
+
userErrors {
|
|
275
|
+
field
|
|
276
|
+
message
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
`;
|
|
281
|
+
const variables = {
|
|
282
|
+
id: discountId,
|
|
283
|
+
basicCodeDiscount: {
|
|
284
|
+
context: {
|
|
285
|
+
customers: {
|
|
286
|
+
add: [customerGid],
|
|
287
|
+
},
|
|
288
|
+
},
|
|
271
289
|
},
|
|
272
290
|
};
|
|
273
291
|
try {
|
|
274
|
-
const response = await
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
});
|
|
279
|
-
if (!response.ok) {
|
|
280
|
-
const errorData = (await response.json());
|
|
281
|
-
const errorMsg = errorData.errors
|
|
282
|
-
? JSON.stringify(errorData.errors)
|
|
283
|
-
: `HTTP ${response.status}`;
|
|
284
|
-
if (response.status === 422 && errorMsg.includes('code')) {
|
|
285
|
-
logger_1.logger.warn('Discount code already exists', { brand, code, priceRuleId });
|
|
286
|
-
const existing = await this.findDiscountCodeByCode(brand, priceRuleId, code);
|
|
287
|
-
if (existing) {
|
|
288
|
-
return { success: true, discountCodeId: existing };
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
logger_1.logger.error('Failed to create Shopify discount code', { brand, error: errorMsg });
|
|
292
|
+
const response = await this.executeGraphQL(config, mutation, variables);
|
|
293
|
+
if (response.errors && response.errors.length > 0) {
|
|
294
|
+
const errorMsg = response.errors.map((e) => e.message).join(', ');
|
|
295
|
+
logger_1.logger.error('GraphQL errors adding customer to discount', { brand, discountId, errors: response.errors });
|
|
292
296
|
return { success: false, error: errorMsg };
|
|
293
297
|
}
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
298
|
+
const result = response.data?.discountCodeBasicUpdate;
|
|
299
|
+
if (result?.userErrors && result.userErrors.length > 0) {
|
|
300
|
+
const errorMsg = result.userErrors.map((e) => e.message).join(', ');
|
|
301
|
+
logger_1.logger.error('User errors adding customer to discount', { brand, discountId, userErrors: result.userErrors });
|
|
302
|
+
return { success: false, error: errorMsg };
|
|
303
|
+
}
|
|
304
|
+
logger_1.logger.info('Customer added to discount eligibility', {
|
|
305
|
+
brand,
|
|
306
|
+
discountId,
|
|
307
|
+
shopifyCustomerId,
|
|
308
|
+
});
|
|
309
|
+
return { success: true };
|
|
299
310
|
}
|
|
300
311
|
catch (error) {
|
|
301
312
|
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
|
302
|
-
logger_1.logger.error('
|
|
313
|
+
logger_1.logger.error('Failed to add customer to discount', { brand, discountId, error: errorMsg });
|
|
303
314
|
return { success: false, error: errorMsg };
|
|
304
315
|
}
|
|
305
316
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
headers: this.getHeaders(config),
|
|
313
|
-
});
|
|
314
|
-
if (!response.ok) {
|
|
315
|
-
return null;
|
|
316
|
-
}
|
|
317
|
-
const data = (await response.json());
|
|
318
|
-
if (data.discount_code) {
|
|
319
|
-
return {
|
|
320
|
-
priceRuleId: data.discount_code.price_rule_id.toString(),
|
|
321
|
-
discountCodeId: data.discount_code.id.toString(),
|
|
322
|
-
};
|
|
323
|
-
}
|
|
324
|
-
return null;
|
|
317
|
+
/**
|
|
318
|
+
* Format customer ID as Shopify GID.
|
|
319
|
+
*/
|
|
320
|
+
formatCustomerGid(shopifyCustomerId) {
|
|
321
|
+
if (shopifyCustomerId.startsWith('gid://shopify/Customer/')) {
|
|
322
|
+
return shopifyCustomerId;
|
|
325
323
|
}
|
|
326
|
-
|
|
327
|
-
|
|
324
|
+
// Extract numeric ID if needed
|
|
325
|
+
const numericId = shopifyCustomerId.replace(/\D/g, '');
|
|
326
|
+
return `gid://shopify/Customer/${numericId}`;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Extract numeric ID from Shopify GID or return as-is if already numeric.
|
|
330
|
+
*
|
|
331
|
+
* @deprecated Use formatCustomerGid instead for GID format
|
|
332
|
+
*/
|
|
333
|
+
extractNumericCustomerId(shopifyCustomerId) {
|
|
334
|
+
if (shopifyCustomerId.startsWith('gid://shopify/Customer/')) {
|
|
335
|
+
const match = shopifyCustomerId.match(/\/(\d+)$/);
|
|
336
|
+
return match ? match[1] : shopifyCustomerId;
|
|
328
337
|
}
|
|
338
|
+
return shopifyCustomerId;
|
|
329
339
|
}
|
|
330
|
-
|
|
340
|
+
/**
|
|
341
|
+
* Lookup discount code by code string.
|
|
342
|
+
* Returns discount ID if found, null otherwise.
|
|
343
|
+
*/
|
|
344
|
+
async getDiscountCodeByCode(brand, code) {
|
|
331
345
|
const config = this.getAdminConfig(brand);
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
346
|
+
const query = `
|
|
347
|
+
query codeDiscountNodeByCode($code: String!) {
|
|
348
|
+
codeDiscountNodeByCode(code: $code) {
|
|
349
|
+
id
|
|
350
|
+
codeDiscount {
|
|
351
|
+
... on DiscountCodeBasic {
|
|
352
|
+
title
|
|
353
|
+
tags
|
|
340
354
|
}
|
|
341
|
-
|
|
342
|
-
const rule = data.price_rules?.find((r) => r.title === title);
|
|
343
|
-
return rule ? rule.id.toString() : null;
|
|
355
|
+
}
|
|
344
356
|
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
async findDiscountCodeByCode(brand, priceRuleId, code) {
|
|
350
|
-
const config = this.getAdminConfig(brand);
|
|
351
|
-
const url = `${config.baseUrl}/price_rules/${priceRuleId}/discount_codes.json`;
|
|
357
|
+
}
|
|
358
|
+
`;
|
|
352
359
|
try {
|
|
353
|
-
const response = await
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
});
|
|
357
|
-
if (!response.ok) {
|
|
360
|
+
const response = await this.executeGraphQL(config, query, { code });
|
|
361
|
+
if (response.errors && response.errors.length > 0) {
|
|
362
|
+
logger_1.logger.warn('GraphQL errors looking up discount', { brand, code, errors: response.errors });
|
|
358
363
|
return null;
|
|
359
364
|
}
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
365
|
+
const node = response.data?.codeDiscountNodeByCode;
|
|
366
|
+
if (node?.id) {
|
|
367
|
+
// For backwards compatibility, extract numeric ID for priceRuleId/discountCodeId
|
|
368
|
+
const numericId = node.id.match(/\/(\d+)$/)?.[1] ?? node.id;
|
|
369
|
+
return {
|
|
370
|
+
discountId: node.id,
|
|
371
|
+
priceRuleId: numericId, // Legacy field for backwards compatibility
|
|
372
|
+
discountCodeId: numericId, // Legacy field for backwards compatibility
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
return null;
|
|
363
376
|
}
|
|
364
|
-
catch {
|
|
377
|
+
catch (error) {
|
|
378
|
+
logger_1.logger.warn('Failed to lookup discount code', { brand, code, error });
|
|
365
379
|
return null;
|
|
366
380
|
}
|
|
367
381
|
}
|
|
@@ -371,18 +385,47 @@ let ShopifyAdminService = class ShopifyAdminService {
|
|
|
371
385
|
if (!adminConfig) {
|
|
372
386
|
throw new Error(`Shopify Admin API not configured for brand: ${brand}`);
|
|
373
387
|
}
|
|
388
|
+
// Build GraphQL URL from base URL
|
|
389
|
+
// baseUrl format: https://store.myshopify.com/admin/api/VERSION
|
|
390
|
+
// graphqlUrl format: https://store.myshopify.com/admin/api/VERSION/graphql.json
|
|
391
|
+
let graphqlUrl = adminConfig.baseUrl;
|
|
392
|
+
// Replace version in URL with GraphQL version
|
|
393
|
+
graphqlUrl = graphqlUrl.replace(/\/api\/[^/]+/, `/api/${this.GRAPHQL_API_VERSION}`);
|
|
394
|
+
// Ensure it ends with graphql.json
|
|
395
|
+
if (!graphqlUrl.endsWith('/graphql.json')) {
|
|
396
|
+
graphqlUrl = graphqlUrl.replace(/\/?$/, '/graphql.json');
|
|
397
|
+
}
|
|
374
398
|
return {
|
|
375
|
-
baseUrl:
|
|
399
|
+
baseUrl: adminConfig.baseUrl,
|
|
400
|
+
graphqlUrl,
|
|
376
401
|
apiKey: adminConfig.apiKey,
|
|
377
402
|
webhookSecret: adminConfig.options?.webhookSecret,
|
|
378
403
|
};
|
|
379
404
|
}
|
|
405
|
+
/**
|
|
406
|
+
* Execute a GraphQL query/mutation against Shopify Admin API.
|
|
407
|
+
*/
|
|
408
|
+
async executeGraphQL(config, query, variables) {
|
|
409
|
+
const response = await fetch(config.graphqlUrl, {
|
|
410
|
+
method: 'POST',
|
|
411
|
+
headers: {
|
|
412
|
+
'Content-Type': 'application/json',
|
|
413
|
+
'X-Shopify-Access-Token': config.apiKey,
|
|
414
|
+
},
|
|
415
|
+
body: JSON.stringify({ query, variables }),
|
|
416
|
+
});
|
|
417
|
+
if (!response.ok) {
|
|
418
|
+
const errorText = await response.text();
|
|
419
|
+
throw new Error(`GraphQL request failed: ${response.status} - ${errorText}`);
|
|
420
|
+
}
|
|
421
|
+
return response.json();
|
|
422
|
+
}
|
|
380
423
|
getWebhookSecret(brand) {
|
|
381
424
|
const config = this.getAdminConfig(brand);
|
|
382
425
|
return config.webhookSecret;
|
|
383
426
|
}
|
|
384
427
|
/**
|
|
385
|
-
* Reactivate a Shopify discount code by
|
|
428
|
+
* Reactivate a Shopify discount code by extending its end date and usage limit.
|
|
386
429
|
* Used when a redeemed coupon needs to be returned to the user (refund/cancellation).
|
|
387
430
|
*/
|
|
388
431
|
async reactivateDiscountCode(brand, discountCode) {
|
|
@@ -395,77 +438,86 @@ let ShopifyAdminService = class ShopifyAdminService {
|
|
|
395
438
|
};
|
|
396
439
|
}
|
|
397
440
|
const config = this.getAdminConfig(brand);
|
|
398
|
-
//
|
|
399
|
-
const
|
|
441
|
+
// First, get current discount state
|
|
442
|
+
const getQuery = `
|
|
443
|
+
query getDiscount($id: ID!) {
|
|
444
|
+
discountNode(id: $id) {
|
|
445
|
+
... on DiscountCodeNode {
|
|
446
|
+
codeDiscount {
|
|
447
|
+
... on DiscountCodeBasic {
|
|
448
|
+
usageLimit
|
|
449
|
+
endsAt
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
`;
|
|
400
456
|
try {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
headers: this.getHeaders(config),
|
|
405
|
-
});
|
|
406
|
-
if (!getResponse.ok) {
|
|
407
|
-
logger_1.logger.error('Failed to get price rule for reactivation', {
|
|
408
|
-
brand,
|
|
409
|
-
discountCode,
|
|
410
|
-
priceRuleId: existing.priceRuleId,
|
|
411
|
-
});
|
|
457
|
+
const getResponse = await this.executeGraphQL(config, getQuery, { id: existing.discountId });
|
|
458
|
+
if (getResponse.errors && getResponse.errors.length > 0) {
|
|
459
|
+
logger_1.logger.error('Failed to get discount for reactivation', { brand, discountCode, errors: getResponse.errors });
|
|
412
460
|
return {
|
|
413
461
|
success: false,
|
|
414
462
|
discountCode,
|
|
415
|
-
error: `Failed to get
|
|
463
|
+
error: `Failed to get discount: ${getResponse.errors[0].message}`,
|
|
416
464
|
};
|
|
417
465
|
}
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
const
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
466
|
+
const currentDiscount = getResponse.data?.discountNode?.codeDiscount;
|
|
467
|
+
const currentUsageLimit = currentDiscount?.usageLimit ?? 1;
|
|
468
|
+
const currentEndsAt = currentDiscount?.endsAt;
|
|
469
|
+
// Calculate new values
|
|
470
|
+
const newUsageLimit = currentUsageLimit + 1;
|
|
471
|
+
const newEndsAt = this.getExtendedEndDate(currentEndsAt);
|
|
472
|
+
// Update discount
|
|
473
|
+
const updateMutation = `
|
|
474
|
+
mutation discountCodeBasicUpdate($id: ID!, $basicCodeDiscount: DiscountCodeBasicInput!) {
|
|
475
|
+
discountCodeBasicUpdate(id: $id, basicCodeDiscount: $basicCodeDiscount) {
|
|
476
|
+
codeDiscountNode {
|
|
477
|
+
id
|
|
478
|
+
}
|
|
479
|
+
userErrors {
|
|
480
|
+
field
|
|
481
|
+
message
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
`;
|
|
486
|
+
const updateVariables = {
|
|
487
|
+
id: existing.discountId,
|
|
488
|
+
basicCodeDiscount: {
|
|
489
|
+
usageLimit: newUsageLimit,
|
|
490
|
+
...(newEndsAt && { endsAt: newEndsAt }),
|
|
491
|
+
},
|
|
492
|
+
};
|
|
493
|
+
const updateResponse = await this.executeGraphQL(config, updateMutation, updateVariables);
|
|
494
|
+
if (updateResponse.errors && updateResponse.errors.length > 0) {
|
|
495
|
+
const errorMsg = updateResponse.errors.map((e) => e.message).join(', ');
|
|
496
|
+
logger_1.logger.error('Failed to reactivate discount', { brand, discountCode, errors: updateResponse.errors });
|
|
497
|
+
return { success: false, discountCode, error: errorMsg };
|
|
498
|
+
}
|
|
499
|
+
const result = updateResponse.data?.discountCodeBasicUpdate;
|
|
500
|
+
if (result?.userErrors && result.userErrors.length > 0) {
|
|
501
|
+
const errorMsg = result.userErrors.map((e) => e.message).join(', ');
|
|
502
|
+
logger_1.logger.error('User errors reactivating discount', { brand, discountCode, userErrors: result.userErrors });
|
|
503
|
+
return { success: false, discountCode, error: errorMsg };
|
|
445
504
|
}
|
|
446
505
|
logger_1.logger.info('Shopify discount code reactivated', {
|
|
447
506
|
brand,
|
|
448
507
|
discountCode,
|
|
449
|
-
|
|
450
|
-
newUsageLimit
|
|
508
|
+
discountId: existing.discountId,
|
|
509
|
+
newUsageLimit,
|
|
451
510
|
});
|
|
452
|
-
return {
|
|
453
|
-
success: true,
|
|
454
|
-
discountCode,
|
|
455
|
-
};
|
|
511
|
+
return { success: true, discountCode };
|
|
456
512
|
}
|
|
457
513
|
catch (error) {
|
|
458
514
|
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
|
459
515
|
logger_1.logger.error('Discount code reactivation failed', { brand, discountCode, error: errorMsg });
|
|
460
|
-
return {
|
|
461
|
-
success: false,
|
|
462
|
-
discountCode,
|
|
463
|
-
error: errorMsg,
|
|
464
|
-
};
|
|
516
|
+
return { success: false, discountCode, error: errorMsg };
|
|
465
517
|
}
|
|
466
518
|
}
|
|
467
519
|
/**
|
|
468
|
-
* Disable
|
|
520
|
+
* Disable/delete a Shopify discount code.
|
|
469
521
|
* Used when an earned coupon needs to be revoked (refund/cancellation).
|
|
470
522
|
*/
|
|
471
523
|
async disableDiscountCode(brand, discountCode) {
|
|
@@ -481,43 +533,34 @@ let ShopifyAdminService = class ShopifyAdminService {
|
|
|
481
533
|
};
|
|
482
534
|
}
|
|
483
535
|
const config = this.getAdminConfig(brand);
|
|
536
|
+
const mutation = `
|
|
537
|
+
mutation discountCodeDelete($id: ID!) {
|
|
538
|
+
discountCodeDelete(id: $id) {
|
|
539
|
+
deletedCodeDiscountId
|
|
540
|
+
userErrors {
|
|
541
|
+
field
|
|
542
|
+
message
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
`;
|
|
484
547
|
try {
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
});
|
|
491
|
-
if (!deleteCodeResponse.ok && deleteCodeResponse.status !== 404) {
|
|
492
|
-
logger_1.logger.error('Failed to delete discount code', {
|
|
493
|
-
brand,
|
|
494
|
-
discountCode,
|
|
495
|
-
status: deleteCodeResponse.status,
|
|
496
|
-
});
|
|
497
|
-
return {
|
|
498
|
-
success: false,
|
|
499
|
-
discountCode,
|
|
500
|
-
error: `Failed to delete discount code: ${deleteCodeResponse.status}`,
|
|
501
|
-
};
|
|
548
|
+
const response = await this.executeGraphQL(config, mutation, { id: existing.discountId });
|
|
549
|
+
if (response.errors && response.errors.length > 0) {
|
|
550
|
+
const errorMsg = response.errors.map((e) => e.message).join(', ');
|
|
551
|
+
logger_1.logger.error('Failed to delete discount', { brand, discountCode, errors: response.errors });
|
|
552
|
+
return { success: false, discountCode, error: errorMsg };
|
|
502
553
|
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
});
|
|
509
|
-
if (!deletePriceRuleResponse.ok && deletePriceRuleResponse.status !== 404) {
|
|
510
|
-
logger_1.logger.warn('Failed to delete price rule (discount code was deleted)', {
|
|
511
|
-
brand,
|
|
512
|
-
discountCode,
|
|
513
|
-
priceRuleId: existing.priceRuleId,
|
|
514
|
-
});
|
|
554
|
+
const result = response.data?.discountCodeDelete;
|
|
555
|
+
if (result?.userErrors && result.userErrors.length > 0) {
|
|
556
|
+
const errorMsg = result.userErrors.map((e) => e.message).join(', ');
|
|
557
|
+
logger_1.logger.error('User errors deleting discount', { brand, discountCode, userErrors: result.userErrors });
|
|
558
|
+
return { success: false, discountCode, error: errorMsg };
|
|
515
559
|
}
|
|
516
|
-
logger_1.logger.info('Shopify discount code
|
|
560
|
+
logger_1.logger.info('Shopify discount code deleted', {
|
|
517
561
|
brand,
|
|
518
562
|
discountCode,
|
|
519
|
-
|
|
520
|
-
discountCodeId: existing.discountCodeId,
|
|
563
|
+
discountId: existing.discountId,
|
|
521
564
|
});
|
|
522
565
|
return {
|
|
523
566
|
success: true,
|
|
@@ -527,23 +570,148 @@ let ShopifyAdminService = class ShopifyAdminService {
|
|
|
527
570
|
}
|
|
528
571
|
catch (error) {
|
|
529
572
|
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
|
530
|
-
logger_1.logger.error('Discount code
|
|
531
|
-
return {
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
573
|
+
logger_1.logger.error('Discount code delete failed', { brand, discountCode, error: errorMsg });
|
|
574
|
+
return { success: false, discountCode, error: errorMsg };
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Get active Shopify discount codes filtered by tag.
|
|
579
|
+
* Returns discounts with code, title, discount type/value, validity dates, and tags.
|
|
580
|
+
*/
|
|
581
|
+
async getDiscountsByTag(brand, tag, limit = 50) {
|
|
582
|
+
const config = this.getAdminConfig(brand);
|
|
583
|
+
const query = `
|
|
584
|
+
query discountNodes($query: String!, $first: Int!) {
|
|
585
|
+
discountNodes(first: $first, query: $query) {
|
|
586
|
+
nodes {
|
|
587
|
+
id
|
|
588
|
+
discount {
|
|
589
|
+
... on DiscountCodeBasic {
|
|
590
|
+
title
|
|
591
|
+
startsAt
|
|
592
|
+
endsAt
|
|
593
|
+
usageLimit
|
|
594
|
+
minimumRequirement {
|
|
595
|
+
... on DiscountMinimumSubtotal {
|
|
596
|
+
greaterThanOrEqualToSubtotal {
|
|
597
|
+
amount
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
customerGets {
|
|
602
|
+
value {
|
|
603
|
+
... on DiscountPercentage {
|
|
604
|
+
percentage
|
|
605
|
+
}
|
|
606
|
+
... on DiscountAmount {
|
|
607
|
+
amount {
|
|
608
|
+
amount
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
appliesOnOneTimePurchase
|
|
613
|
+
}
|
|
614
|
+
codes(first: 1) {
|
|
615
|
+
nodes {
|
|
616
|
+
code
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
metafield(namespace: "custom", key: "discount_upto") {
|
|
622
|
+
value
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
`;
|
|
628
|
+
const searchQuery = `tag:${tag} AND status:ACTIVE`;
|
|
629
|
+
try {
|
|
630
|
+
const response = await this.executeGraphQL(config, query, {
|
|
631
|
+
query: searchQuery,
|
|
632
|
+
first: limit,
|
|
633
|
+
});
|
|
634
|
+
if (response.errors && response.errors.length > 0) {
|
|
635
|
+
logger_1.logger.error('GraphQL errors fetching discounts by tag', { brand, tag, errors: response.errors });
|
|
636
|
+
return [];
|
|
637
|
+
}
|
|
638
|
+
const nodes = response.data?.discountNodes?.nodes ?? [];
|
|
639
|
+
const discounts = [];
|
|
640
|
+
for (const node of nodes) {
|
|
641
|
+
const discount = node.discount;
|
|
642
|
+
if (!discount)
|
|
643
|
+
continue;
|
|
644
|
+
// Extract code from codes array
|
|
645
|
+
const code = discount.codes?.nodes?.[0]?.code;
|
|
646
|
+
if (!code)
|
|
647
|
+
continue;
|
|
648
|
+
// Determine discount type and value
|
|
649
|
+
let discountType = 'ABS';
|
|
650
|
+
let discountValue = 0;
|
|
651
|
+
const customerGetsValue = discount.customerGets?.value;
|
|
652
|
+
if (customerGetsValue) {
|
|
653
|
+
if ('percentage' in customerGetsValue && customerGetsValue.percentage !== undefined) {
|
|
654
|
+
discountType = 'PERC';
|
|
655
|
+
// Shopify stores percentage as decimal (0.10 for 10%)
|
|
656
|
+
discountValue = customerGetsValue.percentage * 100;
|
|
657
|
+
}
|
|
658
|
+
else if ('amount' in customerGetsValue && customerGetsValue.amount?.amount !== undefined) {
|
|
659
|
+
discountType = 'ABS';
|
|
660
|
+
discountValue = parseFloat(customerGetsValue.amount.amount);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
// Extract minimum order value
|
|
664
|
+
let minOrderValue;
|
|
665
|
+
const minReq = discount.minimumRequirement;
|
|
666
|
+
if (minReq && 'greaterThanOrEqualToSubtotal' in minReq) {
|
|
667
|
+
const subtotal = minReq.greaterThanOrEqualToSubtotal?.amount;
|
|
668
|
+
if (subtotal) {
|
|
669
|
+
minOrderValue = parseFloat(subtotal);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
// Extract discount upto from metafield
|
|
673
|
+
let discountUpto;
|
|
674
|
+
if (node.metafield?.value) {
|
|
675
|
+
discountUpto = parseFloat(node.metafield.value);
|
|
676
|
+
}
|
|
677
|
+
// Extract tags from the node ID to get full tag list
|
|
678
|
+
// Note: We know this discount has the requested tag since we filtered by it
|
|
679
|
+
const tags = [tag]; // At minimum, it has the tag we queried for
|
|
680
|
+
discounts.push({
|
|
681
|
+
id: node.id,
|
|
682
|
+
code,
|
|
683
|
+
title: discount.title ?? code,
|
|
684
|
+
discountType,
|
|
685
|
+
discountValue,
|
|
686
|
+
discountUpto,
|
|
687
|
+
minOrderValue,
|
|
688
|
+
startsAt: discount.startsAt,
|
|
689
|
+
endsAt: discount.endsAt ?? undefined,
|
|
690
|
+
usageLimit: discount.usageLimit ?? undefined,
|
|
691
|
+
tags,
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
logger_1.logger.info('Fetched discounts by tag', {
|
|
695
|
+
brand,
|
|
696
|
+
tag,
|
|
697
|
+
count: discounts.length,
|
|
698
|
+
});
|
|
699
|
+
return discounts;
|
|
700
|
+
}
|
|
701
|
+
catch (error) {
|
|
702
|
+
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
|
703
|
+
logger_1.logger.error('Failed to fetch discounts by tag', { brand, tag, error: errorMsg });
|
|
704
|
+
return [];
|
|
536
705
|
}
|
|
537
706
|
}
|
|
538
707
|
/**
|
|
539
708
|
* Sync a used Capillary coupon to Shopify for refund tracking.
|
|
540
|
-
* Creates a
|
|
709
|
+
* Creates a discount code marked as already used (ended).
|
|
541
710
|
* This is needed when GoKwik applies a coupon that doesn't exist in Shopify.
|
|
542
711
|
*
|
|
543
712
|
* Uses original code directly (no prefix).
|
|
544
713
|
*/
|
|
545
714
|
async syncUsedCouponToShopify(brand, couponCode, discountType, discountValue, shopifyCustomerId, orderId) {
|
|
546
|
-
// Use original code directly - no prefix
|
|
547
715
|
const shopifyCode = couponCode;
|
|
548
716
|
// Check if already exists
|
|
549
717
|
const existing = await this.getDiscountCodeByCode(brand, shopifyCode);
|
|
@@ -552,57 +720,80 @@ let ShopifyAdminService = class ShopifyAdminService {
|
|
|
552
720
|
return {
|
|
553
721
|
success: true,
|
|
554
722
|
discountCode: shopifyCode,
|
|
555
|
-
|
|
556
|
-
discountCodeId: existing.discountCodeId,
|
|
723
|
+
discountId: existing.discountId,
|
|
557
724
|
alreadyExists: true,
|
|
558
725
|
};
|
|
559
726
|
}
|
|
560
|
-
|
|
561
|
-
const
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
727
|
+
const config = this.getAdminConfig(brand);
|
|
728
|
+
const customerGid = this.formatCustomerGid(shopifyCustomerId);
|
|
729
|
+
const mutation = `
|
|
730
|
+
mutation discountCodeBasicCreate($basicCodeDiscount: DiscountCodeBasicInput!) {
|
|
731
|
+
discountCodeBasicCreate(basicCodeDiscount: $basicCodeDiscount) {
|
|
732
|
+
codeDiscountNode {
|
|
733
|
+
id
|
|
734
|
+
}
|
|
735
|
+
userErrors {
|
|
736
|
+
field
|
|
737
|
+
message
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
`;
|
|
742
|
+
// Build discount value
|
|
743
|
+
const discountValueObj = discountType === 'PERC'
|
|
744
|
+
? { percentage: discountValue / 100 }
|
|
745
|
+
: { discountAmount: { amount: discountValue, appliesOnEachItem: false } };
|
|
746
|
+
const variables = {
|
|
747
|
+
basicCodeDiscount: {
|
|
748
|
+
title: `${shopifyCode}_USED_${orderId}`,
|
|
749
|
+
code: shopifyCode,
|
|
750
|
+
startsAt: new Date(Date.now() - 86400000).toISOString(), // Started yesterday
|
|
751
|
+
endsAt: new Date(Date.now() - 1000).toISOString(), // Already ended
|
|
752
|
+
usageLimit: 1,
|
|
753
|
+
appliesOncePerCustomer: true,
|
|
754
|
+
tags: [this.CAPILLARY_TAG, 'USED'],
|
|
755
|
+
customerGets: {
|
|
756
|
+
value: discountValueObj,
|
|
757
|
+
items: { all: true },
|
|
758
|
+
},
|
|
759
|
+
context: {
|
|
760
|
+
customers: {
|
|
761
|
+
add: [customerGid],
|
|
762
|
+
},
|
|
763
|
+
},
|
|
764
|
+
},
|
|
576
765
|
};
|
|
577
|
-
|
|
578
|
-
|
|
766
|
+
try {
|
|
767
|
+
const response = await this.executeGraphQL(config, mutation, variables);
|
|
768
|
+
if (response.errors && response.errors.length > 0) {
|
|
769
|
+
const errorMsg = response.errors.map((e) => e.message).join(', ');
|
|
770
|
+
logger_1.logger.error('Failed to sync used coupon', { brand, shopifyCode, errors: response.errors });
|
|
771
|
+
return { success: false, discountCode: shopifyCode, error: errorMsg };
|
|
772
|
+
}
|
|
773
|
+
const result = response.data?.discountCodeBasicCreate;
|
|
774
|
+
if (result?.userErrors && result.userErrors.length > 0) {
|
|
775
|
+
const errorMsg = result.userErrors.map((e) => e.message).join(', ');
|
|
776
|
+
logger_1.logger.error('User errors syncing used coupon', { brand, shopifyCode, userErrors: result.userErrors });
|
|
777
|
+
return { success: false, discountCode: shopifyCode, error: errorMsg };
|
|
778
|
+
}
|
|
779
|
+
const discountId = result?.codeDiscountNode?.id;
|
|
780
|
+
logger_1.logger.info('Synced used coupon to Shopify for refund tracking', {
|
|
781
|
+
brand,
|
|
782
|
+
shopifyCode,
|
|
783
|
+
orderId,
|
|
784
|
+
discountId,
|
|
785
|
+
});
|
|
579
786
|
return {
|
|
580
|
-
success:
|
|
787
|
+
success: true,
|
|
581
788
|
discountCode: shopifyCode,
|
|
582
|
-
|
|
789
|
+
discountId,
|
|
583
790
|
};
|
|
584
791
|
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
discountCode: shopifyCode,
|
|
590
|
-
priceRuleId: priceRule.priceRuleId,
|
|
591
|
-
error: discountCodeResult.error,
|
|
592
|
-
};
|
|
792
|
+
catch (error) {
|
|
793
|
+
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
|
794
|
+
logger_1.logger.error('Failed to sync used coupon', { brand, shopifyCode, error: errorMsg });
|
|
795
|
+
return { success: false, discountCode: shopifyCode, error: errorMsg };
|
|
593
796
|
}
|
|
594
|
-
logger_1.logger.info('Synced used coupon to Shopify for refund tracking', {
|
|
595
|
-
brand,
|
|
596
|
-
shopifyCode,
|
|
597
|
-
orderId,
|
|
598
|
-
priceRuleId: priceRule.priceRuleId,
|
|
599
|
-
});
|
|
600
|
-
return {
|
|
601
|
-
success: true,
|
|
602
|
-
discountCode: shopifyCode,
|
|
603
|
-
priceRuleId: priceRule.priceRuleId,
|
|
604
|
-
discountCodeId: discountCodeResult.discountCodeId,
|
|
605
|
-
};
|
|
606
797
|
}
|
|
607
798
|
/**
|
|
608
799
|
* Get extended end date for reactivating an expired discount.
|
|
@@ -621,12 +812,6 @@ let ShopifyAdminService = class ShopifyAdminService {
|
|
|
621
812
|
}
|
|
622
813
|
return null; // No need to extend
|
|
623
814
|
}
|
|
624
|
-
getHeaders(config) {
|
|
625
|
-
return {
|
|
626
|
-
'Content-Type': 'application/json',
|
|
627
|
-
'X-Shopify-Access-Token': config.apiKey,
|
|
628
|
-
};
|
|
629
|
-
}
|
|
630
815
|
};
|
|
631
816
|
exports.ShopifyAdminService = ShopifyAdminService;
|
|
632
817
|
exports.ShopifyAdminService = ShopifyAdminService = tslib_1.__decorate([
|