@vitrine-kit/vitrine 0.3.1 → 0.4.1

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 (122) hide show
  1. package/dist/index.js +313 -135
  2. package/kit/registry/README.md +9 -0
  3. package/kit/registry/_index.json +74 -0
  4. package/kit/registry/cart/docs/cart.md +13 -0
  5. package/kit/registry/cart/feature.json +51 -0
  6. package/kit/registry/cart/files/app/(frontend)/cart/page.tsx +21 -0
  7. package/kit/registry/cart/files/app/api/cart/route.ts +41 -0
  8. package/kit/registry/cart/files/components/cart/AddToCart.tsx +40 -0
  9. package/kit/registry/cart/files/components/cart/CartIndicator.tsx +11 -0
  10. package/kit/registry/cart/files/components/cart/CartLineItem.tsx +27 -0
  11. package/kit/registry/cart/files/components/cart/CartSummary.tsx +31 -0
  12. package/kit/registry/cart/files/components/cart/CartView.tsx +31 -0
  13. package/kit/registry/cart/files/lib/cart/data.ts +12 -0
  14. package/kit/registry/cart/files/lib/cart/register.ts +10 -0
  15. package/kit/registry/catalog/docs/catalog.md +18 -0
  16. package/kit/registry/catalog/feature.json +43 -0
  17. package/kit/registry/catalog/files/components/catalog/CategoryNav.tsx +26 -0
  18. package/kit/registry/catalog/files/components/catalog/ProductCard.tsx +42 -0
  19. package/kit/registry/catalog/files/components/catalog/ProductGrid.tsx +25 -0
  20. package/kit/registry/catalog/files/lib/catalog/data.ts +24 -0
  21. package/kit/registry/catalog/files/lib/catalog/register.ts +8 -0
  22. package/kit/registry/checkout/docs/checkout.md +17 -0
  23. package/kit/registry/checkout/feature.json +47 -0
  24. package/kit/registry/checkout/files/app/api/checkout/route.ts +14 -0
  25. package/kit/registry/checkout/files/components/checkout/CheckoutButton.tsx +46 -0
  26. package/kit/registry/checkout/files/lib/checkout/fulfill.ts +72 -0
  27. package/kit/registry/checkout/files/lib/checkout/register.ts +8 -0
  28. package/kit/registry/checkout-paddle/docs/checkout-paddle.md +25 -0
  29. package/kit/registry/checkout-paddle/feature.json +62 -0
  30. package/kit/registry/checkout-paddle/files/app/api/webhooks/paddle/route.ts +22 -0
  31. package/kit/registry/checkout-paddle/files/lib/checkout-paddle/provider.ts +80 -0
  32. package/kit/registry/checkout-paddle/files/lib/checkout-paddle/register.ts +7 -0
  33. package/kit/registry/checkout-stripe/docs/checkout-stripe.md +19 -0
  34. package/kit/registry/checkout-stripe/feature.json +54 -0
  35. package/kit/registry/checkout-stripe/files/app/api/webhooks/stripe/route.ts +23 -0
  36. package/kit/registry/checkout-stripe/files/lib/checkout-stripe/provider.ts +80 -0
  37. package/kit/registry/checkout-stripe/files/lib/checkout-stripe/register.ts +7 -0
  38. package/kit/registry/checkout-yookassa/docs/checkout-yookassa.md +23 -0
  39. package/kit/registry/checkout-yookassa/feature.json +52 -0
  40. package/kit/registry/checkout-yookassa/files/app/api/webhooks/yookassa/route.ts +22 -0
  41. package/kit/registry/checkout-yookassa/files/lib/checkout-yookassa/provider.ts +92 -0
  42. package/kit/registry/checkout-yookassa/files/lib/checkout-yookassa/register.ts +7 -0
  43. package/kit/registry/product-page/docs/product-page.md +16 -0
  44. package/kit/registry/product-page/feature.json +39 -0
  45. package/kit/registry/product-page/files/components/product/ProductGallery.tsx +37 -0
  46. package/kit/registry/product-page/files/components/product/ProductView.tsx +42 -0
  47. package/kit/registry/product-page/files/lib/product/data.ts +10 -0
  48. package/kit/registry/seo/docs/seo.md +14 -0
  49. package/kit/registry/seo/feature.json +35 -0
  50. package/kit/registry/seo/files/components/seo/JsonLd.tsx +15 -0
  51. package/kit/registry/seo/files/lib/seo/jsonld.ts +29 -0
  52. package/kit/registry/seo/files/lib/seo/metadata.ts +35 -0
  53. package/kit/registry/stubs/route-glue.d.ts +45 -0
  54. package/kit/registry/tsconfig.json +21 -0
  55. package/kit/registry/tsconfig.routes.json +9 -0
  56. package/kit/templates/README.md +27 -0
  57. package/kit/templates/backend-payload/files/.dockerignore +8 -0
  58. package/kit/templates/backend-payload/files/Dockerfile +29 -0
  59. package/kit/templates/backend-payload/files/app/(payload)/admin/[[...segments]]/not-found.tsx +18 -0
  60. package/kit/templates/backend-payload/files/app/(payload)/admin/[[...segments]]/page.tsx +18 -0
  61. package/kit/templates/backend-payload/files/app/(payload)/admin/importMap.js +3 -0
  62. package/kit/templates/backend-payload/files/app/(payload)/api/[...slug]/route.ts +18 -0
  63. package/kit/templates/backend-payload/files/app/(payload)/api/graphql/route.ts +6 -0
  64. package/kit/templates/backend-payload/files/app/(payload)/api/graphql-playground/route.ts +5 -0
  65. package/kit/templates/backend-payload/files/app/(payload)/custom.scss +1 -0
  66. package/kit/templates/backend-payload/files/app/(payload)/layout.tsx +23 -0
  67. package/kit/templates/backend-payload/files/docker-compose.yml +35 -0
  68. package/kit/templates/backend-payload/files/lib/adapter/db-decision.ts +43 -0
  69. package/kit/templates/backend-payload/files/lib/adapter/db-ping.ts +29 -0
  70. package/kit/templates/backend-payload/files/lib/adapter/db.ts +27 -0
  71. package/kit/templates/backend-payload/files/lib/adapter/index.ts +31 -0
  72. package/kit/templates/backend-payload/files/lib/adapter/map.ts +112 -0
  73. package/kit/templates/backend-payload/files/lib/adapter/payload-catalog.ts +79 -0
  74. package/kit/templates/backend-payload/files/lib/adapter/payload-commerce.ts +154 -0
  75. package/kit/templates/backend-payload/files/lib/adapter/payload-types.ts +50 -0
  76. package/kit/templates/backend-payload/files/lib/seed/admin.ts +23 -0
  77. package/kit/templates/backend-payload/files/lib/seed/demo.ts +79 -0
  78. package/kit/templates/backend-payload/files/lib/seed/guards.ts +5 -0
  79. package/kit/templates/backend-payload/files/lib/seed/richtext.ts +26 -0
  80. package/kit/templates/backend-payload/files/lib/seed/run.ts +51 -0
  81. package/kit/templates/backend-payload/files/next.config.mjs +10 -0
  82. package/kit/templates/backend-payload/files/payload.config.ts +32 -0
  83. package/kit/templates/backend-payload/files/seed-assets/placeholder-1.svg +5 -0
  84. package/kit/templates/backend-payload/files/seed-assets/placeholder-2.svg +5 -0
  85. package/kit/templates/backend-payload/files/seed-assets/placeholder-3.svg +5 -0
  86. package/kit/templates/backend-payload/files/seed-assets/placeholder-4.svg +5 -0
  87. package/kit/templates/backend-payload/files/seed-assets/placeholder-5.svg +5 -0
  88. package/kit/templates/backend-vendure/files/.dockerignore +8 -0
  89. package/kit/templates/backend-vendure/files/Dockerfile +24 -0
  90. package/kit/templates/backend-vendure/files/docker-compose.yml +50 -0
  91. package/kit/templates/backend-vendure/files/lib/adapter/graphql.ts +27 -0
  92. package/kit/templates/backend-vendure/files/lib/adapter/index.ts +20 -0
  93. package/kit/templates/backend-vendure/files/lib/adapter/map.ts +107 -0
  94. package/kit/templates/backend-vendure/files/lib/adapter/vendure-catalog.ts +62 -0
  95. package/kit/templates/backend-vendure/files/lib/adapter/vendure-commerce.ts +76 -0
  96. package/kit/templates/backend-vendure/files/lib/adapter/vendure-types.ts +61 -0
  97. package/kit/templates/backend-vendure/files/lib/seed/populate.ts +22 -0
  98. package/kit/templates/backend-vendure/files/src/index.ts +15 -0
  99. package/kit/templates/backend-vendure/files/vendure-config.ts +56 -0
  100. package/kit/templates/base/files/.claude/commands/add-feature.md +18 -0
  101. package/kit/templates/base/files/.claude/commands/design.md +14 -0
  102. package/kit/templates/base/files/.claude/commands/doctor.md +15 -0
  103. package/kit/templates/base/files/.claude/commands/setup.md +15 -0
  104. package/kit/templates/base/files/.claude/commands/update.md +16 -0
  105. package/kit/templates/base/files/.gitattributes +12 -0
  106. package/kit/templates/base/files/AGENTS.md +20 -0
  107. package/kit/templates/base/files/app/(frontend)/categories/[slug]/page.tsx +28 -0
  108. package/kit/templates/base/files/app/(frontend)/layout.tsx +32 -0
  109. package/kit/templates/base/files/app/(frontend)/page.tsx +27 -0
  110. package/kit/templates/base/files/app/(frontend)/products/[slug]/page.tsx +43 -0
  111. package/kit/templates/base/files/app/globals.css +6 -0
  112. package/kit/templates/base/files/components/layout/SiteFooter.tsx +15 -0
  113. package/kit/templates/base/files/components/layout/SiteHeader.tsx +23 -0
  114. package/kit/templates/base/files/design/README.md +9 -0
  115. package/kit/templates/base/files/lib/site.ts +10 -0
  116. package/kit/templates/base/files/next.config.mjs +7 -0
  117. package/kit/templates/base/files/postcss.config.mjs +7 -0
  118. package/kit/templates/base/files/public/.gitkeep +0 -0
  119. package/kit/templates/base/files/tailwind.config.ts +13 -0
  120. package/kit/templates/base/files/tsconfig.json +25 -0
  121. package/kit/templates/tsconfig.json +18 -0
  122. package/package.json +9 -5
@@ -0,0 +1,46 @@
1
+ // Checkout button — client component. Mounted into the cart.summary slot.
2
+ // POST /api/checkout creates a payment session with the active provider and returns the
3
+ // redirect URL — the component doesn't know which provider is configured (Stripe/Paddle/YooKassa).
4
+ 'use client';
5
+ import { useState } from 'react';
6
+
7
+ export function CheckoutButton() {
8
+ const [pending, setPending] = useState(false);
9
+ const [error, setError] = useState<string | null>(null);
10
+
11
+ async function checkout(): Promise<void> {
12
+ setPending(true);
13
+ setError(null);
14
+ try {
15
+ const res = await fetch('/api/checkout', { method: 'POST' });
16
+ const data = (await res.json().catch(() => ({}))) as { url?: string; error?: string };
17
+ if (!res.ok || !data.url) {
18
+ setError(data.error ?? 'Checkout is unavailable — try again later.');
19
+ return;
20
+ }
21
+ location.assign(data.url);
22
+ } catch {
23
+ setError('Checkout is unavailable — try again later.');
24
+ } finally {
25
+ setPending(false);
26
+ }
27
+ }
28
+
29
+ return (
30
+ <>
31
+ <button
32
+ type="button"
33
+ onClick={checkout}
34
+ disabled={pending}
35
+ className="vt-checkout-button rounded-md bg-primary px-gutter py-unit text-primary-fg transition hover:opacity-90 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 ring-ring"
36
+ >
37
+ {pending ? 'Redirecting to payment…' : 'Checkout'}
38
+ </button>
39
+ {error ? (
40
+ <p role="alert" className="vt-checkout-error text-danger">
41
+ {error}
42
+ </p>
43
+ ) : null}
44
+ </>
45
+ );
46
+ }
@@ -0,0 +1,72 @@
1
+ // Idempotent order creation from a normalized payment event — shared code
2
+ // for all webhook routes (checkout-stripe/paddle/yookassa). The provider has already
3
+ // verified and normalized the event; here it's only Payload persistence.
4
+ // The critical logic (dedup, order snapshot) lives in @vitrine-kit/core (shouldCreateOrder,
5
+ // buildOrderFromCart). Next/Payload glue, not typechecked in the monorepo.
6
+ import { getPayload } from 'payload';
7
+ import config from '@payload-config';
8
+ import type { Cart } from '@vitrine-kit/contracts';
9
+ import {
10
+ buildOrderFromCart,
11
+ shouldCreateOrder,
12
+ type NormalizedPaymentEvent,
13
+ type PaymentProviderName,
14
+ } from '@vitrine-kit/core';
15
+
16
+ /** Creates an order from the cart on a payment event and marks the cart converted. */
17
+ export async function fulfillOrderFromEvent(
18
+ event: NormalizedPaymentEvent,
19
+ providerName: PaymentProviderName,
20
+ ): Promise<void> {
21
+ if (event.kind !== 'checkout_completed') return;
22
+ const cartId = event.cartId;
23
+ if (!cartId) return;
24
+
25
+ const payload = await getPayload({ config });
26
+ const cartDoc = await payload.findByID({ collection: 'carts', id: cartId }).catch(() => null);
27
+ if (!cartDoc) return;
28
+
29
+ // Idempotency: the provider retries the webhook — don't create the order twice
30
+ // (the cart is already converted, or an order with this payment reference already exists).
31
+ const prior = event.providerRef
32
+ ? await payload
33
+ .find({ collection: 'orders', where: { paymentRef: { equals: event.providerRef } }, limit: 1 })
34
+ .catch(() => ({ docs: [] as Array<{ paymentRef?: string }> }))
35
+ : { docs: [] as Array<{ paymentRef?: string }> };
36
+ const consumable = shouldCreateOrder({
37
+ cartStatus: (cartDoc as { status?: string }).status,
38
+ providerRef: event.providerRef,
39
+ existingOrderRefs: (prior.docs as Array<{ paymentRef?: string }>).map((d) => d.paymentRef),
40
+ });
41
+ if (!consumable) return;
42
+
43
+ const cart: Cart = {
44
+ id: String(cartDoc.id),
45
+ lines: (cartDoc.lines as Cart['lines']) ?? [],
46
+ currency: (cartDoc.currency as string) ?? 'USD',
47
+ subtotal: (cartDoc.subtotal as number) ?? 0,
48
+ discountTotal: cartDoc.discountTotal as number | undefined,
49
+ total: (cartDoc.total as number) ?? 0,
50
+ };
51
+ const order = buildOrderFromCart(cart, { id: cartId, email: event.email });
52
+
53
+ await payload.create({
54
+ collection: 'orders',
55
+ data: {
56
+ status: order.status,
57
+ email: order.email,
58
+ currency: order.currency,
59
+ subtotal: order.subtotal,
60
+ total: order.total,
61
+ lines: order.lines,
62
+ createdAt: order.createdAt,
63
+ paymentProvider: providerName,
64
+ paymentRef: event.providerRef,
65
+ },
66
+ });
67
+ await payload.update({
68
+ collection: 'carts',
69
+ id: cartId,
70
+ data: { status: 'converted', paymentRef: event.providerRef },
71
+ });
72
+ }
@@ -0,0 +1,8 @@
1
+ // Registers the checkout feature's slots. The checkout button goes in the cart totals.
2
+ // The payment provider is registered separately — in the checkout-<provider> feature.
3
+ import { registerSlot } from '@vitrine-kit/core';
4
+ import { CheckoutButton } from '../../components/checkout/CheckoutButton.js';
5
+
6
+ export function registerCheckoutSlots(): void {
7
+ registerSlot({ slot: 'cart.summary', component: CheckoutButton, order: 10 });
8
+ }
@@ -0,0 +1,25 @@
1
+ # Feature: checkout-paddle (Paddle payment provider)
2
+
3
+ A Paddle Billing provider for the `checkout` scaffold (which it depends on). Installed instead of
4
+ `checkout-stripe` / `checkout-yookassa` (mutually exclusive). Paddle is a Merchant of
5
+ Record: it handles taxes/VAT. The critical logic (webhook dispatcher, order from cart) lives
6
+ in `@vitrine-kit/core`.
7
+
8
+ - **Provider:** `lib/checkout-paddle/provider.ts` → `paddleProvider`
9
+ (`PaymentProvider`): `createCheckout` creates a Paddle transaction with non-catalog
10
+ line items (prices from the cart, `customData.cartId`); `verifyWebhook` verifies the
11
+ `Paddle-Signature` via `@paddle/paddle-node-sdk`. The notification carries no email, so
12
+ the provider does a best-effort `customers.get(customerId)` lookup for the order's
13
+ `email` — a lookup failure does not fail fulfillment.
14
+ - **Registration:** `registerCheckoutPaddleProvider()` (from `lib/payments.ts`), sets
15
+ `integrations.payments: "paddle"`.
16
+ - **API (Next glue):** `POST /api/webhooks/paddle` → `handlePaymentWebhook` →
17
+ `fulfillOrderFromEvent` (on `transaction.completed`/`transaction.paid`).
18
+ - **env:** `PADDLE_API_KEY`, `PADDLE_WEBHOOK_SECRET` (required — read at call time, a
19
+ missing key throws `[vitrine] <KEY> is not set …`; webhook route → 400);
20
+ `PADDLE_ENVIRONMENT` (`sandbox`|`production`, default `sandbox`),
21
+ `PADDLE_CHECKOUT_URL` (override the hosted checkout, otherwise a default payment link
22
+ in the Paddle dashboard is required) — optional.
23
+
24
+ Paddle expects amounts as a string in the currency's minor units — this matches `Money`,
25
+ so we pass `String(unitPrice)` as-is.
@@ -0,0 +1,62 @@
1
+ {
2
+ "$schema": "../../schemas/feature.schema.json",
3
+ "name": "checkout-paddle",
4
+ "title": "Payment — Paddle",
5
+ "kitVersion": "0.4.1",
6
+ "requiresContracts": ">=1.0.0 <2.0.0",
7
+ "tier": [
8
+ "simple-store",
9
+ "full-store"
10
+ ],
11
+ "registryDependencies": [
12
+ "checkout"
13
+ ],
14
+ "corePackages": {
15
+ "@vitrine-kit/core": ">=0.1.0"
16
+ },
17
+ "npm": [
18
+ "@paddle/paddle-node-sdk@^2.0.0"
19
+ ],
20
+ "files": [
21
+ {
22
+ "from": "files/lib/checkout-paddle/",
23
+ "to": "lib/checkout-paddle/"
24
+ },
25
+ {
26
+ "from": "files/app/",
27
+ "to": "app/"
28
+ }
29
+ ],
30
+ "config": {
31
+ "set": {
32
+ "features.checkout-paddle": true
33
+ }
34
+ },
35
+ "payment": {
36
+ "provider": "paddle"
37
+ },
38
+ "env": [
39
+ {
40
+ "key": "PADDLE_API_KEY",
41
+ "required": true
42
+ },
43
+ {
44
+ "key": "PADDLE_WEBHOOK_SECRET",
45
+ "required": true
46
+ },
47
+ {
48
+ "key": "PADDLE_ENVIRONMENT",
49
+ "required": false
50
+ },
51
+ {
52
+ "key": "PADDLE_CHECKOUT_URL",
53
+ "required": false
54
+ }
55
+ ],
56
+ "claudeDoc": "docs/checkout-paddle.md",
57
+ "conflicts": [
58
+ "checkout-stripe",
59
+ "checkout-yookassa"
60
+ ],
61
+ "removable": true
62
+ }
@@ -0,0 +1,22 @@
1
+ // Paddle webhook: paddleProvider.verifyWebhook (Paddle-Signature) →
2
+ // normalized event → handlePaymentWebhook → the shared fulfillOrderFromEvent.
3
+ import { NextResponse } from 'next/server';
4
+ import { handlePaymentWebhook } from '@vitrine-kit/core';
5
+ import { paddleProvider } from '@/lib/checkout-paddle/provider';
6
+ import { fulfillOrderFromEvent } from '@/lib/checkout/fulfill';
7
+
8
+ export async function POST(req: Request) {
9
+ const rawBody = await req.text();
10
+ const headers = { 'paddle-signature': req.headers.get('paddle-signature') };
11
+
12
+ try {
13
+ const result = await handlePaymentWebhook({
14
+ provider: paddleProvider,
15
+ req: { rawBody, headers },
16
+ handlers: { onCheckoutCompleted: (event) => fulfillOrderFromEvent(event, 'paddle') },
17
+ });
18
+ return NextResponse.json(result);
19
+ } catch (err) {
20
+ return NextResponse.json({ error: (err as Error).message }, { status: 400 });
21
+ }
22
+ }
@@ -0,0 +1,80 @@
1
+ // PaymentProvider over Paddle Billing. The SDK lives here (in the feature), not in the core.
2
+ // createCheckout creates a transaction with non-catalog line items (prices set on the fly
3
+ // from the cart) and custom_data.cartId → returns the hosted checkout URL. verifyWebhook
4
+ // verifies the Paddle-Signature (HMAC) and normalizes the event.
5
+ //
6
+ // Paddle expects amounts as a string in the currency's minor units (like our Money), so
7
+ // we pass String(unitPrice) as-is. For hosted checkout, the Paddle dashboard needs a
8
+ // default payment link OR set PADDLE_CHECKOUT_URL (override).
9
+ import { Environment, Paddle, type EventEntity } from '@paddle/paddle-node-sdk';
10
+ import type { CreateCheckoutArgs, NormalizedPaymentEvent, PaymentProvider, PaymentWebhookRequest } from '@vitrine-kit/core';
11
+
12
+ /** Required env, read at call time — `next build` must not need secrets. */
13
+ function requiredEnv(key: string): string {
14
+ const value = process.env[key];
15
+ if (!value) throw new Error(`[vitrine] ${key} is not set — add it to .env (see .env.example)`);
16
+ return value;
17
+ }
18
+
19
+ function client(): Paddle {
20
+ return new Paddle(requiredEnv('PADDLE_API_KEY'), {
21
+ environment:
22
+ process.env.PADDLE_ENVIRONMENT === 'production' ? Environment.production : Environment.sandbox,
23
+ });
24
+ }
25
+
26
+ export const paddleProvider: PaymentProvider = {
27
+ name: 'paddle',
28
+
29
+ async createCheckout(args: CreateCheckoutArgs): Promise<{ redirectUrl: string }> {
30
+ const { cart, baseUrl, cancelPath = '/cart' } = args;
31
+ const paddle = client();
32
+ const txn = await paddle.transactions.create({
33
+ items: cart.lines.map((l) => ({
34
+ quantity: l.quantity,
35
+ price: {
36
+ description: l.title,
37
+ name: l.title,
38
+ unitPrice: { amount: String(l.unitPrice), currencyCode: cart.currency },
39
+ product: { name: l.title, taxCategory: 'standard' },
40
+ },
41
+ })),
42
+ customData: { cartId: cart.id },
43
+ ...(process.env.PADDLE_CHECKOUT_URL ? { checkout: { url: process.env.PADDLE_CHECKOUT_URL } } : {}),
44
+ });
45
+ return { redirectUrl: txn.checkout?.url ?? `${baseUrl}${cancelPath}` };
46
+ },
47
+
48
+ async verifyWebhook(req: PaymentWebhookRequest): Promise<NormalizedPaymentEvent> {
49
+ const paddle = client();
50
+ const secret = requiredEnv('PADDLE_WEBHOOK_SECRET');
51
+ const signature = req.headers['paddle-signature'] ?? '';
52
+ // Throws on an invalid signature — handlePaymentWebhook returns a 400.
53
+ const event = (await paddle.webhooks.unmarshal(req.rawBody, secret, signature)) as EventEntity | null;
54
+ if (!event) return { kind: 'unknown', raw: req.rawBody };
55
+
56
+ if (event.eventType === 'transaction.completed' || event.eventType === 'transaction.paid') {
57
+ const data = event.data as {
58
+ id?: string;
59
+ customData?: { cartId?: string } | null;
60
+ customerId?: string | null;
61
+ };
62
+ const cartId = (data.customData ?? undefined)?.cartId;
63
+ // The notification carries no email — best-effort lookup on the Customer entity.
64
+ // A lookup failure must not fail fulfillment: email is optional on the event.
65
+ let email: string | undefined;
66
+ if (data.customerId) {
67
+ try {
68
+ email = (await paddle.customers.get(data.customerId)).email ?? undefined;
69
+ } catch {
70
+ // keep email undefined — the order is still created
71
+ }
72
+ }
73
+ return { kind: 'checkout_completed', cartId, providerRef: data.id, email, raw: event };
74
+ }
75
+ if (event.eventType === 'transaction.payment_failed') {
76
+ return { kind: 'payment_failed', raw: event };
77
+ }
78
+ return { kind: 'unknown', raw: event };
79
+ },
80
+ };
@@ -0,0 +1,7 @@
1
+ // Registers the Paddle payment provider. The CLI calls this from lib/payments.ts.
2
+ import { payments } from '@vitrine-kit/core';
3
+ import { paddleProvider } from './provider.js';
4
+
5
+ export function registerCheckoutPaddleProvider(): void {
6
+ payments.register(paddleProvider);
7
+ }
@@ -0,0 +1,19 @@
1
+ # Feature: checkout-stripe (Stripe payment provider)
2
+
3
+ A Stripe Hosted Checkout provider for the `checkout` scaffold (which it depends on). Installed
4
+ instead of `checkout-paddle` / `checkout-yookassa` (mutually exclusive). The critical
5
+ logic (webhook dispatcher, order from cart) lives in `@vitrine-kit/core`.
6
+
7
+ - **Provider:** `lib/checkout-stripe/provider.ts` → `stripeProvider`
8
+ (`PaymentProvider`): `createCheckout` creates a Stripe session; `verifyWebhook`
9
+ verifies the signature via the Stripe SDK and normalizes the event.
10
+ - **Registration:** `registerCheckoutStripeProvider()` (called from `lib/payments.ts`),
11
+ sets `integrations.payments: "stripe"` in `site.config`.
12
+ - **API (Next glue):** `POST /api/webhooks/stripe` → `handlePaymentWebhook` →
13
+ `fulfillOrderFromEvent` (the `checkout` feature's shared code).
14
+ - **env:** `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` (required). Read at call time
15
+ (`next build` needs no secrets); a missing key throws `[vitrine] <KEY> is not set …` —
16
+ in the webhook route this surfaces as a 400, in `POST /api/checkout` as a 500.
17
+
18
+ Flow: cart → `Checkout` → redirect to Stripe → webhook
19
+ `checkout.session.completed` → order in the admin, cart `converted`.
@@ -0,0 +1,54 @@
1
+ {
2
+ "$schema": "../../schemas/feature.schema.json",
3
+ "name": "checkout-stripe",
4
+ "title": "Payment — Stripe",
5
+ "kitVersion": "0.4.1",
6
+ "requiresContracts": ">=1.0.0 <2.0.0",
7
+ "tier": [
8
+ "simple-store",
9
+ "full-store"
10
+ ],
11
+ "registryDependencies": [
12
+ "checkout"
13
+ ],
14
+ "corePackages": {
15
+ "@vitrine-kit/core": ">=0.1.0"
16
+ },
17
+ "npm": [
18
+ "stripe@^17.0.0"
19
+ ],
20
+ "files": [
21
+ {
22
+ "from": "files/lib/checkout-stripe/",
23
+ "to": "lib/checkout-stripe/"
24
+ },
25
+ {
26
+ "from": "files/app/",
27
+ "to": "app/"
28
+ }
29
+ ],
30
+ "config": {
31
+ "set": {
32
+ "features.checkout-stripe": true
33
+ }
34
+ },
35
+ "payment": {
36
+ "provider": "stripe"
37
+ },
38
+ "env": [
39
+ {
40
+ "key": "STRIPE_SECRET_KEY",
41
+ "required": true
42
+ },
43
+ {
44
+ "key": "STRIPE_WEBHOOK_SECRET",
45
+ "required": true
46
+ }
47
+ ],
48
+ "claudeDoc": "docs/checkout-stripe.md",
49
+ "conflicts": [
50
+ "checkout-paddle",
51
+ "checkout-yookassa"
52
+ ],
53
+ "removable": true
54
+ }
@@ -0,0 +1,23 @@
1
+ // Stripe webhook: stripeProvider.verifyWebhook (Stripe SDK signature) → normalized
2
+ // event → handlePaymentWebhook → the shared fulfillOrderFromEvent creates an order from
3
+ // the cart and marks it converted. No provider-specific code here — just glue.
4
+ import { NextResponse } from 'next/server';
5
+ import { handlePaymentWebhook } from '@vitrine-kit/core';
6
+ import { stripeProvider } from '@/lib/checkout-stripe/provider';
7
+ import { fulfillOrderFromEvent } from '@/lib/checkout/fulfill';
8
+
9
+ export async function POST(req: Request) {
10
+ const rawBody = await req.text();
11
+ const headers = { 'stripe-signature': req.headers.get('stripe-signature') };
12
+
13
+ try {
14
+ const result = await handlePaymentWebhook({
15
+ provider: stripeProvider,
16
+ req: { rawBody, headers },
17
+ handlers: { onCheckoutCompleted: (event) => fulfillOrderFromEvent(event, 'stripe') },
18
+ });
19
+ return NextResponse.json(result);
20
+ } catch (err) {
21
+ return NextResponse.json({ error: (err as Error).message }, { status: 400 });
22
+ }
23
+ }
@@ -0,0 +1,80 @@
1
+ // PaymentProvider over Stripe Hosted Checkout. The Stripe SDK lives here (in the feature),
2
+ // not in the core: createCheckout creates a session, verifyWebhook verifies the signature and
3
+ // normalizes the event into a NormalizedPaymentEvent. Mapping the cart into Stripe line items
4
+ // (price_data — dynamic prices, unit_amount in minor units) also lives here.
5
+ import type { Cart } from '@vitrine-kit/contracts';
6
+ import type {
7
+ CreateCheckoutArgs,
8
+ NormalizedPaymentEvent,
9
+ PaymentProvider,
10
+ PaymentWebhookRequest,
11
+ } from '@vitrine-kit/core';
12
+
13
+ /** Required env, read at call time — `next build` must not need secrets. */
14
+ function requiredEnv(key: string): string {
15
+ const value = process.env[key];
16
+ if (!value) throw new Error(`[vitrine] ${key} is not set — add it to .env (see .env.example)`);
17
+ return value;
18
+ }
19
+
20
+ interface StripeLineItem {
21
+ quantity: number;
22
+ price_data: { currency: string; unit_amount: number; product_data: { name: string } };
23
+ }
24
+
25
+ function cartToStripeLineItems(cart: Cart): StripeLineItem[] {
26
+ return cart.lines.map((l) => ({
27
+ quantity: l.quantity,
28
+ price_data: {
29
+ currency: cart.currency.toLowerCase(),
30
+ unit_amount: l.unitPrice,
31
+ product_data: { name: l.title },
32
+ },
33
+ }));
34
+ }
35
+
36
+ export const stripeProvider: PaymentProvider = {
37
+ name: 'stripe',
38
+
39
+ async createCheckout(args: CreateCheckoutArgs): Promise<{ redirectUrl: string }> {
40
+ const { cart, baseUrl, successPath = '/order/success', cancelPath = '/cart' } = args;
41
+ const { default: Stripe } = await import('stripe');
42
+ const stripe = new Stripe(requiredEnv('STRIPE_SECRET_KEY'));
43
+ const session = await stripe.checkout.sessions.create({
44
+ mode: 'payment',
45
+ line_items: cartToStripeLineItems(cart),
46
+ success_url: `${baseUrl}${successPath}?session_id={CHECKOUT_SESSION_ID}`,
47
+ cancel_url: `${baseUrl}${cancelPath}`,
48
+ metadata: { cartId: cart.id },
49
+ });
50
+ return { redirectUrl: session.url ?? `${baseUrl}${cancelPath}` };
51
+ },
52
+
53
+ async verifyWebhook(req: PaymentWebhookRequest): Promise<NormalizedPaymentEvent> {
54
+ const { default: Stripe } = await import('stripe');
55
+ const stripe = new Stripe(requiredEnv('STRIPE_SECRET_KEY'));
56
+ const secret = requiredEnv('STRIPE_WEBHOOK_SECRET');
57
+ const signature = req.headers['stripe-signature'] ?? '';
58
+ // Throws on an invalid signature — handlePaymentWebhook surfaces it as a 400.
59
+ const event = stripe.webhooks.constructEvent(req.rawBody, signature, secret);
60
+
61
+ if (event.type === 'checkout.session.completed') {
62
+ const session = event.data.object as {
63
+ id?: string;
64
+ metadata?: { cartId?: string };
65
+ customer_details?: { email?: string };
66
+ };
67
+ return {
68
+ kind: 'checkout_completed',
69
+ cartId: session.metadata?.cartId,
70
+ providerRef: session.id,
71
+ email: session.customer_details?.email,
72
+ raw: event,
73
+ };
74
+ }
75
+ if (event.type === 'payment_intent.payment_failed') {
76
+ return { kind: 'payment_failed', raw: event };
77
+ }
78
+ return { kind: 'unknown', raw: event };
79
+ },
80
+ };
@@ -0,0 +1,7 @@
1
+ // Registers the Stripe payment provider. The CLI calls this from lib/payments.ts.
2
+ import { payments } from '@vitrine-kit/core';
3
+ import { stripeProvider } from './provider.js';
4
+
5
+ export function registerCheckoutStripeProvider(): void {
6
+ payments.register(stripeProvider);
7
+ }
@@ -0,0 +1,23 @@
1
+ # Feature: checkout-yookassa (YooKassa payment provider)
2
+
3
+ A YooKassa (yookassa.ru) provider — Russian acquiring: cards, SBP, wallets. For the
4
+ `checkout` scaffold (which it depends on). Installed instead of `checkout-stripe` / `checkout-paddle`
5
+ (mutually exclusive). No SDK needed — REST `/v3/payments` + Basic auth.
6
+
7
+ - **Provider:** `lib/checkout-yookassa/provider.ts` → `yookassaProvider`
8
+ (`PaymentProvider`): `createCheckout` creates a payment (`confirmation.redirect`) →
9
+ `confirmation_url`; `verifyWebhook` confirms the payment with a follow-up request to the API.
10
+ - **Registration:** `registerCheckoutYookassaProvider()` (from `lib/payments.ts`),
11
+ sets `integrations.payments: "yookassa"`.
12
+ - **API (Next glue):** `POST /api/webhooks/yookassa` → `handlePaymentWebhook` →
13
+ `fulfillOrderFromEvent`.
14
+ - **env:** `YOOKASSA_SHOP_ID`, `YOOKASSA_SECRET_KEY` (required — read at call time, a
15
+ missing key throws `[vitrine] <KEY> is not set …`; webhook route → 400).
16
+
17
+ **Security:** YooKassa notifications are **not signed** — the provider re-verifies the
18
+ payment via `GET /v3/payments/{id}` and trusts only `status: "succeeded"`. A 404 on that
19
+ check (forged/foreign payment id) is acked as `kind: "unknown"`; other API failures throw,
20
+ so the non-200 response makes YooKassa redeliver the notification.
21
+
22
+ **Money:** YooKassa expects a decimal string (`"1990.00"`); `Money` is in minor
23
+ units, so we divide by 100 (RUB and most currencies have 2 decimal places).
@@ -0,0 +1,52 @@
1
+ {
2
+ "$schema": "../../schemas/feature.schema.json",
3
+ "name": "checkout-yookassa",
4
+ "title": "Payment — YooKassa",
5
+ "kitVersion": "0.4.1",
6
+ "requiresContracts": ">=1.0.0 <2.0.0",
7
+ "tier": [
8
+ "simple-store",
9
+ "full-store"
10
+ ],
11
+ "registryDependencies": [
12
+ "checkout"
13
+ ],
14
+ "corePackages": {
15
+ "@vitrine-kit/core": ">=0.1.0"
16
+ },
17
+ "npm": [],
18
+ "files": [
19
+ {
20
+ "from": "files/lib/checkout-yookassa/",
21
+ "to": "lib/checkout-yookassa/"
22
+ },
23
+ {
24
+ "from": "files/app/",
25
+ "to": "app/"
26
+ }
27
+ ],
28
+ "config": {
29
+ "set": {
30
+ "features.checkout-yookassa": true
31
+ }
32
+ },
33
+ "payment": {
34
+ "provider": "yookassa"
35
+ },
36
+ "env": [
37
+ {
38
+ "key": "YOOKASSA_SHOP_ID",
39
+ "required": true
40
+ },
41
+ {
42
+ "key": "YOOKASSA_SECRET_KEY",
43
+ "required": true
44
+ }
45
+ ],
46
+ "claudeDoc": "docs/checkout-yookassa.md",
47
+ "conflicts": [
48
+ "checkout-stripe",
49
+ "checkout-paddle"
50
+ ],
51
+ "removable": true
52
+ }
@@ -0,0 +1,22 @@
1
+ // YooKassa webhook: the notification is unsigned — yookassaProvider.verifyWebhook
2
+ // re-checks the payment via the API (we trust only succeeded) → normalized
3
+ // event → handlePaymentWebhook → the shared fulfillOrderFromEvent.
4
+ import { NextResponse } from 'next/server';
5
+ import { handlePaymentWebhook } from '@vitrine-kit/core';
6
+ import { yookassaProvider } from '@/lib/checkout-yookassa/provider';
7
+ import { fulfillOrderFromEvent } from '@/lib/checkout/fulfill';
8
+
9
+ export async function POST(req: Request) {
10
+ const rawBody = await req.text();
11
+
12
+ try {
13
+ const result = await handlePaymentWebhook({
14
+ provider: yookassaProvider,
15
+ req: { rawBody, headers: {} },
16
+ handlers: { onCheckoutCompleted: (event) => fulfillOrderFromEvent(event, 'yookassa') },
17
+ });
18
+ return NextResponse.json(result);
19
+ } catch (err) {
20
+ return NextResponse.json({ error: (err as Error).message }, { status: 400 });
21
+ }
22
+ }