@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.
- package/README.md +10 -2
- package/dist/generator.js +10 -4
- package/dist/generator.js.map +1 -1
- package/dist/template/AGENTS.md +3 -3
- package/dist/template/CONTEXT.md +14 -0
- package/dist/template/README.md +25 -3
- package/dist/template/docker-compose.yml +5 -0
- package/dist/template/docs/smtp-smoke.md +37 -0
- package/dist/template/env.example.template +18 -0
- package/dist/template/gitignore.template +2 -0
- package/dist/template/package.json +19 -6
- package/dist/template/pnpm-lock.yaml +2389 -98
- package/dist/template/pnpm-workspace.yaml +12 -1
- package/dist/template/src/dev-storefront.ts +58 -0
- package/dist/template/src/dev.ts +67 -4
- package/dist/template/src/email-smoke.ts +27 -0
- package/dist/template/src/environment.d.ts +6 -0
- package/dist/template/src/migrate.ts +16 -0
- package/dist/template/src/migrations/1786024108434-jarshop-checkout-attempt.ts +28 -0
- package/dist/template/src/migrations/1786024108435-jarshop-legal-and-confirmation.ts +60 -0
- package/dist/template/src/migrations/1786024108436-jarshop-order-email-outbox.ts +37 -0
- package/dist/template/src/migrations/1786030000000-jarshop-order-email-delivery-lease.ts +27 -0
- package/dist/template/src/plugins/jarshop-checkout/checkout-attempt.entity.ts +42 -0
- package/dist/template/src/plugins/jarshop-checkout/cod-payment.test.ts +44 -0
- package/dist/template/src/plugins/jarshop-checkout/cod-payment.ts +28 -0
- package/dist/template/src/plugins/jarshop-checkout/confirmation-token.entity.ts +31 -0
- package/dist/template/src/plugins/jarshop-checkout/confirmation.ts +59 -0
- package/dist/template/src/plugins/jarshop-checkout/dashboard/index.tsx +107 -0
- package/dist/template/src/plugins/jarshop-checkout/dashboard/order-email-delivery-status.ts +43 -0
- package/dist/template/src/plugins/jarshop-checkout/email-smoke-core.ts +56 -0
- package/dist/template/src/plugins/jarshop-checkout/index.ts +51 -0
- package/dist/template/src/plugins/jarshop-checkout/jarshop-checkout.plugin.ts +870 -0
- package/dist/template/src/plugins/jarshop-checkout/legal.entity.ts +39 -0
- package/dist/template/src/plugins/jarshop-checkout/order-consent.entity.ts +32 -0
- package/dist/template/src/plugins/jarshop-checkout/order-email-delivery-core.ts +337 -0
- package/dist/template/src/plugins/jarshop-checkout/order-email-delivery-status.ts +39 -0
- package/dist/template/src/plugins/jarshop-checkout/order-email-delivery.entity.ts +66 -0
- package/dist/template/src/plugins/jarshop-checkout/order-email-outbox.ts +108 -0
- package/dist/template/src/plugins/jarshop-checkout/order-email-transport.ts +35 -0
- package/dist/template/src/plugins/jarshop-checkout/order-email-worker.ts +281 -0
- package/dist/template/src/plugins/jarshop-checkout/shipping.test.ts +41 -0
- package/dist/template/src/plugins/jarshop-checkout/shipping.ts +162 -0
- package/dist/template/src/seed.ts +171 -0
- package/dist/template/src/vendure-config.ts +72 -7
- package/dist/template/static/email/templates/email-verification/body.hbs +4 -4
- package/dist/template/storefront/app/[locale]/[slug]/add-to-cart-form.tsx +44 -0
- package/dist/template/storefront/app/[locale]/[slug]/page.tsx +35 -26
- package/dist/template/storefront/app/[locale]/account/account-mutation-form.tsx +44 -0
- package/dist/template/storefront/app/[locale]/account/change-password-form.tsx +65 -0
- package/dist/template/storefront/app/[locale]/account/login/login-form.tsx +60 -0
- package/dist/template/storefront/app/[locale]/account/login/page.tsx +23 -0
- package/dist/template/storefront/app/[locale]/account/page.tsx +215 -0
- package/dist/template/storefront/app/[locale]/account/password-reset/callback/route.ts +33 -0
- package/dist/template/storefront/app/[locale]/account/password-reset/complete/page.tsx +27 -0
- package/dist/template/storefront/app/[locale]/account/password-reset/page.tsx +37 -0
- package/dist/template/storefront/app/[locale]/account/password-reset/password-reset-forms.tsx +99 -0
- package/dist/template/storefront/app/[locale]/account/register/page.tsx +23 -0
- package/dist/template/storefront/app/[locale]/account/register/register-form.tsx +67 -0
- package/dist/template/storefront/app/[locale]/account/verify/complete/route.ts +33 -0
- package/dist/template/storefront/app/[locale]/account/verify/page.tsx +41 -0
- package/dist/template/storefront/app/[locale]/account/verify/refresh-form.tsx +35 -0
- package/dist/template/storefront/app/[locale]/cart/page.tsx +78 -0
- package/dist/template/storefront/app/[locale]/checkout/checkout-form.tsx +112 -0
- package/dist/template/storefront/app/[locale]/checkout/confirmation/route.ts +21 -0
- package/dist/template/storefront/app/[locale]/checkout/page.tsx +44 -0
- package/dist/template/storefront/app/[locale]/checkout/success/page.tsx +28 -0
- package/dist/template/storefront/app/[locale]/layout.tsx +48 -0
- package/dist/template/storefront/app/[locale]/page.tsx +12 -13
- package/dist/template/storefront/app/globals.css +372 -4
- package/dist/template/storefront/app/layout.tsx +13 -0
- package/dist/template/storefront/eslint.config.mjs +8 -1
- package/dist/template/storefront/gitignore.template +1 -0
- package/dist/template/storefront/lib/account-security.test.ts +74 -0
- package/dist/template/storefront/lib/account-security.ts +81 -0
- package/dist/template/storefront/lib/account.ts +366 -0
- package/dist/template/storefront/lib/cart-contract.ts +52 -0
- package/dist/template/storefront/lib/cart.test.ts +30 -0
- package/dist/template/storefront/lib/cart.ts +130 -0
- package/dist/template/storefront/lib/checkout-contract.test.ts +20 -0
- package/dist/template/storefront/lib/checkout-contract.ts +16 -0
- package/dist/template/storefront/lib/checkout.ts +149 -0
- package/dist/template/storefront/lib/commerce-session.test.ts +77 -0
- package/dist/template/storefront/lib/commerce-session.ts +85 -0
- package/dist/template/storefront/lib/commerce.ts +129 -4
- package/dist/template/storefront/lib/confirmation-cookie.ts +9 -0
- package/dist/template/storefront/lib/confirmation.ts +95 -0
- package/dist/template/storefront/lib/form-values.test.ts +14 -0
- package/dist/template/storefront/lib/form-values.ts +7 -0
- package/dist/template/storefront/lib/i18n.test.ts +50 -0
- package/dist/template/storefront/lib/i18n.ts +44 -0
- package/dist/template/storefront/lib/password-reset-token.test.ts +31 -0
- package/dist/template/storefront/lib/password-reset-token.ts +17 -0
- package/dist/template/storefront/lib/valkey-rate-limit.ts +72 -0
- package/dist/template/storefront/messages/en.json +89 -0
- package/dist/template/storefront/messages/sk.json +89 -0
- package/dist/template/storefront/next.config.ts +3 -1
- package/dist/template/storefront/package.json +11 -4
- package/dist/template/storefront/vitest.config.ts +8 -0
- package/dist/template/tsconfig.json +2 -1
- package/dist/template/turbo.json +8 -0
- package/dist/template/vite.config.mts +4 -1
- package/dist/template-manifest.json +1 -1
- package/dist/template.js +3 -0
- package/dist/template.js.map +1 -1
- package/package.json +1 -1
- package/dist/template/storefront/pnpm-lock.yaml +0 -3562
- package/dist/template/storefront/pnpm-workspace.yaml +0 -26
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { Inject, Injectable, OnApplicationBootstrap } from "@nestjs/common";
|
|
2
|
+
import {
|
|
3
|
+
LanguageCode,
|
|
4
|
+
Order,
|
|
5
|
+
OrderService,
|
|
6
|
+
ProcessContext,
|
|
7
|
+
RequestContextService,
|
|
8
|
+
ScheduledTask,
|
|
9
|
+
TransactionalConnection,
|
|
10
|
+
} from "@vendure/core";
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
12
|
+
import { JarShopOrderEmailDelivery } from "./order-email-delivery.entity.js";
|
|
13
|
+
import {
|
|
14
|
+
assertOrderEmailDeliveryConfiguration,
|
|
15
|
+
createOrderConfirmationUrl,
|
|
16
|
+
orderEmailDeliveryPolicy,
|
|
17
|
+
type OrderConfirmationEmail,
|
|
18
|
+
registerUniqueScheduledTask,
|
|
19
|
+
renderOrderConfirmationEmail,
|
|
20
|
+
scheduleOrderEmailRetry,
|
|
21
|
+
toOrderEmailLanguageCode,
|
|
22
|
+
type OrderEmailTransport,
|
|
23
|
+
} from "./order-email-delivery-core.js";
|
|
24
|
+
import { resolveLocalizedShippingMethodName } from "./shipping.js";
|
|
25
|
+
import { createConfiguredOrderEmailTransport } from "./order-email-transport.js";
|
|
26
|
+
import {
|
|
27
|
+
assertOrderEmailOutboxEncryptionConfiguration,
|
|
28
|
+
decryptOrderEmailOutboxPayload,
|
|
29
|
+
} from "./order-email-outbox.js";
|
|
30
|
+
|
|
31
|
+
type DeliveryResult =
|
|
32
|
+
| { outcome: "idle" }
|
|
33
|
+
| { outcome: "sent"; deliveryId: number }
|
|
34
|
+
| { outcome: "failed"; deliveryId: number; errorCode: string };
|
|
35
|
+
|
|
36
|
+
type ClaimedDelivery = {
|
|
37
|
+
delivery: JarShopOrderEmailDelivery;
|
|
38
|
+
leaseToken: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
class OrderEmailWorkerError extends Error {
|
|
42
|
+
constructor(readonly code: string) {
|
|
43
|
+
super(code);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@Injectable()
|
|
48
|
+
export class JarShopOrderEmailWorker implements OnApplicationBootstrap {
|
|
49
|
+
private emailTransport: OrderEmailTransport | undefined;
|
|
50
|
+
|
|
51
|
+
constructor(
|
|
52
|
+
@Inject(TransactionalConnection)
|
|
53
|
+
private readonly connection: TransactionalConnection,
|
|
54
|
+
@Inject(OrderService)
|
|
55
|
+
private readonly orderService: OrderService,
|
|
56
|
+
@Inject(RequestContextService)
|
|
57
|
+
private readonly requestContextService: RequestContextService,
|
|
58
|
+
@Inject(ProcessContext)
|
|
59
|
+
private readonly processContext: ProcessContext,
|
|
60
|
+
) {}
|
|
61
|
+
|
|
62
|
+
onApplicationBootstrap(): void {
|
|
63
|
+
if (!this.processContext.isServer && !this.processContext.isWorker) return;
|
|
64
|
+
assertOrderEmailOutboxEncryptionConfiguration();
|
|
65
|
+
assertOrderEmailDeliveryConfiguration();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async processNextDueDelivery(now = new Date()): Promise<DeliveryResult> {
|
|
69
|
+
const delivery = await this.claimNextDueDelivery(now);
|
|
70
|
+
if (!delivery) return { outcome: "idle" };
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const payload = decryptOrderEmailOutboxPayload(
|
|
74
|
+
delivery.delivery.payloadCiphertext,
|
|
75
|
+
);
|
|
76
|
+
const email = await this.renderOrderConfirmation(
|
|
77
|
+
delivery.delivery,
|
|
78
|
+
payload.confirmationToken,
|
|
79
|
+
);
|
|
80
|
+
await this.getEmailTransport().deliver({
|
|
81
|
+
deliveryId: delivery.delivery.id,
|
|
82
|
+
email,
|
|
83
|
+
});
|
|
84
|
+
await this.markSent(delivery.delivery.id, delivery.leaseToken, now);
|
|
85
|
+
return { outcome: "sent", deliveryId: delivery.delivery.id };
|
|
86
|
+
} catch (error) {
|
|
87
|
+
const errorCode = toOrderEmailWorkerErrorCode(error);
|
|
88
|
+
const outcome = await this.markFailure(
|
|
89
|
+
delivery.delivery.id,
|
|
90
|
+
delivery.leaseToken,
|
|
91
|
+
delivery.delivery.attemptCount,
|
|
92
|
+
errorCode,
|
|
93
|
+
now,
|
|
94
|
+
);
|
|
95
|
+
return outcome.status === "failed"
|
|
96
|
+
? { outcome: "failed", deliveryId: delivery.delivery.id, errorCode }
|
|
97
|
+
: { outcome: "idle" };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private getEmailTransport(): OrderEmailTransport {
|
|
102
|
+
this.emailTransport ??= createConfiguredOrderEmailTransport();
|
|
103
|
+
return this.emailTransport;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private async claimNextDueDelivery(
|
|
107
|
+
now: Date,
|
|
108
|
+
): Promise<ClaimedDelivery | null> {
|
|
109
|
+
return this.connection.rawConnection.transaction(async (manager) => {
|
|
110
|
+
const repository = manager.getRepository(JarShopOrderEmailDelivery);
|
|
111
|
+
const delivery = await repository
|
|
112
|
+
.createQueryBuilder("delivery")
|
|
113
|
+
.setLock("pessimistic_write")
|
|
114
|
+
.setOnLocked("skip_locked")
|
|
115
|
+
.where(
|
|
116
|
+
"(delivery.status = :pendingStatus AND delivery.nextAttemptAt <= :now) OR (delivery.status = :processingStatus AND (delivery.leaseExpiresAt IS NULL OR delivery.leaseExpiresAt <= :now))",
|
|
117
|
+
{ pendingStatus: "pending", processingStatus: "processing", now },
|
|
118
|
+
)
|
|
119
|
+
.orderBy("delivery.nextAttemptAt", "ASC")
|
|
120
|
+
.addOrderBy("delivery.id", "ASC")
|
|
121
|
+
.getOne();
|
|
122
|
+
if (!delivery) return null;
|
|
123
|
+
|
|
124
|
+
const staleLease = delivery.status === "processing";
|
|
125
|
+
if (
|
|
126
|
+
staleLease &&
|
|
127
|
+
delivery.attemptCount >= orderEmailDeliveryPolicy.maximumAttempts
|
|
128
|
+
) {
|
|
129
|
+
delivery.status = "failed";
|
|
130
|
+
delivery.leaseToken = null;
|
|
131
|
+
delivery.leaseExpiresAt = null;
|
|
132
|
+
delivery.lastErrorCode = "DELIVERY_LEASE_EXPIRED";
|
|
133
|
+
await repository.save(delivery);
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
delivery.status = "processing";
|
|
138
|
+
delivery.attemptCount += 1;
|
|
139
|
+
delivery.leaseToken = randomUUID();
|
|
140
|
+
delivery.leaseExpiresAt = new Date(
|
|
141
|
+
now.getTime() + orderEmailDeliveryPolicy.leaseDurationMs,
|
|
142
|
+
);
|
|
143
|
+
delivery.lastErrorCode = staleLease ? "DELIVERY_LEASE_EXPIRED" : null;
|
|
144
|
+
const claimed = await repository.save(delivery);
|
|
145
|
+
return { delivery: claimed, leaseToken: claimed.leaseToken! };
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private async renderOrderConfirmation(
|
|
150
|
+
delivery: JarShopOrderEmailDelivery,
|
|
151
|
+
confirmationToken: string,
|
|
152
|
+
): Promise<OrderConfirmationEmail> {
|
|
153
|
+
const sourceOrder = await this.connection.rawConnection
|
|
154
|
+
.getRepository(Order)
|
|
155
|
+
.findOne({ where: { id: delivery.orderId }, relations: ["channels"] });
|
|
156
|
+
const channel = sourceOrder?.channels[0];
|
|
157
|
+
if (!sourceOrder) throw new OrderEmailWorkerError("ORDER_NOT_FOUND");
|
|
158
|
+
if (!channel) throw new OrderEmailWorkerError("ORDER_CHANNEL_MISSING");
|
|
159
|
+
|
|
160
|
+
const languageCode = toOrderEmailLanguageCode(delivery.languageCode);
|
|
161
|
+
const ctx = await this.requestContextService.create({
|
|
162
|
+
apiType: "admin",
|
|
163
|
+
// The raw order relation contains the channel identity, not its default
|
|
164
|
+
// tax and shipping zones. Resolve it through Vendure's ChannelService so
|
|
165
|
+
// OrderService can hydrate the order without recalculating against an
|
|
166
|
+
// incomplete Channel entity.
|
|
167
|
+
channelOrToken: channel.token,
|
|
168
|
+
languageCode: languageCode === "sk" ? LanguageCode.sk : LanguageCode.en,
|
|
169
|
+
});
|
|
170
|
+
const order = await this.orderService.findOne(ctx, delivery.orderId, [
|
|
171
|
+
"customer",
|
|
172
|
+
"lines.productVariant",
|
|
173
|
+
"shippingLines.shippingMethod.translations",
|
|
174
|
+
"payments",
|
|
175
|
+
]);
|
|
176
|
+
if (!order?.customer?.emailAddress) {
|
|
177
|
+
throw new OrderEmailWorkerError("ORDER_RECIPIENT_MISSING");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return renderOrderConfirmationEmail({
|
|
181
|
+
recipient: order.customer.emailAddress,
|
|
182
|
+
languageCode,
|
|
183
|
+
orderCode: order.code,
|
|
184
|
+
currencyCode: order.currencyCode,
|
|
185
|
+
totalWithTax: order.totalWithTax,
|
|
186
|
+
shippingWithTax: order.shippingWithTax,
|
|
187
|
+
paymentMethod: order.payments.map((payment) => payment.method).join(", "),
|
|
188
|
+
shippingMethod: order.shippingLines
|
|
189
|
+
.map((line) =>
|
|
190
|
+
resolveLocalizedShippingMethodName(line.shippingMethod, languageCode),
|
|
191
|
+
)
|
|
192
|
+
.filter(Boolean)
|
|
193
|
+
.join(", "),
|
|
194
|
+
confirmationUrl: createOrderConfirmationUrl(
|
|
195
|
+
languageCode,
|
|
196
|
+
confirmationToken,
|
|
197
|
+
),
|
|
198
|
+
lines: order.lines.map((line) => ({
|
|
199
|
+
name: line.productVariant.name,
|
|
200
|
+
quantity: line.quantity,
|
|
201
|
+
totalWithTax: Math.round(line.proratedLinePriceWithTax),
|
|
202
|
+
})),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private async markSent(
|
|
207
|
+
deliveryId: number,
|
|
208
|
+
leaseToken: string,
|
|
209
|
+
now: Date,
|
|
210
|
+
): Promise<void> {
|
|
211
|
+
const result = await this.connection.rawConnection
|
|
212
|
+
.getRepository(JarShopOrderEmailDelivery)
|
|
213
|
+
.update(
|
|
214
|
+
{ id: deliveryId, status: "processing", leaseToken },
|
|
215
|
+
{
|
|
216
|
+
status: "sent",
|
|
217
|
+
sentAt: now,
|
|
218
|
+
leaseToken: null,
|
|
219
|
+
leaseExpiresAt: null,
|
|
220
|
+
lastErrorCode: null,
|
|
221
|
+
},
|
|
222
|
+
);
|
|
223
|
+
if (result.affected !== 1) {
|
|
224
|
+
throw new Error("order email delivery was not owned by this worker");
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private async markFailure(
|
|
229
|
+
deliveryId: number,
|
|
230
|
+
leaseToken: string,
|
|
231
|
+
attemptCount: number,
|
|
232
|
+
errorCode: string,
|
|
233
|
+
now: Date,
|
|
234
|
+
): Promise<ReturnType<typeof scheduleOrderEmailRetry>> {
|
|
235
|
+
const retry = scheduleOrderEmailRetry({ attemptCount, now });
|
|
236
|
+
const result = await this.connection.rawConnection
|
|
237
|
+
.getRepository(JarShopOrderEmailDelivery)
|
|
238
|
+
.update(
|
|
239
|
+
{ id: deliveryId, status: "processing", leaseToken },
|
|
240
|
+
{
|
|
241
|
+
status: retry.status,
|
|
242
|
+
...(retry.status === "pending"
|
|
243
|
+
? { nextAttemptAt: retry.nextAttemptAt }
|
|
244
|
+
: {}),
|
|
245
|
+
leaseToken: null,
|
|
246
|
+
leaseExpiresAt: null,
|
|
247
|
+
lastErrorCode: errorCode,
|
|
248
|
+
},
|
|
249
|
+
);
|
|
250
|
+
if (result.affected !== 1) {
|
|
251
|
+
throw new Error(
|
|
252
|
+
"order email delivery was no longer owned by this worker",
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
return retry;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export const jarShopOrderEmailWorkerTask = new ScheduledTask({
|
|
260
|
+
id: "jarshop-order-email-outbox",
|
|
261
|
+
description: "Deliver one due JarShop order-confirmation email",
|
|
262
|
+
schedule: (cron) => cron.every(1).minutes(),
|
|
263
|
+
timeout: 30_000,
|
|
264
|
+
execute: async ({ injector }) => {
|
|
265
|
+
return injector.get(JarShopOrderEmailWorker).processNextDueDelivery();
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
export function registerJarShopOrderEmailWorkerTask<
|
|
270
|
+
Config extends { schedulerOptions: { tasks: ScheduledTask[] } },
|
|
271
|
+
>(config: Config): Config {
|
|
272
|
+
return registerUniqueScheduledTask(config, jarShopOrderEmailWorkerTask);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function toOrderEmailWorkerErrorCode(error: unknown): string {
|
|
276
|
+
if (error instanceof OrderEmailWorkerError) return error.code;
|
|
277
|
+
if (error instanceof Error && error.message.includes("cannot be decrypted")) {
|
|
278
|
+
return "OUTBOX_PAYLOAD_UNREADABLE";
|
|
279
|
+
}
|
|
280
|
+
return "DELIVERY_FAILED";
|
|
281
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
compareCheckoutSnapshot,
|
|
5
|
+
resolveLocalizedShippingMethodName,
|
|
6
|
+
} from "./shipping.js";
|
|
7
|
+
|
|
8
|
+
describe("compareCheckoutSnapshot", () => {
|
|
9
|
+
it("accepts equivalent GraphQL ID string and number representations", () => {
|
|
10
|
+
expect(
|
|
11
|
+
compareCheckoutSnapshot(
|
|
12
|
+
{
|
|
13
|
+
currencyCode: "EUR",
|
|
14
|
+
subtotalWithTax: 1990,
|
|
15
|
+
lines: [{ lineId: 1, quantity: 1, linePriceWithTax: 1990 }],
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
currencyCode: "EUR",
|
|
19
|
+
subtotalWithTax: 1990,
|
|
20
|
+
lines: [{ lineId: "1", quantity: 1, linePriceWithTax: 1990 }],
|
|
21
|
+
},
|
|
22
|
+
),
|
|
23
|
+
).toEqual({ ok: true });
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("resolveLocalizedShippingMethodName", () => {
|
|
28
|
+
it("uses the order language when a loaded shipping relation has no translated name", () => {
|
|
29
|
+
expect(
|
|
30
|
+
resolveLocalizedShippingMethodName(
|
|
31
|
+
{
|
|
32
|
+
translations: [
|
|
33
|
+
{ languageCode: "en", name: "Personal pickup" },
|
|
34
|
+
{ languageCode: "sk", name: "Osobný odber" },
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
"sk",
|
|
38
|
+
),
|
|
39
|
+
).toBe("Osobný odber");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
export type ShippingMethodCode = "pickup" | "courier";
|
|
2
|
+
|
|
3
|
+
type LocalizedShippingMethod = {
|
|
4
|
+
name?: string | null;
|
|
5
|
+
translations?: ReadonlyArray<{
|
|
6
|
+
languageCode: string;
|
|
7
|
+
name?: string | null;
|
|
8
|
+
}>;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Vendure translates `name` for a request context, but relation loading can
|
|
13
|
+
* leave it empty. Keep delivery communications localized by falling back to
|
|
14
|
+
* the explicitly loaded translation for the order's language.
|
|
15
|
+
*/
|
|
16
|
+
export function resolveLocalizedShippingMethodName(
|
|
17
|
+
shippingMethod: LocalizedShippingMethod | undefined,
|
|
18
|
+
languageCode: string,
|
|
19
|
+
): string {
|
|
20
|
+
const translatedName = shippingMethod?.translations
|
|
21
|
+
?.find((translation) => translation.languageCode === languageCode)
|
|
22
|
+
?.name?.trim();
|
|
23
|
+
return translatedName || shippingMethod?.name?.trim() || "";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CheckoutCartSnapshotLine {
|
|
27
|
+
// GraphQL ID input values can arrive as a JSON string or number, depending
|
|
28
|
+
// on the active Vendure ID strategy. Comparison is canonicalized below.
|
|
29
|
+
lineId: string | number;
|
|
30
|
+
quantity: number;
|
|
31
|
+
linePriceWithTax: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface CheckoutCartSnapshot {
|
|
35
|
+
currencyCode: string;
|
|
36
|
+
subtotalWithTax: number;
|
|
37
|
+
lines: CheckoutCartSnapshotLine[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type CheckoutSnapshotComparison =
|
|
41
|
+
| { readonly ok: true }
|
|
42
|
+
| {
|
|
43
|
+
readonly ok: false;
|
|
44
|
+
readonly errorCode: "PRICE_CHANGED" | "INSUFFICIENT_STOCK";
|
|
45
|
+
readonly affectedLineIds: readonly string[];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export function compareCheckoutSnapshot(
|
|
49
|
+
expected: CheckoutCartSnapshot,
|
|
50
|
+
actual: CheckoutCartSnapshot,
|
|
51
|
+
): CheckoutSnapshotComparison {
|
|
52
|
+
if (expected.currencyCode !== actual.currencyCode) {
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
errorCode: "PRICE_CHANGED",
|
|
56
|
+
affectedLineIds: expected.lines.map((line) => String(line.lineId)),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const actualById = new Map(
|
|
60
|
+
actual.lines.map((line) => [String(line.lineId), line]),
|
|
61
|
+
);
|
|
62
|
+
if (
|
|
63
|
+
actual.lines.length !== expected.lines.length ||
|
|
64
|
+
actual.lines.some(
|
|
65
|
+
(line) =>
|
|
66
|
+
!expected.lines.some(
|
|
67
|
+
(item) => String(item.lineId) === String(line.lineId),
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
) {
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
errorCode: "PRICE_CHANGED",
|
|
74
|
+
affectedLineIds: expected.lines.map((line) => String(line.lineId)),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const stockIssues = expected.lines.filter((line) => {
|
|
78
|
+
const actualLine = actualById.get(String(line.lineId));
|
|
79
|
+
return !actualLine || actualLine.quantity < line.quantity;
|
|
80
|
+
});
|
|
81
|
+
if (stockIssues.length) {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
errorCode: "INSUFFICIENT_STOCK",
|
|
85
|
+
affectedLineIds: stockIssues.map((line) => String(line.lineId)),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
const priceChanged =
|
|
89
|
+
expected.subtotalWithTax !== actual.subtotalWithTax ||
|
|
90
|
+
expected.lines.some((line) => {
|
|
91
|
+
const actualLine = actualById.get(String(line.lineId));
|
|
92
|
+
return (
|
|
93
|
+
!actualLine || actualLine.linePriceWithTax !== line.linePriceWithTax
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
return priceChanged
|
|
97
|
+
? {
|
|
98
|
+
ok: false,
|
|
99
|
+
errorCode: "PRICE_CHANGED",
|
|
100
|
+
affectedLineIds: expected.lines.map((line) => String(line.lineId)),
|
|
101
|
+
}
|
|
102
|
+
: { ok: true };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface ShippingAddressInput {
|
|
106
|
+
streetLine1: string;
|
|
107
|
+
streetLine2?: string;
|
|
108
|
+
city: string;
|
|
109
|
+
postalCode: string;
|
|
110
|
+
countryCode: string;
|
|
111
|
+
company?: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type ShippingValidationResult =
|
|
115
|
+
| { readonly ok: true }
|
|
116
|
+
| {
|
|
117
|
+
readonly ok: false;
|
|
118
|
+
readonly errorCode: "VALIDATION_ERROR" | "SHIPPING_METHOD_INELIGIBLE";
|
|
119
|
+
readonly message: string;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export function validateShippingSelection(input: {
|
|
123
|
+
shippingMethodCode: string;
|
|
124
|
+
shippingAddress?: ShippingAddressInput;
|
|
125
|
+
}): ShippingValidationResult {
|
|
126
|
+
if (
|
|
127
|
+
input.shippingMethodCode !== "pickup" &&
|
|
128
|
+
input.shippingMethodCode !== "courier"
|
|
129
|
+
) {
|
|
130
|
+
return {
|
|
131
|
+
ok: false,
|
|
132
|
+
errorCode: "SHIPPING_METHOD_INELIGIBLE",
|
|
133
|
+
message: "Selected shipping method is not available",
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (input.shippingMethodCode === "pickup") return { ok: true };
|
|
138
|
+
|
|
139
|
+
const address = input.shippingAddress;
|
|
140
|
+
if (!address) {
|
|
141
|
+
return {
|
|
142
|
+
ok: false,
|
|
143
|
+
errorCode: "VALIDATION_ERROR",
|
|
144
|
+
message: "A shipping address is required for courier delivery",
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (
|
|
149
|
+
!address.streetLine1.trim() ||
|
|
150
|
+
!address.city.trim() ||
|
|
151
|
+
!address.postalCode.trim() ||
|
|
152
|
+
address.countryCode !== "SK"
|
|
153
|
+
) {
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
errorCode: "VALIDATION_ERROR",
|
|
157
|
+
message: "Courier delivery requires a valid Slovak shipping address",
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { ok: true };
|
|
162
|
+
}
|
|
@@ -9,10 +9,17 @@ import {
|
|
|
9
9
|
TaxRateService,
|
|
10
10
|
ZoneService,
|
|
11
11
|
ChannelService,
|
|
12
|
+
CountryService,
|
|
13
|
+
PaymentMethodService,
|
|
14
|
+
PaymentMethod,
|
|
15
|
+
ShippingMethod,
|
|
16
|
+
ShippingMethodService,
|
|
17
|
+
TransactionalConnection,
|
|
12
18
|
bootstrap,
|
|
13
19
|
} from "@vendure/core";
|
|
14
20
|
import { GlobalFlag } from "@vendure/common/lib/generated-types";
|
|
15
21
|
import { config } from "./vendure-config";
|
|
22
|
+
import { JarShopLegalConfiguration } from "./plugins/jarshop-checkout/legal.entity.js";
|
|
16
23
|
|
|
17
24
|
const productSlug = "jarshop-demo-product";
|
|
18
25
|
|
|
@@ -31,9 +38,13 @@ async function seed(): Promise<void> {
|
|
|
31
38
|
const variantService = app.get(ProductVariantService);
|
|
32
39
|
const stockLocationService = app.get(StockLocationService);
|
|
33
40
|
const zoneService = app.get(ZoneService);
|
|
41
|
+
const countryService = app.get(CountryService);
|
|
34
42
|
const taxCategoryService = app.get(TaxCategoryService);
|
|
35
43
|
const taxRateService = app.get(TaxRateService);
|
|
36
44
|
const channelService = app.get(ChannelService);
|
|
45
|
+
const paymentMethodService = app.get(PaymentMethodService);
|
|
46
|
+
const shippingMethodService = app.get(ShippingMethodService);
|
|
47
|
+
const connection = app.get(TransactionalConnection);
|
|
37
48
|
const existing = await productService.findOneBySlug(ctx, productSlug);
|
|
38
49
|
|
|
39
50
|
const stockLocation = await stockLocationService.defaultStockLocation(ctx);
|
|
@@ -42,6 +53,23 @@ async function seed(): Promise<void> {
|
|
|
42
53
|
const taxZone =
|
|
43
54
|
zones.items[0] ??
|
|
44
55
|
(await zoneService.create(ctx, { name: "JarShop default tax zone" }));
|
|
56
|
+
const countries = await countryService.findAll(ctx);
|
|
57
|
+
const slovakia =
|
|
58
|
+
countries.items.find((country) => country.code === "SK") ??
|
|
59
|
+
(await countryService.create(ctx, {
|
|
60
|
+
code: "SK",
|
|
61
|
+
enabled: true,
|
|
62
|
+
translations: [
|
|
63
|
+
{ languageCode: LanguageCode.sk, name: "Slovensko" },
|
|
64
|
+
{ languageCode: LanguageCode.en, name: "Slovakia" },
|
|
65
|
+
],
|
|
66
|
+
}));
|
|
67
|
+
if (!taxZone.members.some((country) => country.id === slovakia.id)) {
|
|
68
|
+
await zoneService.addMembersToZone(ctx, {
|
|
69
|
+
zoneId: taxZone.id,
|
|
70
|
+
memberIds: [slovakia.id],
|
|
71
|
+
});
|
|
72
|
+
}
|
|
45
73
|
if (
|
|
46
74
|
!channel.defaultTaxZone ||
|
|
47
75
|
channel.defaultCurrencyCode !== CurrencyCode.EUR
|
|
@@ -58,6 +86,149 @@ async function seed(): Promise<void> {
|
|
|
58
86
|
channelOrToken: channel.token,
|
|
59
87
|
});
|
|
60
88
|
}
|
|
89
|
+
|
|
90
|
+
const shippingMethods =
|
|
91
|
+
await shippingMethodService.getActiveShippingMethods(ctx);
|
|
92
|
+
const pickup =
|
|
93
|
+
shippingMethods.find((method) => method.code === "pickup") ??
|
|
94
|
+
(await shippingMethodService.create(ctx, {
|
|
95
|
+
code: "pickup",
|
|
96
|
+
fulfillmentHandler: "manual-fulfillment",
|
|
97
|
+
checker: {
|
|
98
|
+
code: "default-shipping-eligibility-checker",
|
|
99
|
+
arguments: [{ name: "orderMinimum", value: "0" }],
|
|
100
|
+
},
|
|
101
|
+
calculator: {
|
|
102
|
+
code: "default-shipping-calculator",
|
|
103
|
+
arguments: [
|
|
104
|
+
{ name: "rate", value: "0" },
|
|
105
|
+
{ name: "taxRate", value: "0" },
|
|
106
|
+
{ name: "includesTax", value: "auto" },
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
translations: [
|
|
110
|
+
{
|
|
111
|
+
languageCode: LanguageCode.sk,
|
|
112
|
+
name: "Osobný odber",
|
|
113
|
+
description: "",
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
languageCode: LanguageCode.en,
|
|
117
|
+
name: "Personal pickup",
|
|
118
|
+
description: "",
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
}));
|
|
122
|
+
await channelService.assignToChannels(ctx, ShippingMethod, pickup.id, [
|
|
123
|
+
channel.id,
|
|
124
|
+
]);
|
|
125
|
+
const courier =
|
|
126
|
+
shippingMethods.find((method) => method.code === "courier") ??
|
|
127
|
+
(await shippingMethodService.create(ctx, {
|
|
128
|
+
code: "courier",
|
|
129
|
+
fulfillmentHandler: "manual-fulfillment",
|
|
130
|
+
checker: {
|
|
131
|
+
code: "default-shipping-eligibility-checker",
|
|
132
|
+
arguments: [{ name: "orderMinimum", value: "0" }],
|
|
133
|
+
},
|
|
134
|
+
calculator: {
|
|
135
|
+
code: "default-shipping-calculator",
|
|
136
|
+
arguments: [
|
|
137
|
+
{ name: "rate", value: "490" },
|
|
138
|
+
{ name: "taxRate", value: "0" },
|
|
139
|
+
{ name: "includesTax", value: "auto" },
|
|
140
|
+
],
|
|
141
|
+
},
|
|
142
|
+
translations: [
|
|
143
|
+
{
|
|
144
|
+
languageCode: LanguageCode.sk,
|
|
145
|
+
name: "Kuriér",
|
|
146
|
+
description: "Doručenie kuriérom",
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
languageCode: LanguageCode.en,
|
|
150
|
+
name: "Courier",
|
|
151
|
+
description: "Courier delivery",
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
}));
|
|
155
|
+
await channelService.assignToChannels(ctx, ShippingMethod, courier.id, [
|
|
156
|
+
channel.id,
|
|
157
|
+
]);
|
|
158
|
+
|
|
159
|
+
const paymentMethods = await paymentMethodService.findAll(ctx);
|
|
160
|
+
const legacyDummy = paymentMethods.items.find(
|
|
161
|
+
(method) => method.code === "dummy-payment-method",
|
|
162
|
+
);
|
|
163
|
+
if (legacyDummy?.enabled) {
|
|
164
|
+
await paymentMethodService.update(ctx, {
|
|
165
|
+
id: legacyDummy.id,
|
|
166
|
+
enabled: false,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
const existingCod = paymentMethods.items.find(
|
|
170
|
+
(method) => method.code === "cod",
|
|
171
|
+
);
|
|
172
|
+
const cod = existingCod
|
|
173
|
+
? await paymentMethodService.update(ctx, {
|
|
174
|
+
id: existingCod.id,
|
|
175
|
+
enabled: true,
|
|
176
|
+
handler: {
|
|
177
|
+
code: "jarshop-cod-handler",
|
|
178
|
+
arguments: [],
|
|
179
|
+
},
|
|
180
|
+
})
|
|
181
|
+
: await paymentMethodService.create(ctx, {
|
|
182
|
+
code: "cod",
|
|
183
|
+
enabled: true,
|
|
184
|
+
handler: {
|
|
185
|
+
code: "jarshop-cod-handler",
|
|
186
|
+
arguments: [],
|
|
187
|
+
},
|
|
188
|
+
translations: [
|
|
189
|
+
{
|
|
190
|
+
languageCode: LanguageCode.sk,
|
|
191
|
+
name: "Dobierka",
|
|
192
|
+
description: "",
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
languageCode: LanguageCode.en,
|
|
196
|
+
name: "Cash on delivery",
|
|
197
|
+
description: "",
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
});
|
|
201
|
+
await channelService.assignToChannels(ctx, PaymentMethod, cod.id, [
|
|
202
|
+
channel.id,
|
|
203
|
+
]);
|
|
204
|
+
|
|
205
|
+
const legalRepository = connection.getRepository(
|
|
206
|
+
ctx,
|
|
207
|
+
JarShopLegalConfiguration,
|
|
208
|
+
);
|
|
209
|
+
for (const languageCode of [LanguageCode.sk, LanguageCode.en]) {
|
|
210
|
+
const existingLegal = await legalRepository.findOne({
|
|
211
|
+
where: { channelId: Number(channel.id), languageCode },
|
|
212
|
+
});
|
|
213
|
+
if (!existingLegal) {
|
|
214
|
+
await legalRepository.save(
|
|
215
|
+
legalRepository.create({
|
|
216
|
+
channelId: Number(channel.id),
|
|
217
|
+
languageCode,
|
|
218
|
+
termsVersion: "2026-08-01",
|
|
219
|
+
termsUrl:
|
|
220
|
+
languageCode === LanguageCode.sk
|
|
221
|
+
? "https://example.com/sk/obchodne-podmienky"
|
|
222
|
+
: "https://example.com/en/terms",
|
|
223
|
+
privacyVersion: "2026-08-01",
|
|
224
|
+
privacyUrl:
|
|
225
|
+
languageCode === LanguageCode.sk
|
|
226
|
+
? "https://example.com/sk/ochrana-sukromia"
|
|
227
|
+
: "https://example.com/en/privacy",
|
|
228
|
+
}),
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
61
232
|
const taxCategories = await taxCategoryService.findAll(ctx);
|
|
62
233
|
const taxCategory =
|
|
63
234
|
taxCategories.items.find((item) => item.isDefault) ??
|