@jarwizz/create-jarshop 0.1.1 → 0.1.3

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