Skip to main content

Service Guide

Narrative guide to domain services in src/data/*/. Each service lists methods, inputs, outputs, and side effects.

Trigger: Changes to *.service.ts
Regenerate: Use author-developer-docs skill when service contracts change.


AlertService

Source: src/data/alert/alert.service.ts

notify

AspectDetails
Inputctx: RequestContext, { type, message, entityType?, entityId? }
OutputPromise<void>
Side effectsCreates an Alert record in the database. Used for low-inventory, new-order, and payment/fulfillment failure notifications.

NotificationService

Source: src/data/alert/notification.service.ts

notifyAdminChannel

AspectDetails
Inputctx: RequestContext, tenantId, payload: NotificationPayload, opts: NotifyOptions
OutputPromise<NotifyResult>{ skipped, messageId? }
Side effectsPosts to tenant's announcementChannelId (or adminChannelId fallback) via Discord REST. Dedupes via Valkey.

notifyCustomerDm

AspectDetails
Inputctx: RequestContext, discordUserId, payload: NotificationPayload, opts: NotifyOptions
OutputPromise<NotifyResult>{ skipped, messageId? }
Side effectsCreates DM channel with Discord user, then sends message. Dedupes via Valkey. Retries once on 429.

EmailPort

Source: src/ports/email.port.ts — port contract; src/adapters/sendgrid-email.adapter.ts — production adapter

EmailPort exposes a single send(input: EmailSendInput) method. Two implementations:

  • createSendGridEmailAdapter(config) — uses native https (no SDK). Active when EMAIL_PROVIDER=sendgrid + SENDGRID_API_KEY + EMAIL_FROM are set.
  • createEmailCapturePort() — in-memory inbox used by E2E tests. Active when EMAIL_PROVIDER=capture. Exposes inbox() / inboxFor(to) / clear() so integration specs can assert that the right email went to the right recipient. Test-mode only (see docs/test-harness/email-capture.md).
  • createNoopEmailPort() — always returns { accepted: false }. Used as fallback when email is not configured.

External-API base-URL overrides

Each integration adapter accepts an optional base-URL override so the devtools/<vendor>-mock E2E harnesses can intercept outbound calls without hitting live APIs:

Env varAdapterDefault
STRIPE_API_BASEcreateStripePlatformBillingAdapter (forwarded as host/port/protocol to the SDK)api.stripe.com
EASYPOST_API_BASEcreateEasyPostShippingAdapterhttps://api.easypost.com/v2
TAXJAR_API_BASEcreateTaxJarAdapterhttps://api.taxjar.com/v2
NOWPAYMENTS_API_URLcreateNowPaymentsAdapterhttps://api.nowpayments.io/v1
DISCORD_API_BASE_URLcreateDiscordNotifyServicehttps://discord.com/api/v10

Production leaves all of these unset; CI/E2E tests point them at their companion mock servers. See docs/test-harness/ for per-mock setup.

Wired in src/init/api.init.ts and passed into registerAllEventHandlers via the events registry. Notification consumers call emailPort.send(...).catch(() => undefined) so email failures are non-blocking.

Four transactional email flows are covered by e2e/email-coverage.integration.spec.ts: payment.confirmed (receipt), payment.rejected (admin alert), invoice.payment_failed (platform billing ops alert), and order.shipped (tracking notification). Each spec asserts on the capture-port inbox and will fail if the corresponding emailPort.send() call is removed.


BotConfigService

Source: src/data/bot-config/bot-config.service.ts

upsertForTenant

AspectDetails
Inputctx: RequestContext, config input
OutputPromise<BotConfigRecord>
Side effectsCreates or updates bot config for tenant.

getForTenant

AspectDetails
Inputctx: RequestContext
OutputPromise<BotConfigRecord | null>
Side effectsNone. Read-only.

resolveTenantByGuildId / resolveTenantByDiscordUserId / resolveTenantForInteraction

AspectDetails
InputGuild ID, Discord user ID, or interaction context
OutputPromise<GuildTenantMapping | null> or Promise<InteractionTenantResolution | null>
Side effectsNone. Resolves tenant from Discord context.

registerDiscordUserForTenant

AspectDetails
Inputctx: RequestContext, discordUserId: string
OutputPromise<BotConfigRecord>
Side effectsAdds user to registeredDiscordUserIds.

resolvePaymentInstruction

AspectDetails
Inputctx: RequestContext, method: string
OutputPromise<string>
Side effectsNone. Returns payment instructions text for the method.

listShippingTypes

AspectDetails
Inputctx: RequestContext
OutputPromise<ShippingType[]>
Side effectsNone. Returns [] when no bot config or no shipping types configured for the tenant.

DiscordNotifyService

Source: src/data/discord-commands/discord-notify.service.ts

send

AspectDetails
Inputctx, { message, target, channelId?, tenantId }, { botToken, getDiscordIdsForTenant }
OutputPromise<{ sent, failed, errors }>
Side effectsSends messages via Discord REST API. Channel: posts to given channel. DM: creates DM channels and sends to each tenant customer.

CustomerService

Source: src/data/customer/customer.service.ts

findOrCreateCustomer

AspectDetails
Inputctx: RequestContext, customer input (discordId, discordUsername, etc.)
OutputPromise<FindOrCreateResult> (customer + created flag)
Side effectsMay create new Customer record.

getCustomer / listCustomers

AspectDetails
Inputctx: RequestContext, optional id
OutputPromise<CustomerRecord | null> or Promise<CustomerRecord[]>
Side effectsNone. Read-only.

updateCustomer / deactivateCustomer

AspectDetails
Inputctx: RequestContext, id, update input
OutputPromise<CustomerRecord>
Side effectsUpdates or deactivates customer.

linkCustomerToUser

AspectDetails
Inputctx: RequestContext, customerId, userId
OutputPromise<CustomerRecord>
Side effectsSets customer.userId.

mergeCustomers

AspectDetails
Inputctx: RequestContext, sourceId, targetId, reassignOrders
OutputPromise<MergeResult>
Side effectsMerges source into target; may reassign orders.

addAddress / removeAddress / setDefaultAddress

AspectDetails
Inputctx: RequestContext, customerId, address or addressId
OutputPromise<CustomerRecord>
Side effectsModifies customer addresses.

setDmPreference

AspectDetails
Inputctx: RequestContext, customerId: string, key: keyof CustomerDmPreferences, value: boolean
OutputPromise<CustomerRecord>
Side effectsPatches the named field in customer.dmPreferences. Throws CustomerError('NOT_FOUND') when customer doesn't exist.

setEmailPreference

AspectDetails
Inputctx: RequestContext, customerId: string, key: keyof CustomerEmailPreferences, value: boolean
OutputPromise<CustomerRecord>
Side effectsPatches the named field in customer.emailPreferences. Throws CustomerError('NOT_FOUND') when customer doesn't exist.

DiscountService

Source: src/data/discount/discount.service.ts

getById / listByTenant

AspectDetails
Inputctx: RequestContext, optional id
OutputPromise<DiscountRecord | null> or Promise<DiscountRecord[]>
Side effectsNone. Read-only.

create / update / remove

AspectDetails
Inputctx: RequestContext, input or id, patch
OutputPromise<DiscountRecord> or Promise<void>
Side effectsCRUD on discounts.

evaluateEligibility

AspectDetails
Inputctx: RequestContext, { code, items, customerId?, roleIds?, now? }
OutputPromise<DiscountEvaluationResult>
Side effectsNone. Evaluates discount applicability.

consumeUsage

AspectDetails
Inputctx: RequestContext, { discountId, orderId }
OutputPromise<DiscountUsageConsumeResult>
Side effectsIncrements usageCount, adds orderId to consumedOrderIds.

resolveBestForCustomer

AspectDetails
Inputctx: RequestContext, { customerId: string, subtotalCents: number, items: DiscountEvaluationItem[] }
OutputPromise<ResolveBestForCustomerResult>{ best?: { discountId, discountCode?, discountAmountCents, subtotalCents, totalCents } }
Side effectsNone. Fetches customer assignments and global active discounts in parallel; selects the highest discountAmountCents among eligible ones.

assignToCustomer

AspectDetails
Inputctx: RequestContext, input: DiscountAssignmentCreateInput
OutputPromise<DiscountAssignment>
Side effectsCreates a new assignment or updates the existing one (upsert by discountId+customerId).

revokeAssignment

AspectDetails
Inputctx: RequestContext, assignmentId: string
OutputPromise<DiscountAssignment | null>
Side effectsSets expiresAt to 1 second in the past (soft-revoke). Returns null if not found.

EntitlementService

Source: src/data/entitlement/entitlement.service.ts

generateEntitlementsForOrder

AspectDetails
Inputctx: RequestContext, orderId: string
OutputPromise<EntitlementRecord[]>
Side effectsCreates PENDING entitlement records for each product with grantedRoleIds. Idempotent.

processEntitlements

AspectDetails
Inputctx: RequestContext, orderId: string
OutputPromise<void>
Side effectsCalls Discord REST PUT /guilds/{guildId}/members/{userId}/roles/{roleId} for each PENDING entitlement; marks GRANTED or FAILED.

revokeEntitlementsForOrder

AspectDetails
Inputctx: RequestContext, orderId: string
OutputPromise<void>
Side effectsCalls Discord REST DELETE for each GRANTED entitlement (best-effort); marks REVOKED.

retryEntitlement

AspectDetails
Inputctx: RequestContext, entitlementId: string
OutputPromise<EntitlementRecord>
Side effectsRetries Discord role grant for a FAILED entitlement. No-op if not FAILED.

IdentityService

Source: src/data/identity/identity.service.ts

linkIdentity

AspectDetails
Inputctx: RequestContext, { userId, tenantId?, provider, providerUserId, profile?, secrets? }
OutputPromise<IdentityRecord>
Side effectsCreates Identity linking user to provider.

listUserIdentities

AspectDetails
Inputctx: RequestContext, userId
OutputPromise<IdentityRecord[]>
Side effectsNone. Read-only.

CatalogChannelService

Source: src/data/inventory/catalog-channel.service.ts
Factory: src/data/inventory/catalog-channel.factory.ts

Maintains a live Discord catalog channel for a tenant. Renders active products as Discord embeds, posts them to a configured channel, and edits them in place as inventory or product data changes. Backed by a two-layer debounce (in-process Set + Valkey TTL key) that coalesces burst events into a single render.

enqueueCatalogSync

AspectDetails
Inputctx: RequestContext
OutputPromise<boolean>true if a sync was scheduled, false if coalesced into existing debounce window
Side effectsSets Valkey key catalog-sync:<tenantId> (TTL 5 s). Schedules syncCatalog via setTimeout to fire after the debounce window. No immediate Discord I/O.

syncCatalog

AspectDetails
Inputctx: RequestContext
OutputPromise<void>
Side effectsFetches active products + inventory snapshots. POSTs new Discord embeds, PATCHes existing messages in place, DELETEs surplus managed messages. Persists updated catalogMessageIds to BotConfig via repository.

Debounce internals: enqueueCatalogSync uses an in-process Set (fast path, prevents same-tick races) and a Valkey catalog-sync:<tenantId> key (cross-process / cross-restart). The debounce TTL is 5 seconds. A burst of N events in the window fires exactly one syncCatalog call after the TTL.

Rate-limit handling: the underlying DiscordChannelPort adapter (discord-channel.adapter.ts) reads Retry-After on HTTP 429 and retries once after the specified delay.

Wiring: inject via ctx.repositories.catalogChannelService (type CatalogChannelService). The factory createCatalogChannelService(cache, getBotToken, logger) handles production wiring.


InventoryService

Source: src/data/inventory/inventory.service.ts

reserve

AspectDetails
Inputctx: RequestContext, { productId, orderId, quantity }
OutputPromise<void>
Side effectsDecrements available, increments reserved. Creates InventoryTransaction.

release

AspectDetails
Inputctx: RequestContext, { productId, orderId, quantity }
OutputPromise<void>
Side effectsReverses reserve. Increments available, decrements reserved.

finalizeSale

AspectDetails
Inputctx: RequestContext, { productId, orderId, quantity }
OutputPromise<void>
Side effectsConverts reserved to sold. Decrements reserved; creates SALE transaction.

Presentation utilities (src/data/inventory/inventory.utils.ts)

Pure helpers used by Discord command embeds and admin surfaces. No I/O, no state.

FunctionSignaturePurpose
resolveQtyVisibility(product: 'public'|'private'|'inherit', tenantDefault: 'public'|'private') => 'public'|'private'Applies the product override, falling back to tenant default on 'inherit'.
renderQty(qty: number, visibility: 'public'|'private') => stringRenders numeric qty for public; 'In stock' / 'Out of stock' for private.
resolveProductImage(productUrl?: string, tenantDefault?: string) => stringPriority: product URL → tenant default → DEFAULT_PRODUCT_IMAGE_URL placeholder constant.

OrderService

Source: src/data/order/order.service.ts

create

AspectDetails
Inputctx: RequestContext, OrderCreateInput
OutputPromise<OrderRecord>
Side effectsCreates order. Idempotent via deterministic orderNumber. May reserve inventory, consume discount.

getById / listByTenant

AspectDetails
Inputctx: RequestContext, optional id
OutputPromise<OrderRecord | null> or Promise<OrderRecord[]>
Side effectsNone. Read-only.

update / remove

AspectDetails
Inputctx: RequestContext, id, patch or remove
OutputPromise<OrderRecord> or Promise<void>
Side effectsUpdates or deletes order. May release inventory on cancel.

listOpenOrders

AspectDetails
Inputctx: RequestContext, ListOpenOrdersInput { status?, search?, createdFrom?, createdTo?, page?, pageSize? }
OutputPromise<OpenOrderListResult>{ items: OpenOrderListItem[], page, pageSize, total }
Side effectsNone. Read-only. Fails closed if ctx.tenantId is empty. Caps pageSize at 200. Open statuses: PENDING_PAYMENT, PAID, FULFILLING, SHIPPED.

getOpenOrderSummary

AspectDetails
Inputctx: RequestContext
OutputPromise<OpenOrderSummary>{ totalOpen: number, byStatus: Record<string, number> }
Side effectsNone. Read-only. Fails closed if ctx.tenantId is empty. Returns zero counts for statuses with no orders.

OrderInvoiceLinkService

Source: src/data/order/order-invoice-link.service.ts

Issues and verifies HMAC-SHA256 signed tokens for public pay-by-invoice links. When INVOICE_LINK_SIGNING_SECRET is unset, generateLink throws OrderInvoiceLinkConfigError and verifyLinkToken returns { ok: false, reason: 'config-missing' }.

AspectDetails
Inputctx: RequestContext, { orderId: string; expiresInSeconds?: number }
OutputPromise<{ url: string; token: string; expiresAt: Date }>
Side effectsReads the order (verifies existence + tenant scope). Throws OrderInvoiceLinkConfigError when secret unset; throws OrderInvoiceLinkNotFoundError when order absent.

verifyLinkToken

AspectDetails
Inputtoken: string
OutputPromise<{ ok: true; orderId: string; tenantId: string } | { ok: false; reason: 'invalid' | 'expired' | 'tampered' | 'config-missing' }>
NoteNever throws. Uses timingSafeEqual for HMAC comparison.

DiscordOrderFlowService

Source: src/data/order/order-flow.service.ts

Orchestrates the rich Discord /order flow. Composition layer over OrderSessionService, OrderService, PaymentService, InventoryService, DiscountService, BotConfigService, CustomerService, TaxService (optional), and the event bus.

submit

AspectDetails
Inputctx: RequestContext, { discordUserId }
OutputPromise<OrderFlowSummary>{ orderId, orderNumber, paymentId, paymentMethod, paymentInstructions, subtotalCents, shippingCostCents, discountAmountCents, taxAmountCents, totalCents, currency, referenceCode }
Side effectsResolves best discount → calculates sales tax (via TaxService; non-fatal, falls back to 0) → creates order (PENDING_PAYMENT) → creates payment (pending_payment) → paymentService.submitForApproval → reserves inventory → marks session SUBMITTED → persists admin alert → emits order.created. Idempotent on replay (returns the previously submitted order's summary).

handlePaymentRejected

AspectDetails
Inputctx: RequestContext, { orderId, reason? }
OutputPromise<void>
Side effectsReleases inventory reservations for each line item; transitions order to CANCELLED and appends a reject note. No-op for orders already cancelled. Consumed by payment.rejected.

Other methods

Thin delegators over OrderSessionService with the richer state machine: beginSession, selectProduct, addItem, setShipping, proposeCorrectedAddress, acceptCorrectedAddress, keepOriginalAddress, setShippingType, setPaymentMethod, cancelSession, getActiveSession, confirmPayment.


OrderSessionService

Source: src/data/order-session/order-session.service.ts

begin

AspectDetails
Inputctx: RequestContext, { discordUserId, guildId? }
OutputPromise<OrderSessionRecord>
Side effectsCreates new order session in COLLECTING_ITEMS.

State machine

COLLECTING_ITEMS
→ SELECTING_QUANTITY (selectProduct)
→ SELECTING_SHIPPING (addItem — advances once first item is added)
→ CONFIRMING_ADDRESS (proposeCorrectedAddress)
→ SELECTING_SHIPPING_TYPE (setShipping / acceptCorrectedAddress / keepOriginalAddress)
→ SELECTING_PAYMENT (setShippingType)
→ AWAITING_CONFIRMATION (setPaymentMethod)
→ SUBMITTED (markSubmitted)
→ CANCELLED | EXPIRED (cancel / TTL)

Legacy COLLECTING_SHIPPING and READY_TO_SUBMIT states are preserved as aliases for back-compat with the pre-rich flow.

addItem / setShipping / setShippingType / setPaymentMethod / cancel

AspectDetails
Inputctx: RequestContext, transition-specific input object
OutputPromise<OrderSessionRecord>
Side effectsPersists the state + data patch; invalid transitions throw INVALID_TRANSITION.

getActive / getActiveSession

AspectDetails
Inputctx: RequestContext, discordUserId
OutputPromise<OrderSessionRecord | null>
Side effectsNone. Read-only. getActiveSession is an alias used by the Discord router.

markSubmitted

AspectDetails
Inputctx: RequestContext, { discordUserId, orderId }
OutputPromise<OrderSessionRecord>
Side effectsSets submittedOrderId; links session to created order.

TaxService

Source: src/data/tax/tax.service.ts

Calculates sales tax for an order via the configured TaxPort (TaxJar adapter or noop). When no API key is configured, returns { taxAmountCents: 0, rate: 0 } so checkout is never blocked.

calculateTaxForOrder

AspectDetails
Input{ tenantId, toAddress: { country, state, postalCode, city?, line1? }, subtotalCents, shippingCents, lineItems? }
OutputPromise<OrderTaxResult>{ taxAmountCents, rate, breakdown? }
Side effectsCalls the TaxPort (network request to TaxJar when configured). Uses Delaware (DE) as origin placeholder (0% state tax) until tenant.warehouseAddress is added. Throws TaxServiceError on adapter failure.

RefundService

Source: src/data/order/refund.service.ts

refundOrder

AspectDetails
Inputctx: RequestContext, { orderId, approverUserId, reason }
OutputPromise<void>
Side effectsSets order status to REFUNDED, updates payment status to REFUNDED, reverts inventory via REFUND transactions, emits order.refunded event. Idempotent: no-op if order already REFUNDED.
ErrorsRefundError('ORDER_NOT_FOUND'), RefundError('PAYMENT_NOT_FOUND')

PaymentMethod Helpers

Source: src/data/payment-methods/payment-methods.service.ts

Pure helper functions over the PAYMENT_METHOD_REGISTRY constant. No dependencies to inject; call directly.

getPaymentMethodDescriptor

AspectDetails
Inputmethod: PaymentMethod
OutputPaymentMethodDescriptor{ value, displayName, kind, icon, requiresProcessor, supportsAutomaticConfirmation, supportsRefund, description }
Side effectsNone

getEnabledDescriptors

AspectDetails
Inputenabled: readonly PaymentMethod[]
Outputreadonly PaymentMethodDescriptor[] — in input order
Side effectsNone

filterDescriptorsByKind

AspectDetails
Inputkind: PaymentMethodKind, enabled: readonly PaymentMethod[]
Outputreadonly PaymentMethodDescriptor[] — enabled methods whose kind matches, in input order
Side effectsNone

PaymentService

Source: src/data/payment/payment.service.ts

createPending

AspectDetails
Inputctx: RequestContext, { orderId, method, amountCents, currency, referenceText? }
OutputPromise<PaymentRecord>
Side effectsCreates pending payment record.

confirmByOrder

AspectDetails
Inputctx: RequestContext, { orderId, confirmedBy, providerRef? }
OutputPromise<PaymentRecord>
Side effectsSets status to PAID, confirmedBy, confirmedAt.

getByOrder

AspectDetails
Inputctx: RequestContext, orderId
OutputPromise<PaymentRecord | null>
Side effectsNone. Read-only.

submitForApproval

AspectDetails
Inputctx: RequestContext, paymentId: string
OutputPromise<PaymentRecord>
Side effectsTransitions pending_payment → awaiting_admin_approval. Idempotent — no-op if already there. Seam point for ordering flow.

confirmPayment

AspectDetails
Inputctx: RequestContext, paymentId: string, approverUserId: string, referenceNote?: string
OutputPromise<PaymentRecord>
Side effectsTransitions awaiting_admin_approval → confirmed. Sets approverUserId, approvedAt, customerReferenceNote. Emits payment.confirmed. Idempotent.

rejectPayment

AspectDetails
Inputctx: RequestContext, paymentId: string, approverUserId: string, reason: string
OutputPromise<PaymentRecord>
Side effectsTransitions awaiting_admin_approval → rejected. Sets approverUserId, rejectedAt, rejectionReason. Emits payment.rejected. Idempotent.

PaymentNowPaymentsService

Source: src/data/payment/payment-nowpayments.service.ts

Factory: createPaymentNowPaymentsService(deps: PaymentNowPaymentsDeps): PaymentNowPaymentsService

Bridges the CryptoPaymentPort adapter with the PaymentRepository to create and track NOWPayments BTC invoices. Instantiated at the composition root only when NOWPAYMENTS_API_KEY is configured; absent otherwise (callers check for undefined).

Deps: { logger: Logger; nowpayments: CryptoPaymentPort; paymentRepo: PaymentRepository }

createInvoice

AspectDetails
Inputctx: RequestContext, { paymentId: string; amountCents: number; currency: string; customerEmail?: string; callbackUrl?: string }
OutputPromise<NowPaymentsInvoiceResult>{ invoiceId, hostedUrl, depositAddress, depositAmountCrypto, cryptoCurrency, expiresAt? }
Side effectsCalls CryptoPaymentPort.createInvoice(...) with cryptoCoin='btc' and orderRef=paymentId. On success, persists providerRef plus five nowpayments* fields to the Payment record. Persist failure logs a warning but still returns the invoice result so the customer can pay.

getInvoiceStatus

AspectDetails
InputinvoiceId: string
OutputPromise<NowPaymentsStatus> — one of 'pending' | 'finished' | 'failed' | 'expired'
Side effectsCalls CryptoPaymentPort.getInvoice(invoiceId). Maps port status (pending, partially_paid, finished, failed, expired) to the local union. Unknown statuses fall back to 'pending'.

ProviderService

Source: src/data/provider/provider.service.ts

createOrActivateProvider

AspectDetails
Inputctx: RequestContext, { type, tenantId?, isActive?, config? }
OutputPromise<ProviderRecord>
Side effectsCreates or reactivates auth provider (discord, local).

getActiveProviderByType

AspectDetails
Inputctx: RequestContext, type, tenantId?
OutputPromise<ProviderRecord | null>
Side effectsNone. Read-only.

deactivateProvider

AspectDetails
Inputctx: RequestContext, id
OutputPromise<ProviderRecord>
Side effectsSets isActive=false.

RoleService

Source: src/data/role/role.service.ts

createRole

AspectDetails
Inputctx: RequestContext, { tenantId?, name, permissionKeys }
OutputPromise<RoleRecord>
Side effectsCreates role.

seedDefaultRoles

AspectDetails
Inputctx: RequestContext, defaults array
OutputPromise<RoleRecord[]>
Side effectsCreates default roles if missing.

assignRoleToUser / unassignRoleFromUser

AspectDetails
Inputctx: RequestContext, userId, roleId
OutputPromise<void>
Side effectsCreates or deletes RoleAssignment.

getPermissionKeysForUser / hasPermission

AspectDetails
Inputctx: RequestContext, userId, optional permissionKey
OutputPromise<string[]> or Promise<boolean>
Side effectsNone. Read-only.

UserSettingsService

Source: src/data/user-settings/user-settings.service.ts

User-scoped. Does not take RequestContext; operates on userId only.

getOrCreate

AspectDetails
InputuserId: string
OutputPromise<UserSettingsRecord>
Side effectsCreates default settings if none exist.

update / setActiveTheme

AspectDetails
InputuserId: string, UpdateSettingsInput or themeId: string
OutputPromise<UserSettingsRecord>
Side effectsUpserts settings.

getActiveThemeId

AspectDetails
InputuserId: string
OutputPromise<string | undefined>
Side effectsNone. Read-only.

UserThemeService

Source: src/data/user-theme/user-theme.service.ts

listAvailableForUser

AspectDetails
InputuserId: string
OutputPromise<UserThemeRecord[]>
Side effectsNone. Returns system themes + user's custom themes.

getTheme

AspectDetails
Inputid: string
OutputPromise<UserThemeRecord | null>
Side effectsNone. Read-only.

createTheme / copyTheme / updateTheme

AspectDetails
InputCreate/copy/update input with ownerUserId
OutputPromise<UserThemeRecord>
Side effectsCreates or updates theme. Owner-only for update.

deleteTheme

AspectDetails
Inputid: string, userId: string (must be owner)
OutputPromise<void>
Side effectsDeletes custom theme. Fails for system themes.


EntitlementService (Wave 2)

Source: src/data/entitlement/entitlement.service.ts

generateEntitlementsForOrder

AspectDetails
Inputctx: RequestContext, orderId: string
OutputPromise<void>
Side effectsCreates one PENDING Entitlement record per (item × product.grantedRoleId). Idempotent.

processEntitlements

AspectDetails
Inputctx: RequestContext, orderId: string
OutputPromise<void>
Side effectsCalls Discord PUT /guilds/{guildId}/members/{userId}/roles/{roleId} for each PENDING entitlement. Updates status to GRANTED or FAILED. Idempotent.

revokeEntitlementsForOrder

AspectDetails
Inputctx: RequestContext, orderId: string
OutputPromise<void>
Side effectsCalls Discord DELETE .../roles/{roleId} for each GRANTED entitlement. Marks all REVOKED regardless of Discord response (refund is already confirmed).

retryEntitlement

AspectDetails
Inputctx: RequestContext, entitlementId: string
OutputPromise<EntitlementRecord>
Side effectsRe-attempts the Discord role grant for a single FAILED entitlement.

RefundService (Wave 2)

Source: src/data/order/refund.service.ts

refundOrder

AspectDetails
Inputctx: RequestContext, { orderId, approverUserId, reason }
OutputPromise<{ orderId: string; previousStatus: string }>
Side effects1. Transitions order.status → REFUNDED. 2. Transitions payment.status → REFUNDED. 3. Creates REFUND inventory transactions for each item. 4. Emits order.refunded event. Idempotent.

Downstream of order.refunded:

  • entitlementService.revokeEntitlementsForOrder() — removes Discord roles.
  • Customer DM notification stub (Notifications agent — TBD).

DiscountService — new methods (Wave 2)

Source: src/data/discount/discount.service.ts

assignToCustomer

AspectDetails
Inputctx: RequestContext, DiscountAssignmentCreateInput
OutputPromise<DiscountAssignment>
Side effectsCreates or updates (idempotent) a DiscountAssignment for the customer+discount pair.

revokeAssignment

AspectDetails
Inputctx: RequestContext, assignmentId: string
OutputPromise<DiscountAssignment | null>
Side effectsSoft-revokes by setting expiresAt to one second in the past. Returns null if not found.

ShipmentService

Source: src/data/shipment/shipment.service.ts

Two paths share the same service: manual mark-shipped (markShipped) is always available, and partner-driven label purchase + tracking is available when the shipping-partner port is bound (EasyPost adapter when EASYPOST_API_KEY is set).

createForOrder

AspectDetails
Inputctx, { orderId, shippingTypeCode, shippingCostCents, ... }
OutputPromise<ShipmentRecord>
Side effectsCreates a Shipment record. Throws if order not found.

markShipped

AspectDetails
Inputctx, orderId: string, { carrier: string, trackingNumber: string }
OutputPromise<ShipmentRecord>
Side effectsUpdates shipment to in_transit, updates order to SHIPPED, emits shipment.shipped + order.shipped. Idempotent on matching inputs.

purchaseLabel

AspectDetails
Inputctx, shipmentId: string, { serviceLevel?: string, carrier?: string }
OutputPromise<ShipmentRecord>
Side effectsCalls ShippingPartnerPort.createLabel. Persists tracking number, label URL, carrier, and shippingCostCents from the partner. Transitions shipment to label_created. Emits shipment.label-created. Idempotent (cached label).
ErrorsThrows SHIPPING_PARTNER_NOT_CONFIGURED when no adapter is bound; SHIPMENT_NOT_FOUND when the id is missing.

applyTrackingUpdate

AspectDetails
Inputctx, trackingNumber: string, update: TrackingResult, raw?: unknown
OutputPromise<ShipmentRecord | null> (null when no shipment matches the tracking code)
Side effectsMaps partner status to internal status. Transitions shipment, sets shippedAt / deliveredAt as appropriate, emits shipment.shipped / shipment.delivered / shipment.exception. No-op when status hasn't changed (idempotent). Unknown partner statuses are recorded but skipped.

listShippingTypes (BotConfigService)

See BotConfigService.listShippingTypes. Ordering flow will call this to present shipping options. Returns [] when no types configured.


CommunityFundService

Source: src/data/community-fund/community-fund.service.ts

Manages the per-tenant informal community fund: voluntary contributions by members and admin-approved disbursements to members in need. Balance is always computed on read (not persisted). Order-flow / discord-router / payment integrations are deferred.

recordContribution

AspectDetails
Input{ tenantId, customerId, orderId?, amountCents, anonymous }
OutputPromise<CommunityFundContribution>
Side effectsInserts one CommunityFundContribution document. Throws CommunityFundDisabledError when fund is disabled.

recordDisbursement

AspectDetails
Input{ tenantId, recipientCustomerId, amountCents, reason, approvedBy }
OutputPromise<CommunityFundDisbursement>
Side effectsChecks current balance; throws CommunityFundInsufficientBalanceError if insufficient. Inserts disbursement document.

getBalance

AspectDetails
InputtenantId: string
OutputPromise<{ totalContributedCents, totalDisbursedCents, currentBalanceCents }>

getOrCreateConfig / updateConfig

AspectDetails
InputtenantId, and for update: patch: CommunityFundConfigPatch
OutputPromise<CommunityFundConfig> — creates with defaults on first call

getStats

AspectDetails
InputtenantId: string, period: { from: Date, to: Date }
OutputPromise<FundStats> — contribution and disbursement counts + totals for the period

Stub services (no implementation yet)

These services export empty stubs; method tables will be added when implemented:

  • AddressServicesrc/data/address/address.service.ts
  • AuditLogServicesrc/data/audit-log/audit-log.service.ts
  • FeatureFlagServicesrc/data/feature-flag/feature-flag.service.ts
  • ProductServicesrc/data/product/product.service.ts
  • TenantServicesrc/data/tenant/tenant.service.ts
  • UserServicesrc/data/user/user.service.ts
  • WebhookEventServicesrc/data/webhook-event/webhook-event.service.ts

AnalyticsService

File: src/data/analytics/analytics.service.ts Factory: createAnalyticsService(deps)

DepTypeNotes
OrderModelMongoose modelRequired
cacheICachePortValkey; 60s TTL per report
logger{ warn }Optional

getRevenueOverview

Inputctx: { tenantId }, period: { from: Date, to: Date }
OutputPromise<RevenueOverviewReport>
Side effectsReads from OrderModel; caches result 60 s

getSalesVelocity

Inputctx: { tenantId }, period: { from: Date, to: Date }
OutputPromise<SalesVelocityReport> — daily counts + DOW×hour heatmap
Side effectsReads from OrderModel; caches result 60 s

getProductPerformance

Inputctx: { tenantId }, period: { from: Date, to: Date }
OutputPromise<ProductPerformanceReport> — products ranked by revenue
Side effectsReads from OrderModel ($unwind items); caches result 60 s

Stub methods (reports 4–10)

getCustomerInsights, getCouponImpact, getTimeOfDayHeatmap, getInventoryTurnover, getRefundCancelRate, getChannelPerformance, getGeographicDistribution — each throws AnalyticsNotYetImplementedError with a specSection referencing docs/specs/2026-04-26-analytics.md.


AnalyticsShareLinkService

File: src/data/analytics-share-link/analytics-share-link.service.ts Factory: createAnalyticsShareLinkService(deps)

DepTypeNotes
logger{ warn }Required
clock{ now: () => Date }Swappable for tests
config.analyticsShareSigningSecretstring | undefinedDeny-all when absent
config.appBaseUrlstringBase for generated URLs
config.defaultTtlSecondsnumberDefault share link TTL
Input{ reportType, tenantId, period, expiresInSeconds? }
OutputPromise<{ url, expiresAt, reportType, period }>
ThrowsAnalyticsShareLinkConfigError when secret absent

verifyShareLinkToken

InputrawToken: string
OutputPromise<{ ok: true, payload } | { ok: false, reason }>
Side effectsNone; constant-time comparison via timingSafeEqual

PaymentNowPaymentsService

Source: src/data/payment/payment-nowpayments.service.ts

Orchestrates NOWPayments-hosted BTC invoice creation and status polling for BTC_NOWPAYMENTS checkouts. Only instantiated at the composition root when NOWPAYMENTS_API_KEY is set.

Local test harness: devtools/nowpayments-mock/ (see docs/test-harness/nowpayments-mock.md).

createInvoice

AspectDetails
Inputctx: RequestContext, { paymentId, amountCents, currency, customerEmail?, callbackUrl? }
OutputPromise<NowPaymentsInvoiceResult>{ hostedUrl, depositAddress, depositAmountCrypto, cryptoCurrency, expiresAt }
Side effectsCalls NOWPayments API; persists providerRef, nowpaymentsHostedUrl, nowpaymentsDepositAddress on the Payment record via paymentRepo.update. Persist failures are logged but do not suppress the invoice response.
ThrowsNowPaymentsServiceError('INVOICE_CREATION_FAILED', detail) on adapter error

getInvoiceStatus

AspectDetails
InputinvoiceId: string (stored as providerRef on the Payment record)
OutputPromise<NowPaymentsStatus>pending | finished | failed | expired
ThrowsNowPaymentsServiceError('STATUS_FETCH_FAILED', detail) on adapter error