@usequark/quark-create-app 1.23.11
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 +210 -0
- package/package.json +56 -0
- package/src/dependabot-config.test.js +128 -0
- package/src/deploy/adapters/iac.js +203 -0
- package/src/deploy/adapters/index.js +27 -0
- package/src/deploy/adapters/railway.js +508 -0
- package/src/deploy/adapters/railway.test.js +318 -0
- package/src/deploy/contract.js +59 -0
- package/src/deploy/deploy.integration.test.js +183 -0
- package/src/deploy/deploy.js +430 -0
- package/src/deploy/deploy.test.js +105 -0
- package/src/deploy/discovery.js +115 -0
- package/src/deploy/discovery.test.js +141 -0
- package/src/deploy/index.js +38 -0
- package/src/deploy/inspect.js +130 -0
- package/src/index.js +2920 -0
- package/src/mobile-template.test.js +232 -0
- package/src/prepare-hooks.test.js +535 -0
- package/src/scaffold-guards.test.js +140 -0
- package/src/scaffold-output.test.js +228 -0
- package/src/template-scripts.test.js +160 -0
- package/src/utils.js +77 -0
- package/src/utils.test.js +95 -0
- package/src/worker-dockerfile.test.js +85 -0
- package/templates/base-project/.cursor/rules/quark.mdc +172 -0
- package/templates/base-project/.dockerignore +14 -0
- package/templates/base-project/.env.railway.example +87 -0
- package/templates/base-project/.github/copilot-instructions.md +65 -0
- package/templates/base-project/.github/dependabot.yml +31 -0
- package/templates/base-project/.github/skills/project-context/SKILL.md +109 -0
- package/templates/base-project/.github/workflows/ci.yml +116 -0
- package/templates/base-project/.github/workflows/dependabot-auto-merge.yml +22 -0
- package/templates/base-project/.github/workflows/install-windows.yml +27 -0
- package/templates/base-project/.github/workflows/release.yml +67 -0
- package/templates/base-project/.railway/railway.ts +40 -0
- package/templates/base-project/CLAUDE.md +163 -0
- package/templates/base-project/MAIN.md +21 -0
- package/templates/base-project/README.md +164 -0
- package/templates/base-project/apps/web/.env.example +99 -0
- package/templates/base-project/apps/web/Dockerfile +38 -0
- package/templates/base-project/apps/web/biome.json +8 -0
- package/templates/base-project/apps/web/jsconfig.json +9 -0
- package/templates/base-project/apps/web/next.config.js +154 -0
- package/templates/base-project/apps/web/package.json +42 -0
- package/templates/base-project/apps/web/postcss.config.mjs +7 -0
- package/templates/base-project/apps/web/public/.well-known/security.txt +3 -0
- package/templates/base-project/apps/web/public/quark.svg +47 -0
- package/templates/base-project/apps/web/scripts/prepare-standalone.mjs +71 -0
- package/templates/base-project/apps/web/src/app/_components/AsciiPanel.js +103 -0
- package/templates/base-project/apps/web/src/app/_components/HealthIndicator.js +85 -0
- package/templates/base-project/apps/web/src/app/_components/HomeThemeToggle.js +63 -0
- package/templates/base-project/apps/web/src/app/_components/QuarkAnimation.js +168 -0
- package/templates/base-project/apps/web/src/app/_components/SyncedPanels.js +212 -0
- package/templates/base-project/apps/web/src/app/_components/UmamiReplayRecorder.js +198 -0
- package/templates/base-project/apps/web/src/app/_components/UmamiWebVitals.js +39 -0
- package/templates/base-project/apps/web/src/app/api/auth/[...nextauth]/route.js +7 -0
- package/templates/base-project/apps/web/src/app/api/auth/apple/route.js +82 -0
- package/templates/base-project/apps/web/src/app/api/auth/google/route.js +59 -0
- package/templates/base-project/apps/web/src/app/api/auth/refresh/route.js +48 -0
- package/templates/base-project/apps/web/src/app/api/auth/register/route.js +63 -0
- package/templates/base-project/apps/web/src/app/api/auth/token/route.js +44 -0
- package/templates/base-project/apps/web/src/app/api/csrf/route.js +40 -0
- package/templates/base-project/apps/web/src/app/api/device/register/route.js +57 -0
- package/templates/base-project/apps/web/src/app/api/error-handler.js +23 -0
- package/templates/base-project/apps/web/src/app/api/files/[id]/route.js +89 -0
- package/templates/base-project/apps/web/src/app/api/files/route.js +120 -0
- package/templates/base-project/apps/web/src/app/api/health/route.js +160 -0
- package/templates/base-project/apps/web/src/app/api/users/[id]/route.js +54 -0
- package/templates/base-project/apps/web/src/app/api/users/me/route.js +67 -0
- package/templates/base-project/apps/web/src/app/api/users/route.js +45 -0
- package/templates/base-project/apps/web/src/app/auth/error/page.js +83 -0
- package/templates/base-project/apps/web/src/app/auth/forgot-password/page.js +58 -0
- package/templates/base-project/apps/web/src/app/auth/provider-loading.js +25 -0
- package/templates/base-project/apps/web/src/app/auth/register/RegisterPageClient.js +340 -0
- package/templates/base-project/apps/web/src/app/auth/register/page.js +17 -0
- package/templates/base-project/apps/web/src/app/auth/signin/SignInPageClient.js +230 -0
- package/templates/base-project/apps/web/src/app/auth/signin/page.js +10 -0
- package/templates/base-project/apps/web/src/app/favicon.ico +0 -0
- package/templates/base-project/apps/web/src/app/global-error.js +58 -0
- package/templates/base-project/apps/web/src/app/globals.css +1166 -0
- package/templates/base-project/apps/web/src/app/icon.svg +47 -0
- package/templates/base-project/apps/web/src/app/layout.js +57 -0
- package/templates/base-project/apps/web/src/app/manifest.js +12 -0
- package/templates/base-project/apps/web/src/app/not-found.js +39 -0
- package/templates/base-project/apps/web/src/app/page.js +262 -0
- package/templates/base-project/apps/web/src/app/robots.js +23 -0
- package/templates/base-project/apps/web/src/app/sitemap.js +14 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-before-send.js +29 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-config.js +95 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-csp.js +24 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-marketing.js +131 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-replay.js +204 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-web-vitals.js +237 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami.js +57 -0
- package/templates/base-project/apps/web/src/lib/auth-middleware.js +82 -0
- package/templates/base-project/apps/web/src/lib/auth-signup.js +5 -0
- package/templates/base-project/apps/web/src/lib/auth.js +191 -0
- package/templates/base-project/apps/web/src/lib/csrf-cookie.js +11 -0
- package/templates/base-project/apps/web/src/lib/jwt.js +84 -0
- package/templates/base-project/apps/web/src/lib/load-crm-config.js +35 -0
- package/templates/base-project/apps/web/src/lib/normalize-auth-request.js +33 -0
- package/templates/base-project/apps/web/src/lib/proxy-auth.js +80 -0
- package/templates/base-project/apps/web/src/lib/seo/indexing.js +25 -0
- package/templates/base-project/apps/web/src/lib/seo/site-metadata.js +74 -0
- package/templates/base-project/apps/web/src/lib/sitemap-entries.js +22 -0
- package/templates/base-project/apps/web/src/preflight.js +55 -0
- package/templates/base-project/apps/web/src/proxy.js +328 -0
- package/templates/base-project/biome.json +52 -0
- package/templates/base-project/docker-compose.override.yml +17 -0
- package/templates/base-project/docker-compose.yml +37 -0
- package/templates/base-project/docs/FIRST_FEATURE.md +74 -0
- package/templates/base-project/docs/START_HERE.md +70 -0
- package/templates/base-project/docs/adr/README.md +42 -0
- package/templates/base-project/package.json +58 -0
- package/templates/base-project/packages/db/package.json +38 -0
- package/templates/base-project/packages/db/prisma/migrations/20260202061128_initial/migration.sql +202 -0
- package/templates/base-project/packages/db/prisma/migrations/migration_lock.toml +3 -0
- package/templates/base-project/packages/db/prisma/schema.prisma +161 -0
- package/templates/base-project/packages/db/prisma/seed.js +152 -0
- package/templates/base-project/packages/db/prisma.config.js +27 -0
- package/templates/base-project/packages/db/src/client.js +75 -0
- package/templates/base-project/packages/db/src/connection.js +44 -0
- package/templates/base-project/packages/db/src/context.js +275 -0
- package/templates/base-project/packages/db/src/index.js +5 -0
- package/templates/base-project/packages/db/src/ping.js +66 -0
- package/templates/base-project/packages/db/src/queries.js +292 -0
- package/templates/base-project/packages/db/src/schemas.js +30 -0
- package/templates/base-project/packages/db/src/umami.js +299 -0
- package/templates/base-project/pnpm-workspace.yaml +20 -0
- package/templates/base-project/scripts/check-loading.mjs +49 -0
- package/templates/base-project/scripts/check-standards.mjs +186 -0
- package/templates/base-project/scripts/clean-workspace.mjs +479 -0
- package/templates/base-project/scripts/dev-preflight.mjs +48 -0
- package/templates/base-project/scripts/doctor.js +408 -0
- package/templates/base-project/scripts/prepare.js +490 -0
- package/templates/base-project/scripts/run-tests.mjs +71 -0
- package/templates/base-project/skills/add-dashboard/SKILL.md +28 -0
- package/templates/base-project/skills/add-endpoint/SKILL.md +29 -0
- package/templates/base-project/skills/add-model/SKILL.md +28 -0
- package/templates/base-project/skills/ecommerce/SKILL.md +224 -0
- package/templates/base-project/skills/ecommerce-cart/SKILL.md +193 -0
- package/templates/base-project/skills/ecommerce-catalog/SKILL.md +179 -0
- package/templates/base-project/skills/ecommerce-checkout/SKILL.md +195 -0
- package/templates/base-project/skills/i18n/SKILL.md +211 -0
- package/templates/base-project/skills/payment/SKILL.md +280 -0
- package/templates/base-project/skills/quark-skills/SKILL.md +37 -0
- package/templates/base-project/turbo.json +143 -0
- package/templates/config/package.json +20 -0
- package/templates/config/src/app-url.js +134 -0
- package/templates/config/src/environment.js +284 -0
- package/templates/config/src/index.js +16 -0
- package/templates/config/src/load-config.js +157 -0
- package/templates/config/src/rate-limit.js +136 -0
- package/templates/config/src/redis.js +43 -0
- package/templates/config/src/validate-env.js +532 -0
- package/templates/jobs/README.md +52 -0
- package/templates/jobs/package.json +9 -0
- package/templates/jobs/src/definitions.js +13 -0
- package/templates/jobs/src/index.js +1 -0
- package/templates/mobile/.env.example +16 -0
- package/templates/mobile/CHECKLIST.md +40 -0
- package/templates/mobile/README.md +130 -0
- package/templates/mobile/app/(app)/_layout.tsx +83 -0
- package/templates/mobile/app/(app)/index.tsx +147 -0
- package/templates/mobile/app/(app)/profile.tsx +204 -0
- package/templates/mobile/app/(app)/settings.tsx +80 -0
- package/templates/mobile/app/(auth)/_layout.tsx +10 -0
- package/templates/mobile/app/(auth)/sign-in.tsx +140 -0
- package/templates/mobile/app/(auth)/sign-up.tsx +178 -0
- package/templates/mobile/app/+not-found.tsx +32 -0
- package/templates/mobile/app/_layout.tsx +18 -0
- package/templates/mobile/app.json +47 -0
- package/templates/mobile/assets/adaptive-icon.png +0 -0
- package/templates/mobile/assets/icon.png +0 -0
- package/templates/mobile/assets/notification-icon.png +0 -0
- package/templates/mobile/assets/splash.png +0 -0
- package/templates/mobile/biome.json +27 -0
- package/templates/mobile/components/AuthGate.tsx +19 -0
- package/templates/mobile/components/ThemeProvider.tsx +23 -0
- package/templates/mobile/eas.json +29 -0
- package/templates/mobile/hooks/use-auth.ts +127 -0
- package/templates/mobile/lib/api-client.ts +138 -0
- package/templates/mobile/lib/api.ts +83 -0
- package/templates/mobile/lib/auth-providers-google.ts +74 -0
- package/templates/mobile/lib/auth-providers.ts +74 -0
- package/templates/mobile/lib/auth.ts +100 -0
- package/templates/mobile/lib/config.ts +14 -0
- package/templates/mobile/lib/errors.ts +46 -0
- package/templates/mobile/lib/notifications.ts +77 -0
- package/templates/mobile/lib/storage.ts +25 -0
- package/templates/mobile/lib/theme-tokens.ts +16 -0
- package/templates/mobile/package.json +37 -0
- package/templates/mobile/tsconfig.json +10 -0
- package/templates/pwa/app/_components/PWARegister.js +15 -0
- package/templates/pwa/app/manifest.js +32 -0
- package/templates/pwa/public/sw.js +72 -0
- package/templates/ui/README.md +44 -0
- package/templates/ui/package.json +21 -0
- package/templates/ui/src/badge.js +23 -0
- package/templates/ui/src/button.js +111 -0
- package/templates/ui/src/card.js +128 -0
- package/templates/ui/src/checkbox.js +20 -0
- package/templates/ui/src/container.js +13 -0
- package/templates/ui/src/dialog.js +137 -0
- package/templates/ui/src/error-banner.js +15 -0
- package/templates/ui/src/footer.js +239 -0
- package/templates/ui/src/form-field.integration-test.js +142 -0
- package/templates/ui/src/form-field.js +50 -0
- package/templates/ui/src/index.js +24 -0
- package/templates/ui/src/input.js +11 -0
- package/templates/ui/src/label.js +14 -0
- package/templates/ui/src/lightbox.integration-test.js +167 -0
- package/templates/ui/src/lightbox.js +243 -0
- package/templates/ui/src/logo.js +52 -0
- package/templates/ui/src/navbar.js +602 -0
- package/templates/ui/src/password-input.js +31 -0
- package/templates/ui/src/rich-text.js +514 -0
- package/templates/ui/src/select.js +317 -0
- package/templates/ui/src/skeleton.js +10 -0
- package/templates/ui/src/spinner.js +31 -0
- package/templates/ui/src/table.js +169 -0
- package/templates/ui/src/textarea.js +11 -0
- package/templates/ui/src/theme-constants.js +24 -0
- package/templates/ui/src/theme.js +196 -0
- package/templates/ui/src/toast.js +206 -0
- package/templates/worker/Dockerfile +64 -0
- package/templates/worker/README.md +56 -0
- package/templates/worker/package.json +36 -0
- package/templates/worker/src/config/pricing.js +46 -0
- package/templates/worker/src/handlers/email-job-validation.js +36 -0
- package/templates/worker/src/handlers/email.js +98 -0
- package/templates/worker/src/handlers/files.js +59 -0
- package/templates/worker/src/handlers/index.js +20 -0
- package/templates/worker/src/handlers/push-notification.js +234 -0
- package/templates/worker/src/index.js +505 -0
- package/templates/worker/src/lib/railway.js +438 -0
- package/templates/worker/src/lib/tokens.js +81 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ecommerce-checkout
|
|
3
|
+
description: Checkout flow, order lifecycle, and inventory management for Quark ecommerce. Use when building checkout, orders, payments, fulfillment, or stock management.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Ecommerce Checkout Skill
|
|
7
|
+
|
|
8
|
+
Build the checkout flow, order lifecycle, and inventory management for an ecommerce system on Quark.
|
|
9
|
+
|
|
10
|
+
## Models
|
|
11
|
+
|
|
12
|
+
```prisma
|
|
13
|
+
enum OrderStatus {
|
|
14
|
+
PENDING
|
|
15
|
+
PAID
|
|
16
|
+
PROCESSING
|
|
17
|
+
FULFILLED
|
|
18
|
+
DELIVERED
|
|
19
|
+
REFUNDED
|
|
20
|
+
CANCELLED
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
model Order {
|
|
24
|
+
id String @id @default(cuid())
|
|
25
|
+
userId String?
|
|
26
|
+
user User? @relation(fields: [userId], references: [id])
|
|
27
|
+
status OrderStatus @default(PENDING)
|
|
28
|
+
email String
|
|
29
|
+
shippingName String
|
|
30
|
+
shippingAddress String @db.Text
|
|
31
|
+
shippingCity String
|
|
32
|
+
shippingZip String
|
|
33
|
+
shippingCountry String
|
|
34
|
+
total Decimal @db.Decimal(10, 2)
|
|
35
|
+
currency String @default("USD")
|
|
36
|
+
stripeSessionId String? @unique
|
|
37
|
+
paidAt DateTime?
|
|
38
|
+
fulfilledAt DateTime?
|
|
39
|
+
deliveredAt DateTime?
|
|
40
|
+
createdAt DateTime @default(now())
|
|
41
|
+
updatedAt DateTime @updatedAt
|
|
42
|
+
items OrderItem[]
|
|
43
|
+
|
|
44
|
+
@@index([userId, status])
|
|
45
|
+
@@index([status])
|
|
46
|
+
@@index([stripeSessionId])
|
|
47
|
+
@@index([createdAt])
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
model OrderItem {
|
|
51
|
+
id String @id @default(cuid())
|
|
52
|
+
orderId String
|
|
53
|
+
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
|
54
|
+
variantId String
|
|
55
|
+
variant ProductVariant @relation(fields: [variantId], references: [id])
|
|
56
|
+
quantity Int
|
|
57
|
+
unitPrice Decimal @db.Decimal(10, 2)
|
|
58
|
+
createdAt DateTime @default(now())
|
|
59
|
+
updatedAt DateTime @updatedAt
|
|
60
|
+
|
|
61
|
+
@@index([orderId])
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Checkout action
|
|
66
|
+
|
|
67
|
+
```js
|
|
68
|
+
"use server";
|
|
69
|
+
import { z } from "zod";
|
|
70
|
+
import { prisma } from "@__QUARK_SCOPE__/db";
|
|
71
|
+
import { AppError, ValidationError } from "@techstream/quark-core/errors";
|
|
72
|
+
import { createLogger } from "@techstream/quark-core";
|
|
73
|
+
|
|
74
|
+
const log = createLogger("action:checkout");
|
|
75
|
+
|
|
76
|
+
const checkoutSchema = z.object({
|
|
77
|
+
cartId: z.string().min(1),
|
|
78
|
+
email: z.string().email(),
|
|
79
|
+
shippingName: z.string().min(1),
|
|
80
|
+
shippingAddress: z.string().min(1),
|
|
81
|
+
shippingCity: z.string().min(1),
|
|
82
|
+
shippingZip: z.string().min(1),
|
|
83
|
+
shippingCountry: z.string().min(2).max(2),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
export async function createOrder(formData) {
|
|
87
|
+
const parsed = checkoutSchema.safeParse(Object.fromEntries(formData));
|
|
88
|
+
if (!parsed.success) throw new ValidationError(parsed.error.flatten());
|
|
89
|
+
|
|
90
|
+
const { cartId } = parsed.data;
|
|
91
|
+
|
|
92
|
+
const order = await prisma.$transaction(async (tx) => {
|
|
93
|
+
const cart = await tx.cart.findUnique({
|
|
94
|
+
where: { id: cartId },
|
|
95
|
+
include: { items: { include: { variant: true } } },
|
|
96
|
+
});
|
|
97
|
+
if (!cart || cart.items.length === 0) {
|
|
98
|
+
throw new AppError("Cart is empty", 400, "EMPTY_CART");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const item of cart.items) {
|
|
102
|
+
const updated = await tx.productVariant.updateMany({
|
|
103
|
+
where: { id: item.variantId, stock: { gte: item.quantity } },
|
|
104
|
+
data: { stock: { decrement: item.quantity } },
|
|
105
|
+
});
|
|
106
|
+
if (updated.count === 0) {
|
|
107
|
+
throw new AppError(`Out of stock: ${item.variant.name}`, 409, "OUT_OF_STOCK");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const total = cart.items.reduce(
|
|
112
|
+
(sum, item) => sum + Number(item.variant.price) * item.quantity, 0
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const order = await tx.order.create({
|
|
116
|
+
data: {
|
|
117
|
+
...parsed.data,
|
|
118
|
+
total,
|
|
119
|
+
items: {
|
|
120
|
+
create: cart.items.map((item) => ({
|
|
121
|
+
variantId: item.variantId,
|
|
122
|
+
quantity: item.quantity,
|
|
123
|
+
unitPrice: item.variant.price,
|
|
124
|
+
})),
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
await tx.cartItem.deleteMany({ where: { cartId } });
|
|
130
|
+
log.info("order created", { orderId: order.id, total });
|
|
131
|
+
return order;
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
return order;
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Order lifecycle
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
PENDING → PAID (payment webhook)
|
|
142
|
+
PAID → PROCESSING (admin starts fulfillment)
|
|
143
|
+
PROCESSING → FULFILLED (admin ships)
|
|
144
|
+
FULFILLED → DELIVERED (carrier/admin)
|
|
145
|
+
PAID/PROCESSING/FULFILLED → REFUNDED
|
|
146
|
+
Any → CANCELLED (before fulfillment, admin only)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
```js
|
|
150
|
+
const ORDER_TRANSITIONS = {
|
|
151
|
+
PENDING: ["PAID", "CANCELLED"],
|
|
152
|
+
PAID: ["PROCESSING", "REFUNDED", "CANCELLED"],
|
|
153
|
+
PROCESSING: ["FULFILLED", "REFUNDED"],
|
|
154
|
+
FULFILLED: ["DELIVERED", "REFUNDED"],
|
|
155
|
+
DELIVERED: ["REFUNDED"],
|
|
156
|
+
REFUNDED: [],
|
|
157
|
+
CANCELLED: [],
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export function canTransitionOrder(from, to) {
|
|
161
|
+
return ORDER_TRANSITIONS[from]?.includes(to) ?? false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function applyOrderTransition(order, newStatus, opts = {}) {
|
|
165
|
+
if (!canTransitionOrder(order.status, newStatus)) {
|
|
166
|
+
throw new ValidationError(`Cannot transition from ${order.status} to ${newStatus}`);
|
|
167
|
+
}
|
|
168
|
+
const data = { status: newStatus };
|
|
169
|
+
if (newStatus === "PAID") data.paidAt = new Date();
|
|
170
|
+
if (newStatus === "FULFILLED") data.fulfilledAt = new Date();
|
|
171
|
+
if (newStatus === "DELIVERED") data.deliveredAt = new Date();
|
|
172
|
+
return data;
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Inventory management
|
|
177
|
+
|
|
178
|
+
Each `ProductVariant` tracks `stock` (total) and `reserved` (pending orders). **Available** = `stock - reserved`. Use atomic decrement in a transaction to prevent overselling.
|
|
179
|
+
|
|
180
|
+
Optional reserve/release pattern:
|
|
181
|
+
|
|
182
|
+
```js
|
|
183
|
+
// Reserve on cart add
|
|
184
|
+
await tx.productVariant.update({ where: { id: variantId }, data: { reserved: { increment: qty } } });
|
|
185
|
+
// Release on cart abandon/cancel
|
|
186
|
+
await tx.productVariant.update({ where: { id: variantId }, data: { reserved: { decrement: qty } } });
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Payment integration
|
|
190
|
+
|
|
191
|
+
Reference the payment skill for Stripe Checkout:
|
|
192
|
+
1. Create a Checkout Session with cart line items.
|
|
193
|
+
2. Store `stripeSessionId` on the Order.
|
|
194
|
+
3. Handle `checkout.session.completed` webhook → transition to `PAID`.
|
|
195
|
+
4. Handle refunds via Stripe API → transition to `REFUNDED`.
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: i18n
|
|
3
|
+
description: Add internationalization and localization to Quark apps. Use when adding multi-language support, locale routing, hreflang SEO, locale-aware sitemaps, or localized CMS content.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# i18n Skill
|
|
7
|
+
|
|
8
|
+
Add multi-language support to a Quark app using `next-intl` with prefix-based routing (`/en/...`), locale detection, and localized CMS content queries. This skill gives you the domain context, Quark framework patterns, and end-result shape. You generate the code that fits the user's exact requirements.
|
|
9
|
+
|
|
10
|
+
## Context
|
|
11
|
+
|
|
12
|
+
A localized app must handle locale detection, routing, content translation, and SEO. The core concerns:
|
|
13
|
+
|
|
14
|
+
- **Locale detection** — resolve the active locale from URL prefix, cookie, `Accept-Language` header, then fallback to default.
|
|
15
|
+
- **Routing** — prefix-based (`/en/about`, `/es/acerca-de`) for SEO and shareable URLs.
|
|
16
|
+
- **Content localization** — locale field on CMS models, queries filtered by locale, fallback chains.
|
|
17
|
+
- **SEO** — `hreflang` alternates, locale-aware sitemaps, `lang` attribute on `<html>`.
|
|
18
|
+
- **UI** — locale switcher component, localized formatting (dates, numbers).
|
|
19
|
+
|
|
20
|
+
## Locale detection chain
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
1. URL path prefix (/en/...) → explicit user choice
|
|
24
|
+
2. Cookie (n-lang) ← set by LocaleSwitcher component
|
|
25
|
+
3. Accept-Language ← browser default
|
|
26
|
+
4. defaultLocale ← configured fallback ("en")
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Framework context (build on Quark)
|
|
30
|
+
|
|
31
|
+
- **Library:** `next-intl` — purpose-built for Next.js App Router, native Server Component support, middleware-based locale negotiation, ~2 KB bundle.
|
|
32
|
+
- **Routing:** prefix-based with `localePrefix: 'always'`. All public URLs include the locale prefix. Admin (`/admin`), auth (`/auth`), and API (`/api`) routes stay outside `[locale]`.
|
|
33
|
+
- **Middleware:** `next-intl/middleware` handles locale detection, redirects, and 404s. Configure in `src/i18n/middleware.js`.
|
|
34
|
+
- **Request config:** `getRequestConfig` from `next-intl/server` resolves the locale per request and loads message files.
|
|
35
|
+
- **Models:** CMS models get a `locale String` field. Queries filter by locale with fallback chain.
|
|
36
|
+
- **Validation** — Zod for all Server Actions and API routes. Use a locale enum for localized content submissions.
|
|
37
|
+
- **Auth** via `getCurrentSession` from `@techstream/quark-core`.
|
|
38
|
+
- **Locale utilities** from `@techstream/quark-core/locale`: `getDefaultLocale()`, `getSupportedLocales()`, `isLocaleSupported()`.
|
|
39
|
+
|
|
40
|
+
## Workflow
|
|
41
|
+
|
|
42
|
+
1. Read `CLAUDE.md` and `MAIN.md` for project conventions.
|
|
43
|
+
2. Install `next-intl` and configure `getRequestConfig`, middleware, and routing.
|
|
44
|
+
3. Create message files per locale under `messages/` (e.g., `messages/en.json`, `messages/es.json`).
|
|
45
|
+
4. Wrap the app root with `NextIntlClientProvider`; set `html lang` attribute.
|
|
46
|
+
5. Add a locale switcher component (client-side, updates cookie + router).
|
|
47
|
+
6. Add CMS localization: locale field on models, query helpers filtered by locale.
|
|
48
|
+
7. Add SEO: `hreflang` alternates, locale-aware sitemap, metadata per locale.
|
|
49
|
+
|
|
50
|
+
## Example: routing config
|
|
51
|
+
|
|
52
|
+
```js
|
|
53
|
+
// src/i18n/routing.js
|
|
54
|
+
import { defineRouting } from "next-intl/routing";
|
|
55
|
+
import { getDefaultLocale, getSupportedLocales } from "@techstream/quark-core/locale";
|
|
56
|
+
|
|
57
|
+
const supportedLocales = getSupportedLocales();
|
|
58
|
+
|
|
59
|
+
export const routing = defineRouting({
|
|
60
|
+
locales: supportedLocales,
|
|
61
|
+
defaultLocale: getDefaultLocale(),
|
|
62
|
+
localePrefix: "always",
|
|
63
|
+
});
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Example: request config
|
|
67
|
+
|
|
68
|
+
```js
|
|
69
|
+
// src/i18n/request.js
|
|
70
|
+
import { getRequestConfig } from "next-intl/server";
|
|
71
|
+
import { hasLocale } from "next-intl";
|
|
72
|
+
import { routing } from "./routing.js";
|
|
73
|
+
|
|
74
|
+
export default getRequestConfig(async ({ requestLocale }) => {
|
|
75
|
+
const requested = await requestLocale;
|
|
76
|
+
const locale = hasLocale(routing.locales, requested)
|
|
77
|
+
? requested
|
|
78
|
+
: routing.defaultLocale;
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
locale,
|
|
82
|
+
messages: (await import(`../../messages/${locale}.json`)).default,
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Example: middleware setup
|
|
88
|
+
|
|
89
|
+
```js
|
|
90
|
+
// src/i18n/middleware.js
|
|
91
|
+
import createMiddleware from "next-intl/middleware";
|
|
92
|
+
import { routing } from "./routing.js";
|
|
93
|
+
|
|
94
|
+
export default createMiddleware(routing);
|
|
95
|
+
|
|
96
|
+
export const config = {
|
|
97
|
+
matcher: ["/((?!api|admin|auth|_next|.*\\..*).*)"],
|
|
98
|
+
};
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Example: locale switcher component
|
|
102
|
+
|
|
103
|
+
```jsx
|
|
104
|
+
"use client";
|
|
105
|
+
import { useLocale, useTranslations } from "next-intl";
|
|
106
|
+
import { useRouter, usePathname } from "next/navigation";
|
|
107
|
+
import { getSupportedLocales } from "@techstream/quark-core/locale";
|
|
108
|
+
|
|
109
|
+
export function LocaleSwitcher() {
|
|
110
|
+
const locale = useLocale();
|
|
111
|
+
const router = useRouter();
|
|
112
|
+
const pathname = usePathname();
|
|
113
|
+
const t = useTranslations("LocaleSwitcher");
|
|
114
|
+
const locales = getSupportedLocales();
|
|
115
|
+
|
|
116
|
+
function onSelect(next) {
|
|
117
|
+
const path = pathname.replace(`/${locale}`, `/${next}`);
|
|
118
|
+
document.cookie = `n-lang=${next};path=/;max-age=31536000`;
|
|
119
|
+
router.push(path);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<select aria-label={t("label")} value={locale} onChange={(e) => onSelect(e.target.value)}>
|
|
124
|
+
{locales.map((l) => (
|
|
125
|
+
<option key={l} value={l}>{l.toUpperCase()}</option>
|
|
126
|
+
))}
|
|
127
|
+
</select>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Example: CMS model with locale
|
|
133
|
+
|
|
134
|
+
```prisma
|
|
135
|
+
model Page {
|
|
136
|
+
id String @id @default(cuid())
|
|
137
|
+
title String
|
|
138
|
+
slug String
|
|
139
|
+
body String @db.Text
|
|
140
|
+
locale String @default("en")
|
|
141
|
+
status ContentStatus @default(DRAFT)
|
|
142
|
+
authorId String
|
|
143
|
+
author User @relation(fields: [authorId], references: [id])
|
|
144
|
+
createdAt DateTime @default(now())
|
|
145
|
+
updatedAt DateTime @updatedAt
|
|
146
|
+
|
|
147
|
+
@@unique([slug, locale])
|
|
148
|
+
@@index([locale])
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Example: locale-aware query
|
|
153
|
+
|
|
154
|
+
```js
|
|
155
|
+
import { getDefaultLocale, isLocaleSupported } from "@techstream/quark-core/locale";
|
|
156
|
+
|
|
157
|
+
export async function getPageBySlug(slug, locale) {
|
|
158
|
+
const validLocale = isLocaleSupported(locale) ? locale : getDefaultLocale();
|
|
159
|
+
return prisma.page.findFirst({
|
|
160
|
+
where: { slug, locale: validLocale, status: "PUBLISHED" },
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## SEO: hreflang and sitemap
|
|
166
|
+
|
|
167
|
+
```jsx
|
|
168
|
+
// In your page component's generateMetadata:
|
|
169
|
+
export async function generateMetadata({ params }) {
|
|
170
|
+
const { locale } = await params;
|
|
171
|
+
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
alternates: {
|
|
175
|
+
languages: Object.fromEntries(
|
|
176
|
+
getSupportedLocales().map((l) => [l, `${baseUrl}/${l}/${slug}`])
|
|
177
|
+
),
|
|
178
|
+
},
|
|
179
|
+
lang: locale,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Example: Zod validation for localized content
|
|
185
|
+
|
|
186
|
+
```js
|
|
187
|
+
import { z } from "zod";
|
|
188
|
+
import { isLocaleSupported } from "@techstream/quark-core/locale";
|
|
189
|
+
|
|
190
|
+
export const localizedPageSchema = z.object({
|
|
191
|
+
title: z.string().min(1).max(255),
|
|
192
|
+
slug: z.string().min(1).max(255),
|
|
193
|
+
body: z.string().min(1),
|
|
194
|
+
locale: z.string().refine(isLocaleSupported, "Unsupported locale"),
|
|
195
|
+
status: z.enum(["DRAFT", "PUBLISHED"]).default("DRAFT"),
|
|
196
|
+
});
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Workflow summary
|
|
200
|
+
|
|
201
|
+
1. Install `next-intl`, configure routing + middleware + request config.
|
|
202
|
+
2. Create `messages/{locale}.json` with translation keys.
|
|
203
|
+
3. Wrap root layout with `NextIntlClientProvider`, set `html lang`.
|
|
204
|
+
4. Build a `LocaleSwitcher` client component.
|
|
205
|
+
5. Add `locale` field to CMS models, update queries with fallback.
|
|
206
|
+
6. Add `hreflang` alternates and locale-aware sitemap.
|
|
207
|
+
7. Test detection chain: URL → cookie → Accept-Language → default.
|
|
208
|
+
|
|
209
|
+
## End result
|
|
210
|
+
|
|
211
|
+
A localized Quark app where users switch languages via a prefix-based URL scheme, locale is detected from URL → cookie → header → default, CMS content is filtered by locale with fallback, SEO metadata includes `hreflang` alternates and locale-aware sitemaps, and all content submissions validate locale against the configured supported set — following Quark conventions throughout.
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: payment
|
|
3
|
+
description: Stripe payment integration - checkout sessions, webhooks, customer management, and fulfillment. Use when building payment flows, checkout, subscriptions, or handling Stripe webhooks.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Payment Skill
|
|
7
|
+
|
|
8
|
+
Build Stripe payment flows on Quark. This skill covers the full Stripe lifecycle: customer management, checkout sessions, webhook verification, and post-payment fulfillment.
|
|
9
|
+
|
|
10
|
+
## Domain context
|
|
11
|
+
|
|
12
|
+
Stripe integration lifecycle:
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
Customer → Checkout Session → Payment → Webhook → Fulfillment
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Key concepts:
|
|
19
|
+
- **Stripe Customer** — persistent record of a payer; store the `stripeCustomerId` on your User model.
|
|
20
|
+
- **Checkout Session** — hosted payment page for one-time or subscription payments.
|
|
21
|
+
- **Webhook** — Stripe sends HTTP events to your server after payment events occur.
|
|
22
|
+
- **Fulfillment** — your server's response to a successful payment (update status, send email, grant access).
|
|
23
|
+
- **Idempotency** — webhook handler must be safe to retry; use `eventId` deduplication.
|
|
24
|
+
|
|
25
|
+
## Framework context
|
|
26
|
+
|
|
27
|
+
- **Stripe client** — `createStripeClient()` from `@techstream/quark-core/stripe`. Reads `STRIPE_SECRET_KEY` from env.
|
|
28
|
+
- **Webhook route** — `apps/web/src/app/api/stripe/webhook/route.js`. Use `getStripeWebhookEvent()` from `@techstream/quark-core/stripe` to verify signatures.
|
|
29
|
+
- **Validation** — Zod for all incoming data. Webhook event data is already validated by Stripe's SDK.
|
|
30
|
+
- **Errors** — `AppError` / `ValidationError` from `@techstream/quark-core/errors` for app errors. Never throw on webhook failures — return 200 to prevent Stripe retries for unfixable errors.
|
|
31
|
+
- **Logging** — `createLogger("stripe:webhook")` from `@techstream/quark-core`.
|
|
32
|
+
- **Models** — add `stripeCustomerId String?` to User, `stripeSessionId String?` / `stripeSubscriptionId String?` to relevant models. Every model needs `createdAt` / `updatedAt`.
|
|
33
|
+
|
|
34
|
+
## Checkout sessions
|
|
35
|
+
|
|
36
|
+
### Zod validation for checkout input
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
import { z } from "zod";
|
|
40
|
+
|
|
41
|
+
export const checkoutSchema = z.object({
|
|
42
|
+
priceId: z.string().min(1),
|
|
43
|
+
quantity: z.number().int().positive().default(1),
|
|
44
|
+
successUrl: z.string().url().optional(),
|
|
45
|
+
cancelUrl: z.string().url().optional(),
|
|
46
|
+
});
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### One-time payment
|
|
50
|
+
|
|
51
|
+
```js
|
|
52
|
+
import { createStripeClient } from "@techstream/quark-core/stripe";
|
|
53
|
+
|
|
54
|
+
const stripe = await createStripeClient();
|
|
55
|
+
const session = await stripe.checkout.sessions.create({
|
|
56
|
+
mode: "payment",
|
|
57
|
+
customer: stripeCustomerId, // or omit to create anonymous checkout
|
|
58
|
+
line_items: [{ price: priceId, quantity: 1 }],
|
|
59
|
+
success_url: `${origin}/payment/success?session_id={CHECKOUT_SESSION_ID}`,
|
|
60
|
+
cancel_url: `${origin}/payment/cancel`,
|
|
61
|
+
metadata: { orderId },
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Subscription
|
|
66
|
+
|
|
67
|
+
```js
|
|
68
|
+
const session = await stripe.checkout.sessions.create({
|
|
69
|
+
mode: "subscription",
|
|
70
|
+
customer: stripeCustomerId,
|
|
71
|
+
line_items: [{ price: priceId, quantity: 1 }],
|
|
72
|
+
success_url: `${origin}/account?upgraded=true`,
|
|
73
|
+
cancel_url: `${origin}/pricing`,
|
|
74
|
+
metadata: { userId },
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Webhooks
|
|
79
|
+
|
|
80
|
+
### Webhook route handler
|
|
81
|
+
|
|
82
|
+
```js
|
|
83
|
+
// apps/web/src/app/api/stripe/webhook/route.js
|
|
84
|
+
import { NextResponse } from "next/server";
|
|
85
|
+
import { getStripeWebhookEvent } from "@techstream/quark-core/stripe";
|
|
86
|
+
import { createLogger } from "@techstream/quark-core";
|
|
87
|
+
import { prisma } from "@__QUARK_SCOPE__/db";
|
|
88
|
+
|
|
89
|
+
const log = createLogger("stripe:webhook");
|
|
90
|
+
|
|
91
|
+
export async function POST(request) {
|
|
92
|
+
const body = await request.text();
|
|
93
|
+
const signature = request.headers.get("stripe-signature");
|
|
94
|
+
|
|
95
|
+
let event;
|
|
96
|
+
try {
|
|
97
|
+
event = await getStripeWebhookEvent(body, signature);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
log.error("webhook signature verification failed", { error: err.message });
|
|
100
|
+
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
log.info("webhook received", { type: event.type, id: event.id });
|
|
104
|
+
|
|
105
|
+
// Idempotency check — skip if already processed
|
|
106
|
+
const existing = await prisma.webhookEvent.findUnique({
|
|
107
|
+
where: { stripeEventId: event.id },
|
|
108
|
+
});
|
|
109
|
+
if (existing) {
|
|
110
|
+
return NextResponse.json({ received: true });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Record event for idempotency
|
|
114
|
+
await prisma.webhookEvent.create({
|
|
115
|
+
data: {
|
|
116
|
+
stripeEventId: event.id,
|
|
117
|
+
type: event.type,
|
|
118
|
+
payload: event,
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
switch (event.type) {
|
|
124
|
+
case "checkout.session.completed":
|
|
125
|
+
await handleCheckoutCompleted(event.data.object);
|
|
126
|
+
break;
|
|
127
|
+
case "invoice.paid":
|
|
128
|
+
await handleInvoicePaid(event.data.object);
|
|
129
|
+
break;
|
|
130
|
+
case "customer.subscription.deleted":
|
|
131
|
+
await handleSubscriptionDeleted(event.data.object);
|
|
132
|
+
break;
|
|
133
|
+
default:
|
|
134
|
+
log.info("unhandled event type", { type: event.type });
|
|
135
|
+
}
|
|
136
|
+
} catch (err) {
|
|
137
|
+
log.error("webhook handler error", { type: event.type, error: err.message });
|
|
138
|
+
// Return 200 to prevent Stripe retry for handler errors
|
|
139
|
+
// Log and alert instead — retrying won't fix app bugs
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return NextResponse.json({ received: true });
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Event verification
|
|
147
|
+
|
|
148
|
+
`await getStripeWebhookEvent(body, signature)` calls `stripe.webhooks.constructEvent()` under the hood. It reads `STRIPE_WEBHOOK_SECRET` from env and throws if the signature is invalid.
|
|
149
|
+
|
|
150
|
+
### Common events to handle
|
|
151
|
+
|
|
152
|
+
| Event | Use case |
|
|
153
|
+
|---|---|
|
|
154
|
+
| `checkout.session.completed` | Mark order/booking as paid, grant access |
|
|
155
|
+
| `invoice.paid` | Subscription renewal succeeded |
|
|
156
|
+
| `invoice.payment_failed` | Subscription payment failed, notify user |
|
|
157
|
+
| `customer.subscription.deleted` | Revoke access, update status |
|
|
158
|
+
| `charge.refunded` | Process refund, update order status |
|
|
159
|
+
|
|
160
|
+
### Idempotency
|
|
161
|
+
|
|
162
|
+
Use a `WebhookEvent` model to record processed event IDs. Check before processing to prevent duplicate fulfillment:
|
|
163
|
+
|
|
164
|
+
```prisma
|
|
165
|
+
model WebhookEvent {
|
|
166
|
+
id String @id @default(cuid())
|
|
167
|
+
stripeEventId String @unique
|
|
168
|
+
type String
|
|
169
|
+
payload Json
|
|
170
|
+
createdAt DateTime @default(now())
|
|
171
|
+
updatedAt DateTime @updatedAt
|
|
172
|
+
|
|
173
|
+
@@index([stripeEventId])
|
|
174
|
+
@@index([type])
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Customer management
|
|
179
|
+
|
|
180
|
+
### Create or retrieve Stripe customer
|
|
181
|
+
|
|
182
|
+
```js
|
|
183
|
+
import { createStripeClient } from "@techstream/quark-core/stripe";
|
|
184
|
+
|
|
185
|
+
export async function getOrCreateStripeCustomer(user) {
|
|
186
|
+
if (user.stripeCustomerId) return user.stripeCustomerId;
|
|
187
|
+
|
|
188
|
+
const stripe = await createStripeClient();
|
|
189
|
+
const customer = await stripe.customers.create({
|
|
190
|
+
email: user.email,
|
|
191
|
+
name: user.name,
|
|
192
|
+
metadata: { userId: user.id },
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
await prisma.user.update({
|
|
196
|
+
where: { id: user.id },
|
|
197
|
+
data: { stripeCustomerId: customer.id },
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
return customer.id;
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### User model additions
|
|
205
|
+
|
|
206
|
+
Add to your User model in Prisma schema:
|
|
207
|
+
|
|
208
|
+
```prisma
|
|
209
|
+
model User {
|
|
210
|
+
// ... existing fields
|
|
211
|
+
stripeCustomerId String? @unique
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## Integration patterns
|
|
216
|
+
|
|
217
|
+
### Booking deposits
|
|
218
|
+
|
|
219
|
+
Collect a deposit at booking time:
|
|
220
|
+
|
|
221
|
+
1. Create booking in `PENDING` status with `stripeSessionId`.
|
|
222
|
+
2. Redirect to Stripe Checkout.
|
|
223
|
+
3. On `checkout.session.completed` webhook: update booking to `CONFIRMED`.
|
|
224
|
+
4. On cancellation: refund via `stripe.refunds.create({ payment_intent })`.
|
|
225
|
+
|
|
226
|
+
### Ecommerce checkout
|
|
227
|
+
|
|
228
|
+
1. Create an Order model with `stripeSessionId` and status `PENDING`.
|
|
229
|
+
2. Checkout Session with line items from cart.
|
|
230
|
+
3. Webhook confirms payment → update Order to `PAID`, decrement inventory.
|
|
231
|
+
4. Handle `payment_failed` → notify user, keep order in `PENDING`.
|
|
232
|
+
|
|
233
|
+
## Testing
|
|
234
|
+
|
|
235
|
+
### Stripe test mode
|
|
236
|
+
|
|
237
|
+
- Use Stripe test keys (`sk_test_...`) in `.env` for development.
|
|
238
|
+
- Test card: `4242 4242 4242 4242` (any future expiry, any CVC).
|
|
239
|
+
- Stripe Dashboard → Developers → Webhooks → add `http://localhost:3000/api/stripe/webhook` for local testing.
|
|
240
|
+
|
|
241
|
+
### Webhook CLI
|
|
242
|
+
|
|
243
|
+
Install the Stripe CLI and forward webhooks to your local server:
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
stripe listen --forward-to localhost:3000/api/stripe/webhook
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
The CLI prints a webhook signing secret (`whsec_...`) — add it to `.env` as `STRIPE_WEBHOOK_SECRET`.
|
|
250
|
+
|
|
251
|
+
### Test pattern
|
|
252
|
+
|
|
253
|
+
```js
|
|
254
|
+
import { test } from "node:test";
|
|
255
|
+
import assert from "node:assert";
|
|
256
|
+
|
|
257
|
+
test("handleCheckoutCompleted updates booking status", async () => {
|
|
258
|
+
// Create a mock Stripe session object
|
|
259
|
+
// Assert booking status changes from PENDING to CONFIRMED
|
|
260
|
+
});
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
## Workflow
|
|
264
|
+
|
|
265
|
+
1. Read `CLAUDE.md` and project conventions.
|
|
266
|
+
2. Add `stripe` dependency to the consuming app if not already present.
|
|
267
|
+
3. Set up env vars: `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`.
|
|
268
|
+
4. Create Stripe Customer ID field on User model (if customer management is needed).
|
|
269
|
+
5. Build checkout session creation (Server Action or API route).
|
|
270
|
+
6. Implement webhook route handler with signature verification and idempotency.
|
|
271
|
+
7. Add fulfillment logic (update models, send emails, grant access).
|
|
272
|
+
8. Test with Stripe CLI webhook forwarding and test card numbers.
|
|
273
|
+
|
|
274
|
+
## Reference
|
|
275
|
+
|
|
276
|
+
Stripe docs: https://docs.stripe.com
|
|
277
|
+
|
|
278
|
+
## End result
|
|
279
|
+
|
|
280
|
+
A working payment system where users can complete Stripe Checkout for one-time or subscription payments, webhooks verify signatures and deduplicate events via `WebhookEvent`, Stripe customers are created lazily and stored on the User model, and post-payment fulfillment (booking confirmation, order status, access grants) runs idempotently — all following Quark conventions.
|