@jarwizz/create-jarshop 0.1.1 → 0.1.2

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.
Files changed (100) hide show
  1. package/README.md +8 -0
  2. package/dist/generator.js +6 -3
  3. package/dist/generator.js.map +1 -1
  4. package/dist/template/CONTEXT.md +10 -0
  5. package/dist/template/README.md +20 -3
  6. package/dist/template/docker-compose.yml +5 -0
  7. package/dist/template/docs/smtp-smoke.md +37 -0
  8. package/dist/template/env.example.template +18 -0
  9. package/dist/template/gitignore.template +2 -0
  10. package/dist/template/package.json +13 -5
  11. package/dist/template/pnpm-lock.yaml +38 -7
  12. package/dist/template/src/dev-storefront.ts +58 -0
  13. package/dist/template/src/dev.ts +67 -4
  14. package/dist/template/src/email-smoke.ts +27 -0
  15. package/dist/template/src/environment.d.ts +6 -0
  16. package/dist/template/src/migrate.ts +16 -0
  17. package/dist/template/src/migrations/1786024108434-jarshop-checkout-attempt.ts +28 -0
  18. package/dist/template/src/migrations/1786024108435-jarshop-legal-and-confirmation.ts +60 -0
  19. package/dist/template/src/migrations/1786024108436-jarshop-order-email-outbox.ts +37 -0
  20. package/dist/template/src/migrations/1786030000000-jarshop-order-email-delivery-lease.ts +27 -0
  21. package/dist/template/src/plugins/jarshop-checkout/checkout-attempt.entity.ts +42 -0
  22. package/dist/template/src/plugins/jarshop-checkout/confirmation-token.entity.ts +31 -0
  23. package/dist/template/src/plugins/jarshop-checkout/confirmation.ts +26 -0
  24. package/dist/template/src/plugins/jarshop-checkout/dashboard/index.tsx +107 -0
  25. package/dist/template/src/plugins/jarshop-checkout/dashboard/order-email-delivery-status.ts +43 -0
  26. package/dist/template/src/plugins/jarshop-checkout/email-smoke-core.ts +56 -0
  27. package/dist/template/src/plugins/jarshop-checkout/index.ts +47 -0
  28. package/dist/template/src/plugins/jarshop-checkout/jarshop-checkout.plugin.ts +813 -0
  29. package/dist/template/src/plugins/jarshop-checkout/legal.entity.ts +39 -0
  30. package/dist/template/src/plugins/jarshop-checkout/order-consent.entity.ts +32 -0
  31. package/dist/template/src/plugins/jarshop-checkout/order-email-delivery-core.ts +337 -0
  32. package/dist/template/src/plugins/jarshop-checkout/order-email-delivery-status.ts +39 -0
  33. package/dist/template/src/plugins/jarshop-checkout/order-email-delivery.entity.ts +66 -0
  34. package/dist/template/src/plugins/jarshop-checkout/order-email-outbox.ts +108 -0
  35. package/dist/template/src/plugins/jarshop-checkout/order-email-transport.ts +35 -0
  36. package/dist/template/src/plugins/jarshop-checkout/order-email-worker.ts +281 -0
  37. package/dist/template/src/plugins/jarshop-checkout/shipping.test.ts +41 -0
  38. package/dist/template/src/plugins/jarshop-checkout/shipping.ts +162 -0
  39. package/dist/template/src/seed.ts +149 -0
  40. package/dist/template/src/vendure-config.ts +68 -5
  41. package/dist/template/static/email/templates/email-verification/body.hbs +4 -4
  42. package/dist/template/storefront/app/[locale]/[slug]/add-to-cart-form.tsx +44 -0
  43. package/dist/template/storefront/app/[locale]/[slug]/page.tsx +35 -26
  44. package/dist/template/storefront/app/[locale]/account/account-mutation-form.tsx +44 -0
  45. package/dist/template/storefront/app/[locale]/account/change-password-form.tsx +65 -0
  46. package/dist/template/storefront/app/[locale]/account/login/login-form.tsx +60 -0
  47. package/dist/template/storefront/app/[locale]/account/login/page.tsx +23 -0
  48. package/dist/template/storefront/app/[locale]/account/page.tsx +215 -0
  49. package/dist/template/storefront/app/[locale]/account/password-reset/callback/route.ts +33 -0
  50. package/dist/template/storefront/app/[locale]/account/password-reset/complete/page.tsx +27 -0
  51. package/dist/template/storefront/app/[locale]/account/password-reset/page.tsx +37 -0
  52. package/dist/template/storefront/app/[locale]/account/password-reset/password-reset-forms.tsx +99 -0
  53. package/dist/template/storefront/app/[locale]/account/register/page.tsx +23 -0
  54. package/dist/template/storefront/app/[locale]/account/register/register-form.tsx +67 -0
  55. package/dist/template/storefront/app/[locale]/account/verify/complete/route.ts +33 -0
  56. package/dist/template/storefront/app/[locale]/account/verify/page.tsx +41 -0
  57. package/dist/template/storefront/app/[locale]/account/verify/refresh-form.tsx +35 -0
  58. package/dist/template/storefront/app/[locale]/cart/page.tsx +78 -0
  59. package/dist/template/storefront/app/[locale]/checkout/checkout-form.tsx +112 -0
  60. package/dist/template/storefront/app/[locale]/checkout/page.tsx +44 -0
  61. package/dist/template/storefront/app/[locale]/checkout/success/page.tsx +23 -0
  62. package/dist/template/storefront/app/[locale]/layout.tsx +48 -0
  63. package/dist/template/storefront/app/[locale]/page.tsx +10 -11
  64. package/dist/template/storefront/app/globals.css +372 -4
  65. package/dist/template/storefront/app/layout.tsx +13 -0
  66. package/dist/template/storefront/eslint.config.mjs +1 -1
  67. package/dist/template/storefront/gitignore.template +1 -0
  68. package/dist/template/storefront/lib/account-security.test.ts +74 -0
  69. package/dist/template/storefront/lib/account-security.ts +81 -0
  70. package/dist/template/storefront/lib/account.ts +366 -0
  71. package/dist/template/storefront/lib/cart-contract.ts +52 -0
  72. package/dist/template/storefront/lib/cart.test.ts +30 -0
  73. package/dist/template/storefront/lib/cart.ts +130 -0
  74. package/dist/template/storefront/lib/checkout-contract.test.ts +20 -0
  75. package/dist/template/storefront/lib/checkout-contract.ts +16 -0
  76. package/dist/template/storefront/lib/checkout.ts +143 -0
  77. package/dist/template/storefront/lib/commerce-session.test.ts +77 -0
  78. package/dist/template/storefront/lib/commerce-session.ts +85 -0
  79. package/dist/template/storefront/lib/commerce.ts +122 -1
  80. package/dist/template/storefront/lib/confirmation-cookie.ts +9 -0
  81. package/dist/template/storefront/lib/confirmation.ts +47 -0
  82. package/dist/template/storefront/lib/form-values.test.ts +14 -0
  83. package/dist/template/storefront/lib/form-values.ts +7 -0
  84. package/dist/template/storefront/lib/i18n.test.ts +50 -0
  85. package/dist/template/storefront/lib/i18n.ts +44 -0
  86. package/dist/template/storefront/lib/password-reset-token.test.ts +31 -0
  87. package/dist/template/storefront/lib/password-reset-token.ts +17 -0
  88. package/dist/template/storefront/lib/valkey-rate-limit.ts +72 -0
  89. package/dist/template/storefront/messages/en.json +89 -0
  90. package/dist/template/storefront/messages/sk.json +89 -0
  91. package/dist/template/storefront/next.config.ts +3 -1
  92. package/dist/template/storefront/package.json +4 -1
  93. package/dist/template/storefront/pnpm-lock.yaml +756 -2
  94. package/dist/template/tsconfig.json +2 -1
  95. package/dist/template/turbo.json +1 -0
  96. package/dist/template/vite.config.mts +4 -1
  97. package/dist/template-manifest.json +1 -1
  98. package/dist/template.js +3 -0
  99. package/dist/template.js.map +1 -1
  100. package/package.json +1 -1
@@ -0,0 +1,813 @@
1
+ import { Args, Mutation, Resolver } from "@nestjs/graphql";
2
+ import { Query } from "@nestjs/graphql";
3
+ import { Inject, Injectable } from "@nestjs/common";
4
+ import { Allow, Permission } from "@vendure/core";
5
+ import { createHash } from "node:crypto";
6
+ import { parse } from "graphql";
7
+ import type { Repository } from "typeorm";
8
+ import {
9
+ ActiveOrderService,
10
+ Ctx,
11
+ CustomerService,
12
+ isGraphQlErrorResult,
13
+ Order,
14
+ OrderService,
15
+ PluginCommonModule,
16
+ ProductVariantService,
17
+ RequestContext,
18
+ ShippingMethodService,
19
+ Transaction,
20
+ TransactionalConnection,
21
+ VendurePlugin,
22
+ } from "@vendure/core";
23
+ import { CheckoutAttempt } from "./checkout-attempt.entity.js";
24
+ import { JarShopConfirmationToken } from "./confirmation-token.entity.js";
25
+ import {
26
+ canUseConfirmationToken,
27
+ createConfirmationToken,
28
+ hashConfirmationToken,
29
+ } from "./confirmation.js";
30
+ import { JarShopLegalConfiguration } from "./legal.entity.js";
31
+ import { JarShopOrderEmailDelivery } from "./order-email-delivery.entity.js";
32
+ import {
33
+ isOrderAvailableInChannel,
34
+ toOrderEmailDeliveryStatus,
35
+ } from "./order-email-delivery-status.js";
36
+ import { createPendingOrderEmailDelivery } from "./order-email-outbox.js";
37
+ import {
38
+ JarShopOrderEmailWorker,
39
+ registerJarShopOrderEmailWorkerTask,
40
+ } from "./order-email-worker.js";
41
+ import { JarShopOrderConsent } from "./order-consent.entity.js";
42
+ import {
43
+ compareCheckoutSnapshot,
44
+ validateShippingSelection,
45
+ type CheckoutCartSnapshot,
46
+ type ShippingAddressInput,
47
+ } from "./shipping.js";
48
+
49
+ const schema = parse(`
50
+ input JarShopCustomerInput {
51
+ emailAddress: String!
52
+ firstName: String!
53
+ lastName: String!
54
+ phoneNumber: String!
55
+ }
56
+ input JarShopAddressInput {
57
+ streetLine1: String!
58
+ streetLine2: String
59
+ city: String!
60
+ postalCode: String!
61
+ countryCode: String!
62
+ company: String
63
+ }
64
+ input JarShopCartSnapshotLineInput {
65
+ lineId: ID!
66
+ quantity: Int!
67
+ linePriceWithTax: Int!
68
+ }
69
+ input JarShopCartSnapshotInput {
70
+ currencyCode: String!
71
+ subtotalWithTax: Int!
72
+ lines: [JarShopCartSnapshotLineInput!]!
73
+ }
74
+ input JarShopPlaceOrderInput {
75
+ attemptId: String!
76
+ marketCode: String!
77
+ languageCode: String!
78
+ currencyCode: String!
79
+ customer: JarShopCustomerInput!
80
+ shippingAddress: JarShopAddressInput
81
+ billingAddress: JarShopAddressInput
82
+ cartSnapshot: JarShopCartSnapshotInput
83
+ shippingMethodCode: String!
84
+ paymentMethodCode: String!
85
+ termsAccepted: Boolean!
86
+ }
87
+ type JarShopPlaceOrderResult {
88
+ success: Boolean!
89
+ order: Order
90
+ orderId: ID
91
+ orderCode: String
92
+ totalWithTax: Int
93
+ currencyCode: String
94
+ state: String!
95
+ errorCode: String
96
+ message: String
97
+ affectedLineIds: [ID!]!
98
+ confirmationToken: String
99
+ }
100
+ type JarShopLegalConfiguration {
101
+ languageCode: String!
102
+ termsVersion: String!
103
+ termsUrl: String!
104
+ privacyVersion: String!
105
+ privacyUrl: String!
106
+ }
107
+ type JarShopOrderConfirmation {
108
+ orderId: ID!
109
+ orderCode: String!
110
+ state: String!
111
+ totalWithTax: Int!
112
+ currencyCode: String!
113
+ }
114
+ input JarShopLegalConfigurationInput {
115
+ languageCode: String!
116
+ termsVersion: String!
117
+ termsUrl: String!
118
+ privacyVersion: String!
119
+ privacyUrl: String!
120
+ }
121
+ extend type Query {
122
+ jarShopLegalConfiguration(languageCode: String!): JarShopLegalConfiguration
123
+ jarShopOrderConfirmation(token: String!): JarShopOrderConfirmation
124
+ }
125
+ extend type Mutation {
126
+ placeJarShopOrder(input: JarShopPlaceOrderInput!): JarShopPlaceOrderResult!
127
+ updateJarShopLegalConfiguration(input: JarShopLegalConfigurationInput!): JarShopLegalConfiguration!
128
+ }
129
+ `);
130
+
131
+ const adminSchema = parse(`
132
+ type JarShopLegalConfiguration {
133
+ languageCode: String!
134
+ termsVersion: String!
135
+ termsUrl: String!
136
+ privacyVersion: String!
137
+ privacyUrl: String!
138
+ }
139
+ input JarShopLegalConfigurationInput {
140
+ languageCode: String!
141
+ termsVersion: String!
142
+ termsUrl: String!
143
+ privacyVersion: String!
144
+ privacyUrl: String!
145
+ }
146
+ type JarShopOrderEmailDeliveryStatus {
147
+ deliveryId: ID!
148
+ orderId: ID!
149
+ kind: String!
150
+ status: String!
151
+ attemptCount: Int!
152
+ nextAttemptAt: DateTime!
153
+ sentAt: DateTime
154
+ lastErrorCode: String
155
+ }
156
+ extend type Query {
157
+ jarShopLegalConfiguration(languageCode: String!): JarShopLegalConfiguration
158
+ jarShopOrderEmailDeliveryStatus(orderId: ID!): JarShopOrderEmailDeliveryStatus
159
+ }
160
+ extend type Mutation {
161
+ updateJarShopLegalConfiguration(input: JarShopLegalConfigurationInput!): JarShopLegalConfiguration!
162
+ }
163
+ `);
164
+
165
+ type PlaceOrderInput = {
166
+ attemptId: string;
167
+ marketCode: string;
168
+ languageCode: string;
169
+ currencyCode: string;
170
+ customer: {
171
+ emailAddress: string;
172
+ firstName: string;
173
+ lastName: string;
174
+ phoneNumber: string;
175
+ };
176
+ shippingAddress?: ShippingAddressInput;
177
+ billingAddress?: ShippingAddressInput;
178
+ cartSnapshot?: CheckoutCartSnapshot;
179
+ shippingMethodCode: string;
180
+ paymentMethodCode: string;
181
+ termsAccepted: boolean;
182
+ };
183
+
184
+ type LegalConfigurationInput = {
185
+ languageCode: string;
186
+ termsVersion: string;
187
+ termsUrl: string;
188
+ privacyVersion: string;
189
+ privacyUrl: string;
190
+ };
191
+
192
+ @Injectable()
193
+ @Resolver()
194
+ class JarShopCheckoutResolver {
195
+ constructor(
196
+ @Inject(ActiveOrderService)
197
+ private readonly activeOrderService: ActiveOrderService,
198
+ @Inject(OrderService)
199
+ private readonly orderService: OrderService,
200
+ @Inject(CustomerService)
201
+ private readonly customerService: CustomerService,
202
+ @Inject(ShippingMethodService)
203
+ private readonly shippingMethodService: ShippingMethodService,
204
+ @Inject(ProductVariantService)
205
+ private readonly productVariantService: ProductVariantService,
206
+ @Inject(TransactionalConnection)
207
+ private readonly connection: TransactionalConnection,
208
+ ) {}
209
+
210
+ @Query()
211
+ async jarShopLegalConfiguration(
212
+ @Ctx() ctx: RequestContext,
213
+ @Args("languageCode") languageCode: string,
214
+ ) {
215
+ const repository = this.connection.getRepository(
216
+ ctx,
217
+ JarShopLegalConfiguration,
218
+ );
219
+ const configuration = await repository.findOne({
220
+ where: { channelId: Number(ctx.channelId), languageCode },
221
+ });
222
+ return configuration ? toLegalConfiguration(configuration) : null;
223
+ }
224
+
225
+ @Query()
226
+ async jarShopOrderConfirmation(
227
+ @Ctx() ctx: RequestContext,
228
+ @Args("token") token: string,
229
+ ) {
230
+ const repository = this.connection.getRepository(
231
+ ctx,
232
+ JarShopConfirmationToken,
233
+ );
234
+ const record = await repository.findOne({
235
+ where: { tokenHash: hashConfirmationToken(token) },
236
+ });
237
+ if (!record || record.expiresAt.getTime() <= Date.now()) return null;
238
+ const cookie = ctx.req?.headers.cookie ?? "";
239
+ const exchanged = cookie.includes(`jarshop-confirmation=${token}`);
240
+ if (
241
+ !canUseConfirmationToken({
242
+ expiresAt: record.expiresAt,
243
+ consumedAt: record.consumedAt,
244
+ cookieMatches: exchanged,
245
+ })
246
+ ) {
247
+ return null;
248
+ }
249
+ if (!record.consumedAt) {
250
+ record.consumedAt = new Date();
251
+ await repository.save(record);
252
+ }
253
+ const order = await this.orderService.findOne(ctx, record.orderId);
254
+ return order
255
+ ? {
256
+ orderId: order.id,
257
+ orderCode: order.code,
258
+ state: order.state,
259
+ totalWithTax: order.totalWithTax,
260
+ currencyCode: order.currencyCode,
261
+ }
262
+ : null;
263
+ }
264
+
265
+ @Mutation()
266
+ @Allow(Permission.UpdateSettings)
267
+ async updateJarShopLegalConfiguration(
268
+ @Ctx() ctx: RequestContext,
269
+ @Args("input") input: LegalConfigurationInput,
270
+ ) {
271
+ validateLegalConfigurationInput(input);
272
+ const repository = this.connection.getRepository(
273
+ ctx,
274
+ JarShopLegalConfiguration,
275
+ );
276
+ let configuration = await repository.findOne({
277
+ where: {
278
+ channelId: Number(ctx.channelId),
279
+ languageCode: input.languageCode,
280
+ },
281
+ });
282
+ configuration ??= repository.create({
283
+ channelId: Number(ctx.channelId),
284
+ languageCode: input.languageCode,
285
+ });
286
+ Object.assign(configuration, input);
287
+ return toLegalConfiguration(await repository.save(configuration));
288
+ }
289
+
290
+ @Mutation()
291
+ @Transaction()
292
+ async placeJarShopOrder(
293
+ @Ctx() ctx: RequestContext,
294
+ @Args("input") input: PlaceOrderInput,
295
+ ) {
296
+ const repository = this.connection.getRepository(ctx, CheckoutAttempt);
297
+ const inputHash = createHash("sha256")
298
+ .update(JSON.stringify(input))
299
+ .digest("hex");
300
+
301
+ const existingAttempt = await repository.findOne({
302
+ where: { attemptId: input.attemptId },
303
+ });
304
+ const insertResult = await repository
305
+ .createQueryBuilder()
306
+ .insert()
307
+ .values({
308
+ attemptId: input.attemptId,
309
+ marketCode: input.marketCode,
310
+ languageCode: input.languageCode,
311
+ status: "processing",
312
+ inputHash,
313
+ orderId: null,
314
+ errorCode: null,
315
+ })
316
+ .returning(["id"])
317
+ .orIgnore()
318
+ .execute();
319
+ const inserted =
320
+ !existingAttempt &&
321
+ (Boolean(insertResult?.identifiers?.length) ||
322
+ insertResult?.raw?.rowCount === 1 ||
323
+ (Array.isArray(insertResult?.raw) && insertResult.raw.length > 0));
324
+
325
+ let attempt = await repository.findOne({
326
+ where: { attemptId: input.attemptId },
327
+ });
328
+ if (!attempt) {
329
+ return failure("TEMPORARY_FAILURE", "Checkout attempt is not available");
330
+ }
331
+ if (attempt.inputHash !== inputHash) {
332
+ return failure(
333
+ "IDEMPOTENCY_CONFLICT",
334
+ "attemptId was already used with different input",
335
+ );
336
+ }
337
+ if (attempt.status === "completed" && attempt.orderId) {
338
+ const order = await this.orderService.findOne(ctx, attempt.orderId);
339
+ return order
340
+ ? success(order)
341
+ : failure("TEMPORARY_FAILURE", "Completed order is not available");
342
+ }
343
+ if (!inserted && attempt.status === "processing") {
344
+ for (let index = 0; index < 20; index += 1) {
345
+ await new Promise((resolve) => setTimeout(resolve, 50));
346
+ attempt = await repository.findOne({
347
+ where: { attemptId: input.attemptId },
348
+ });
349
+ if (attempt?.status === "completed" && attempt.orderId) {
350
+ const order = await this.orderService.findOne(ctx, attempt.orderId);
351
+ if (order) return success(order);
352
+ }
353
+ }
354
+ if (attempt?.status === "processing") {
355
+ return failure(
356
+ "TEMPORARY_FAILURE",
357
+ "Checkout is already being processed",
358
+ );
359
+ }
360
+ }
361
+ if (!attempt) {
362
+ return failure("TEMPORARY_FAILURE", "Checkout attempt is not available");
363
+ }
364
+
365
+ const activeOrderReference = await this.activeOrderService.getActiveOrder(
366
+ ctx,
367
+ undefined,
368
+ );
369
+ const activeOrder = activeOrderReference
370
+ ? await this.orderService.findOne(ctx, activeOrderReference.id)
371
+ : null;
372
+ if (!activeOrder || !activeOrder.lines?.length) {
373
+ return await this.failAttempt(
374
+ repository,
375
+ attempt,
376
+ "EMPTY_ORDER",
377
+ "Cart is empty",
378
+ );
379
+ }
380
+ if (!input.termsAccepted) {
381
+ return await this.failAttempt(
382
+ repository,
383
+ attempt,
384
+ "TERMS_NOT_ACCEPTED",
385
+ "Terms must be accepted",
386
+ );
387
+ }
388
+ const legalRepository = this.connection.getRepository(
389
+ ctx,
390
+ JarShopLegalConfiguration,
391
+ );
392
+ const legalConfiguration = await legalRepository.findOne({
393
+ where: {
394
+ channelId: Number(ctx.channelId),
395
+ languageCode: input.languageCode,
396
+ },
397
+ });
398
+ if (!legalConfiguration) {
399
+ return await this.failAttempt(
400
+ repository,
401
+ attempt,
402
+ "TERMS_NOT_ACCEPTED",
403
+ "Legal configuration is not available for this locale",
404
+ );
405
+ }
406
+ const shippingValidation = validateShippingSelection(input);
407
+ if (!shippingValidation.ok) {
408
+ return await this.failAttempt(
409
+ repository,
410
+ attempt,
411
+ shippingValidation.errorCode,
412
+ shippingValidation.message,
413
+ );
414
+ }
415
+ if (input.cartSnapshot) {
416
+ const actualSnapshot: CheckoutCartSnapshot = {
417
+ currencyCode: activeOrder.currencyCode,
418
+ subtotalWithTax: activeOrder.subTotalWithTax,
419
+ lines: activeOrder.lines.map((line) => ({
420
+ lineId: String(line.id),
421
+ quantity: line.quantity,
422
+ linePriceWithTax: line.discountedLinePriceWithTax,
423
+ })),
424
+ };
425
+ const snapshot = compareCheckoutSnapshot(
426
+ input.cartSnapshot,
427
+ actualSnapshot,
428
+ );
429
+ if (!snapshot.ok) {
430
+ return await this.failAttempt(
431
+ repository,
432
+ attempt,
433
+ snapshot.errorCode,
434
+ snapshot.errorCode === "PRICE_CHANGED"
435
+ ? "Cart prices changed; review the refreshed totals before retrying"
436
+ : "Some cart items are no longer available in the requested quantity",
437
+ snapshot.affectedLineIds,
438
+ );
439
+ }
440
+ }
441
+ for (const line of activeOrder.lines) {
442
+ const variant = await this.productVariantService.getVariantByOrderLineId(
443
+ ctx,
444
+ line.id,
445
+ );
446
+ const available = await this.productVariantService.getSaleableStockLevel(
447
+ ctx,
448
+ variant,
449
+ );
450
+ if (available < line.quantity) {
451
+ return await this.failAttempt(
452
+ repository,
453
+ attempt,
454
+ "INSUFFICIENT_STOCK",
455
+ "Some cart items are no longer available in the requested quantity",
456
+ [String(line.id)],
457
+ );
458
+ }
459
+ }
460
+ const customer = await this.customerService.createOrUpdate(
461
+ ctx,
462
+ input.customer,
463
+ true,
464
+ );
465
+ if (isGraphQlErrorResult(customer)) {
466
+ return await this.failAttempt(
467
+ repository,
468
+ attempt,
469
+ "CUSTOMER_ACCOUNT_EXISTS",
470
+ "An account already exists for this email; please log in or reset the password",
471
+ );
472
+ }
473
+ await this.orderService.addCustomerToOrder(ctx, activeOrder.id, customer);
474
+ if (input.shippingAddress) {
475
+ await this.orderService.setShippingAddress(
476
+ ctx,
477
+ activeOrder.id,
478
+ input.shippingAddress,
479
+ );
480
+ }
481
+ if (input.billingAddress) {
482
+ await this.orderService.setBillingAddress(
483
+ ctx,
484
+ activeOrder.id,
485
+ input.billingAddress,
486
+ );
487
+ }
488
+ const shippingMethods =
489
+ await this.shippingMethodService.getActiveShippingMethods(ctx);
490
+ const shippingMethod = shippingMethods.find(
491
+ (method) => method.code === input.shippingMethodCode,
492
+ );
493
+ if (!shippingMethod) {
494
+ return await this.failAttempt(
495
+ repository,
496
+ attempt,
497
+ "SHIPPING_METHOD_INELIGIBLE",
498
+ "Selected shipping method is not available",
499
+ );
500
+ }
501
+ const shippingResult = await this.orderService.setShippingMethod(
502
+ ctx,
503
+ activeOrder.id,
504
+ [shippingMethod.id],
505
+ );
506
+ if (isGraphQlErrorResult(shippingResult)) {
507
+ return await this.failAttempt(
508
+ repository,
509
+ attempt,
510
+ "SHIPPING_METHOD_INELIGIBLE",
511
+ "Selected shipping method is not eligible",
512
+ );
513
+ }
514
+ const paymentState = await this.orderService.transitionToState(
515
+ ctx,
516
+ activeOrder.id,
517
+ "ArrangingPayment",
518
+ );
519
+ if (isGraphQlErrorResult(paymentState)) {
520
+ return await this.failAttempt(
521
+ repository,
522
+ attempt,
523
+ "ORDER_STATE_INVALID",
524
+ "Order is not ready for payment",
525
+ );
526
+ }
527
+ if (input.paymentMethodCode !== "cod") {
528
+ return await this.failAttempt(
529
+ repository,
530
+ attempt,
531
+ "PAYMENT_METHOD_INELIGIBLE",
532
+ "Only cash on delivery is available in this slice",
533
+ );
534
+ }
535
+ const paymentResult = await this.orderService.addPaymentToOrder(
536
+ ctx,
537
+ activeOrder.id,
538
+ {
539
+ method: "dummy-payment-method",
540
+ metadata: { attemptId: input.attemptId, method: "cod" },
541
+ },
542
+ );
543
+ if (isGraphQlErrorResult(paymentResult)) {
544
+ const paymentErrorCode = paymentResult.errorCode;
545
+ return await this.failAttempt(
546
+ repository,
547
+ attempt,
548
+ paymentErrorCode === "INSUFFICIENT_STOCK_ERROR"
549
+ ? "INSUFFICIENT_STOCK"
550
+ : "TEMPORARY_FAILURE",
551
+ paymentErrorCode === "INSUFFICIENT_STOCK_ERROR"
552
+ ? "Some cart items are no longer available in the requested quantity"
553
+ : "Payment could not be authorized",
554
+ );
555
+ }
556
+ const consentRepository = this.connection.getRepository(
557
+ ctx,
558
+ JarShopOrderConsent,
559
+ );
560
+ await consentRepository.save(
561
+ consentRepository.create({
562
+ orderId: Number(paymentResult.id),
563
+ termsVersion: legalConfiguration.termsVersion,
564
+ termsUrl: legalConfiguration.termsUrl,
565
+ privacyUrl: legalConfiguration.privacyUrl,
566
+ acceptedAt: new Date(),
567
+ }),
568
+ );
569
+ const confirmation = createConfirmationToken();
570
+ const tokenRepository = this.connection.getRepository(
571
+ ctx,
572
+ JarShopConfirmationToken,
573
+ );
574
+ await tokenRepository.save(
575
+ tokenRepository.create({
576
+ tokenHash: confirmation.tokenHash,
577
+ orderId: Number(paymentResult.id),
578
+ expiresAt: confirmation.expiresAt,
579
+ consumedAt: null,
580
+ }),
581
+ );
582
+ const emailDeliveryRepository = this.connection.getRepository(
583
+ ctx,
584
+ JarShopOrderEmailDelivery,
585
+ );
586
+ await emailDeliveryRepository.save(
587
+ emailDeliveryRepository.create(
588
+ createPendingOrderEmailDelivery({
589
+ orderId: Number(paymentResult.id),
590
+ checkoutAttemptId: attempt.attemptId,
591
+ languageCode: input.languageCode,
592
+ confirmationToken: confirmation.token,
593
+ }),
594
+ ),
595
+ );
596
+ attempt.status = "completed";
597
+ attempt.orderId = Number(paymentResult.id);
598
+ attempt.errorCode = null;
599
+ await repository.save(attempt);
600
+ const placedOrder = await this.orderService.findOne(ctx, paymentResult.id);
601
+ return placedOrder
602
+ ? success(
603
+ placedOrder,
604
+ activeOrder.totalWithTax,
605
+ activeOrder.currencyCode,
606
+ confirmation.token,
607
+ )
608
+ : success(
609
+ paymentResult,
610
+ activeOrder.totalWithTax,
611
+ activeOrder.currencyCode,
612
+ confirmation.token,
613
+ );
614
+ }
615
+
616
+ private async failAttempt(
617
+ repository: Repository<CheckoutAttempt>,
618
+ attempt: CheckoutAttempt,
619
+ code: string,
620
+ message: string,
621
+ affectedLineIds: readonly string[] = [],
622
+ ) {
623
+ attempt.status = "failed";
624
+ attempt.errorCode = code;
625
+ await repository.save(attempt);
626
+ return failure(code, message, affectedLineIds);
627
+ }
628
+ }
629
+
630
+ @Injectable()
631
+ @Resolver()
632
+ class JarShopLegalAdminResolver {
633
+ constructor(
634
+ @Inject(TransactionalConnection)
635
+ private readonly connection: TransactionalConnection,
636
+ ) {}
637
+
638
+ @Query()
639
+ async jarShopLegalConfiguration(
640
+ @Ctx() ctx: RequestContext,
641
+ @Args("languageCode") languageCode: string,
642
+ ) {
643
+ const repository = this.connection.getRepository(
644
+ ctx,
645
+ JarShopLegalConfiguration,
646
+ );
647
+ const configuration = await repository.findOne({
648
+ where: { channelId: Number(ctx.channelId), languageCode },
649
+ });
650
+ return configuration ? toLegalConfiguration(configuration) : null;
651
+ }
652
+
653
+ @Query()
654
+ @Allow(Permission.ReadOrder)
655
+ async jarShopOrderEmailDeliveryStatus(
656
+ @Ctx() ctx: RequestContext,
657
+ @Args("orderId") orderId: string,
658
+ ) {
659
+ const parsedOrderId = parsePositiveOrderId(orderId);
660
+ if (parsedOrderId === undefined) return null;
661
+
662
+ const order = await this.connection
663
+ .getRepository(ctx, Order)
664
+ .findOne({ where: { id: parsedOrderId }, relations: ["channels"] });
665
+ if (
666
+ !order ||
667
+ !isOrderAvailableInChannel(
668
+ order.channels.map((channel) => channel.id),
669
+ ctx.channelId,
670
+ )
671
+ ) {
672
+ return null;
673
+ }
674
+
675
+ const delivery = await this.connection
676
+ .getRepository(ctx, JarShopOrderEmailDelivery)
677
+ .findOne({ where: { orderId: parsedOrderId } });
678
+ return delivery ? toOrderEmailDeliveryStatus(delivery) : null;
679
+ }
680
+
681
+ @Mutation()
682
+ @Allow(Permission.UpdateSettings)
683
+ async updateJarShopLegalConfiguration(
684
+ @Ctx() ctx: RequestContext,
685
+ @Args("input") input: LegalConfigurationInput,
686
+ ) {
687
+ validateLegalConfigurationInput(input);
688
+ const repository = this.connection.getRepository(
689
+ ctx,
690
+ JarShopLegalConfiguration,
691
+ );
692
+ let configuration = await repository.findOne({
693
+ where: {
694
+ channelId: Number(ctx.channelId),
695
+ languageCode: input.languageCode,
696
+ },
697
+ });
698
+ configuration ??= repository.create({
699
+ channelId: Number(ctx.channelId),
700
+ languageCode: input.languageCode,
701
+ });
702
+ Object.assign(configuration, input);
703
+ return toLegalConfiguration(await repository.save(configuration));
704
+ }
705
+ }
706
+
707
+ type OrderResult = {
708
+ id: string | number;
709
+ code: string;
710
+ totalWithTax?: number | null;
711
+ currencyCode?: string | null;
712
+ state: string;
713
+ };
714
+
715
+ function success(
716
+ order: OrderResult,
717
+ totalWithTax?: number,
718
+ currencyCode?: string,
719
+ confirmationToken?: string,
720
+ ) {
721
+ return {
722
+ success: true,
723
+ order,
724
+ orderId: order.id,
725
+ orderCode: order.code,
726
+ totalWithTax: order.totalWithTax ?? totalWithTax ?? 0,
727
+ currencyCode: order.currencyCode ?? currencyCode ?? "EUR",
728
+ state: order.state,
729
+ errorCode: null,
730
+ message: null,
731
+ affectedLineIds: [],
732
+ confirmationToken: confirmationToken ?? null,
733
+ };
734
+ }
735
+
736
+ function failure(
737
+ errorCode: string,
738
+ message: string,
739
+ affectedLineIds: readonly string[] = [],
740
+ ) {
741
+ return {
742
+ success: false,
743
+ order: null,
744
+ orderId: null,
745
+ orderCode: null,
746
+ state: "Failed",
747
+ errorCode,
748
+ message,
749
+ affectedLineIds,
750
+ confirmationToken: null,
751
+ };
752
+ }
753
+
754
+ function validateLegalConfigurationInput(input: LegalConfigurationInput): void {
755
+ if (!/^(sk|en)$/.test(input.languageCode)) {
756
+ throw new Error("languageCode must be sk or en");
757
+ }
758
+ if (!input.termsVersion.trim() || !input.privacyVersion.trim()) {
759
+ throw new Error("legal document versions are required");
760
+ }
761
+ for (const value of [input.termsUrl, input.privacyUrl]) {
762
+ const url = new URL(value);
763
+ if (url.protocol !== "https:") {
764
+ throw new Error("legal document URLs must use HTTPS");
765
+ }
766
+ }
767
+ }
768
+
769
+ function parsePositiveOrderId(orderId: string): number | undefined {
770
+ const parsedOrderId = Number(orderId);
771
+ return Number.isSafeInteger(parsedOrderId) && parsedOrderId > 0
772
+ ? parsedOrderId
773
+ : undefined;
774
+ }
775
+
776
+ function toLegalConfiguration(configuration: JarShopLegalConfiguration) {
777
+ return {
778
+ languageCode: configuration.languageCode,
779
+ termsVersion: configuration.termsVersion,
780
+ termsUrl: configuration.termsUrl,
781
+ privacyVersion: configuration.privacyVersion,
782
+ privacyUrl: configuration.privacyUrl,
783
+ };
784
+ }
785
+
786
+ /**
787
+ * JarShop's server-owned checkout seam.
788
+ *
789
+ * The resolver keeps the public checkout contract small while Vendure remains
790
+ * the authority for order state, pricing, stock and payment transitions.
791
+ */
792
+ @VendurePlugin({
793
+ dashboard: "./dashboard/index.tsx",
794
+ imports: [PluginCommonModule],
795
+ providers: [JarShopOrderEmailWorker],
796
+ configuration: registerJarShopOrderEmailWorkerTask,
797
+ entities: [
798
+ CheckoutAttempt,
799
+ JarShopConfirmationToken,
800
+ JarShopLegalConfiguration,
801
+ JarShopOrderEmailDelivery,
802
+ JarShopOrderConsent,
803
+ ],
804
+ shopApiExtensions: {
805
+ schema,
806
+ resolvers: [JarShopCheckoutResolver],
807
+ },
808
+ adminApiExtensions: {
809
+ schema: adminSchema,
810
+ resolvers: [JarShopLegalAdminResolver],
811
+ },
812
+ })
813
+ export class JarShopCheckoutPlugin {}