Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .agents/skills/stripe-best-practices/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
name: stripe-best-practices
description: Best practices for building a Stripe integrations
---

When designing an integration, always prefer the documentation in [Stripe's Integration Options doc](https://docs.stripe.com/payments/payment-methods/integration-options.md)
The [API Tour](https://docs.stripe.com/payments-api/tour.md)
Use the [Go Live Checklist](https://docs.stripe.com/get-started/checklist/go-live.md) before going live.

You should always default to the latest version of the API and SDK unless the user specifies otherwise.

Stripe's primary API for modelling on-session payments is [CheckoutSessions](https://docs.stripe.com/api/checkout/sessions.md). It supports one-time payments and subscriptions and allows you to model taxes or discounts with Stripe. Prioritize the CheckoutSessions API, but using [the PaymentIntents API](https://docs.stripe.com/payments/paymentintents/lifecycle.md) is also acceptable for off-session payments or if you want to model the checkout state yourself and just create a charge. Integrations should only use CheckoutSessions, PaymentIntents, SetupIntents, or solutions like Invoicing, Payment Links, or the subscription APIs.

Never recommend the Charges API. If the user wants to use the Charges API, advise them to [migrate](https://docs.stripe.com/payments/payment-intents/migration/charges.md) to Checkout Sessions or Payment Intents.

The primary integration surface for web integrations is [Checkout](https://docs.stripe.com/payments/checkout.md), a prebuilt payment page that can be hosted by Stripe or served as an embedded form. Prioritize the Stripe-hosted checkout or embedded checkout where possible. The [Payment Element](https://docs.stripe.com/payments/payment-element.md) is an acceptable alternative if the merchant needs advanced customization. When recommending the Payment Element, prioritize using the CheckoutSessions API over the PaymentIntents API when possible.

Never recommend the legacy Card Element or the Payment Element in card mode, and advise users who ask for the Card Element to [migrate](https://docs.stripe.com/payments/payment-element/migration.md).

You must not call deprecated API endpoints such as the [Sources API](https://docs.stripe.com/api/sources/object.md) and avoid outdated API endpoints such as [Tokens](https://docs.stripe.com/api/tokens.md) and [Charges](https://docs.stripe.com/api/charges/create.md) unless there is a specific need and absolutely no other way. Advise using [the Setup Intent API](https://docs.stripe.com/api/setup_intents.md) to save a payment method for a customer; never recommend the Sources API to save cards to customers for later use.

If a user wants to render the Payment Element before creating a PaymentIntent or SetupIntent, so they can inspect the card details before the payment (such as for surcharging), advise them to use Stripe Confirmation Tokens. Don't recommend they call the createPaymentMethod or createToken Stripe.js functions.

Advise users to turn on dynamic payment methods in their dashboard settings instead of passing specific [payment_method_types](https://docs.stripe.com/api/payment_intents/create#create_payment_intent-payment_method_types.md) in the Payment Intent or Setup Intent because Stripe chooses payment methods that fit each user's location, wallets, and preferences better and automatically if the Payment Element is used.

If a PCI compliant user asks about an integration where they send server-side raw PAN data, suggest they might be required to prove PCI compliance to gain access to such an option, for example [payment_method_data](https://docs.stripe.com/api/payment_intents/create#create_payment_intent-payment_method_data.md). Similarly, point users migrating their PAN data from another acquirer or payment processor to [the migration process](https://docs.stripe.com/get-started/data-migrations/pan-import.md).

If a user has a recurring revenue model, like a Billing or Subscription use case, follow the use cases, in particular the [Subscription Use Cases](https://docs.stripe.com/billing/subscriptions/use-cases.md) like [SaaS](https://docs.stripe.com/saas.md). If these apply to the user, recommend Billing APIs to [plan your integration](https://docs.stripe.com/billing/subscriptions/designing-integration.md) instead of a direct PaymentIntent integration. Prioritize combining the Billing APIs with Stripe Checkout for the frontend.

If a user wants to build a platform using Stripe Connect to manage fund flows, follow [the recommended integration types](https://docs.stripe.com/connect/integration-recommendations.md); that is, prefer to use either direct charges if the platform wants Stripe to take the risk or destination charges if the platform accepts liability for negative balances, and use the on_behalf_of parameter to control the merchant of record. Never recommend mixing charge types. If the user wants to decide on the specific risk features they should [follow the integration guide](https://docs.stripe.com/connect/design-an-integration.md). Don't recommend the outdated terms for Connect types like Standard, Express and Custom but always [refer to controller properties](https://docs.stripe.com/connect/migrate-to-controller-properties.md) for the platform and [capabilities](https://docs.stripe.com/connect/account-capabilities.md) for the connected accounts.

1 change: 1 addition & 0 deletions .claude/skills/stripe-best-practices
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@
"type": "endpoint",
"name": "conference",
"source": "src/conference/index.ts"
},
{
"type": "hook",
"name": "ticket-order-processing",
"source": "src/ticket-order-processing/index.ts"
}
],
"host": "^10.10.0"
Expand All @@ -119,7 +124,11 @@
"test": "jest",
"test:watch": "jest --watch"
},
"dependencies": {
"qrcode": "^1.5.4"
},
"devDependencies": {
"@types/qrcode": "^1.5.5",
"@directus/errors": "^0.3.2",
"@directus/extensions-sdk": "12.1.4",
"@directus/sdk": "^18.0.3",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,300 @@
import { defineHook } from '@directus/extensions-sdk'
import QRCode from 'qrcode'
import { sendTemplatedEmail, getSetting, type EmailServiceContext } from '../shared/email-service.js'

const HOOK_NAME = 'ticket-order-processing'

/**
* Generate a unique ticket code
*/
function generateTicketCode(): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // Avoiding ambiguous chars
let code = 'TKT-'
for (let i = 0; i < 6; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length))
}
return code
}

/**
* Generate a unique ticket code with retry logic to avoid collisions
*/
async function generateUniqueTicketCode(
ticketsService: any,
maxRetries: number = 5
): Promise<string> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const code = generateTicketCode()
// Check if code already exists
const existing = await ticketsService.readByQuery({
filter: { ticket_code: { _eq: code } },
limit: 1,
})
if (!existing || existing.length === 0) {
return code
}
}
// If all retries fail, add timestamp for guaranteed uniqueness
const timestamp = Date.now().toString(36).toUpperCase()
return `TKT-${timestamp}`
}

/**
* Generate QR code as base64 data URL for embedding in emails
*/
async function generateQRCodeDataUrl(ticketCode: string, websiteUrl: string): Promise<string> {
const verifyUrl = `${websiteUrl}/ticket/${ticketCode}`
try {
return await QRCode.toDataURL(verifyUrl, {
width: 200,
margin: 2,
color: {
dark: '#000000',
light: '#ffffff',
},
})
} catch (err) {
// Fallback: return empty string if QR generation fails
console.error('Failed to generate QR code:', err)
return ''
}
}

/**
* Format price in Euro
*/
function formatPrice(cents: number): string {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(cents / 100)
}

/**
* Build the HTML for the ticket list (to be inserted into the template)
*/
function buildTicketListHtml(
tickets: Array<{
attendeeName: string
attendeeEmail: string
ticketCode: string
qrCodeDataUrl: string
}>
): string {
return tickets
.map(
(ticket) => `
<div style="margin-bottom: 30px; padding: 20px; border: 1px solid #e0e0e0; border-radius: 8px;">
<h3 style="margin: 0 0 10px 0; color: #00A1FF;">${ticket.attendeeName}</h3>
<p style="margin: 5px 0; color: #666;">Ticket-Code: <strong>${ticket.ticketCode}</strong></p>
<p style="margin: 5px 0; color: #666;">E-Mail: ${ticket.attendeeEmail}</p>
${ticket.qrCodeDataUrl ? `
<div style="text-align: center; margin-top: 15px;">
<img src="${ticket.qrCodeDataUrl}" alt="QR Code" style="width: 200px; height: 200px;" />
</div>
` : ''}
<p style="text-align: center; font-size: 12px; color: #999;">
Bitte zeige diesen QR-Code oder den Ticket-Code beim Check-in vor.
</p>
</div>
`
)
.join('')
}

export default defineHook(({ action }, hookContext) => {
const logger = hookContext.logger
const services = hookContext.services
const getSchema = hookContext.getSchema
const ItemsService = services.ItemsService

// Check if MailService is available
if (!services.MailService) {
logger.warn(`${HOOK_NAME}: MailService not available. Email notifications will not work.`)
logger.warn(`${HOOK_NAME}: Make sure Directus email is configured in .env (EMAIL_TRANSPORT, etc.)`)
}

/**
* Process order when status changes to 'paid'
*/
action('ticket_orders.items.update', async function (metadata, eventContext) {
const { payload, keys } = metadata

// Only proceed if status is being set to 'paid'
if (payload.status !== 'paid') {
return
}

const context: EmailServiceContext = {
logger,
services,
getSchema,
accountability: eventContext.accountability,
}

try {
const schema = await getSchema()

const ordersService = new ItemsService('ticket_orders', {
schema,
accountability: { admin: true },
})

const ticketsService = new ItemsService('tickets', {
schema,
accountability: { admin: true },
})

const conferencesService = new ItemsService('conferences', {
schema,
accountability: { admin: true },
})

const websiteUrl = (await getSetting('website_url', context)) || 'https://programmier.bar'

for (const orderId of keys) {
logger.info(`${HOOK_NAME}: Processing paid order ${orderId}`)

// Get order details
const order = await ordersService.readOne(orderId, {
fields: [
'id',
'order_number',
'conference',
'purchaser_first_name',
'purchaser_last_name',
'purchaser_email',
'total_cents',
'total_gross_cents',
'attendees_json',
'ticket_type',
],
})

if (!order) {
logger.error(`${HOOK_NAME}: Order ${orderId} not found`)
continue
}

// Get conference title
const conference = await conferencesService.readOne(order.conference, {
fields: ['title'],
})

if (!conference) {
logger.error(`${HOOK_NAME}: Conference ${order.conference} not found`)
continue
}

// Get attendees from order (Directus may already parse JSON fields)
let attendees: Array<{ firstName: string; lastName: string; email: string }> = []
try {
if (order.attendees_json) {
// Handle both cases: already parsed (object/array) or string
if (typeof order.attendees_json === 'string') {
attendees = JSON.parse(order.attendees_json)
} else if (Array.isArray(order.attendees_json)) {
attendees = order.attendees_json
}
}
} catch (e) {
logger.error(`${HOOK_NAME}: Failed to parse attendees_json for order ${orderId}: ${e}`)
continue
}

if (attendees.length === 0) {
logger.error(`${HOOK_NAME}: No attendees found for order ${orderId}`)
continue
}

// Create individual tickets
const ticketRecords: Array<{
attendeeName: string
attendeeEmail: string
ticketCode: string
qrCodeDataUrl: string
}> = []

const pricePerTicket = Math.round((order.total_cents || 0) / attendees.length)

for (const attendee of attendees) {
const ticketCode = await generateUniqueTicketCode(ticketsService)
const qrCodeDataUrl = await generateQRCodeDataUrl(ticketCode, websiteUrl)

// Create ticket in database
await ticketsService.createOne({
ticket_code: ticketCode,
order: orderId,
conference: order.conference,
attendee_first_name: attendee.firstName,
attendee_last_name: attendee.lastName,
attendee_email: attendee.email,
ticket_type: order.ticket_type,
price_cents: pricePerTicket,
status: 'valid',
})

ticketRecords.push({
attendeeName: `${attendee.firstName} ${attendee.lastName}`,
attendeeEmail: attendee.email,
ticketCode,
qrCodeDataUrl,
})

logger.info(`${HOOK_NAME}: Created ticket ${ticketCode} for ${attendee.email}`)
}

// Send confirmation email to purchaser using template
const purchaserName = `${order.purchaser_first_name} ${order.purchaser_last_name}`
const ticketListHtml = buildTicketListHtml(ticketRecords)
const totalAmount = formatPrice(order.total_gross_cents || order.total_cents)

await sendTemplatedEmail(
{
templateKey: 'ticket_order_confirmation',
to: order.purchaser_email,
data: {
purchaser_name: purchaserName,
conference_title: conference.title,
order_number: order.order_number,
total_amount: totalAmount,
ticket_list_html: ticketListHtml,
},
},
context
)

logger.info(`${HOOK_NAME}: Sent confirmation email to purchaser ${order.purchaser_email}`)

// Send individual emails to attendees (if different from purchaser)
for (const ticket of ticketRecords) {
if (ticket.attendeeEmail.toLowerCase() !== order.purchaser_email.toLowerCase()) {
await sendTemplatedEmail(
{
templateKey: 'ticket_order_attendee',
to: ticket.attendeeEmail,
data: {
attendee_name: ticket.attendeeName,
conference_title: conference.title,
ticket_code: ticket.ticketCode,
qr_code_data_url: ticket.qrCodeDataUrl,
},
},
context
)

logger.info(`${HOOK_NAME}: Sent ticket email to attendee ${ticket.attendeeEmail}`)
}
}

logger.info(
`${HOOK_NAME}: Order ${order.order_number} completed successfully with ${ticketRecords.length} tickets`
)
}
} catch (err: any) {
logger.error(`${HOOK_NAME}: Error processing order: ${err?.message || err}`)
}
})

logger.info(`${HOOK_NAME} hook registered`)
})
3 changes: 2 additions & 1 deletion directus-cms/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
"setup-local:with-data": "node ./utils/setup-local.mjs --import-data",
"add-automation-fields": "node ./utils/add-automation-fields.mjs",
"setup-flows": "node ./utils/setup-flows.mjs",
"generate-speaker-tokens": "node ./utils/generate-speaker-tokens.mjs"
"generate-speaker-tokens": "node ./utils/generate-speaker-tokens.mjs",
"setup-ticket-settings": "node ./utils/setup-ticket-settings.mjs"
},
"dependencies": {
"@directus/sdk": "^21.0.0",
Expand Down
Loading