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
| Aspect | Details |
|---|
| Input | ctx: RequestContext, { type, message, entityType?, entityId? } |
| Output | Promise<void> |
| Side effects | Creates 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
| Aspect | Details |
|---|
| Input | ctx: RequestContext, tenantId, payload: NotificationPayload, opts: NotifyOptions |
| Output | Promise<NotifyResult> — { skipped, messageId? } |
| Side effects | Posts to tenant's announcementChannelId (or adminChannelId fallback) via Discord REST. Dedupes via Valkey. |
notifyCustomerDm
| Aspect | Details |
|---|
| Input | ctx: RequestContext, discordUserId, payload: NotificationPayload, opts: NotifyOptions |
| Output | Promise<NotifyResult> — { skipped, messageId? } |
| Side effects | Creates 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 var | Adapter | Default |
|---|
STRIPE_API_BASE | createStripePlatformBillingAdapter (forwarded as host/port/protocol to the SDK) | api.stripe.com |
EASYPOST_API_BASE | createEasyPostShippingAdapter | https://api.easypost.com/v2 |
TAXJAR_API_BASE | createTaxJarAdapter | https://api.taxjar.com/v2 |
NOWPAYMENTS_API_URL | createNowPaymentsAdapter | https://api.nowpayments.io/v1 |
DISCORD_API_BASE_URL | createDiscordNotifyService | https://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
| Aspect | Details |
|---|
| Input | ctx: RequestContext, config input |
| Output | Promise<BotConfigRecord> |
| Side effects | Creates or updates bot config for tenant. |
getForTenant
| Aspect | Details |
|---|
| Input | ctx: RequestContext |
| Output | Promise<BotConfigRecord | null> |
| Side effects | None. Read-only. |
resolveTenantByGuildId / resolveTenantByDiscordUserId / resolveTenantForInteraction
| Aspect | Details |
|---|
| Input | Guild ID, Discord user ID, or interaction context |
| Output | Promise<GuildTenantMapping | null> or Promise<InteractionTenantResolution | null> |
| Side effects | None. Resolves tenant from Discord context. |
registerDiscordUserForTenant
| Aspect | Details |
|---|
| Input | ctx: RequestContext, discordUserId: string |
| Output | Promise<BotConfigRecord> |
| Side effects | Adds user to registeredDiscordUserIds. |
resolvePaymentInstruction
| Aspect | Details |
|---|
| Input | ctx: RequestContext, method: string |
| Output | Promise<string> |
| Side effects | None. Returns payment instructions text for the method. |
listShippingTypes
| Aspect | Details |
|---|
| Input | ctx: RequestContext |
| Output | Promise<ShippingType[]> |
| Side effects | None. Returns [] when no bot config or no shipping types configured for the tenant. |
DiscordNotifyService
Source: src/data/discord-commands/discord-notify.service.ts
send
| Aspect | Details |
|---|
| Input | ctx, { message, target, channelId?, tenantId }, { botToken, getDiscordIdsForTenant } |
| Output | Promise<{ sent, failed, errors }> |
| Side effects | Sends 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
| Aspect | Details |
|---|
| Input | ctx: RequestContext, customer input (discordId, discordUsername, etc.) |
| Output | Promise<FindOrCreateResult> (customer + created flag) |
| Side effects | May create new Customer record. |
getCustomer / listCustomers
| Aspect | Details |
|---|
| Input | ctx: RequestContext, optional id |
| Output | Promise<CustomerRecord | null> or Promise<CustomerRecord[]> |
| Side effects | None. Read-only. |
updateCustomer / deactivateCustomer
| Aspect | Details |
|---|
| Input | ctx: RequestContext, id, update input |
| Output | Promise<CustomerRecord> |
| Side effects | Updates or deactivates customer. |
linkCustomerToUser
| Aspect | Details |
|---|
| Input | ctx: RequestContext, customerId, userId |
| Output | Promise<CustomerRecord> |
| Side effects | Sets customer.userId. |
mergeCustomers
| Aspect | Details |
|---|
| Input | ctx: RequestContext, sourceId, targetId, reassignOrders |
| Output | Promise<MergeResult> |
| Side effects | Merges source into target; may reassign orders. |
addAddress / removeAddress / setDefaultAddress
| Aspect | Details |
|---|
| Input | ctx: RequestContext, customerId, address or addressId |
| Output | Promise<CustomerRecord> |
| Side effects | Modifies customer addresses. |
setDmPreference
| Aspect | Details |
|---|
| Input | ctx: RequestContext, customerId: string, key: keyof CustomerDmPreferences, value: boolean |
| Output | Promise<CustomerRecord> |
| Side effects | Patches the named field in customer.dmPreferences. Throws CustomerError('NOT_FOUND') when customer doesn't exist. |
setEmailPreference
| Aspect | Details |
|---|
| Input | ctx: RequestContext, customerId: string, key: keyof CustomerEmailPreferences, value: boolean |
| Output | Promise<CustomerRecord> |
| Side effects | Patches 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
| Aspect | Details |
|---|
| Input | ctx: RequestContext, optional id |
| Output | Promise<DiscountRecord | null> or Promise<DiscountRecord[]> |
| Side effects | None. Read-only. |
create / update / remove
| Aspect | Details |
|---|
| Input | ctx: RequestContext, input or id, patch |
| Output | Promise<DiscountRecord> or Promise<void> |
| Side effects | CRUD on discounts. |
evaluateEligibility
| Aspect | Details |
|---|
| Input | ctx: RequestContext, { code, items, customerId?, roleIds?, now? } |
| Output | Promise<DiscountEvaluationResult> |
| Side effects | None. Evaluates discount applicability. |
consumeUsage
| Aspect | Details |
|---|
| Input | ctx: RequestContext, { discountId, orderId } |
| Output | Promise<DiscountUsageConsumeResult> |
| Side effects | Increments usageCount, adds orderId to consumedOrderIds. |
resolveBestForCustomer
| Aspect | Details |
|---|
| Input | ctx: RequestContext, { customerId: string, subtotalCents: number, items: DiscountEvaluationItem[] } |
| Output | Promise<ResolveBestForCustomerResult> — { best?: { discountId, discountCode?, discountAmountCents, subtotalCents, totalCents } } |
| Side effects | None. Fetches customer assignments and global active discounts in parallel; selects the highest discountAmountCents among eligible ones. |
assignToCustomer
| Aspect | Details |
|---|
| Input | ctx: RequestContext, input: DiscountAssignmentCreateInput |
| Output | Promise<DiscountAssignment> |
| Side effects | Creates a new assignment or updates the existing one (upsert by discountId+customerId). |
revokeAssignment
| Aspect | Details |
|---|
| Input | ctx: RequestContext, assignmentId: string |
| Output | Promise<DiscountAssignment | null> |
| Side effects | Sets expiresAt to 1 second in the past (soft-revoke). Returns null if not found. |
EntitlementService
Source: src/data/entitlement/entitlement.service.ts
generateEntitlementsForOrder
| Aspect | Details |
|---|
| Input | ctx: RequestContext, orderId: string |
| Output | Promise<EntitlementRecord[]> |
| Side effects | Creates PENDING entitlement records for each product with grantedRoleIds. Idempotent. |
processEntitlements
| Aspect | Details |
|---|
| Input | ctx: RequestContext, orderId: string |
| Output | Promise<void> |
| Side effects | Calls Discord REST PUT /guilds/{guildId}/members/{userId}/roles/{roleId} for each PENDING entitlement; marks GRANTED or FAILED. |
revokeEntitlementsForOrder
| Aspect | Details |
|---|
| Input | ctx: RequestContext, orderId: string |
| Output | Promise<void> |
| Side effects | Calls Discord REST DELETE for each GRANTED entitlement (best-effort); marks REVOKED. |
retryEntitlement
| Aspect | Details |
|---|
| Input | ctx: RequestContext, entitlementId: string |
| Output | Promise<EntitlementRecord> |
| Side effects | Retries Discord role grant for a FAILED entitlement. No-op if not FAILED. |
IdentityService
Source: src/data/identity/identity.service.ts
linkIdentity
| Aspect | Details |
|---|
| Input | ctx: RequestContext, { userId, tenantId?, provider, providerUserId, profile?, secrets? } |
| Output | Promise<IdentityRecord> |
| Side effects | Creates Identity linking user to provider. |
listUserIdentities
| Aspect | Details |
|---|
| Input | ctx: RequestContext, userId |
| Output | Promise<IdentityRecord[]> |
| Side effects | None. 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
| Aspect | Details |
|---|
| Input | ctx: RequestContext |
| Output | Promise<boolean> — true if a sync was scheduled, false if coalesced into existing debounce window |
| Side effects | Sets Valkey key catalog-sync:<tenantId> (TTL 5 s). Schedules syncCatalog via setTimeout to fire after the debounce window. No immediate Discord I/O. |
syncCatalog
| Aspect | Details |
|---|
| Input | ctx: RequestContext |
| Output | Promise<void> |
| Side effects | Fetches 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
| Aspect | Details |
|---|
| Input | ctx: RequestContext, { productId, orderId, quantity } |
| Output | Promise<void> |
| Side effects | Decrements available, increments reserved. Creates InventoryTransaction. |
release
| Aspect | Details |
|---|
| Input | ctx: RequestContext, { productId, orderId, quantity } |
| Output | Promise<void> |
| Side effects | Reverses reserve. Increments available, decrements reserved. |
finalizeSale
| Aspect | Details |
|---|
| Input | ctx: RequestContext, { productId, orderId, quantity } |
| Output | Promise<void> |
| Side effects | Converts 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.
| Function | Signature | Purpose |
|---|
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') => string | Renders numeric qty for public; 'In stock' / 'Out of stock' for private. |
resolveProductImage | (productUrl?: string, tenantDefault?: string) => string | Priority: product URL → tenant default → DEFAULT_PRODUCT_IMAGE_URL placeholder constant. |
OrderService
Source: src/data/order/order.service.ts
create
| Aspect | Details |
|---|
| Input | ctx: RequestContext, OrderCreateInput |
| Output | Promise<OrderRecord> |
| Side effects | Creates order. Idempotent via deterministic orderNumber. May reserve inventory, consume discount. |
getById / listByTenant
| Aspect | Details |
|---|
| Input | ctx: RequestContext, optional id |
| Output | Promise<OrderRecord | null> or Promise<OrderRecord[]> |
| Side effects | None. Read-only. |
update / remove
| Aspect | Details |
|---|
| Input | ctx: RequestContext, id, patch or remove |
| Output | Promise<OrderRecord> or Promise<void> |
| Side effects | Updates or deletes order. May release inventory on cancel. |
listOpenOrders
| Aspect | Details |
|---|
| Input | ctx: RequestContext, ListOpenOrdersInput { status?, search?, createdFrom?, createdTo?, page?, pageSize? } |
| Output | Promise<OpenOrderListResult> — { items: OpenOrderListItem[], page, pageSize, total } |
| Side effects | None. Read-only. Fails closed if ctx.tenantId is empty. Caps pageSize at 200. Open statuses: PENDING_PAYMENT, PAID, FULFILLING, SHIPPED. |
getOpenOrderSummary
| Aspect | Details |
|---|
| Input | ctx: RequestContext |
| Output | Promise<OpenOrderSummary> — { totalOpen: number, byStatus: Record<string, number> } |
| Side effects | None. 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' }.
generateLink
| Aspect | Details |
|---|
| Input | ctx: RequestContext, { orderId: string; expiresInSeconds?: number } |
| Output | Promise<{ url: string; token: string; expiresAt: Date }> |
| Side effects | Reads the order (verifies existence + tenant scope). Throws OrderInvoiceLinkConfigError when secret unset; throws OrderInvoiceLinkNotFoundError when order absent. |
verifyLinkToken
| Aspect | Details |
|---|
| Input | token: string |
| Output | Promise<{ ok: true; orderId: string; tenantId: string } | { ok: false; reason: 'invalid' | 'expired' | 'tampered' | 'config-missing' }> |
| Note | Never 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
| Aspect | Details |
|---|
| Input | ctx: RequestContext, { discordUserId } |
| Output | Promise<OrderFlowSummary> — { orderId, orderNumber, paymentId, paymentMethod, paymentInstructions, subtotalCents, shippingCostCents, discountAmountCents, taxAmountCents, totalCents, currency, referenceCode } |
| Side effects | Resolves 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
| Aspect | Details |
|---|
| Input | ctx: RequestContext, { orderId, reason? } |
| Output | Promise<void> |
| Side effects | Releases 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
| Aspect | Details |
|---|
| Input | ctx: RequestContext, { discordUserId, guildId? } |
| Output | Promise<OrderSessionRecord> |
| Side effects | Creates 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
| Aspect | Details |
|---|
| Input | ctx: RequestContext, transition-specific input object |
| Output | Promise<OrderSessionRecord> |
| Side effects | Persists the state + data patch; invalid transitions throw INVALID_TRANSITION. |
getActive / getActiveSession
| Aspect | Details |
|---|
| Input | ctx: RequestContext, discordUserId |
| Output | Promise<OrderSessionRecord | null> |
| Side effects | None. Read-only. getActiveSession is an alias used by the Discord router. |
markSubmitted
| Aspect | Details |
|---|
| Input | ctx: RequestContext, { discordUserId, orderId } |
| Output | Promise<OrderSessionRecord> |
| Side effects | Sets 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
| Aspect | Details |
|---|
| Input | { tenantId, toAddress: { country, state, postalCode, city?, line1? }, subtotalCents, shippingCents, lineItems? } |
| Output | Promise<OrderTaxResult> — { taxAmountCents, rate, breakdown? } |
| Side effects | Calls 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
| Aspect | Details |
|---|
| Input | ctx: RequestContext, { orderId, approverUserId, reason } |
| Output | Promise<void> |
| Side effects | Sets 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. |
| Errors | RefundError('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
| Aspect | Details |
|---|
| Input | method: PaymentMethod |
| Output | PaymentMethodDescriptor — { value, displayName, kind, icon, requiresProcessor, supportsAutomaticConfirmation, supportsRefund, description } |
| Side effects | None |
getEnabledDescriptors
| Aspect | Details |
|---|
| Input | enabled: readonly PaymentMethod[] |
| Output | readonly PaymentMethodDescriptor[] — in input order |
| Side effects | None |
filterDescriptorsByKind
| Aspect | Details |
|---|
| Input | kind: PaymentMethodKind, enabled: readonly PaymentMethod[] |
| Output | readonly PaymentMethodDescriptor[] — enabled methods whose kind matches, in input order |
| Side effects | None |
PaymentService
Source: src/data/payment/payment.service.ts
createPending
| Aspect | Details |
|---|
| Input | ctx: RequestContext, { orderId, method, amountCents, currency, referenceText? } |
| Output | Promise<PaymentRecord> |
| Side effects | Creates pending payment record. |
confirmByOrder
| Aspect | Details |
|---|
| Input | ctx: RequestContext, { orderId, confirmedBy, providerRef? } |
| Output | Promise<PaymentRecord> |
| Side effects | Sets status to PAID, confirmedBy, confirmedAt. |
getByOrder
| Aspect | Details |
|---|
| Input | ctx: RequestContext, orderId |
| Output | Promise<PaymentRecord | null> |
| Side effects | None. Read-only. |
submitForApproval
| Aspect | Details |
|---|
| Input | ctx: RequestContext, paymentId: string |
| Output | Promise<PaymentRecord> |
| Side effects | Transitions pending_payment → awaiting_admin_approval. Idempotent — no-op if already there. Seam point for ordering flow. |
confirmPayment
| Aspect | Details |
|---|
| Input | ctx: RequestContext, paymentId: string, approverUserId: string, referenceNote?: string |
| Output | Promise<PaymentRecord> |
| Side effects | Transitions awaiting_admin_approval → confirmed. Sets approverUserId, approvedAt, customerReferenceNote. Emits payment.confirmed. Idempotent. |
rejectPayment
| Aspect | Details |
|---|
| Input | ctx: RequestContext, paymentId: string, approverUserId: string, reason: string |
| Output | Promise<PaymentRecord> |
| Side effects | Transitions 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
| Aspect | Details |
|---|
| Input | ctx: RequestContext, { paymentId: string; amountCents: number; currency: string; customerEmail?: string; callbackUrl?: string } |
| Output | Promise<NowPaymentsInvoiceResult> — { invoiceId, hostedUrl, depositAddress, depositAmountCrypto, cryptoCurrency, expiresAt? } |
| Side effects | Calls 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
| Aspect | Details |
|---|
| Input | invoiceId: string |
| Output | Promise<NowPaymentsStatus> — one of 'pending' | 'finished' | 'failed' | 'expired' |
| Side effects | Calls 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
| Aspect | Details |
|---|
| Input | ctx: RequestContext, { type, tenantId?, isActive?, config? } |
| Output | Promise<ProviderRecord> |
| Side effects | Creates or reactivates auth provider (discord, local). |
getActiveProviderByType
| Aspect | Details |
|---|
| Input | ctx: RequestContext, type, tenantId? |
| Output | Promise<ProviderRecord | null> |
| Side effects | None. Read-only. |
deactivateProvider
| Aspect | Details |
|---|
| Input | ctx: RequestContext, id |
| Output | Promise<ProviderRecord> |
| Side effects | Sets isActive=false. |
RoleService
Source: src/data/role/role.service.ts
createRole
| Aspect | Details |
|---|
| Input | ctx: RequestContext, { tenantId?, name, permissionKeys } |
| Output | Promise<RoleRecord> |
| Side effects | Creates role. |
seedDefaultRoles
| Aspect | Details |
|---|
| Input | ctx: RequestContext, defaults array |
| Output | Promise<RoleRecord[]> |
| Side effects | Creates default roles if missing. |
assignRoleToUser / unassignRoleFromUser
| Aspect | Details |
|---|
| Input | ctx: RequestContext, userId, roleId |
| Output | Promise<void> |
| Side effects | Creates or deletes RoleAssignment. |
getPermissionKeysForUser / hasPermission
| Aspect | Details |
|---|
| Input | ctx: RequestContext, userId, optional permissionKey |
| Output | Promise<string[]> or Promise<boolean> |
| Side effects | None. Read-only. |
UserSettingsService
Source: src/data/user-settings/user-settings.service.ts
User-scoped. Does not take RequestContext; operates on userId only.
getOrCreate
| Aspect | Details |
|---|
| Input | userId: string |
| Output | Promise<UserSettingsRecord> |
| Side effects | Creates default settings if none exist. |
update / setActiveTheme
| Aspect | Details |
|---|
| Input | userId: string, UpdateSettingsInput or themeId: string |
| Output | Promise<UserSettingsRecord> |
| Side effects | Upserts settings. |
getActiveThemeId
| Aspect | Details |
|---|
| Input | userId: string |
| Output | Promise<string | undefined> |
| Side effects | None. Read-only. |
UserThemeService
Source: src/data/user-theme/user-theme.service.ts
listAvailableForUser
| Aspect | Details |
|---|
| Input | userId: string |
| Output | Promise<UserThemeRecord[]> |
| Side effects | None. Returns system themes + user's custom themes. |
getTheme
| Aspect | Details |
|---|
| Input | id: string |
| Output | Promise<UserThemeRecord | null> |
| Side effects | None. Read-only. |
createTheme / copyTheme / updateTheme
| Aspect | Details |
|---|
| Input | Create/copy/update input with ownerUserId |
| Output | Promise<UserThemeRecord> |
| Side effects | Creates or updates theme. Owner-only for update. |
deleteTheme
| Aspect | Details |
|---|
| Input | id: string, userId: string (must be owner) |
| Output | Promise<void> |
| Side effects | Deletes custom theme. Fails for system themes. |
EntitlementService (Wave 2)
Source: src/data/entitlement/entitlement.service.ts
generateEntitlementsForOrder
| Aspect | Details |
|---|
| Input | ctx: RequestContext, orderId: string |
| Output | Promise<void> |
| Side effects | Creates one PENDING Entitlement record per (item × product.grantedRoleId). Idempotent. |
processEntitlements
| Aspect | Details |
|---|
| Input | ctx: RequestContext, orderId: string |
| Output | Promise<void> |
| Side effects | Calls Discord PUT /guilds/{guildId}/members/{userId}/roles/{roleId} for each PENDING entitlement. Updates status to GRANTED or FAILED. Idempotent. |
revokeEntitlementsForOrder
| Aspect | Details |
|---|
| Input | ctx: RequestContext, orderId: string |
| Output | Promise<void> |
| Side effects | Calls Discord DELETE .../roles/{roleId} for each GRANTED entitlement. Marks all REVOKED regardless of Discord response (refund is already confirmed). |
retryEntitlement
| Aspect | Details |
|---|
| Input | ctx: RequestContext, entitlementId: string |
| Output | Promise<EntitlementRecord> |
| Side effects | Re-attempts the Discord role grant for a single FAILED entitlement. |
RefundService (Wave 2)
Source: src/data/order/refund.service.ts
refundOrder
| Aspect | Details |
|---|
| Input | ctx: RequestContext, { orderId, approverUserId, reason } |
| Output | Promise<{ orderId: string; previousStatus: string }> |
| Side effects | 1. 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
| Aspect | Details |
|---|
| Input | ctx: RequestContext, DiscountAssignmentCreateInput |
| Output | Promise<DiscountAssignment> |
| Side effects | Creates or updates (idempotent) a DiscountAssignment for the customer+discount pair. |
revokeAssignment
| Aspect | Details |
|---|
| Input | ctx: RequestContext, assignmentId: string |
| Output | Promise<DiscountAssignment | null> |
| Side effects | Soft-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
| Aspect | Details |
|---|
| Input | ctx, { orderId, shippingTypeCode, shippingCostCents, ... } |
| Output | Promise<ShipmentRecord> |
| Side effects | Creates a Shipment record. Throws if order not found. |
markShipped
| Aspect | Details |
|---|
| Input | ctx, orderId: string, { carrier: string, trackingNumber: string } |
| Output | Promise<ShipmentRecord> |
| Side effects | Updates shipment to in_transit, updates order to SHIPPED, emits shipment.shipped + order.shipped. Idempotent on matching inputs. |
purchaseLabel
| Aspect | Details |
|---|
| Input | ctx, shipmentId: string, { serviceLevel?: string, carrier?: string } |
| Output | Promise<ShipmentRecord> |
| Side effects | Calls 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). |
| Errors | Throws SHIPPING_PARTNER_NOT_CONFIGURED when no adapter is bound; SHIPMENT_NOT_FOUND when the id is missing. |
applyTrackingUpdate
| Aspect | Details |
|---|
| Input | ctx, trackingNumber: string, update: TrackingResult, raw?: unknown |
| Output | Promise<ShipmentRecord | null> (null when no shipment matches the tracking code) |
| Side effects | Maps 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.
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
| Aspect | Details |
|---|
| Input | { tenantId, customerId, orderId?, amountCents, anonymous } |
| Output | Promise<CommunityFundContribution> |
| Side effects | Inserts one CommunityFundContribution document. Throws CommunityFundDisabledError when fund is disabled. |
recordDisbursement
| Aspect | Details |
|---|
| Input | { tenantId, recipientCustomerId, amountCents, reason, approvedBy } |
| Output | Promise<CommunityFundDisbursement> |
| Side effects | Checks current balance; throws CommunityFundInsufficientBalanceError if insufficient. Inserts disbursement document. |
getBalance
| Aspect | Details |
|---|
| Input | tenantId: string |
| Output | Promise<{ totalContributedCents, totalDisbursedCents, currentBalanceCents }> |
getOrCreateConfig / updateConfig
| Aspect | Details |
|---|
| Input | tenantId, and for update: patch: CommunityFundConfigPatch |
| Output | Promise<CommunityFundConfig> — creates with defaults on first call |
getStats
| Aspect | Details |
|---|
| Input | tenantId: string, period: { from: Date, to: Date } |
| Output | Promise<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:
- AddressService —
src/data/address/address.service.ts
- AuditLogService —
src/data/audit-log/audit-log.service.ts
- FeatureFlagService —
src/data/feature-flag/feature-flag.service.ts
- ProductService —
src/data/product/product.service.ts
- TenantService —
src/data/tenant/tenant.service.ts
- UserService —
src/data/user/user.service.ts
- WebhookEventService —
src/data/webhook-event/webhook-event.service.ts
AnalyticsService
File: src/data/analytics/analytics.service.ts
Factory: createAnalyticsService(deps)
| Dep | Type | Notes |
|---|
OrderModel | Mongoose model | Required |
cache | ICachePort | Valkey; 60s TTL per report |
logger | { warn } | Optional |
getRevenueOverview
| |
|---|
| Input | ctx: { tenantId }, period: { from: Date, to: Date } |
| Output | Promise<RevenueOverviewReport> |
| Side effects | Reads from OrderModel; caches result 60 s |
getSalesVelocity
| |
|---|
| Input | ctx: { tenantId }, period: { from: Date, to: Date } |
| Output | Promise<SalesVelocityReport> — daily counts + DOW×hour heatmap |
| Side effects | Reads from OrderModel; caches result 60 s |
| |
|---|
| Input | ctx: { tenantId }, period: { from: Date, to: Date } |
| Output | Promise<ProductPerformanceReport> — products ranked by revenue |
| Side effects | Reads 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)
| Dep | Type | Notes |
|---|
logger | { warn } | Required |
clock | { now: () => Date } | Swappable for tests |
config.analyticsShareSigningSecret | string | undefined | Deny-all when absent |
config.appBaseUrl | string | Base for generated URLs |
config.defaultTtlSeconds | number | Default share link TTL |
generateShareLink
| |
|---|
| Input | { reportType, tenantId, period, expiresInSeconds? } |
| Output | Promise<{ url, expiresAt, reportType, period }> |
| Throws | AnalyticsShareLinkConfigError when secret absent |
verifyShareLinkToken
| |
|---|
| Input | rawToken: string |
| Output | Promise<{ ok: true, payload } | { ok: false, reason }> |
| Side effects | None; 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
| Aspect | Details |
|---|
| Input | ctx: RequestContext, { paymentId, amountCents, currency, customerEmail?, callbackUrl? } |
| Output | Promise<NowPaymentsInvoiceResult> — { hostedUrl, depositAddress, depositAmountCrypto, cryptoCurrency, expiresAt } |
| Side effects | Calls NOWPayments API; persists providerRef, nowpaymentsHostedUrl, nowpaymentsDepositAddress on the Payment record via paymentRepo.update. Persist failures are logged but do not suppress the invoice response. |
| Throws | NowPaymentsServiceError('INVOICE_CREATION_FAILED', detail) on adapter error |
getInvoiceStatus
| Aspect | Details |
|---|
| Input | invoiceId: string (stored as providerRef on the Payment record) |
| Output | Promise<NowPaymentsStatus> — pending | finished | failed | expired |
| Throws | NowPaymentsServiceError('STATUS_FETCH_FAILED', detail) on adapter error |