@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.
- package/dist/index.js +313 -135
- package/kit/registry/README.md +9 -0
- package/kit/registry/_index.json +74 -0
- package/kit/registry/cart/docs/cart.md +13 -0
- package/kit/registry/cart/feature.json +51 -0
- package/kit/registry/cart/files/app/(frontend)/cart/page.tsx +21 -0
- package/kit/registry/cart/files/app/api/cart/route.ts +41 -0
- package/kit/registry/cart/files/components/cart/AddToCart.tsx +40 -0
- package/kit/registry/cart/files/components/cart/CartIndicator.tsx +11 -0
- package/kit/registry/cart/files/components/cart/CartLineItem.tsx +27 -0
- package/kit/registry/cart/files/components/cart/CartSummary.tsx +31 -0
- package/kit/registry/cart/files/components/cart/CartView.tsx +31 -0
- package/kit/registry/cart/files/lib/cart/data.ts +12 -0
- package/kit/registry/cart/files/lib/cart/register.ts +10 -0
- package/kit/registry/catalog/docs/catalog.md +18 -0
- package/kit/registry/catalog/feature.json +43 -0
- package/kit/registry/catalog/files/components/catalog/CategoryNav.tsx +26 -0
- package/kit/registry/catalog/files/components/catalog/ProductCard.tsx +42 -0
- package/kit/registry/catalog/files/components/catalog/ProductGrid.tsx +25 -0
- package/kit/registry/catalog/files/lib/catalog/data.ts +24 -0
- package/kit/registry/catalog/files/lib/catalog/register.ts +8 -0
- package/kit/registry/checkout/docs/checkout.md +17 -0
- package/kit/registry/checkout/feature.json +47 -0
- package/kit/registry/checkout/files/app/api/checkout/route.ts +14 -0
- package/kit/registry/checkout/files/components/checkout/CheckoutButton.tsx +46 -0
- package/kit/registry/checkout/files/lib/checkout/fulfill.ts +72 -0
- package/kit/registry/checkout/files/lib/checkout/register.ts +8 -0
- package/kit/registry/checkout-paddle/docs/checkout-paddle.md +25 -0
- package/kit/registry/checkout-paddle/feature.json +62 -0
- package/kit/registry/checkout-paddle/files/app/api/webhooks/paddle/route.ts +22 -0
- package/kit/registry/checkout-paddle/files/lib/checkout-paddle/provider.ts +80 -0
- package/kit/registry/checkout-paddle/files/lib/checkout-paddle/register.ts +7 -0
- package/kit/registry/checkout-stripe/docs/checkout-stripe.md +19 -0
- package/kit/registry/checkout-stripe/feature.json +54 -0
- package/kit/registry/checkout-stripe/files/app/api/webhooks/stripe/route.ts +23 -0
- package/kit/registry/checkout-stripe/files/lib/checkout-stripe/provider.ts +80 -0
- package/kit/registry/checkout-stripe/files/lib/checkout-stripe/register.ts +7 -0
- package/kit/registry/checkout-yookassa/docs/checkout-yookassa.md +23 -0
- package/kit/registry/checkout-yookassa/feature.json +52 -0
- package/kit/registry/checkout-yookassa/files/app/api/webhooks/yookassa/route.ts +22 -0
- package/kit/registry/checkout-yookassa/files/lib/checkout-yookassa/provider.ts +92 -0
- package/kit/registry/checkout-yookassa/files/lib/checkout-yookassa/register.ts +7 -0
- package/kit/registry/product-page/docs/product-page.md +16 -0
- package/kit/registry/product-page/feature.json +39 -0
- package/kit/registry/product-page/files/components/product/ProductGallery.tsx +37 -0
- package/kit/registry/product-page/files/components/product/ProductView.tsx +42 -0
- package/kit/registry/product-page/files/lib/product/data.ts +10 -0
- package/kit/registry/seo/docs/seo.md +14 -0
- package/kit/registry/seo/feature.json +35 -0
- package/kit/registry/seo/files/components/seo/JsonLd.tsx +15 -0
- package/kit/registry/seo/files/lib/seo/jsonld.ts +29 -0
- package/kit/registry/seo/files/lib/seo/metadata.ts +35 -0
- package/kit/registry/stubs/route-glue.d.ts +45 -0
- package/kit/registry/tsconfig.json +21 -0
- package/kit/registry/tsconfig.routes.json +9 -0
- package/kit/templates/README.md +27 -0
- package/kit/templates/backend-payload/files/.dockerignore +8 -0
- package/kit/templates/backend-payload/files/Dockerfile +29 -0
- package/kit/templates/backend-payload/files/app/(payload)/admin/[[...segments]]/not-found.tsx +18 -0
- package/kit/templates/backend-payload/files/app/(payload)/admin/[[...segments]]/page.tsx +18 -0
- package/kit/templates/backend-payload/files/app/(payload)/admin/importMap.js +3 -0
- package/kit/templates/backend-payload/files/app/(payload)/api/[...slug]/route.ts +18 -0
- package/kit/templates/backend-payload/files/app/(payload)/api/graphql/route.ts +6 -0
- package/kit/templates/backend-payload/files/app/(payload)/api/graphql-playground/route.ts +5 -0
- package/kit/templates/backend-payload/files/app/(payload)/custom.scss +1 -0
- package/kit/templates/backend-payload/files/app/(payload)/layout.tsx +23 -0
- package/kit/templates/backend-payload/files/docker-compose.yml +35 -0
- package/kit/templates/backend-payload/files/lib/adapter/db-decision.ts +43 -0
- package/kit/templates/backend-payload/files/lib/adapter/db-ping.ts +29 -0
- package/kit/templates/backend-payload/files/lib/adapter/db.ts +27 -0
- package/kit/templates/backend-payload/files/lib/adapter/index.ts +31 -0
- package/kit/templates/backend-payload/files/lib/adapter/map.ts +112 -0
- package/kit/templates/backend-payload/files/lib/adapter/payload-catalog.ts +79 -0
- package/kit/templates/backend-payload/files/lib/adapter/payload-commerce.ts +154 -0
- package/kit/templates/backend-payload/files/lib/adapter/payload-types.ts +50 -0
- package/kit/templates/backend-payload/files/lib/seed/admin.ts +23 -0
- package/kit/templates/backend-payload/files/lib/seed/demo.ts +79 -0
- package/kit/templates/backend-payload/files/lib/seed/guards.ts +5 -0
- package/kit/templates/backend-payload/files/lib/seed/richtext.ts +26 -0
- package/kit/templates/backend-payload/files/lib/seed/run.ts +51 -0
- package/kit/templates/backend-payload/files/next.config.mjs +10 -0
- package/kit/templates/backend-payload/files/payload.config.ts +32 -0
- package/kit/templates/backend-payload/files/seed-assets/placeholder-1.svg +5 -0
- package/kit/templates/backend-payload/files/seed-assets/placeholder-2.svg +5 -0
- package/kit/templates/backend-payload/files/seed-assets/placeholder-3.svg +5 -0
- package/kit/templates/backend-payload/files/seed-assets/placeholder-4.svg +5 -0
- package/kit/templates/backend-payload/files/seed-assets/placeholder-5.svg +5 -0
- package/kit/templates/backend-vendure/files/.dockerignore +8 -0
- package/kit/templates/backend-vendure/files/Dockerfile +24 -0
- package/kit/templates/backend-vendure/files/docker-compose.yml +50 -0
- package/kit/templates/backend-vendure/files/lib/adapter/graphql.ts +27 -0
- package/kit/templates/backend-vendure/files/lib/adapter/index.ts +20 -0
- package/kit/templates/backend-vendure/files/lib/adapter/map.ts +107 -0
- package/kit/templates/backend-vendure/files/lib/adapter/vendure-catalog.ts +62 -0
- package/kit/templates/backend-vendure/files/lib/adapter/vendure-commerce.ts +76 -0
- package/kit/templates/backend-vendure/files/lib/adapter/vendure-types.ts +61 -0
- package/kit/templates/backend-vendure/files/lib/seed/populate.ts +22 -0
- package/kit/templates/backend-vendure/files/src/index.ts +15 -0
- package/kit/templates/backend-vendure/files/vendure-config.ts +56 -0
- package/kit/templates/base/files/.claude/commands/add-feature.md +18 -0
- package/kit/templates/base/files/.claude/commands/design.md +14 -0
- package/kit/templates/base/files/.claude/commands/doctor.md +15 -0
- package/kit/templates/base/files/.claude/commands/setup.md +15 -0
- package/kit/templates/base/files/.claude/commands/update.md +16 -0
- package/kit/templates/base/files/.gitattributes +12 -0
- package/kit/templates/base/files/AGENTS.md +20 -0
- package/kit/templates/base/files/app/(frontend)/categories/[slug]/page.tsx +28 -0
- package/kit/templates/base/files/app/(frontend)/layout.tsx +32 -0
- package/kit/templates/base/files/app/(frontend)/page.tsx +27 -0
- package/kit/templates/base/files/app/(frontend)/products/[slug]/page.tsx +43 -0
- package/kit/templates/base/files/app/globals.css +6 -0
- package/kit/templates/base/files/components/layout/SiteFooter.tsx +15 -0
- package/kit/templates/base/files/components/layout/SiteHeader.tsx +23 -0
- package/kit/templates/base/files/design/README.md +9 -0
- package/kit/templates/base/files/lib/site.ts +10 -0
- package/kit/templates/base/files/next.config.mjs +7 -0
- package/kit/templates/base/files/postcss.config.mjs +7 -0
- package/kit/templates/base/files/public/.gitkeep +0 -0
- package/kit/templates/base/files/tailwind.config.ts +13 -0
- package/kit/templates/base/files/tsconfig.json +25 -0
- package/kit/templates/tsconfig.json +18 -0
- 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
|
+
}
|