From 7bd8efaf033d654369c73d302a4f47a66caef6d5 Mon Sep 17 00:00:00 2001 From: tkahng Date: Tue, 26 May 2026 19:04:30 -0700 Subject: [PATCH 1/2] docs: add Javadoc to all service classes and package-info.java files Covers all 57 service classes across 30 domain modules. Each service gets class-level doc (domain responsibility, key collaborators) and public method-level doc (business purpose, invariants, side effects). Adds ~80 package-info.java files so new developers get module-level orientation before diving into individual classes. --- .../io/k2dv/garden/account/package-info.java | 7 + .../account/service/AccountService.java | 26 ++++ .../garden/account/service/package-info.java | 6 + .../k2dv/garden/admin/iam/package-info.java | 6 + .../admin/iam/service/AdminIamService.java | 33 +++++ .../io/k2dv/garden/admin/package-info.java | 6 + .../k2dv/garden/admin/user/package-info.java | 6 + .../admin/user/service/AdminUserService.java | 49 +++++++ .../k2dv/garden/audit/aspect/AuditAspect.java | 7 + .../garden/audit/aspect/package-info.java | 6 + .../io/k2dv/garden/audit/package-info.java | 6 + .../garden/audit/service/AuditLogService.java | 15 +++ .../garden/audit/service/package-info.java | 5 + .../k2dv/garden/auth/model/package-info.java | 8 ++ .../io/k2dv/garden/auth/package-info.java | 8 ++ .../garden/auth/repository/package-info.java | 7 + .../garden/auth/security/package-info.java | 8 ++ .../k2dv/garden/auth/service/AuthService.java | 56 ++++++++ .../garden/auth/service/EmailService.java | 42 ++++++ .../auth/service/ImpersonationService.java | 12 ++ .../k2dv/garden/auth/service/JwtService.java | 16 +++ .../garden/auth/service/SmtpEmailService.java | 7 + .../garden/auth/service/TokenService.java | 18 +++ .../garden/auth/service/package-info.java | 10 ++ .../garden/automation/AutoTagService.java | 12 ++ .../k2dv/garden/automation/package-info.java | 7 + .../garden/b2b/controller/package-info.java | 6 + .../k2dv/garden/b2b/model/package-info.java | 6 + .../java/io/k2dv/garden/b2b/package-info.java | 7 + .../service/CompanyApprovalRuleService.java | 32 ++++- .../b2b/service/CompanyInvitationService.java | 27 ++++ .../garden/b2b/service/CompanyService.java | 88 +++++++++++++ .../CompanyShippingAddressService.java | 26 ++++ .../b2b/service/CreditAccountService.java | 30 ++++- .../garden/b2b/service/DepartmentService.java | 30 +++++ .../garden/b2b/service/InvoicePdfService.java | 11 ++ .../garden/b2b/service/InvoiceService.java | 36 +++++ .../garden/b2b/service/PriceListService.java | 38 ++++++ .../k2dv/garden/b2b/service/package-info.java | 7 + .../io/k2dv/garden/blob/package-info.java | 8 ++ .../k2dv/garden/blob/service/BlobService.java | 50 +++++++ .../garden/blob/service/S3StorageService.java | 6 + .../garden/blob/service/StorageService.java | 5 + .../garden/blob/service/package-info.java | 7 + .../k2dv/garden/cart/model/package-info.java | 6 + .../io/k2dv/garden/cart/package-info.java | 6 + .../k2dv/garden/cart/service/CartService.java | 76 +++++++++++ .../garden/cart/service/package-info.java | 6 + .../k2dv/garden/collection/package-info.java | 7 + .../service/CollectionMembershipService.java | 5 + .../collection/service/CollectionService.java | 70 ++++++++++ .../collection/service/package-info.java | 7 + .../io/k2dv/garden/config/package-info.java | 6 + .../io/k2dv/garden/content/package-info.java | 6 + .../content/service/ArticleImageService.java | 18 +++ .../content/service/ArticleService.java | 26 ++++ .../garden/content/service/PageService.java | 27 ++++ .../garden/content/service/package-info.java | 6 + .../garden/discount/model/package-info.java | 6 + .../io/k2dv/garden/discount/package-info.java | 6 + .../discount/service/DiscountService.java | 52 ++++++++ .../garden/discount/service/package-info.java | 6 + .../k2dv/garden/fulfillment/package-info.java | 6 + .../service/FulfillmentService.java | 22 ++++ .../fulfillment/service/package-info.java | 6 + .../garden/giftcard/model/package-info.java | 6 + .../io/k2dv/garden/giftcard/package-info.java | 6 + .../giftcard/service/GiftCardService.java | 49 +++++++ .../garden/giftcard/service/package-info.java | 6 + .../k2dv/garden/iam/model/package-info.java | 6 + .../java/io/k2dv/garden/iam/package-info.java | 7 + .../k2dv/garden/iam/service/IamService.java | 20 +++ .../k2dv/garden/iam/service/package-info.java | 6 + .../garden/inventory/model/package-info.java | 7 + .../k2dv/garden/inventory/package-info.java | 8 ++ .../inventory/service/InventoryService.java | 42 ++++++ .../inventory/service/LocationService.java | 21 +++ .../inventory/service/package-info.java | 7 + .../k2dv/garden/newsletter/package-info.java | 6 + .../newsletter/service/NewsletterService.java | 10 ++ .../newsletter/service/package-info.java | 5 + .../garden/notification/package-info.java | 6 + .../NotificationPreferenceService.java | 13 ++ .../notification/service/package-info.java | 5 + .../k2dv/garden/order/event/package-info.java | 6 + .../k2dv/garden/order/model/package-info.java | 6 + .../io/k2dv/garden/order/package-info.java | 6 + .../order/service/OrderEventService.java | 13 ++ .../garden/order/service/OrderService.java | 124 ++++++++++++++++++ .../order/service/ReturnRequestService.java | 38 ++++++ .../garden/order/service/package-info.java | 6 + .../service/OrderTemplateService.java | 23 ++++ .../garden/payment/gateway/package-info.java | 6 + .../io/k2dv/garden/payment/package-info.java | 6 + .../payment/service/PaymentService.java | 36 +++++ .../garden/payment/service/package-info.java | 6 + .../garden/product/model/package-info.java | 7 + .../io/k2dv/garden/product/package-info.java | 7 + .../product/repository/package-info.java | 6 + .../garden/product/service/OptionService.java | 29 ++++ .../product/service/ProductImageService.java | 18 +++ .../product/service/ProductService.java | 56 ++++++++ .../product/service/VariantService.java | 23 ++++ .../garden/product/service/package-info.java | 7 + .../k2dv/garden/quote/model/package-info.java | 7 + .../io/k2dv/garden/quote/package-info.java | 7 + .../quote/service/QuoteCartService.java | 39 ++++++ .../garden/quote/service/QuotePdfService.java | 10 ++ .../garden/quote/service/QuoteService.java | 115 +++++++++++++++- .../garden/quote/service/package-info.java | 7 + .../garden/recommendation/package-info.java | 6 + .../service/RecommendationService.java | 10 ++ .../recommendation/service/package-info.java | 5 + .../io/k2dv/garden/review/package-info.java | 6 + .../review/service/ProductReviewService.java | 23 ++++ .../garden/review/service/package-info.java | 5 + .../k2dv/garden/scheduler/package-info.java | 6 + .../io/k2dv/garden/search/package-info.java | 6 + .../garden/search/service/SearchService.java | 10 ++ .../garden/search/service/package-info.java | 6 + .../k2dv/garden/shared/dto/package-info.java | 5 + .../garden/shared/exception/package-info.java | 6 + .../io/k2dv/garden/shared/package-info.java | 6 + .../garden/shipping/model/package-info.java | 7 + .../io/k2dv/garden/shipping/package-info.java | 7 + .../shipping/service/ShippingService.java | 41 ++++++ .../garden/shipping/service/package-info.java | 6 + .../io/k2dv/garden/stats/package-info.java | 6 + .../garden/stats/service/StatsService.java | 22 ++++ .../garden/stats/service/package-info.java | 5 + .../k2dv/garden/user/model/package-info.java | 7 + .../io/k2dv/garden/user/package-info.java | 8 ++ .../garden/user/repository/package-info.java | 7 + .../io/k2dv/garden/webhook/package-info.java | 7 + .../service/OutboundWebhookService.java | 16 +++ .../service/WebhookDispatchService.java | 14 ++ .../garden/webhook/service/package-info.java | 6 + .../io/k2dv/garden/wishlist/package-info.java | 6 + .../wishlist/service/WishlistService.java | 18 +++ .../garden/wishlist/service/package-info.java | 5 + 140 files changed, 2313 insertions(+), 10 deletions(-) create mode 100644 src/main/java/io/k2dv/garden/account/package-info.java create mode 100644 src/main/java/io/k2dv/garden/account/service/package-info.java create mode 100644 src/main/java/io/k2dv/garden/admin/iam/package-info.java create mode 100644 src/main/java/io/k2dv/garden/admin/package-info.java create mode 100644 src/main/java/io/k2dv/garden/admin/user/package-info.java create mode 100644 src/main/java/io/k2dv/garden/audit/aspect/package-info.java create mode 100644 src/main/java/io/k2dv/garden/audit/package-info.java create mode 100644 src/main/java/io/k2dv/garden/audit/service/package-info.java create mode 100644 src/main/java/io/k2dv/garden/auth/model/package-info.java create mode 100644 src/main/java/io/k2dv/garden/auth/package-info.java create mode 100644 src/main/java/io/k2dv/garden/auth/repository/package-info.java create mode 100644 src/main/java/io/k2dv/garden/auth/security/package-info.java create mode 100644 src/main/java/io/k2dv/garden/auth/service/package-info.java create mode 100644 src/main/java/io/k2dv/garden/automation/package-info.java create mode 100644 src/main/java/io/k2dv/garden/b2b/controller/package-info.java create mode 100644 src/main/java/io/k2dv/garden/b2b/model/package-info.java create mode 100644 src/main/java/io/k2dv/garden/b2b/package-info.java create mode 100644 src/main/java/io/k2dv/garden/b2b/service/package-info.java create mode 100644 src/main/java/io/k2dv/garden/blob/package-info.java create mode 100644 src/main/java/io/k2dv/garden/blob/service/package-info.java create mode 100644 src/main/java/io/k2dv/garden/cart/model/package-info.java create mode 100644 src/main/java/io/k2dv/garden/cart/package-info.java create mode 100644 src/main/java/io/k2dv/garden/cart/service/package-info.java create mode 100644 src/main/java/io/k2dv/garden/collection/package-info.java create mode 100644 src/main/java/io/k2dv/garden/collection/service/package-info.java create mode 100644 src/main/java/io/k2dv/garden/config/package-info.java create mode 100644 src/main/java/io/k2dv/garden/content/package-info.java create mode 100644 src/main/java/io/k2dv/garden/content/service/package-info.java create mode 100644 src/main/java/io/k2dv/garden/discount/model/package-info.java create mode 100644 src/main/java/io/k2dv/garden/discount/package-info.java create mode 100644 src/main/java/io/k2dv/garden/discount/service/package-info.java create mode 100644 src/main/java/io/k2dv/garden/fulfillment/package-info.java create mode 100644 src/main/java/io/k2dv/garden/fulfillment/service/package-info.java create mode 100644 src/main/java/io/k2dv/garden/giftcard/model/package-info.java create mode 100644 src/main/java/io/k2dv/garden/giftcard/package-info.java create mode 100644 src/main/java/io/k2dv/garden/giftcard/service/package-info.java create mode 100644 src/main/java/io/k2dv/garden/iam/model/package-info.java create mode 100644 src/main/java/io/k2dv/garden/iam/package-info.java create mode 100644 src/main/java/io/k2dv/garden/iam/service/package-info.java create mode 100644 src/main/java/io/k2dv/garden/inventory/model/package-info.java create mode 100644 src/main/java/io/k2dv/garden/inventory/package-info.java create mode 100644 src/main/java/io/k2dv/garden/inventory/service/package-info.java create mode 100644 src/main/java/io/k2dv/garden/newsletter/package-info.java create mode 100644 src/main/java/io/k2dv/garden/newsletter/service/package-info.java create mode 100644 src/main/java/io/k2dv/garden/notification/package-info.java create mode 100644 src/main/java/io/k2dv/garden/notification/service/package-info.java create mode 100644 src/main/java/io/k2dv/garden/order/event/package-info.java create mode 100644 src/main/java/io/k2dv/garden/order/model/package-info.java create mode 100644 src/main/java/io/k2dv/garden/order/package-info.java create mode 100644 src/main/java/io/k2dv/garden/order/service/package-info.java create mode 100644 src/main/java/io/k2dv/garden/payment/gateway/package-info.java create mode 100644 src/main/java/io/k2dv/garden/payment/package-info.java create mode 100644 src/main/java/io/k2dv/garden/payment/service/package-info.java create mode 100644 src/main/java/io/k2dv/garden/product/model/package-info.java create mode 100644 src/main/java/io/k2dv/garden/product/package-info.java create mode 100644 src/main/java/io/k2dv/garden/product/repository/package-info.java create mode 100644 src/main/java/io/k2dv/garden/product/service/package-info.java create mode 100644 src/main/java/io/k2dv/garden/quote/model/package-info.java create mode 100644 src/main/java/io/k2dv/garden/quote/package-info.java create mode 100644 src/main/java/io/k2dv/garden/quote/service/package-info.java create mode 100644 src/main/java/io/k2dv/garden/recommendation/package-info.java create mode 100644 src/main/java/io/k2dv/garden/recommendation/service/package-info.java create mode 100644 src/main/java/io/k2dv/garden/review/package-info.java create mode 100644 src/main/java/io/k2dv/garden/review/service/package-info.java create mode 100644 src/main/java/io/k2dv/garden/scheduler/package-info.java create mode 100644 src/main/java/io/k2dv/garden/search/package-info.java create mode 100644 src/main/java/io/k2dv/garden/search/service/package-info.java create mode 100644 src/main/java/io/k2dv/garden/shared/dto/package-info.java create mode 100644 src/main/java/io/k2dv/garden/shared/exception/package-info.java create mode 100644 src/main/java/io/k2dv/garden/shared/package-info.java create mode 100644 src/main/java/io/k2dv/garden/shipping/model/package-info.java create mode 100644 src/main/java/io/k2dv/garden/shipping/package-info.java create mode 100644 src/main/java/io/k2dv/garden/shipping/service/package-info.java create mode 100644 src/main/java/io/k2dv/garden/stats/package-info.java create mode 100644 src/main/java/io/k2dv/garden/stats/service/package-info.java create mode 100644 src/main/java/io/k2dv/garden/user/model/package-info.java create mode 100644 src/main/java/io/k2dv/garden/user/package-info.java create mode 100644 src/main/java/io/k2dv/garden/user/repository/package-info.java create mode 100644 src/main/java/io/k2dv/garden/webhook/package-info.java create mode 100644 src/main/java/io/k2dv/garden/webhook/service/package-info.java create mode 100644 src/main/java/io/k2dv/garden/wishlist/package-info.java create mode 100644 src/main/java/io/k2dv/garden/wishlist/service/package-info.java diff --git a/src/main/java/io/k2dv/garden/account/package-info.java b/src/main/java/io/k2dv/garden/account/package-info.java new file mode 100644 index 0000000..56fe567 --- /dev/null +++ b/src/main/java/io/k2dv/garden/account/package-info.java @@ -0,0 +1,7 @@ +/** + * The account module provides storefront user self-service capabilities: reading and + * updating personal profile information (name, phone) and managing the address book + * used for shipping and billing. This module operates on the authenticated user's own + * data only and delegates identity concerns to the auth module. + */ +package io.k2dv.garden.account; diff --git a/src/main/java/io/k2dv/garden/account/service/AccountService.java b/src/main/java/io/k2dv/garden/account/service/AccountService.java index 4329ab4..eb4f119 100644 --- a/src/main/java/io/k2dv/garden/account/service/AccountService.java +++ b/src/main/java/io/k2dv/garden/account/service/AccountService.java @@ -18,6 +18,12 @@ import java.util.List; import java.util.UUID; +/** + * Handles storefront user profile management: reading and updating personal details + * (name, phone) and maintaining the user's address book. Enforces ownership checks + * to ensure users can only access and modify their own addresses. Works directly with + * {@code UserRepository} and {@code AddressRepository}; no IAM or auth concerns here. + */ @Service @RequiredArgsConstructor public class AccountService { @@ -25,11 +31,16 @@ public class AccountService { private final UserRepository userRepo; private final AddressRepository addressRepo; + /** Retrieves the profile information for the authenticated user. */ @Transactional(readOnly = true) public AccountResponse getAccount(UUID userId) { return AccountResponse.from(findUser(userId)); } + /** + * Applies a partial update to the user's profile. Only non-null fields in the request + * are written, so callers may send only the fields they wish to change (PATCH semantics). + */ @Transactional public AccountResponse updateAccount(UUID userId, UpdateAccountRequest req) { User user = findUser(userId); @@ -39,6 +50,7 @@ public AccountResponse updateAccount(UUID userId, UpdateAccountRequest req) { return AccountResponse.from(userRepo.save(user)); } + /** Returns all saved addresses for the user, ordered as stored. */ @Transactional(readOnly = true) public List listAddresses(UUID userId) { return addressRepo.findByUserId(userId).stream() @@ -46,6 +58,11 @@ public List listAddresses(UUID userId) { .toList(); } + /** + * Adds a new address to the user's address book. If the new address is marked as + * default, any existing default address is cleared first to maintain the one-default + * invariant. + */ @Transactional public AddressResponse createAddress(UUID userId, AddressRequest req) { findUser(userId); // verify user exists @@ -58,6 +75,11 @@ public AddressResponse createAddress(UUID userId, AddressRequest req) { return AddressResponse.from(addressRepo.save(address)); } + /** + * Replaces the fields of an existing address after verifying the address belongs to + * the requesting user. Enforces the one-default invariant when the request sets + * {@code isDefault} to true. + */ @Transactional public AddressResponse updateAddress(UUID userId, UUID addressId, AddressRequest req) { Address address = findAddress(addressId); @@ -69,6 +91,10 @@ public AddressResponse updateAddress(UUID userId, UUID addressId, AddressRequest return AddressResponse.from(addressRepo.save(address)); } + /** + * Permanently removes an address from the user's address book after verifying + * ownership. Throws {@code ForbiddenException} if the address belongs to a different user. + */ @Transactional public void deleteAddress(UUID userId, UUID addressId) { Address address = findAddress(addressId); diff --git a/src/main/java/io/k2dv/garden/account/service/package-info.java b/src/main/java/io/k2dv/garden/account/service/package-info.java new file mode 100644 index 0000000..4f02c06 --- /dev/null +++ b/src/main/java/io/k2dv/garden/account/service/package-info.java @@ -0,0 +1,6 @@ +/** + * Service layer for the account module. {@link io.k2dv.garden.account.service.AccountService} + * is the sole service here, handling profile reads and updates as well as the full + * CRUD lifecycle of user addresses with ownership enforcement and default-address management. + */ +package io.k2dv.garden.account.service; diff --git a/src/main/java/io/k2dv/garden/admin/iam/package-info.java b/src/main/java/io/k2dv/garden/admin/iam/package-info.java new file mode 100644 index 0000000..04487a2 --- /dev/null +++ b/src/main/java/io/k2dv/garden/admin/iam/package-info.java @@ -0,0 +1,6 @@ +/** + * Admin sub-module for IAM catalog management: allows administrators to create and delete + * custom roles, assign or revoke permissions on roles, and browse the full permission catalog. + * Predefined system roles (CUSTOMER, STAFF, MANAGER, OWNER) are protected from modification. + */ +package io.k2dv.garden.admin.iam; diff --git a/src/main/java/io/k2dv/garden/admin/iam/service/AdminIamService.java b/src/main/java/io/k2dv/garden/admin/iam/service/AdminIamService.java index 5a48c24..bd25aff 100644 --- a/src/main/java/io/k2dv/garden/admin/iam/service/AdminIamService.java +++ b/src/main/java/io/k2dv/garden/admin/iam/service/AdminIamService.java @@ -15,6 +15,12 @@ import java.util.Set; import java.util.UUID; +/** + * Admin service for managing the role and permission catalog used by the IAM system. + * Allows creation of custom roles, assignment of fine-grained permissions to roles, + * and deletion of non-system roles. Predefined system roles (CUSTOMER, STAFF, MANAGER, OWNER) + * are protected from deletion. + */ @Service @RequiredArgsConstructor public class AdminIamService { @@ -24,11 +30,19 @@ public class AdminIamService { private final RoleRepository roleRepo; private final PermissionRepository permissionRepo; + /** + * Returns all roles defined in the system, including both predefined and custom roles, + * with their associated permissions. + */ @Transactional(readOnly = true) public List listRoles() { return roleRepo.findAll().stream().map(RoleResponse::from).toList(); } + /** + * Creates a new custom role with a unique name and optional description. + * Role names are case-sensitive and must be unique across the system. + */ @Transactional public RoleResponse createRole(CreateRoleRequest req) { if (roleRepo.findByName(req.name()).isPresent()) { @@ -40,6 +54,9 @@ public RoleResponse createRole(CreateRoleRequest req) { return RoleResponse.from(roleRepo.save(role)); } + /** + * Updates the name and/or description of an existing role; only non-null fields are applied. + */ @Transactional public RoleResponse updateRole(UUID id, UpdateRoleRequest req) { Role role = findRole(id); @@ -48,6 +65,10 @@ public RoleResponse updateRole(UUID id, UpdateRoleRequest req) { return RoleResponse.from(roleRepo.save(role)); } + /** + * Deletes a custom role. Predefined system roles (CUSTOMER, STAFF, MANAGER, OWNER) + * are immutable and cannot be removed. + */ @Transactional public void deleteRole(UUID id) { Role role = findRole(id); @@ -57,11 +78,20 @@ public void deleteRole(UUID id) { roleRepo.delete(role); } + /** + * Returns all permissions registered in the system, used to populate the permission + * picker when configuring a role. + */ @Transactional(readOnly = true) public List listPermissions() { return permissionRepo.findAll().stream().map(PermissionResponse::from).toList(); } + /** + * Adds a permission to a role's permission set; idempotent if already assigned. + * The change is reflected immediately for future token mints but does not evict + * existing user permission caches — a user re-login is required to pick up the change. + */ @Transactional public RoleResponse assignPermission(UUID roleId, AssignPermissionRequest req) { Role role = findRole(roleId); @@ -71,6 +101,9 @@ public RoleResponse assignPermission(UUID roleId, AssignPermissionRequest req) { return RoleResponse.from(roleRepo.save(role)); } + /** + * Removes a permission from a role; no-ops if the permission was not assigned. + */ @Transactional public void removePermission(UUID roleId, UUID permissionId) { Role role = findRole(roleId); diff --git a/src/main/java/io/k2dv/garden/admin/package-info.java b/src/main/java/io/k2dv/garden/admin/package-info.java new file mode 100644 index 0000000..2ac9486 --- /dev/null +++ b/src/main/java/io/k2dv/garden/admin/package-info.java @@ -0,0 +1,6 @@ +/** + * Admin modules: back-office operations for platform administrators covering user management + * and IAM administration. All endpoints and services in this package are restricted to + * users with administrative roles and are not exposed to regular customers. + */ +package io.k2dv.garden.admin; diff --git a/src/main/java/io/k2dv/garden/admin/user/package-info.java b/src/main/java/io/k2dv/garden/admin/user/package-info.java new file mode 100644 index 0000000..8b5750d --- /dev/null +++ b/src/main/java/io/k2dv/garden/admin/user/package-info.java @@ -0,0 +1,6 @@ +/** + * Admin sub-module for user management: provides filterable user search, profile editing, + * account suspension and reactivation, bulk status operations, tag and metadata management, + * and role assignment. Exposes a CSV export endpoint for offline reporting. + */ +package io.k2dv.garden.admin.user; diff --git a/src/main/java/io/k2dv/garden/admin/user/service/AdminUserService.java b/src/main/java/io/k2dv/garden/admin/user/service/AdminUserService.java index ad3d42a..40b0400 100644 --- a/src/main/java/io/k2dv/garden/admin/user/service/AdminUserService.java +++ b/src/main/java/io/k2dv/garden/admin/user/service/AdminUserService.java @@ -22,6 +22,12 @@ import java.util.List; import java.util.UUID; +/** + * Back-office service for managing platform users on behalf of administrators. + * Provides filterable user search, profile editing, account suspension, bulk status + * operations, tag and metadata management, and role assignment through + * {@link io.k2dv.garden.iam.service.IamService}. + */ @Service @RequiredArgsConstructor public class AdminUserService { @@ -29,6 +35,10 @@ public class AdminUserService { private final UserRepository userRepo; private final IamService iamService; + /** + * Returns a paginated, filterable list of users with their current roles, suitable + * for the admin user-management table. + */ @Transactional(readOnly = true) public PagedResult listUsers(UserFilter filter, Pageable pageable) { Page page = userRepo.findAll(UserSpecification.toSpec(filter), pageable); @@ -45,6 +55,10 @@ public AdminUserResponse getUser(UUID id) { return AdminUserResponse.from(user, userRepo.findRoleNamesByUserId(id)); } + /** + * Applies a partial update to a user's profile fields (name, phone, email). + * Only non-null fields in the request are written; roles are not affected here. + */ @Transactional public AdminUserResponse updateUser(UUID id, UpdateUserRequest req) { User user = findUser(id); @@ -56,6 +70,9 @@ public AdminUserResponse updateUser(UUID id, UpdateUserRequest req) { return AdminUserResponse.from(user, userRepo.findRoleNamesByUserId(id)); } + /** + * Suspends a user account, preventing authentication until reactivated. + */ @Transactional public void suspendUser(UUID id) { User user = findUser(id); @@ -63,6 +80,9 @@ public void suspendUser(UUID id) { userRepo.save(user); } + /** + * Restores a suspended user to active status, allowing them to authenticate again. + */ @Transactional public void reactivateUser(UUID id) { User user = findUser(id); @@ -70,6 +90,9 @@ public void reactivateUser(UUID id) { userRepo.save(user); } + /** + * Suspends multiple user accounts in a single transaction, used by the admin bulk-action UI. + */ @Transactional public void bulkSuspend(List ids) { List users = userRepo.findAllById(ids); @@ -77,6 +100,9 @@ public void bulkSuspend(List ids) { userRepo.saveAll(users); } + /** + * Reactivates multiple suspended user accounts in a single transaction. + */ @Transactional public void bulkReactivate(List ids) { List users = userRepo.findAllById(ids); @@ -84,6 +110,9 @@ public void bulkReactivate(List ids) { userRepo.saveAll(users); } + /** + * Replaces the internal admin notes on a user record; notes are never shown to the user. + */ @Transactional public AdminUserResponse updateNotes(UUID id, String adminNotes) { User user = findUser(id); @@ -92,6 +121,10 @@ public AdminUserResponse updateNotes(UUID id, String adminNotes) { return AdminUserResponse.from(user, userRepo.findRoleNamesByUserId(id)); } + /** + * Replaces the tag list on a user record; tags drive segmentation in marketing and + * automation rules. Passing null or an empty list clears all tags. + */ @Transactional public AdminUserResponse updateTags(UUID id, List tags) { User user = findUser(id); @@ -100,6 +133,10 @@ public AdminUserResponse updateTags(UUID id, List tags) { return AdminUserResponse.from(user, userRepo.findRoleNamesByUserId(id)); } + /** + * Replaces the free-form metadata blob on a user, used for custom integrations + * and CRM annotations. + */ @Transactional public AdminUserResponse updateMetadata(UUID id, java.util.Map metadata) { User user = findUser(id); @@ -108,16 +145,28 @@ public AdminUserResponse updateMetadata(UUID id, java.util.Map m return AdminUserResponse.from(user, userRepo.findRoleNamesByUserId(id)); } + /** + * Grants a platform role to a user, delegating to {@link io.k2dv.garden.iam.service.IamService} + * which also evicts the permission cache. + */ @Transactional public void assignRole(UUID userId, String roleName) { iamService.assignRoleByName(userId, roleName); } + /** + * Revokes a platform role from a user, delegating to {@link io.k2dv.garden.iam.service.IamService} + * which also evicts the permission cache. + */ @Transactional public void removeRole(UUID userId, String roleName) { iamService.removeRoleByName(userId, roleName); } + /** + * Exports all users matching the given filter as a CSV, including roles and tags, + * for use in offline reporting or bulk-import workflows. + */ @Transactional(readOnly = true) public String exportCsv(UserFilter filter) { List users = userRepo.findAll( diff --git a/src/main/java/io/k2dv/garden/audit/aspect/AuditAspect.java b/src/main/java/io/k2dv/garden/audit/aspect/AuditAspect.java index 9a44437..c277fab 100644 --- a/src/main/java/io/k2dv/garden/audit/aspect/AuditAspect.java +++ b/src/main/java/io/k2dv/garden/audit/aspect/AuditAspect.java @@ -18,6 +18,13 @@ import java.lang.reflect.Parameter; import java.util.UUID; +/** + * Spring AOP aspect that intercepts methods annotated with {@link Audited} and delegates + * to {@link io.k2dv.garden.audit.service.AuditLogService} to record the actor, action, and + * affected entity after the method returns successfully. + * The entity ID is resolved from the annotation's SpEL expression against the method arguments + * using a restricted {@code SimpleEvaluationContext} to prevent injection attacks. + */ @Aspect @Component @RequiredArgsConstructor diff --git a/src/main/java/io/k2dv/garden/audit/aspect/package-info.java b/src/main/java/io/k2dv/garden/audit/aspect/package-info.java new file mode 100644 index 0000000..e004541 --- /dev/null +++ b/src/main/java/io/k2dv/garden/audit/aspect/package-info.java @@ -0,0 +1,6 @@ +/** + * AOP aspect and annotation that form the declarative audit instrumentation layer. + * Methods annotated with {@code @Audited} are intercepted by {@code AuditAspect}, + * which resolves the entity ID via SpEL and delegates to {@code AuditLogService}. + */ +package io.k2dv.garden.audit.aspect; diff --git a/src/main/java/io/k2dv/garden/audit/package-info.java b/src/main/java/io/k2dv/garden/audit/package-info.java new file mode 100644 index 0000000..87816a7 --- /dev/null +++ b/src/main/java/io/k2dv/garden/audit/package-info.java @@ -0,0 +1,6 @@ +/** + * Audit logging module that records admin mutations via the {@code @Audited} AOP aspect. + * Entries are written to an append-only audit log table in an independent transaction, + * ensuring durability even when the triggering business transaction is rolled back. + */ +package io.k2dv.garden.audit; diff --git a/src/main/java/io/k2dv/garden/audit/service/AuditLogService.java b/src/main/java/io/k2dv/garden/audit/service/AuditLogService.java index ad0fec5..183d131 100644 --- a/src/main/java/io/k2dv/garden/audit/service/AuditLogService.java +++ b/src/main/java/io/k2dv/garden/audit/service/AuditLogService.java @@ -13,12 +13,23 @@ import java.util.UUID; +/** + * Persists and queries the append-only audit log that records every admin mutation + * captured by the {@link io.k2dv.garden.audit.aspect.AuditAspect}. + * Writes always run in a new, independent transaction so that an audit record is + * committed even if the calling transaction rolls back. + */ @Service @RequiredArgsConstructor public class AuditLogService { private final AuditLogRepository repo; + /** + * Writes a single immutable audit entry in its own transaction ({@code REQUIRES_NEW}) + * so the log survives even when the outer business transaction is rolled back. + * Typically called by {@link io.k2dv.garden.audit.aspect.AuditAspect} rather than directly. + */ @Transactional(propagation = Propagation.REQUIRES_NEW) public void record(UUID actorId, String actorEmail, String action, String entityType, String entityId, @@ -34,6 +45,10 @@ public void record(UUID actorId, String actorEmail, String action, repo.save(entry); } + /** + * Queries the audit log with optional filters for entity type, entity ID, and actor email, + * returning results in reverse-chronological order for the admin audit trail UI. + */ @Transactional(readOnly = true) public PagedResult list(String entityType, String entityId, String actorEmail, Pageable pageable) { diff --git a/src/main/java/io/k2dv/garden/audit/service/package-info.java b/src/main/java/io/k2dv/garden/audit/service/package-info.java new file mode 100644 index 0000000..3de2d10 --- /dev/null +++ b/src/main/java/io/k2dv/garden/audit/service/package-info.java @@ -0,0 +1,5 @@ +/** + * Service layer for the audit module, providing write-once log entry creation and + * filterable read access for the admin audit trail interface. + */ +package io.k2dv.garden.audit.service; diff --git a/src/main/java/io/k2dv/garden/auth/model/package-info.java b/src/main/java/io/k2dv/garden/auth/model/package-info.java new file mode 100644 index 0000000..507cc46 --- /dev/null +++ b/src/main/java/io/k2dv/garden/auth/model/package-info.java @@ -0,0 +1,8 @@ +/** + * JPA entities and enumerations for the auth module. Includes the {@code Identity} entity + * (linking a user to an authentication provider such as CREDENTIALS or OAuth2), token + * entities for one-time and refresh tokens, and the {@code ImpersonationToken} audit record. + * The {@code TokenType} and {@code IdentityProvider} enums define the allowed values for + * discriminator columns used across these tables. + */ +package io.k2dv.garden.auth.model; diff --git a/src/main/java/io/k2dv/garden/auth/package-info.java b/src/main/java/io/k2dv/garden/auth/package-info.java new file mode 100644 index 0000000..b58e1af --- /dev/null +++ b/src/main/java/io/k2dv/garden/auth/package-info.java @@ -0,0 +1,8 @@ +/** + * The auth module owns the entire authentication and authorization surface for the platform. + * It handles JWT-based access tokens (RS256), rotating refresh tokens, single-use one-time + * tokens (email verification, password reset), email/password identity management, OAuth2 + * social login, per-request rate limiting, and administrator impersonation of customer + * accounts. + */ +package io.k2dv.garden.auth; diff --git a/src/main/java/io/k2dv/garden/auth/repository/package-info.java b/src/main/java/io/k2dv/garden/auth/repository/package-info.java new file mode 100644 index 0000000..aa1a04f --- /dev/null +++ b/src/main/java/io/k2dv/garden/auth/repository/package-info.java @@ -0,0 +1,7 @@ +/** + * Spring Data JPA repositories for the auth module's persistence layer. Provides + * data access for identity credentials, one-time tokens, rotating refresh tokens, + * and impersonation audit records. Repositories in this package are used exclusively + * by auth-domain services and must not be referenced from other modules directly. + */ +package io.k2dv.garden.auth.repository; diff --git a/src/main/java/io/k2dv/garden/auth/security/package-info.java b/src/main/java/io/k2dv/garden/auth/security/package-info.java new file mode 100644 index 0000000..21182d5 --- /dev/null +++ b/src/main/java/io/k2dv/garden/auth/security/package-info.java @@ -0,0 +1,8 @@ +/** + * Spring Security integration layer for the auth module. Contains the JWT bearer-token + * converter that validates incoming RS256 access tokens, custom security annotations + * ({@code @Authenticated}, {@code @HasPermission}) for declarative access control, + * and the {@code @CurrentUser} argument resolver that injects the authenticated principal + * directly into controller method parameters. + */ +package io.k2dv.garden.auth.security; diff --git a/src/main/java/io/k2dv/garden/auth/service/AuthService.java b/src/main/java/io/k2dv/garden/auth/service/AuthService.java index 27308b4..bab92f3 100644 --- a/src/main/java/io/k2dv/garden/auth/service/AuthService.java +++ b/src/main/java/io/k2dv/garden/auth/service/AuthService.java @@ -25,6 +25,14 @@ import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +/** + * Core authentication service responsible for the full identity lifecycle: registration, + * login, logout, email verification, and password management. Applies enumeration + * protection (silent no-ops on unknown emails) and in-process rate limiting for + * password-reset requests. Collaborates with {@link TokenService} for one-time tokens, + * {@link JwtService} for access-token minting, and {@link IamService} for RBAC role + * assignment at registration time. + */ @Service @RequiredArgsConstructor public class AuthService { @@ -48,6 +56,11 @@ public class AuthService { private final AppProperties props; private final BCryptPasswordEncoder passwordEncoder; + /** + * Creates a new user account with CREDENTIALS identity, assigns the CUSTOMER role, + * sends an email-verification link, and returns a fresh access + refresh token pair. + * Throws {@code ConflictException} if the email address is already in use. + */ @Transactional public AuthTokenResponse register(RegisterRequest req) { if (userRepo.existsByEmail(req.email())) { @@ -78,6 +91,12 @@ public AuthTokenResponse register(RegisterRequest req) { return mintTokenPair(user); } + /** + * Authenticates a user by email and password and returns a new token pair. + * Throws {@code UnauthorizedException} using a generic message for both bad + * credentials and unknown emails to prevent account enumeration. Throws + * {@code ForbiddenException} if the account is suspended. + */ @Transactional public AuthTokenResponse login(LoginRequest req) { User user = userRepo.findByEmail(req.email()) @@ -97,6 +116,11 @@ public AuthTokenResponse login(LoginRequest req) { return mintTokenPair(user); } + /** + * Rotates the refresh token (invalidating the old one) and issues a new access + refresh + * token pair. Detects token-reuse attacks by revoking all active sessions for the user + * if a previously-consumed token is presented. + */ @Transactional public AuthTokenResponse refresh(RefreshRequest req) { TokenService.RotatedRefreshToken rotated = @@ -111,12 +135,21 @@ public AuthTokenResponse refresh(RefreshRequest req) { return new AuthTokenResponse(accessToken, rotated.newRawToken()); } + /** + * Revokes the refresh token to terminate the session. Safe to call with an already-revoked + * or unknown token — the operation is idempotent and never throws. + */ @Transactional public void logout(String rawRefreshToken) { // Revoke the rotating refresh token; no-op if already revoked or not found tokenService.revokeRefreshToken(rawRefreshToken); } + /** + * Consumes a single-use email-verification token and transitions the user's status from + * UNVERIFIED to ACTIVE. Throws {@code UnauthorizedException} if the token is invalid or + * expired, and {@code NotFoundException} if the associated user no longer exists. + */ @Transactional public void verifyEmail(String rawToken) { UUID userId = tokenService.validateAndConsume(rawToken, TokenType.EMAIL_VERIFICATION); @@ -127,6 +160,10 @@ public void verifyEmail(String rawToken) { userRepo.save(user); } + /** + * Re-sends the email-verification link for an unverified account. Silently succeeds + * when the email is not found or already verified, preventing account enumeration. + */ @Transactional public void resendVerification(String email) { userRepo.findByEmail(email).ifPresent(user -> { @@ -139,6 +176,12 @@ public void resendVerification(String email) { // Silent if email not found — prevents account enumeration } + /** + * Initiates a password-reset flow by sending a one-time reset link to the given email. + * Rate-limited to one request per email per minute and always returns silently — both + * the rate-limit short-circuit and the unknown-email case produce no observable response, + * preventing enumeration attacks. + */ @Transactional public void requestPasswordReset(String email) { // Rate-limit: one request per email per minute. @@ -159,6 +202,10 @@ public void requestPasswordReset(String email) { // Silent if email not found — prevents user enumeration } + /** + * Completes a password-reset flow by consuming the one-time token and replacing the + * stored password hash. The token is invalidated on first use, so replaying it will fail. + */ @Transactional public void confirmPasswordReset(String rawToken, PasswordResetConfirmRequest req) { UUID userId = tokenService.validateAndConsume(rawToken, TokenType.PASSWORD_RESET); @@ -170,6 +217,11 @@ public void confirmPasswordReset(String rawToken, PasswordResetConfirmRequest re identityRepo.save(identity); } + /** + * Allows an authenticated user to change their own password by verifying the current + * password before storing the new hash. Throws {@code UnauthorizedException} if the + * current password does not match, preventing unauthorized credential changes. + */ @Transactional public void updatePassword(UUID userId, UpdatePasswordRequest req) { User user = userRepo.findById(userId) @@ -191,6 +243,10 @@ private AuthTokenResponse mintTokenPair(User user) { return new AuthTokenResponse(accessToken, refreshToken); } + /** + * Checks whether an email address is already registered, for use during pre-validation + * (e.g., real-time availability feedback in the registration form). + */ @Transactional(readOnly = true) public boolean emailExists(String email) { return userRepo.existsByEmail(email); diff --git a/src/main/java/io/k2dv/garden/auth/service/EmailService.java b/src/main/java/io/k2dv/garden/auth/service/EmailService.java index 4535516..fb9876e 100644 --- a/src/main/java/io/k2dv/garden/auth/service/EmailService.java +++ b/src/main/java/io/k2dv/garden/auth/service/EmailService.java @@ -4,23 +4,65 @@ import java.util.List; import java.util.UUID; +/** + * Contract for all transactional and lifecycle email notifications sent by the platform. + * Covers identity flows (verification, password reset), B2B quote lifecycle events, + * order fulfilment notifications, and operational alerts (low stock, abandoned cart). + * Implementations are expected to handle delivery failures gracefully without + * propagating exceptions to callers. + */ public interface EmailService { + /** Sends the email-address verification link to a newly registered user. */ void sendEmailVerification(String to, String token); + + /** Sends a one-time password-reset link; called only after rate-limit checks pass. */ void sendPasswordReset(String to, String token); + + /** Notifies the customer that their quote request has been received and is under review. */ void sendQuoteSubmitted(String to, UUID quoteId); + + /** Alerts internal staff that a new quote request requires attention. */ void sendQuoteNewRequest(String to, UUID quoteId); + + /** Delivers the quote PDF as an email attachment for offline review. */ void sendQuotePdf(String to, UUID quoteId, byte[] pdfBytes); + + /** Notifies the customer that their quote was accepted and an order was created. */ void sendQuoteAccepted(String to, UUID quoteId, UUID orderId); + + /** Prompts a B2B approver that a quote is awaiting their internal sign-off. */ void sendQuotePendingApproval(String to, UUID quoteId); + + /** Informs the customer that the internal approval step has been cleared and the quote is approved. */ void sendQuoteApproved(String to, UUID quoteId); + + /** Notifies internal staff that the customer has rejected the submitted quote. */ void sendQuoteRejectedByUser(String to, UUID quoteId); + + /** Notifies the customer that the internal approval for their quote was denied. */ void sendQuoteApprovalRejected(String to, UUID quoteId); + + /** Notifies the customer that their quote has passed its validity window without acceptance. */ void sendQuoteExpired(String to, UUID quoteId); + + /** Sends a B2B company membership invitation with a single-use acceptance token. */ void sendCompanyInvitation(String to, String companyName, String inviterName, String token); + + /** Sends the post-purchase order confirmation with line-item summary and order total. */ void sendOrderConfirmation(String to, String orderRef, BigDecimal total, String currency, List itemLines, String storeFrontUrl); + + /** Notifies the customer that their order has shipped, including carrier tracking details. */ void sendShippingNotification(String to, String orderRef, String trackingNumber, String trackingCompany, String trackingUrl, String storeFrontUrl); + + /** Informs the customer that their order has been cancelled. */ void sendOrderCancelled(String to, String orderRef, String storeFrontUrl); + + /** Confirms delivery and prompts a post-purchase review or follow-up action. */ void sendOrderDelivered(String to, String orderRef, String productHandle, String storeFrontUrl); + + /** Sends a re-engagement reminder to a shopper who left items in their cart. */ void sendAbandonedCartReminder(String to, String firstName, List itemLines, String cartUrl); + + /** Sends an operational alert to staff listing inventory variants that have fallen below threshold. */ void sendLowStockAlert(String to, List itemLines); } diff --git a/src/main/java/io/k2dv/garden/auth/service/ImpersonationService.java b/src/main/java/io/k2dv/garden/auth/service/ImpersonationService.java index effc750..4ef6db3 100644 --- a/src/main/java/io/k2dv/garden/auth/service/ImpersonationService.java +++ b/src/main/java/io/k2dv/garden/auth/service/ImpersonationService.java @@ -22,6 +22,13 @@ import java.util.Set; import java.util.UUID; +/** + * Enables authorized administrators to act on behalf of customer accounts for support + * and debugging purposes. Issues short-lived (30-minute) impersonation JWTs that carry + * an empty permission set and an {@code impersonatedBy} claim for audit purposes. Staff + * and admin accounts are explicitly excluded from being impersonated to prevent privilege + * escalation. + */ @Service @RequiredArgsConstructor public class ImpersonationService { @@ -33,6 +40,11 @@ public class ImpersonationService { private final UserRepository userRepo; private final JwtService jwtService; + /** + * Initiates an impersonation session: verifies that the target is not a staff/admin + * account, mints an impersonation JWT, and persists an audit record. Returns a response + * containing the token and its expiry so the caller can relay it to the admin client. + */ @Transactional public ImpersonateResponse impersonate(UUID targetUserId, UUID adminUserId) { User target = userRepo.findById(targetUserId) diff --git a/src/main/java/io/k2dv/garden/auth/service/JwtService.java b/src/main/java/io/k2dv/garden/auth/service/JwtService.java index 2f60f49..5459fdc 100644 --- a/src/main/java/io/k2dv/garden/auth/service/JwtService.java +++ b/src/main/java/io/k2dv/garden/auth/service/JwtService.java @@ -18,6 +18,12 @@ import java.util.Base64; import java.util.List; +/** + * Responsible for minting RS256-signed JWTs used as short-lived access tokens. + * The RSA key pair is loaded once at startup from application configuration; both + * regular access tokens and impersonation tokens (which carry an {@code impersonatedBy} + * claim) are produced here. + */ @Service public class JwtService { @@ -39,6 +45,11 @@ public JwtService(AppProperties props) { } } + /** + * Issues a standard access token embedding the user's identity, email, email-verified + * timestamp (if present), and their resolved permission set. The token lifetime is + * governed by the configured {@code jwt.access-token-ttl}. + */ public String mintAccessToken(User user, List permissions) { Instant now = Instant.now(); JwsHeader header = JwsHeader.with(SignatureAlgorithm.RS256).build(); @@ -56,6 +67,11 @@ public String mintAccessToken(User user, List permissions) { return encoder.encode(JwtEncoderParameters.from(header, claims.build())).getTokenValue(); } + /** + * Issues a short-lived impersonation token that acts on behalf of a target user. + * The {@code impersonatedBy} claim records the admin's UUID for audit trails, and + * the token intentionally carries an empty permissions list to limit blast radius. + */ public String mintImpersonationToken(User user, java.util.UUID adminUserId, java.time.Instant expiresAt) { JwsHeader header = JwsHeader.with(SignatureAlgorithm.RS256).build(); JwtClaimsSet claims = JwtClaimsSet.builder() diff --git a/src/main/java/io/k2dv/garden/auth/service/SmtpEmailService.java b/src/main/java/io/k2dv/garden/auth/service/SmtpEmailService.java index 3cce13f..cffd986 100644 --- a/src/main/java/io/k2dv/garden/auth/service/SmtpEmailService.java +++ b/src/main/java/io/k2dv/garden/auth/service/SmtpEmailService.java @@ -17,6 +17,13 @@ import java.util.List; import java.util.UUID; +/** + * SMTP-backed implementation of {@link EmailService} that delivers transactional emails + * via Spring's {@link JavaMailSender}. Simple notifications use plain-text messages + * while richer emails (order confirmations, quote lifecycle events) are rendered from + * Thymeleaf HTML templates. All send operations catch and log delivery failures rather + * than propagating them, ensuring email errors never abort a business transaction. + */ @Slf4j @Service @RequiredArgsConstructor diff --git a/src/main/java/io/k2dv/garden/auth/service/TokenService.java b/src/main/java/io/k2dv/garden/auth/service/TokenService.java index 034925e..124af6c 100644 --- a/src/main/java/io/k2dv/garden/auth/service/TokenService.java +++ b/src/main/java/io/k2dv/garden/auth/service/TokenService.java @@ -17,6 +17,13 @@ import java.util.HexFormat; import java.util.UUID; +/** + * Manages the lifecycle of all non-JWT tokens: single-use one-time tokens (email + * verification, password reset) stored in the {@code auth.tokens} table, and rotating + * refresh tokens stored in {@code auth.refresh_tokens}. All tokens are stored as + * SHA-256 hashes; raw values are never persisted, only returned to callers for + * delivery to the client. + */ @Service @RequiredArgsConstructor @Slf4j @@ -25,6 +32,12 @@ public class TokenService { private final TokenRepository tokenRepo; private final RefreshTokenRepository refreshTokenRepo; + /** + * Creates and persists a new one-time token of the given type for the user. Any + * existing token of the same type is deleted first, ensuring only one active + * token exists per purpose (e.g. a re-sent verification link invalidates the previous one). + * Returns the raw (unhashed) token value to be delivered out-of-band to the user. + */ @Transactional public String createToken(UUID userId, TokenType type, Duration ttl) { // Verification/reset tokens are single-purpose. Refresh tokens are @@ -46,6 +59,11 @@ public String createToken(UUID userId, TokenType type, Duration ttl) { return raw; } + /** + * Validates a one-time token against the expected type, deletes it (consuming it + * for single-use semantics), and returns the owning user's ID. Throws + * {@code UnauthorizedException} if the token is not found, already consumed, or expired. + */ @Transactional public UUID validateAndConsume(String rawToken, TokenType type) { String hash = hash(rawToken); diff --git a/src/main/java/io/k2dv/garden/auth/service/package-info.java b/src/main/java/io/k2dv/garden/auth/service/package-info.java new file mode 100644 index 0000000..7a708d7 --- /dev/null +++ b/src/main/java/io/k2dv/garden/auth/service/package-info.java @@ -0,0 +1,10 @@ +/** + * Business-logic services for the auth module. {@link io.k2dv.garden.auth.service.AuthService} + * is the primary entry point covering registration, login, logout, email verification, and + * password management. Supporting services handle JWT minting ({@link io.k2dv.garden.auth.service.JwtService}), + * token lifecycle ({@link io.k2dv.garden.auth.service.TokenService}), transactional email + * delivery ({@link io.k2dv.garden.auth.service.EmailService} / + * {@link io.k2dv.garden.auth.service.SmtpEmailService}), and admin impersonation + * ({@link io.k2dv.garden.auth.service.ImpersonationService}). + */ +package io.k2dv.garden.auth.service; diff --git a/src/main/java/io/k2dv/garden/automation/AutoTagService.java b/src/main/java/io/k2dv/garden/automation/AutoTagService.java index 48a481e..31bd7f2 100644 --- a/src/main/java/io/k2dv/garden/automation/AutoTagService.java +++ b/src/main/java/io/k2dv/garden/automation/AutoTagService.java @@ -16,6 +16,12 @@ import java.util.Set; import java.util.UUID; +/** + * Automatically classifies users with lifecycle tags based on their purchase history. + * Tags such as "first-time-buyer", "repeat-customer", "loyal-customer", and "vip" are + * maintained on the user record and drive segmentation in marketing and admin tools. + * The VIP spend threshold is configurable via {@link io.k2dv.garden.config.AppProperties}. + */ @Slf4j @Service @RequiredArgsConstructor @@ -28,6 +34,12 @@ public class AutoTagService { private final OrderRepository orderRepo; private final AppProperties appProperties; + /** + * Recalculates and applies purchase-based tags for a user after an order is confirmed. + * Evaluates total confirmed order count and lifetime spend to assign or remove tags; + * the "first-time-buyer" tag is replaced by "repeat-customer" on the second confirmed order. + * No-ops silently if the user no longer exists. + */ @Transactional public void applyOrderTags(UUID userId) { User user = userRepo.findById(userId).orElse(null); diff --git a/src/main/java/io/k2dv/garden/automation/package-info.java b/src/main/java/io/k2dv/garden/automation/package-info.java new file mode 100644 index 0000000..c6af324 --- /dev/null +++ b/src/main/java/io/k2dv/garden/automation/package-info.java @@ -0,0 +1,7 @@ +/** + * Automation module: rule-based auto-tagging of users based on their purchase history and + * configurable spend thresholds. Tags such as "first-time-buyer", "repeat-customer", + * "loyal-customer", and "vip" are applied to user records after order confirmation and + * drive segmentation in marketing, admin tools, and downstream integrations. + */ +package io.k2dv.garden.automation; diff --git a/src/main/java/io/k2dv/garden/b2b/controller/package-info.java b/src/main/java/io/k2dv/garden/b2b/controller/package-info.java new file mode 100644 index 0000000..8a63283 --- /dev/null +++ b/src/main/java/io/k2dv/garden/b2b/controller/package-info.java @@ -0,0 +1,6 @@ +/** + * REST controllers for the B2B module, exposing endpoints under the {@code /api/b2b} prefix. + * Controllers enforce authentication and company-membership authorization, then delegate + * all business logic to the corresponding service classes in the sibling service package. + */ +package io.k2dv.garden.b2b.controller; diff --git a/src/main/java/io/k2dv/garden/b2b/model/package-info.java b/src/main/java/io/k2dv/garden/b2b/model/package-info.java new file mode 100644 index 0000000..01d773e --- /dev/null +++ b/src/main/java/io/k2dv/garden/b2b/model/package-info.java @@ -0,0 +1,6 @@ +/** + * JPA entity and enumeration types for the B2B module. Includes Company, CompanyMembership, + * Department, CreditAccount, Invoice, PriceList, and related enumerations such as + * CompanyRole, InvoiceStatus, and PriceListAdjustmentType. + */ +package io.k2dv.garden.b2b.model; diff --git a/src/main/java/io/k2dv/garden/b2b/package-info.java b/src/main/java/io/k2dv/garden/b2b/package-info.java new file mode 100644 index 0000000..c341a9d --- /dev/null +++ b/src/main/java/io/k2dv/garden/b2b/package-info.java @@ -0,0 +1,7 @@ +/** + * B2B module: manages company accounts, department hierarchies, and member management + * with role-based controls (OWNER, MANAGER, MEMBER). Encompasses spend approval rules, + * credit accounts with net-terms payment flows, price lists with volume tiers, invoice + * generation and payment tracking, and token-based company invitations. + */ +package io.k2dv.garden.b2b; diff --git a/src/main/java/io/k2dv/garden/b2b/service/CompanyApprovalRuleService.java b/src/main/java/io/k2dv/garden/b2b/service/CompanyApprovalRuleService.java index 76766f5..5b128fa 100644 --- a/src/main/java/io/k2dv/garden/b2b/service/CompanyApprovalRuleService.java +++ b/src/main/java/io/k2dv/garden/b2b/service/CompanyApprovalRuleService.java @@ -24,6 +24,12 @@ import java.util.UUID; import java.util.stream.Collectors; +/** + * Manages spend-threshold approval rules for B2B companies and drives the quote + * approval workflow. When an order or quote exceeds a configured threshold, this + * service creates {@code QuoteApprovalPendency} records that must be resolved by + * members with the required role before the order can be finalized. + */ @Service @RequiredArgsConstructor public class CompanyApprovalRuleService { @@ -32,11 +38,18 @@ public class CompanyApprovalRuleService { private final QuoteApprovalPendencyRepository pendencyRepo; private final CompanyMembershipRepository membershipRepo; + /** + * Returns all approval rules configured for the given company, both active and inactive. + */ @Transactional(readOnly = true) public List listByCompany(UUID companyId) { return ruleRepo.findByCompanyId(companyId).stream().map(this::toResponse).toList(); } + /** + * Creates a new spend-threshold rule for the company, defaulting to active status. + * The rule fires when an order or quote total meets or exceeds the configured threshold. + */ @Transactional public CompanyApprovalRuleResponse create(UUID companyId, CompanyApprovalRuleRequest req) { CompanyApprovalRule rule = new CompanyApprovalRule(); @@ -48,6 +61,10 @@ public CompanyApprovalRuleResponse create(UUID companyId, CompanyApprovalRuleReq return toResponse(ruleRepo.save(rule)); } + /** + * Updates the threshold, required role, or display name of an existing approval rule. + * Enforces company ownership of the rule before making changes. + */ @Transactional public CompanyApprovalRuleResponse update(UUID ruleId, UUID companyId, CompanyApprovalRuleRequest req) { CompanyApprovalRule rule = ruleRepo.findById(ruleId) @@ -61,6 +78,10 @@ public CompanyApprovalRuleResponse update(UUID ruleId, UUID companyId, CompanyAp return toResponse(ruleRepo.save(rule)); } + /** + * Permanently deletes an approval rule. Blocked if any quotes have unresolved pendencies + * tied to this rule; in that case, deactivating via {@link #toggleActive} is preferred. + */ @Transactional public void delete(UUID ruleId, UUID companyId) { CompanyApprovalRule rule = ruleRepo.findById(ruleId) @@ -77,6 +98,10 @@ public void delete(UUID ruleId, UUID companyId) { ruleRepo.delete(rule); } + /** + * Activates or deactivates an approval rule without deleting it. + * Inactive rules are ignored by {@link #evaluateAndCreatePendencies}. + */ @Transactional public void toggleActive(UUID ruleId, UUID companyId, boolean active) { CompanyApprovalRule rule = ruleRepo.findById(ruleId) @@ -88,7 +113,12 @@ public void toggleActive(UUID ruleId, UUID companyId, boolean active) { ruleRepo.save(rule); } - /** Returns true and creates pendency records if any rules fire; false if no rules apply. */ + /** + * Evaluates all active rules against the order/quote total and creates a + * {@code QuoteApprovalPendency} record for each rule that fires. + * Returns {@code true} if at least one rule triggered (approval required), {@code false} if the + * order can proceed immediately. + */ @Transactional public boolean evaluateAndCreatePendencies(UUID quoteId, UUID companyId, BigDecimal totalAmount) { List rules = ruleRepo.findByCompanyIdAndActiveTrue(companyId) diff --git a/src/main/java/io/k2dv/garden/b2b/service/CompanyInvitationService.java b/src/main/java/io/k2dv/garden/b2b/service/CompanyInvitationService.java index 1ddc169..a7ada5b 100644 --- a/src/main/java/io/k2dv/garden/b2b/service/CompanyInvitationService.java +++ b/src/main/java/io/k2dv/garden/b2b/service/CompanyInvitationService.java @@ -21,6 +21,12 @@ import java.util.List; import java.util.UUID; +/** + * Manages the token-based invitation flow that brings new users into a company. + * Invitations are scoped to a specific email address, carry a pre-configured role and + * optional spending limit, expire after seven days, and trigger a notification email + * via {@link io.k2dv.garden.auth.service.EmailService}. + */ @Service @RequiredArgsConstructor public class CompanyInvitationService { @@ -33,6 +39,11 @@ public class CompanyInvitationService { private final UserRepository userRepo; private final EmailService emailService; + /** + * Sends a new company invitation to the specified email address, enforcing that only + * owners and managers may invite and that no duplicate pending invitation exists. + * Dispatches an invitation email with the unique acceptance token. + */ @Transactional public InvitationResponse invite(UUID companyId, UUID requestorId, CreateInvitationRequest req) { Company company = companyRepo.findById(companyId) @@ -77,6 +88,10 @@ public InvitationResponse invite(UUID companyId, UUID requestorId, CreateInvitat return toResponse(invitation, company.getName()); } + /** + * Looks up an invitation by its opaque token, used by the acceptance UI to display + * company and role information before the recipient confirms. + */ @Transactional(readOnly = true) public InvitationResponse getByToken(UUID token) { CompanyInvitation invitation = requireByToken(token); @@ -84,6 +99,11 @@ public InvitationResponse getByToken(UUID token) { return toResponse(invitation, company.getName()); } + /** + * Accepts a pending invitation, creating a company membership for the authenticated user. + * Validates expiry, PENDING status, and that the caller's email matches the invitation; + * marks the invitation ACCEPTED and enrolls the user with the pre-configured role and spending limit. + */ @Transactional public InvitationResponse accept(UUID token, UUID userId) { CompanyInvitation invitation = requireByToken(token); @@ -124,6 +144,10 @@ public InvitationResponse accept(UUID token, UUID userId) { return toResponse(invitation, company.getName()); } + /** + * Cancels a pending invitation before it is accepted; only owners and managers may cancel. + * Has no effect on accepted or already-cancelled invitations (throws ConflictException if not PENDING). + */ @Transactional public InvitationResponse cancel(UUID companyId, UUID invitationId, UUID requestorId) { requireOwnerOrManager(companyId, requestorId); @@ -142,6 +166,9 @@ public InvitationResponse cancel(UUID companyId, UUID invitationId, UUID request return toResponse(invitation, company.getName()); } + /** + * Returns all outstanding (PENDING) invitations for a company; restricted to owners and managers. + */ @Transactional(readOnly = true) public List listPending(UUID companyId, UUID requestorId) { requireOwnerOrManager(companyId, requestorId); diff --git a/src/main/java/io/k2dv/garden/b2b/service/CompanyService.java b/src/main/java/io/k2dv/garden/b2b/service/CompanyService.java index 946db91..4c08bdb 100644 --- a/src/main/java/io/k2dv/garden/b2b/service/CompanyService.java +++ b/src/main/java/io/k2dv/garden/b2b/service/CompanyService.java @@ -31,6 +31,12 @@ import java.util.Set; import java.util.UUID; +/** + * Core B2B service that manages company lifecycle, membership, and spending controls. + * Handles company creation (which auto-assigns the creator as OWNER), member role management, + * per-member spending limits, a company-scoped product catalog, and aggregated spending + * analytics across orders and invoices. + */ @Service @RequiredArgsConstructor public class CompanyService { @@ -43,6 +49,10 @@ public class CompanyService { private final OrderRepository orderRepo; private final InvoiceRepository invoiceRepo; + /** + * Creates a new company and automatically enrolls the requestor as its OWNER. + * The OWNER role is the only role that can manage members and mutate company settings. + */ @Transactional public CompanyResponse create(UUID requestorId, CreateCompanyRequest req) { Company company = new Company(); @@ -66,6 +76,9 @@ public CompanyResponse create(UUID requestorId, CreateCompanyRequest req) { return toResponse(company); } + /** + * Returns all companies the given user belongs to, regardless of their role within each company. + */ @Transactional(readOnly = true) public List listForUser(UUID userId) { List companyIds = membershipRepo.findByUserId(userId).stream() @@ -76,6 +89,9 @@ public List listForUser(UUID userId) { .toList(); } + /** + * Retrieves a single company by ID, enforcing that the caller is a member of that company. + */ @Transactional(readOnly = true) public CompanyResponse getById(UUID companyId, UUID userId) { requireMember(companyId, userId); @@ -84,6 +100,10 @@ public CompanyResponse getById(UUID companyId, UUID userId) { return toResponse(company); } + /** + * Updates company profile and billing details; restricted to the company OWNER. + * The optional {@code taxExempt} flag, when set, suppresses tax calculation on future orders. + */ @Transactional public CompanyResponse update(UUID companyId, UUID requestorId, UpdateCompanyRequest req) { requireOwner(companyId, requestorId); @@ -102,6 +122,10 @@ public CompanyResponse update(UUID companyId, UUID requestorId, UpdateCompanyReq return toResponse(companyRepo.save(company)); } + /** + * Lists all members of a company along with their roles and spending limits. + * Accessible to any member of the company. + */ @Transactional(readOnly = true) public List listMembers(UUID companyId, UUID requestorId) { requireMember(companyId, requestorId); @@ -112,6 +136,11 @@ public List listMembers(UUID companyId, UUID requestorId) }).toList(); } + /** + * Directly adds an existing platform user to the company with the MEMBER role. + * Prefer the invitation flow ({@link CompanyInvitationService}) when the user + * may not yet have an account. Throws if the user is already a member. + */ @Transactional public CompanyMemberResponse addMember(UUID companyId, UUID requestorId, AddMemberRequest req) { requireOwner(companyId, requestorId); @@ -129,6 +158,10 @@ public CompanyMemberResponse addMember(UUID companyId, UUID requestorId, AddMemb return toMemberResponse(membership, user); } + /** + * Removes a member from the company. The OWNER cannot remove themselves to + * prevent a company from becoming ownerless. + */ @Transactional public void removeMember(UUID companyId, UUID requestorId, UUID targetUserId) { requireOwner(companyId, requestorId); @@ -140,6 +173,11 @@ public void removeMember(UUID companyId, UUID requestorId, UUID targetUserId) { membershipRepo.delete(membership); } + /** + * Sets or clears the per-order spending cap for a specific member. + * A null limit means unlimited spend; a non-null limit is enforced by + * {@link #assertSpendingLimit} at order placement time. + */ @Transactional public CompanyMemberResponse updateSpendingLimit(UUID companyId, UUID targetUserId, UUID requestorId, UpdateSpendingLimitRequest req) { requireOwner(companyId, requestorId); @@ -151,15 +189,25 @@ public CompanyMemberResponse updateSpendingLimit(UUID companyId, UUID targetUser return toMemberResponse(membership, user); } + /** + * Guard used by other services to assert that the user is a company member; + * throws {@link io.k2dv.garden.shared.exception.ForbiddenException} otherwise. + */ public void requireMemberAccess(UUID companyId, UUID userId) { requireMember(companyId, userId); } + /** + * Admin-only: returns all companies in the platform without access filtering. + */ @Transactional(readOnly = true) public List listAll() { return companyRepo.findAll().stream().map(this::toResponse).toList(); } + /** + * Admin-only: retrieves a company by ID without membership verification. + */ @Transactional(readOnly = true) public CompanyResponse adminGetById(UUID companyId) { return companyRepo.findById(companyId) @@ -167,6 +215,10 @@ public CompanyResponse adminGetById(UUID companyId) { .orElseThrow(() -> new NotFoundException("COMPANY_NOT_FOUND", "Company not found")); } + /** + * Admin-only: performs a partial update on any company field including the + * assigned sales rep, which is not exposed in the self-service update endpoint. + */ @Transactional public CompanyResponse adminUpdate(UUID companyId, AdminUpdateCompanyRequest req) { Company company = companyRepo.findById(companyId) @@ -185,6 +237,10 @@ public CompanyResponse adminUpdate(UUID companyId, AdminUpdateCompanyRequest req return toResponse(companyRepo.save(company)); } + /** + * Replaces the free-form metadata blob on a company, used for custom integrations + * and back-office annotations. + */ @Transactional public CompanyResponse updateMetadata(UUID companyId, Map metadata) { Company company = companyRepo.findById(companyId) @@ -193,6 +249,9 @@ public CompanyResponse updateMetadata(UUID companyId, Map metada return toResponse(companyRepo.save(company)); } + /** + * Returns whether the company holds tax-exempt status; used by the order/tax calculation pipeline. + */ @Transactional(readOnly = true) public boolean isTaxExempt(UUID companyId) { return companyRepo.findById(companyId) @@ -200,6 +259,9 @@ public boolean isTaxExempt(UUID companyId) { .orElse(false); } + /** + * Returns the per-order spending cap configured for this member, or null if unlimited. + */ @Transactional(readOnly = true) public BigDecimal getSpendingLimit(UUID companyId, UUID userId) { return membershipRepo.findByCompanyIdAndUserId(companyId, userId) @@ -207,6 +269,11 @@ public BigDecimal getSpendingLimit(UUID companyId, UUID userId) { .orElse(null); } + /** + * Throws a {@link io.k2dv.garden.shared.exception.ValidationException} if the order total + * exceeds the member's configured spending limit; no-ops when no limit is set. + * Called during order placement to enforce B2B purchasing controls. + */ @Transactional(readOnly = true) public void assertSpendingLimit(UUID companyId, UUID userId, BigDecimal orderTotal) { BigDecimal limit = getSpendingLimit(companyId, userId); @@ -235,6 +302,10 @@ public boolean isOwnerOrManager(UUID companyId, UUID userId) { .orElse(false); } + /** + * Changes a member's role within the company. Promoting to OWNER is disallowed to + * preserve the single-owner invariant; changing the existing OWNER's role is also blocked. + */ @Transactional public CompanyMemberResponse updateMemberRole(UUID companyId, UUID targetUserId, UUID requestorId, UpdateMemberRoleRequest req) { @@ -280,12 +351,21 @@ private CompanyResponse toResponse(Company c) { // ─── Catalog ────────────────────────────────────────────────────────────── + /** + * Returns the IDs of all products in the company's curated product catalog. + * An empty catalog means no restriction; a non-empty catalog implies the company + * only has access to those listed products. + */ @Transactional(readOnly = true) public List getCatalogProductIds(UUID companyId) { assertCompanyExists(companyId); return catalogRepo.findProductIdsByCompanyId(companyId); } + /** + * Adds a product to the company's catalog; idempotent if already present. + * Validates that the product exists and is not soft-deleted. + */ @Transactional public void addToCatalog(UUID companyId, UUID productId) { assertCompanyExists(companyId); @@ -299,6 +379,9 @@ public void addToCatalog(UUID companyId, UUID productId) { } } + /** + * Removes a product from the company's catalog; no-ops if the product was not present. + */ @Transactional public void removeFromCatalog(UUID companyId, UUID productId) { catalogRepo.deleteByCompanyIdAndProductId(companyId, productId); @@ -310,6 +393,11 @@ private void assertCompanyExists(UUID companyId) { } } + /** + * Builds a comprehensive spending report for a company: total order volume, invoice aging + * buckets (pending / overdue / paid), and per-member utilization percentages for members + * who have a spending limit configured. Uses bulk queries to avoid N+1 patterns. + */ @Transactional(readOnly = true) public CompanySpendingSummaryResponse getSpendingSummary(UUID companyId) { if (!companyRepo.existsById(companyId)) { diff --git a/src/main/java/io/k2dv/garden/b2b/service/CompanyShippingAddressService.java b/src/main/java/io/k2dv/garden/b2b/service/CompanyShippingAddressService.java index 2d16783..516ef75 100644 --- a/src/main/java/io/k2dv/garden/b2b/service/CompanyShippingAddressService.java +++ b/src/main/java/io/k2dv/garden/b2b/service/CompanyShippingAddressService.java @@ -15,6 +15,12 @@ import java.util.List; import java.util.UUID; +/** + * Manages the pool of reusable shipping addresses that belong to a company. + * Only owners and managers may mutate addresses; any member may read them. + * Exactly one address per company may be flagged as the default, and this service + * atomically clears any previous default when a new one is set. + */ @Service @RequiredArgsConstructor public class CompanyShippingAddressService { @@ -23,6 +29,10 @@ public class CompanyShippingAddressService { private final CompanyRepository companyRepo; private final CompanyService companyService; + /** + * Returns all shipping addresses for the company, ordered with the default address first. + * Accessible to any company member. + */ @Transactional(readOnly = true) public List list(UUID companyId, UUID userId) { companyService.requireMemberAccess(companyId, userId); @@ -30,6 +40,10 @@ public List list(UUID companyId, UUID userId) { .stream().map(CompanyAddressResponse::from).toList(); } + /** + * Adds a new shipping address to the company's address book. If the address is marked + * as default, any previously default address is atomically cleared first. + */ @Transactional public CompanyAddressResponse add(UUID companyId, UUID userId, CompanyAddressRequest req) { requireOwnerOrManager(companyId, userId); @@ -44,6 +58,10 @@ public CompanyAddressResponse add(UUID companyId, UUID userId, CompanyAddressReq return CompanyAddressResponse.from(addressRepo.save(address)); } + /** + * Updates an existing shipping address. Promotes it to default (and clears the previous + * default) if {@code isDefault} changes from false to true. + */ @Transactional public CompanyAddressResponse update(UUID companyId, UUID addressId, UUID userId, CompanyAddressRequest req) { requireOwnerOrManager(companyId, userId); @@ -57,6 +75,10 @@ public CompanyAddressResponse update(UUID companyId, UUID addressId, UUID userId return CompanyAddressResponse.from(addressRepo.save(address)); } + /** + * Permanently removes a shipping address from the company's address book. + * Restricted to owners and managers. + */ @Transactional public void delete(UUID companyId, UUID addressId, UUID userId) { requireOwnerOrManager(companyId, userId); @@ -64,6 +86,10 @@ public void delete(UUID companyId, UUID addressId, UUID userId) { addressRepo.delete(address); } + /** + * Designates an address as the company's default shipping address, atomically clearing + * any previously set default. Validates address ownership before clearing. + */ @Transactional public CompanyAddressResponse setDefault(UUID companyId, UUID addressId, UUID userId) { requireOwnerOrManager(companyId, userId); diff --git a/src/main/java/io/k2dv/garden/b2b/service/CreditAccountService.java b/src/main/java/io/k2dv/garden/b2b/service/CreditAccountService.java index 6160aa8..e00e953 100644 --- a/src/main/java/io/k2dv/garden/b2b/service/CreditAccountService.java +++ b/src/main/java/io/k2dv/garden/b2b/service/CreditAccountService.java @@ -18,6 +18,12 @@ import java.util.Optional; import java.util.UUID; +/** + * Manages net-terms credit accounts that allow B2B companies to purchase on credit + * and pay later via invoices. Each company may have at most one credit account; this + * service tracks the credit limit, outstanding invoice balance, and payment term days + * (e.g. NET_30) used when generating invoices. + */ @Service @RequiredArgsConstructor public class CreditAccountService { @@ -26,6 +32,10 @@ public class CreditAccountService { private final InvoiceRepository invoiceRepo; private final CompanyRepository companyRepo; + /** + * Opens a new credit account for a company, applying default values of 30 payment-term + * days and USD currency when not specified. Enforces one-account-per-company. + */ @Transactional public CreditAccountResponse create(CreateCreditAccountRequest req) { companyRepo.findById(req.companyId()) @@ -48,6 +58,9 @@ public CreditAccountResponse getByCompany(UUID companyId) { return toResponse(account); } + /** + * Updates the credit limit and optionally the payment term days for a company's credit account. + */ @Transactional public CreditAccountResponse update(UUID companyId, UpdateCreditAccountRequest req) { CreditAccount account = requireByCompany(companyId); @@ -56,6 +69,10 @@ public CreditAccountResponse update(UUID companyId, UpdateCreditAccountRequest r return toResponse(creditAccountRepo.save(account)); } + /** + * Removes a company's credit account, reverting the company to pay-at-checkout. + * Existing invoices are unaffected. + */ @Transactional public void delete(UUID companyId) { CreditAccount account = requireByCompany(companyId); @@ -67,14 +84,18 @@ public Optional findByCompanyId(UUID companyId) { return creditAccountRepo.findByCompanyId(companyId); } + /** + * Returns the sum of all unpaid invoice amounts for the company, representing + * the currently drawn-down portion of the credit limit. + */ @Transactional(readOnly = true) public BigDecimal getOutstandingBalance(UUID companyId) { return invoiceRepo.computeOutstandingBalance(companyId); } /** - * Throws ValidationException if the company has a credit account but insufficient available credit. - * No-ops if the company has no credit account (pay-at-checkout flow). + * Returns the number of days until invoice payment is due for this company's credit account, + * or 0 if the company has no credit account. */ @Transactional(readOnly = true) public int getPaymentTermsDays(UUID companyId) { @@ -83,6 +104,11 @@ public int getPaymentTermsDays(UUID companyId) { .orElse(0); } + /** + * Throws {@link io.k2dv.garden.shared.exception.ValidationException} if the company has a + * credit account but the order total would exceed the remaining available credit. + * No-ops if the company has no credit account (pay-at-checkout flow). + */ @Transactional(readOnly = true) public void assertCreditAvailable(UUID companyId, BigDecimal orderTotal) { creditAccountRepo.findByCompanyId(companyId).ifPresent(account -> { diff --git a/src/main/java/io/k2dv/garden/b2b/service/DepartmentService.java b/src/main/java/io/k2dv/garden/b2b/service/DepartmentService.java index 15089c1..9ace3f8 100644 --- a/src/main/java/io/k2dv/garden/b2b/service/DepartmentService.java +++ b/src/main/java/io/k2dv/garden/b2b/service/DepartmentService.java @@ -18,6 +18,12 @@ import java.util.Set; import java.util.UUID; +/** + * Manages the hierarchical department structure within a B2B company. + * Departments form an arbitrary-depth tree; members can be assigned to a department + * to support organizational reporting and spend segmentation. This service enforces + * cycle detection when re-parenting departments and propagates child re-parenting on delete. + */ @Service @RequiredArgsConstructor public class DepartmentService { @@ -25,6 +31,10 @@ public class DepartmentService { private final DepartmentRepository deptRepo; private final CompanyMembershipRepository membershipRepo; + /** + * Returns the full department hierarchy for a company as a nested tree, with root + * departments at the top level and their descendants embedded recursively. + */ @Transactional(readOnly = true) public List tree(UUID companyId) { List all = deptRepo.findByCompanyId(companyId); @@ -32,11 +42,19 @@ public List tree(UUID companyId) { return roots.stream().map(r -> buildTree(r, all)).toList(); } + /** + * Returns all departments for a company as a flat list without nesting, useful for + * drop-down selection in admin UIs. + */ @Transactional(readOnly = true) public List listFlat(UUID companyId) { return deptRepo.findByCompanyId(companyId).stream().map(d -> toResponse(d, List.of())).toList(); } + /** + * Creates a new department under the given company, optionally as a child of an existing + * department. Department names must be unique within the company. + */ @Transactional public DepartmentResponse create(UUID companyId, DepartmentRequest req) { if (deptRepo.existsByCompanyIdAndName(companyId, req.name())) { @@ -54,6 +72,10 @@ public DepartmentResponse create(UUID companyId, DepartmentRequest req) { return toResponse(deptRepo.save(dept), List.of()); } + /** + * Renames a department and optionally re-parents it within the hierarchy. + * Guards against cycles: setting an ancestor as a child's parent is rejected. + */ @Transactional public DepartmentResponse rename(UUID deptId, UUID companyId, DepartmentRequest req) { Department dept = requireOwned(deptId, companyId); @@ -92,6 +114,10 @@ private boolean isDescendant(UUID rootId, UUID candidateAncestorId) { return false; } + /** + * Deletes a department, automatically re-parenting its immediate children to the + * deleted department's parent so the tree remains intact. + */ @Transactional public void delete(UUID deptId, UUID companyId) { Department dept = requireOwned(deptId, companyId); @@ -103,6 +129,10 @@ public void delete(UUID deptId, UUID companyId) { deptRepo.delete(dept); } + /** + * Assigns or clears the department for a company member. Passing a null department ID + * removes the member from their current department. + */ @Transactional public void assignMemberDepartment(UUID companyId, UUID userId, AssignDepartmentRequest req) { if (req.departmentId() != null) { diff --git a/src/main/java/io/k2dv/garden/b2b/service/InvoicePdfService.java b/src/main/java/io/k2dv/garden/b2b/service/InvoicePdfService.java index 7b45576..f0533d8 100644 --- a/src/main/java/io/k2dv/garden/b2b/service/InvoicePdfService.java +++ b/src/main/java/io/k2dv/garden/b2b/service/InvoicePdfService.java @@ -33,6 +33,12 @@ import java.util.UUID; import java.util.stream.Collectors; +/** + * Renders printable PDF invoices by combining invoice data, order line items, + * and payment history into a Thymeleaf HTML template and converting it to PDF + * via OpenHTMLToPDF. Enriches line items with product and variant names to + * produce a human-readable document rather than raw variant IDs. + */ @Service @RequiredArgsConstructor public class InvoicePdfService { @@ -49,6 +55,11 @@ public class InvoicePdfService { private final ProductRepository productRepo; private final CompanyRepository companyRepo; + /** + * Builds and returns the raw PDF bytes for the given invoice, ready to be streamed + * to the client as an attachment. Resolves product names, variant titles, and + * payment rows in bulk to avoid N+1 queries. + */ @Transactional(readOnly = true) public byte[] generate(UUID invoiceId) { Invoice invoice = invoiceRepo.findById(invoiceId) diff --git a/src/main/java/io/k2dv/garden/b2b/service/InvoiceService.java b/src/main/java/io/k2dv/garden/b2b/service/InvoiceService.java index c33a633..dd14274 100644 --- a/src/main/java/io/k2dv/garden/b2b/service/InvoiceService.java +++ b/src/main/java/io/k2dv/garden/b2b/service/InvoiceService.java @@ -33,6 +33,12 @@ import java.util.Map; import java.util.UUID; +/** + * Handles the full lifecycle of B2B invoices: creation from orders, partial and full + * payment recording, overdue marking, and voiding. Works in tandem with + * {@link CreditAccountService} for net-terms customers and fires webhook events on + * status transitions so downstream systems (e.g. ERP integrations) stay in sync. + */ @Service @RequiredArgsConstructor public class InvoiceService { @@ -43,6 +49,11 @@ public class InvoiceService { private final OutboundWebhookService outboundWebhookService; private final OrderService orderService; + /** + * Creates a single invoice for an existing order on demand, used by back-office staff + * when the invoice was not generated automatically at order placement. + * Throws if an invoice already exists for the order. + */ @Transactional public InvoiceResponse createManualInvoice(UUID orderId, UUID companyId, int paymentTermsDays) { boolean exists = invoiceRepo.existsByOrderId(orderId); @@ -56,6 +67,11 @@ public InvoiceResponse createManualInvoice(UUID orderId, UUID companyId, int pay return toResponse(invoice); } + /** + * Internal helper called by the order and quote flows to generate an invoice and + * advance the linked order to {@code INVOICED} status. Fires the {@code INVOICE_ISSUED} + * webhook event for downstream notification. + */ @Transactional public Invoice createFromOrder(UUID companyId, UUID orderId, UUID quoteId, BigDecimal total, String currency, int paymentTermsDays) { @@ -83,6 +99,11 @@ public Invoice createFromOrder(UUID companyId, UUID orderId, UUID quoteId, return invoice; } + /** + * Applies a payment against an open invoice, advancing the invoice to {@code PARTIAL} + * or {@code PAID} status. When fully paid, propagates {@code PAID} to the linked order + * and fires the {@code INVOICE_PAID} webhook. Overpayments are rejected. + */ @Transactional public InvoiceResponse recordPayment(UUID invoiceId, RecordPaymentRequest req) { Invoice invoice = requireInvoice(invoiceId); @@ -120,6 +141,10 @@ public InvoiceResponse recordPayment(UUID invoiceId, RecordPaymentRequest req) { return toResponse(invoice); } + /** + * Transitions an {@code ISSUED} or {@code PARTIAL} invoice to {@code OVERDUE} and fires + * the {@code INVOICE_OVERDUE} webhook. Typically called by a scheduled job after the due date passes. + */ @Transactional public InvoiceResponse markOverdue(UUID invoiceId) { Invoice invoice = requireInvoice(invoiceId); @@ -134,6 +159,10 @@ public InvoiceResponse markOverdue(UUID invoiceId) { return toResponse(invoice); } + /** + * Voids an unpaid invoice, preventing further payments, and cancels the linked order + * if it is still in {@code INVOICED} status. Cannot void an already paid invoice. + */ @Transactional public InvoiceResponse voidInvoice(UUID invoiceId) { Invoice invoice = requireInvoice(invoiceId); @@ -162,6 +191,9 @@ public List listByCompany(UUID companyId) { .stream().map(this::toResponse).toList(); } + /** + * Admin-facing paginated invoice query with optional filters for company, status, and order. + */ @Transactional(readOnly = true) public PagedResult listAll(UUID companyId, InvoiceStatus status, UUID orderId, Pageable pageable) { Specification spec = (root, query, cb) -> { @@ -174,6 +206,10 @@ public PagedResult listAll(UUID companyId, InvoiceStatus status return PagedResult.of(invoiceRepo.findAll(spec, pageable), this::toResponse); } + /** + * Generates a CSV account statement for a company covering all invoices within + * the optional date range, suitable for download or emailing to the customer. + */ @Transactional(readOnly = true) public String generateStatementCsv(UUID companyId, Instant from, Instant to) { Specification spec = (root, query, cb) -> { diff --git a/src/main/java/io/k2dv/garden/b2b/service/PriceListService.java b/src/main/java/io/k2dv/garden/b2b/service/PriceListService.java index c3d0a2b..cfc5da1 100644 --- a/src/main/java/io/k2dv/garden/b2b/service/PriceListService.java +++ b/src/main/java/io/k2dv/garden/b2b/service/PriceListService.java @@ -27,6 +27,12 @@ import java.util.UUID; import java.util.stream.Collectors; +/** + * Manages custom price lists assigned to B2B companies, including volume-tiered + * per-variant pricing and list-level percentage adjustments. The key method + * {@link #resolvePrice} is called at cart and order creation time to determine + * the effective price for a given company, variant, and quantity combination. + */ @Service @RequiredArgsConstructor public class PriceListService { @@ -37,6 +43,11 @@ public class PriceListService { private final ProductVariantRepository variantRepo; private final ProductRepository productRepo; + /** + * Creates a new price list for a company, with optional activation window (startsAt/endsAt) + * and an optional list-level adjustment rule (percentage off or markup) that applies when + * no per-variant entry matches. + */ @Transactional public PriceListResponse create(CreatePriceListRequest req) { companyRepo.findById(req.companyId()) @@ -67,6 +78,11 @@ public PriceListResponse getById(UUID id) { return toResponse(requirePriceList(id)); } + /** + * Updates all mutable fields of a price list including its activation window and + * list-level adjustment rule; validates that adjustmentType and adjustmentValue are + * both set or both null. + */ @Transactional public PriceListResponse update(UUID id, UpdatePriceListRequest req) { validateAdjustment(req.adjustmentType(), req.adjustmentValue()); @@ -88,6 +104,11 @@ public void delete(UUID id) { priceListRepo.deleteById(id); } + /** + * Creates or updates a volume-tier entry for a specific variant on a price list. + * The combination of (priceListId, variantId, minQty) is the natural key; if a matching + * entry already exists, its price is overwritten. + */ @Transactional public PriceListEntryResponse upsertEntry(UUID priceListId, UUID variantId, UpsertPriceListEntryRequest req) { requirePriceList(priceListId); @@ -118,6 +139,13 @@ public List listEntries(UUID priceListId) { .stream().map(this::toEntryResponse).toList(); } + /** + * Resolves the effective unit price for a company/variant/quantity combination by + * evaluating all currently active price lists in priority order. Checks per-variant + * volume tiers first; falls back to list-level percentage adjustment rules; and + * returns the catalog price when no B2B pricing applies. This is the primary + * entry point called at cart and order time. + */ @Transactional(readOnly = true) public ResolvedPriceResponse resolvePrice(UUID companyId, UUID variantId, int qty) { companyRepo.findById(companyId) @@ -162,6 +190,11 @@ public ResolvedPriceResponse resolvePrice(UUID companyId, UUID variantId, int qt ); } + /** + * Returns price list entries visible to a company, enriched with product title, handle, + * variant title, SKU, and the retail price for comparison. Validates that the price list + * belongs to the requesting company. + */ @Transactional(readOnly = true) public List listEntriesForCustomer(UUID priceListId, UUID companyId) { PriceList pl = requirePriceList(priceListId); @@ -196,6 +229,11 @@ public List listEntriesForCustomer(UUID priceListId, }).toList(); } + /** + * Returns all volume-pricing tiers grouped by variant for a product, filtered to the + * company's currently active price lists. Used by the storefront to display tiered + * pricing tables on product detail pages. + */ @Transactional(readOnly = true) public List getProductTiers(UUID companyId, String productHandle) { companyRepo.findById(companyId) diff --git a/src/main/java/io/k2dv/garden/b2b/service/package-info.java b/src/main/java/io/k2dv/garden/b2b/service/package-info.java new file mode 100644 index 0000000..a50e8bf --- /dev/null +++ b/src/main/java/io/k2dv/garden/b2b/service/package-info.java @@ -0,0 +1,7 @@ +/** + * Service layer for the B2B module, containing transactional business logic for companies, + * departments, memberships, invitations, approval rules, credit accounts, price lists, + * and invoice lifecycle management. These services are consumed by the B2B controllers + * and by cross-module services (e.g. order placement) that need B2B context. + */ +package io.k2dv.garden.b2b.service; diff --git a/src/main/java/io/k2dv/garden/blob/package-info.java b/src/main/java/io/k2dv/garden/blob/package-info.java new file mode 100644 index 0000000..3e19647 --- /dev/null +++ b/src/main/java/io/k2dv/garden/blob/package-info.java @@ -0,0 +1,8 @@ +/** + * Blob/storage module for managing uploaded binary assets such as product images, article images, + * and downloadable PDFs. Files are stored in an S3-compatible backend (configured via + * {@code StorageProperties}) and tracked in the {@code BlobObject} database table. The module + * provides upload validation, image dimension extraction, folder organisation, usage tracking, + * and signed public URL resolution. + */ +package io.k2dv.garden.blob; diff --git a/src/main/java/io/k2dv/garden/blob/service/BlobService.java b/src/main/java/io/k2dv/garden/blob/service/BlobService.java index 9ea6203..97c6044 100644 --- a/src/main/java/io/k2dv/garden/blob/service/BlobService.java +++ b/src/main/java/io/k2dv/garden/blob/service/BlobService.java @@ -28,6 +28,12 @@ import java.util.Set; import java.util.UUID; +/** + * Application-level service for managing uploaded file assets (blobs). Handles file validation, + * upload to the configured storage backend, image dimension extraction, folder organisation, + * and lifecycle operations such as replacement and deletion. Tracks where each blob is used + * (product images, article images) so referential integrity can be inspected before deletion. + */ @Service @RequiredArgsConstructor public class BlobService { @@ -42,6 +48,10 @@ public class BlobService { private final ProductImageRepository productImageRepo; private final ArticleImageRepository articleImageRepo; + /** + * Returns a filterable, sortable paginated list of all uploaded blobs, each decorated with + * a resolved public URL. Supports filtering by content type, filename substring, and folder. + */ @Transactional(readOnly = true) public PagedResult list(BlobFilter filter, Pageable pageable) { Sort sort = resolveSort(filter); @@ -69,12 +79,20 @@ public PagedResult list(BlobFilter filter, Pageable pageable) { b -> BlobResponse.from(b, storageService.resolveUrl(b.getKey()))); } + /** + * Fetches a single blob record by ID, resolving its storage key to a public URL. + */ @Transactional(readOnly = true) public BlobResponse getById(UUID id) { BlobObject blob = findOrThrow(id); return BlobResponse.from(blob, storageService.resolveUrl(blob.getKey())); } + /** + * Validates, stores, and registers an uploaded file. Image files have their dimensions + * extracted at upload time for use in responsive rendering. Enforces the configured maximum + * file size and sanitises the filename before writing to storage. + */ @Transactional public BlobResponse upload(MultipartFile file) { if (file.getSize() > storageProperties.getMaxUploadSize()) { @@ -112,6 +130,10 @@ public BlobResponse upload(MultipartFile file) { return BlobResponse.from(blob, storageService.resolveUrl(key)); } + /** + * Overwrites the binary content of an existing blob at its current storage key, preserving + * all existing references (product images, articles) while updating metadata and dimensions. + */ @Transactional public BlobResponse replace(UUID id, MultipartFile file) { if (file.getSize() > storageProperties.getMaxUploadSize()) { @@ -148,11 +170,19 @@ public BlobResponse replace(UUID id, MultipartFile file) { return BlobResponse.from(blob, storageService.resolveUrl(blob.getKey())); } + /** + * Returns the distinct list of folder names in use, for populating folder-picker UIs + * in the admin media library. + */ @Transactional(readOnly = true) public List listFolders() { return blobRepo.findDistinctFolders(); } + /** + * Returns aggregate storage statistics (total blob count and cumulative byte size) for + * the admin dashboard. + */ @Transactional(readOnly = true) public BlobStatsResponse getStats() { Object[] row = blobRepo.findStats(); @@ -161,6 +191,10 @@ public BlobStatsResponse getStats() { return new BlobStatsResponse(count, bytes); } + /** + * Bulk-assigns a set of blobs to a named folder, or moves them to the root (unorganised) + * when the folder argument is null or blank. + */ @Transactional public void moveToFolder(List ids, String folder) { String target = (folder != null && !folder.isBlank()) ? folder.strip() : null; @@ -169,6 +203,10 @@ public void moveToFolder(List ids, String folder) { blobRepo.saveAll(blobs); } + /** + * Updates the descriptive metadata (alt text, title, folder) of a blob without touching + * the stored binary. Used by the admin media library to improve SEO and accessibility. + */ @Transactional public BlobResponse updateMetadata(UUID id, UpdateBlobRequest req) { BlobObject blob = findOrThrow(id); @@ -181,6 +219,10 @@ public BlobResponse updateMetadata(UUID id, UpdateBlobRequest req) { return BlobResponse.from(blob, storageService.resolveUrl(blob.getKey())); } + /** + * Permanently removes a blob from both the storage backend and the database. Callers should + * check {@link #getUsages} first to avoid breaking product or article images. + */ @Transactional public void delete(UUID id) { BlobObject blob = findOrThrow(id); @@ -188,6 +230,10 @@ public void delete(UUID id) { blobRepo.delete(blob); } + /** + * Bulk-deletes a list of blobs, removing each from storage before purging the database + * records in a single transaction. + */ @Transactional public void bulkDelete(List ids) { List blobs = blobRepo.findAllById(ids); @@ -197,6 +243,10 @@ public void bulkDelete(List ids) { blobRepo.deleteAll(blobs); } + /** + * Enumerates every product image and article image that references this blob, enabling the + * admin UI to warn before a destructive delete. + */ @Transactional(readOnly = true) public List getUsages(UUID id) { findOrThrow(id); diff --git a/src/main/java/io/k2dv/garden/blob/service/S3StorageService.java b/src/main/java/io/k2dv/garden/blob/service/S3StorageService.java index 9478584..26c0c8d 100644 --- a/src/main/java/io/k2dv/garden/blob/service/S3StorageService.java +++ b/src/main/java/io/k2dv/garden/blob/service/S3StorageService.java @@ -12,6 +12,12 @@ import java.io.IOException; import java.io.InputStream; +/** + * AWS S3 (and S3-compatible, e.g., MinIO) implementation of {@link StorageService}. Derives the + * default bucket and base URL from {@link io.k2dv.garden.blob.config.StorageProperties}. Note that + * the S3 client requires an explicit content-length for all uploads; chunked/streaming uploads + * without a known size are not supported. + */ @Service @RequiredArgsConstructor public class S3StorageService implements StorageService { diff --git a/src/main/java/io/k2dv/garden/blob/service/StorageService.java b/src/main/java/io/k2dv/garden/blob/service/StorageService.java index 3c8cfe8..ff443c8 100644 --- a/src/main/java/io/k2dv/garden/blob/service/StorageService.java +++ b/src/main/java/io/k2dv/garden/blob/service/StorageService.java @@ -2,6 +2,11 @@ import java.io.InputStream; +/** + * Abstraction over an object-storage backend (S3, MinIO, or any S3-compatible service). + * All operations are keyed by bucket and object key; convenience overloads default to the + * application's configured public bucket. {@code S3StorageService} is the production implementation. + */ public interface StorageService { /** diff --git a/src/main/java/io/k2dv/garden/blob/service/package-info.java b/src/main/java/io/k2dv/garden/blob/service/package-info.java new file mode 100644 index 0000000..98223e1 --- /dev/null +++ b/src/main/java/io/k2dv/garden/blob/service/package-info.java @@ -0,0 +1,7 @@ +/** + * Service layer for the blob/storage domain. {@code BlobService} is the application-level + * orchestrator for file uploads, metadata management, and lifecycle operations. {@code StorageService} + * is the low-level storage abstraction; {@code S3StorageService} is its AWS S3 / MinIO + * implementation that delegates to the AWS SDK v2 {@code S3Client}. + */ +package io.k2dv.garden.blob.service; diff --git a/src/main/java/io/k2dv/garden/cart/model/package-info.java b/src/main/java/io/k2dv/garden/cart/model/package-info.java new file mode 100644 index 0000000..06ce16b --- /dev/null +++ b/src/main/java/io/k2dv/garden/cart/model/package-info.java @@ -0,0 +1,6 @@ +/** + * JPA entities for the cart module. A {@code Cart} is owned by either a registered user (userId) + * or an anonymous session (sessionId) and may carry a B2B company reference; {@code CartItem} + * holds the variant, quantity, and resolved unit price for each line in the cart. + */ +package io.k2dv.garden.cart.model; diff --git a/src/main/java/io/k2dv/garden/cart/package-info.java b/src/main/java/io/k2dv/garden/cart/package-info.java new file mode 100644 index 0000000..64ee1ff --- /dev/null +++ b/src/main/java/io/k2dv/garden/cart/package-info.java @@ -0,0 +1,6 @@ +/** + * Cart module: manages shopping carts for authenticated users and anonymous guests. + * Supports B2B company context for price-list resolution and volume tiers, CSV bulk-add, + * reorder-from-history, and reusable order templates that load directly into the active cart. + */ +package io.k2dv.garden.cart; diff --git a/src/main/java/io/k2dv/garden/cart/service/CartService.java b/src/main/java/io/k2dv/garden/cart/service/CartService.java index 1890f93..dbaa5f4 100644 --- a/src/main/java/io/k2dv/garden/cart/service/CartService.java +++ b/src/main/java/io/k2dv/garden/cart/service/CartService.java @@ -44,6 +44,12 @@ import java.util.stream.Collectors; import org.springframework.web.multipart.MultipartFile; +/** + * Manages shopping cart lifecycle for both authenticated users and anonymous guests. + * Handles item-level price resolution — delegating to {@link PriceListService} for B2B + * company price-lists and volume tiers — and exposes internal API methods consumed by + * {@code PaymentService} during checkout. + */ @Service @RequiredArgsConstructor @Slf4j @@ -60,6 +66,9 @@ public class CartService { private final OrderItemRepository orderItemRepo; private final AppProperties appProperties; + /** + * Returns the user's active cart, creating one on-the-fly if none exists yet. + */ @Transactional public CartResponse getOrCreateActiveCart(UUID userId) { Cart cart = cartRepo.findByUserIdAndStatus(userId, CartStatus.ACTIVE) @@ -71,6 +80,10 @@ public CartResponse getOrCreateActiveCart(UUID userId) { return toResponse(cart); } + /** + * Associates a B2B company with the user's cart and re-prices all existing items using + * the company's price list; rejects the operation if the user is not a member of that company. + */ @Transactional public CartResponse setCompanyContext(UUID userId, UUID companyId) { if (!membershipRepo.existsByCompanyIdAndUserId(companyId, userId)) { @@ -96,6 +109,10 @@ public CartResponse setCompanyContext(UUID userId, UUID companyId) { return toResponse(cart); } + /** + * Removes the B2B company association from the cart and reverts item prices to the base + * variant retail price; items whose variant no longer exists are retained at their current price. + */ @Transactional public CartResponse clearCompanyContext(UUID userId) { Cart cart = findActiveCartOrThrow(userId); @@ -120,6 +137,11 @@ public CartResponse clearCompanyContext(UUID userId) { return toResponse(cart); } + /** + * Adds a product variant to the authenticated user's cart, accumulating quantity if the + * item already exists. Enforces minimum-order-quantity and rejects quote-only variants + * when no company context is set. Price is resolved from the company price list when applicable. + */ @Transactional public CartResponse addItem(UUID userId, AddCartItemRequest req) { Cart cart = findActiveCartOrThrow(userId); @@ -155,6 +177,10 @@ public CartResponse addItem(UUID userId, AddCartItemRequest req) { return toResponse(cart); } + /** + * Sets an exact quantity on an existing cart item, re-pricing it if the cart has a company + * context (volume tiers may change the unit price). Enforces minimum-order-quantity. + */ @Transactional public CartResponse updateItem(UUID userId, UUID itemId, UpdateCartItemRequest req) { Cart cart = findActiveCartOrThrow(userId); @@ -176,6 +202,9 @@ public CartResponse updateItem(UUID userId, UUID itemId, UpdateCartItemRequest r return toResponse(cart); } + /** + * Removes a single item from the authenticated user's active cart. + */ @Transactional public CartResponse removeItem(UUID userId, UUID itemId) { Cart cart = findActiveCartOrThrow(userId); @@ -185,6 +214,10 @@ public CartResponse removeItem(UUID userId, UUID itemId) { return toResponse(cart); } + /** + * Marks the user's active cart as ABANDONED; typically called on session expiry or explicit + * user logout. No-ops if no active cart exists. + */ @Transactional public void abandonCart(UUID userId) { cartRepo.findByUserIdAndStatus(userId, CartStatus.ACTIVE).ifPresent(cart -> { @@ -195,6 +228,9 @@ public void abandonCart(UUID userId) { // --- Guest cart --- + /** + * Returns the guest cart for the given anonymous session token, creating one if none exists. + */ @Transactional public CartResponse getOrCreateGuestCart(UUID sessionId) { Cart cart = cartRepo.findBySessionIdAndStatus(sessionId, CartStatus.ACTIVE) @@ -206,6 +242,10 @@ public CartResponse getOrCreateGuestCart(UUID sessionId) { return toResponse(cart); } + /** + * Adds a product variant to an anonymous guest cart; quote-only variants are rejected because + * guests cannot carry a company context for price resolution. + */ @Transactional public CartResponse addGuestItem(UUID sessionId, AddCartItemRequest req) { Cart cart = findActiveGuestCartOrThrow(sessionId); @@ -233,6 +273,10 @@ public CartResponse addGuestItem(UUID sessionId, AddCartItemRequest req) { return toResponse(cart); } + /** + * Updates the quantity of an item in a guest cart. No company-based re-pricing is applied + * because guest carts always use base retail prices. + */ @Transactional public CartResponse updateGuestItem(UUID sessionId, UUID itemId, UpdateCartItemRequest req) { Cart cart = findActiveGuestCartOrThrow(sessionId); @@ -243,6 +287,9 @@ public CartResponse updateGuestItem(UUID sessionId, UUID itemId, UpdateCartItemR return toResponse(cart); } + /** + * Removes a single item from an anonymous guest cart. + */ @Transactional public CartResponse removeGuestItem(UUID sessionId, UUID itemId) { Cart cart = findActiveGuestCartOrThrow(sessionId); @@ -252,6 +299,9 @@ public CartResponse removeGuestItem(UUID sessionId, UUID itemId) { return toResponse(cart); } + /** + * Marks the guest cart for the given session as ABANDONED. No-ops if no active cart exists. + */ @Transactional public void abandonGuestCart(UUID sessionId) { cartRepo.findBySessionIdAndStatus(sessionId, CartStatus.ACTIVE).ifPresent(cart -> { @@ -260,6 +310,11 @@ public void abandonGuestCart(UUID sessionId) { }); } + /** + * Replaces the user's current cart contents with items from a previous order, skipping any + * variants that have been deleted or whose products are no longer active. Prices are resolved + * fresh (company price list if applicable) rather than re-used from the original order. + */ @Transactional public CartResponse reorderFromHistory(UUID userId, UUID orderId) { Order order = orderRepo.findById(orderId) @@ -315,11 +370,19 @@ public CartResponse reorderFromHistory(UUID userId, UUID orderId) { // --- Internal API for PaymentService --- + /** + * Returns the raw {@link Cart} entity for an authenticated user, throwing if none is active; + * intended for use by {@code PaymentService} prior to checkout. + */ @Transactional(readOnly = true) public Cart requireActiveCart(UUID userId) { return findActiveCartOrThrow(userId); } + /** + * Returns the raw {@link Cart} entity for a guest session, throwing if none is active; + * intended for use by {@code PaymentService} prior to guest checkout. + */ @Transactional(readOnly = true) public Cart requireActiveGuestCart(UUID sessionId) { return findActiveGuestCartOrThrow(sessionId); @@ -335,11 +398,19 @@ private Cart findActiveGuestCartOrThrow(UUID sessionId) { .orElseThrow(() -> new ValidationException("NO_ACTIVE_CART", "No active guest cart found")); } + /** + * Retrieves all items for the given cart; used by {@code PaymentService} to build the + * Stripe line-item list without re-acquiring the cart entity. + */ @Transactional(readOnly = true) public List getCartItems(UUID cartId) { return cartItemRepo.findByCartId(cartId); } + /** + * Transitions the cart to CHECKED_OUT status once the payment flow has successfully started; + * called by {@code PaymentService} to prevent duplicate checkouts from the same cart. + */ @Transactional public void markCheckedOut(UUID cartId) { cartRepo.findById(cartId).ifPresent(cart -> { @@ -397,6 +468,11 @@ private CartResponse toResponse(Cart cart) { return new CartResponse(cart.getId(), cart.getStatus(), cart.getCompanyId(), items, cart.getCreatedAt()); } + /** + * Parses an uploaded CSV file (columns: SKU, quantity) and adds each valid row to the user's + * cart, reporting per-row success, error, or not-found status in the response. Enforces a + * configurable maximum row count to prevent abuse. + */ @Transactional public BulkAddToCartResponse addItemsFromCsv(UUID userId, MultipartFile file) { if (file.isEmpty()) { diff --git a/src/main/java/io/k2dv/garden/cart/service/package-info.java b/src/main/java/io/k2dv/garden/cart/service/package-info.java new file mode 100644 index 0000000..d0be903 --- /dev/null +++ b/src/main/java/io/k2dv/garden/cart/service/package-info.java @@ -0,0 +1,6 @@ +/** + * Service layer for the cart module. Contains {@code CartService}, which is the single entry point + * for all cart mutations and also exposes an internal API consumed by {@code PaymentService} during + * checkout to atomically lock and transition the cart. + */ +package io.k2dv.garden.cart.service; diff --git a/src/main/java/io/k2dv/garden/collection/package-info.java b/src/main/java/io/k2dv/garden/collection/package-info.java new file mode 100644 index 0000000..25499d4 --- /dev/null +++ b/src/main/java/io/k2dv/garden/collection/package-info.java @@ -0,0 +1,7 @@ +/** + * Collection module for curating and merchandising groups of products (e.g., "Summer Sale", + * "Featured Items"). Collections are either MANUAL (merchants hand-pick members) or AUTOMATED + * (membership is derived from tag-matching rules evaluated by {@code CollectionMembershipService}). + * Both types support an ordered product grid and storefront-facing status management. + */ +package io.k2dv.garden.collection; diff --git a/src/main/java/io/k2dv/garden/collection/service/CollectionMembershipService.java b/src/main/java/io/k2dv/garden/collection/service/CollectionMembershipService.java index 7a4c750..22cba92 100644 --- a/src/main/java/io/k2dv/garden/collection/service/CollectionMembershipService.java +++ b/src/main/java/io/k2dv/garden/collection/service/CollectionMembershipService.java @@ -23,6 +23,11 @@ import java.util.UUID; import java.util.stream.Collectors; +/** + * Maintains the membership relationship between products and AUTOMATED collections by evaluating + * tag-based rules. Provides targeted sync operations triggered by product tag changes or + * collection rule updates, as well as bulk removal when a product is deleted or archived. + */ @Service @RequiredArgsConstructor public class CollectionMembershipService { diff --git a/src/main/java/io/k2dv/garden/collection/service/CollectionService.java b/src/main/java/io/k2dv/garden/collection/service/CollectionService.java index 7f92680..73304da 100644 --- a/src/main/java/io/k2dv/garden/collection/service/CollectionService.java +++ b/src/main/java/io/k2dv/garden/collection/service/CollectionService.java @@ -51,6 +51,12 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; +/** + * Manages curated product collections used for merchandising (e.g., "Summer Sale", + * "Featured"). Supports both manually curated ({@code MANUAL}) and rule-driven + * ({@code AUTOMATED}) collection types, with separate read paths for the admin + * back-office and the customer-facing storefront. + */ @Service @RequiredArgsConstructor public class CollectionService { @@ -66,6 +72,11 @@ public class CollectionService { // --- Collection CRUD --- + /** + * Creates a new collection in DRAFT status, auto-generating a URL handle from the title + * if one is not supplied. Rejects the request if the handle conflicts with an existing + * non-deleted collection. + */ @Transactional public AdminCollectionResponse create(CreateCollectionRequest req) { String handle = req.handle() != null ? req.handle() : slugify(req.title()); @@ -85,11 +96,18 @@ public AdminCollectionResponse create(CreateCollectionRequest req) { return toAdminResponse(saved); } + /** + * Fetches a collection with its full detail (rules, product count) for the admin view. + */ @Transactional(readOnly = true) public AdminCollectionResponse getAdmin(UUID id) { return toAdminResponse(findActiveOrThrow(id)); } + /** + * Returns a paginated admin list of collections with product counts batch-loaded to + * prevent N+1 queries per collection. + */ @Transactional(readOnly = true) public PagedResult listAdmin(CollectionFilterRequest filter, Pageable pageable) { Page page = collectionRepo.findAll(CollectionSpecification.toSpec(filter), pageable); @@ -105,6 +123,10 @@ public PagedResult listAdmin(CollectionFilterReq countMap.getOrDefault(c.getId(), 0L), c.getCreatedAt())); } + /** + * Applies partial updates to a collection's display attributes and SEO fields. + * Handle changes are validated for uniqueness before being applied. + */ @Transactional public AdminCollectionResponse update(UUID id, UpdateCollectionRequest req) { Collection c = findActiveOrThrow(id); @@ -121,6 +143,10 @@ public AdminCollectionResponse update(UUID id, UpdateCollectionRequest req) { return toAdminResponse(collectionRepo.save(c)); } + /** + * Transitions a collection to a new publishing status (e.g., DRAFT to ACTIVE), + * controlling its visibility on the storefront. + */ @Transactional public AdminCollectionResponse changeStatus(UUID id, CollectionStatusRequest req) { Collection c = findActiveOrThrow(id); @@ -128,6 +154,10 @@ public AdminCollectionResponse changeStatus(UUID id, CollectionStatusRequest req return toAdminResponse(collectionRepo.save(c)); } + /** + * Soft-deletes a collection, first purging all product memberships and rules to leave + * no orphaned data. The collection record is retained for historical reference. + */ @Transactional public void softDelete(UUID id) { Collection c = findActiveOrThrow(id); @@ -139,6 +169,9 @@ public void softDelete(UUID id) { // --- Rules --- + /** + * Lists the tag-matching rules that govern automated product membership for a collection. + */ @Transactional(readOnly = true) public List listRules(UUID collectionId) { findActiveOrThrow(collectionId); @@ -146,6 +179,10 @@ public List listRules(UUID collectionId) { .map(this::toRuleResponse).toList(); } + /** + * Adds a tag-matching rule to an AUTOMATED collection and immediately triggers a full + * membership sync so the collection's product set reflects the new rule set. + */ @Transactional public CollectionRuleResponse addRule(UUID collectionId, CreateCollectionRuleRequest req) { Collection c = findActiveOrThrow(collectionId); @@ -162,6 +199,10 @@ public CollectionRuleResponse addRule(UUID collectionId, CreateCollectionRuleReq return toRuleResponse(saved); } + /** + * Removes a rule from an AUTOMATED collection and re-syncs membership so products that + * no longer qualify are immediately removed from the collection. + */ @Transactional public void deleteRule(UUID collectionId, UUID ruleId) { Collection c = findActiveOrThrow(collectionId); @@ -177,6 +218,9 @@ public void deleteRule(UUID collectionId, UUID ruleId) { // --- Manual product membership --- + /** + * Returns the ordered, paginated list of products in a collection, as seen by the admin. + */ @Transactional(readOnly = true) public PagedResult listProducts(UUID collectionId, Pageable pageable) { findActiveOrThrow(collectionId); @@ -192,6 +236,10 @@ public PagedResult listProducts(UUID collectionId, Pa }); } + /** + * Manually adds a product to a MANUAL collection, appending it at the next available + * position. Rejects duplicates and disallows direct membership edits on AUTOMATED collections. + */ @Transactional public CollectionProductResponse addProduct(UUID collectionId, AddCollectionProductRequest req) { Collection c = findActiveOrThrow(collectionId); @@ -213,6 +261,10 @@ public CollectionProductResponse addProduct(UUID collectionId, AddCollectionProd return toProductResponse(saved, product); } + /** + * Manually removes a product from a MANUAL collection. Throws if the product is not + * a current member, or if the collection is AUTOMATED (managed by rules, not by hand). + */ @Transactional public void removeProduct(UUID collectionId, UUID productId) { Collection c = findActiveOrThrow(collectionId); @@ -225,6 +277,10 @@ public void removeProduct(UUID collectionId, UUID productId) { cpRepo.deleteByCollectionIdAndProductId(collectionId, productId); } + /** + * Updates the display position of a product within a collection, allowing merchandisers + * to control the sort order of the product grid. + */ @Transactional public CollectionProductResponse updateProductPosition(UUID collectionId, UUID productId, UpdateCollectionProductPositionRequest req) { @@ -238,6 +294,10 @@ public CollectionProductResponse updateProductPosition(UUID collectionId, UUID p // --- Storefront --- + /** + * Returns a customer-facing paginated list of active collections, each decorated with a + * resolved featured image URL. + */ @Transactional(readOnly = true) public PagedResult listStorefront(Pageable pageable) { Page page = collectionRepo.findAll(CollectionSpecification.storefrontSpec(), pageable); @@ -247,6 +307,10 @@ public PagedResult listStorefront(Pageable pageable) c.getFeaturedImageId() != null ? imageUrls.get(c.getFeaturedImageId()) : null)); } + /** + * Fetches a single active collection by its URL handle for the storefront detail page, + * including SEO metadata and a resolved featured image URL. + */ @Transactional(readOnly = true) public CollectionDetailResponse getByHandle(String handle) { Collection c = collectionRepo.findByHandleAndDeletedAtIsNullAndStatus(handle, CollectionStatus.ACTIVE) @@ -260,6 +324,12 @@ public CollectionDetailResponse getByHandle(String handle) { c.getMetaTitle(), c.getMetaDescription()); } + /** + * Returns the paginated, sorted product listing for a collection's storefront page. + * Supports multiple sort modes including featured (position-based), date, and title. + * B2B catalog restrictions are enforced via {@code companyId}: only products accessible + * to the company are returned when a company context is present. + */ @Transactional(readOnly = true) public PagedResult listProductsStorefront(String handle, int page, int size, String sortBy, String sortDir, UUID companyId) { Collection c = collectionRepo.findByHandleAndDeletedAtIsNullAndStatus(handle, CollectionStatus.ACTIVE) diff --git a/src/main/java/io/k2dv/garden/collection/service/package-info.java b/src/main/java/io/k2dv/garden/collection/service/package-info.java new file mode 100644 index 0000000..4d8325b --- /dev/null +++ b/src/main/java/io/k2dv/garden/collection/service/package-info.java @@ -0,0 +1,7 @@ +/** + * Service layer for the collection domain. {@code CollectionService} manages the collection + * lifecycle, rule authoring, and product membership for both admin and storefront read paths. + * {@code CollectionMembershipService} is the low-level engine that evaluates tag rules and keeps + * AUTOMATED collection memberships consistent whenever products or rules change. + */ +package io.k2dv.garden.collection.service; diff --git a/src/main/java/io/k2dv/garden/config/package-info.java b/src/main/java/io/k2dv/garden/config/package-info.java new file mode 100644 index 0000000..0c8bf18 --- /dev/null +++ b/src/main/java/io/k2dv/garden/config/package-info.java @@ -0,0 +1,6 @@ +/** + * Spring Boot configuration beans for the application, including security filters, + * web MVC settings, async task decorators, OpenAPI documentation, and infrastructure + * components such as the distributed scheduler lock and rate-limit filter. + */ +package io.k2dv.garden.config; diff --git a/src/main/java/io/k2dv/garden/content/package-info.java b/src/main/java/io/k2dv/garden/content/package-info.java new file mode 100644 index 0000000..2464245 --- /dev/null +++ b/src/main/java/io/k2dv/garden/content/package-info.java @@ -0,0 +1,6 @@ +/** + * CMS module covering blog articles and static site pages, each with publish/draft workflow + * and soft-deletion support. Provides separate admin and storefront access paths so draft + * content is never exposed to unauthenticated visitors. + */ +package io.k2dv.garden.content; diff --git a/src/main/java/io/k2dv/garden/content/service/ArticleImageService.java b/src/main/java/io/k2dv/garden/content/service/ArticleImageService.java index ade6ab8..4c141e5 100644 --- a/src/main/java/io/k2dv/garden/content/service/ArticleImageService.java +++ b/src/main/java/io/k2dv/garden/content/service/ArticleImageService.java @@ -18,6 +18,12 @@ import java.util.List; import java.util.UUID; +/** + * Manages the collection of images attached to a CMS article, including upload linkage, + * deletion, and display-order reordering. + * Automatically promotes the first uploaded image to featured status and demotes gracefully + * when the featured image is removed. + */ @Service @RequiredArgsConstructor public class ArticleImageService { @@ -28,6 +34,10 @@ public class ArticleImageService { private final BlobObjectRepository blobRepo; private final StorageService storageService; + /** + * Attaches a pre-uploaded blob to an article as an image, appending it at the next available + * position and auto-assigning it as the featured image when no featured image is set yet. + */ @Transactional public ArticleImageResponse addImage(UUID blogId, UUID articleId, CreateArticleImageRequest req) { verifyBlogExists(blogId); @@ -49,6 +59,10 @@ public ArticleImageResponse addImage(UUID blogId, UUID articleId, CreateArticleI return toResponse(img); } + /** + * Removes an image from an article; if the removed image was the featured image, promotes + * the next image in position order to featured, or clears the featured reference if none remain. + */ @Transactional public void deleteImage(UUID blogId, UUID articleId, UUID imageId) { verifyBlogExists(blogId); @@ -67,6 +81,10 @@ public void deleteImage(UUID blogId, UUID articleId, UUID imageId) { } } + /** + * Applies a caller-specified display order to the images of an article, enabling + * drag-and-drop reordering in the admin UI without creating or deleting any records. + */ @Transactional public void reorderImages(UUID blogId, UUID articleId, List items) { verifyBlogExists(blogId); diff --git a/src/main/java/io/k2dv/garden/content/service/ArticleService.java b/src/main/java/io/k2dv/garden/content/service/ArticleService.java index 92ba38b..460919b 100644 --- a/src/main/java/io/k2dv/garden/content/service/ArticleService.java +++ b/src/main/java/io/k2dv/garden/content/service/ArticleService.java @@ -21,6 +21,11 @@ import java.util.*; import java.util.stream.Collectors; +/** + * Manages the full lifecycle of blogs and their articles, covering both admin (CRUD + status + * transitions) and storefront (published-only) access paths. + * Handles handle uniqueness enforcement, tag management, and image URL resolution on read. + */ @Service @RequiredArgsConstructor public class ArticleService { @@ -35,6 +40,10 @@ public class ArticleService { // ---- Blog operations ---- + /** + * Creates a new blog, auto-generating a URL handle from the title if none is provided. + * Throws {@link io.k2dv.garden.shared.exception.ConflictException} if the handle is already in use. + */ @Transactional public AdminBlogResponse createBlog(CreateBlogRequest req) { String handle = req.handle() != null ? req.handle() : PageService.slugify(req.title(), "blog"); @@ -58,6 +67,10 @@ public AdminBlogResponse getBlog(UUID id) { return toBlogAdminResponse(findBlogOrThrow(id)); } + /** + * Partially updates a blog's title and/or handle, guarding against handle collisions + * with other existing blogs. + */ @Transactional public AdminBlogResponse updateBlog(UUID id, UpdateBlogRequest req) { Blog blog = findBlogOrThrow(id); @@ -77,6 +90,10 @@ public void deleteBlog(UUID id) { blogRepo.delete(blog); } + /** + * Retrieves a blog by its URL handle for storefront display; throws {@link io.k2dv.garden.shared.exception.NotFoundException} + * if no blog with that handle exists. + */ @Transactional(readOnly = true) public BlogResponse getBlogByHandle(String handle) { Blog blog = blogRepo.findByHandle(handle) @@ -95,6 +112,10 @@ public PagedResult listBlogsPublic(BlogFilterRequest filter, Pagea // ---- Article operations ---- + /** + * Creates a new article in draft state under the specified blog, auto-generating a handle + * if not provided and creating any new content tags on the fly. + */ @Transactional public AdminArticleResponse createArticle(UUID blogId, CreateArticleRequest req) { Blog blog = findBlogOrThrow(blogId); @@ -158,6 +179,11 @@ public AdminArticleResponse updateArticle(UUID blogId, UUID articleId, UpdateArt return toArticleAdminResponse(article); } + /** + * Transitions an article's publication status; when publishing, stamps the current time as + * {@code publishedAt} and auto-populates the author name from the user record if not already set. + * Un-publishing clears the {@code publishedAt} timestamp. + */ @Transactional public AdminArticleResponse changeArticleStatus(UUID blogId, UUID articleId, ArticleStatusRequest req) { findBlogOrThrow(blogId); diff --git a/src/main/java/io/k2dv/garden/content/service/PageService.java b/src/main/java/io/k2dv/garden/content/service/PageService.java index 9f16b64..92c8764 100644 --- a/src/main/java/io/k2dv/garden/content/service/PageService.java +++ b/src/main/java/io/k2dv/garden/content/service/PageService.java @@ -17,12 +17,22 @@ import java.time.Instant; import java.util.UUID; +/** + * Manages the full lifecycle of CMS static pages, including draft/publish transitions, + * handle uniqueness enforcement, and soft deletion. + * Exposes separate admin (all statuses) and storefront (published-only) read paths to + * prevent draft content from leaking to unauthenticated users. + */ @Service @RequiredArgsConstructor public class PageService { private final PageRepository pageRepo; + /** + * Creates a new static page in draft state, auto-generating a URL-safe handle from the title + * if none is supplied. Throws {@link io.k2dv.garden.shared.exception.ConflictException} if the handle is already taken. + */ @Transactional public AdminPageResponse create(CreatePageRequest req) { String handle = req.handle() != null ? req.handle() : slugify(req.title(), "page"); @@ -51,6 +61,10 @@ public AdminPageResponse get(UUID id) { return toAdminResponse(findOrThrow(id)); } + /** + * Partially updates a page's content and metadata; only non-null fields are applied, + * guarding against handle collisions with other existing pages. + */ @Transactional public AdminPageResponse update(UUID id, UpdatePageRequest req) { SitePage page = findOrThrow(id); @@ -67,6 +81,11 @@ public AdminPageResponse update(UUID id, UpdatePageRequest req) { return toAdminResponse(page); } + /** + * Transitions a page's publication status; publishing stamps the current timestamp as + * {@code publishedAt} so the storefront knows when the page went live. + * Un-publishing clears that timestamp and hides the page from the public listing. + */ @Transactional public AdminPageResponse changeStatus(UUID id, PageStatusRequest req) { SitePage page = findOrThrow(id); @@ -79,12 +98,20 @@ public AdminPageResponse changeStatus(UUID id, PageStatusRequest req) { return toAdminResponse(page); } + /** + * Soft-deletes a page by stamping {@code deletedAt}, preserving the record for audit + * purposes while immediately removing it from all storefront queries. + */ @Transactional public void delete(UUID id) { SitePage page = findOrThrow(id); page.setDeletedAt(Instant.now()); } + /** + * Fetches a published page by its URL handle for storefront rendering; throws + * {@link io.k2dv.garden.shared.exception.NotFoundException} if the handle does not correspond to a live page. + */ @Transactional(readOnly = true) public PageResponse getByHandle(String handle) { var pages = pageRepo.findAll( diff --git a/src/main/java/io/k2dv/garden/content/service/package-info.java b/src/main/java/io/k2dv/garden/content/service/package-info.java new file mode 100644 index 0000000..79ffebf --- /dev/null +++ b/src/main/java/io/k2dv/garden/content/service/package-info.java @@ -0,0 +1,6 @@ +/** + * Service layer for the content module, containing {@code ArticleService} for blog and article + * lifecycle, {@code ArticleImageService} for article media management, and {@code PageService} + * for static page CRUD and status transitions. + */ +package io.k2dv.garden.content.service; diff --git a/src/main/java/io/k2dv/garden/discount/model/package-info.java b/src/main/java/io/k2dv/garden/discount/model/package-info.java new file mode 100644 index 0000000..9fe7631 --- /dev/null +++ b/src/main/java/io/k2dv/garden/discount/model/package-info.java @@ -0,0 +1,6 @@ +/** + * JPA entities for the discount module. {@code Discount} represents a coupon or automatic + * promotion, and {@code DiscountType} enumerates the supported discount strategies + * (PERCENTAGE, FIXED_AMOUNT, FREE_SHIPPING). + */ +package io.k2dv.garden.discount.model; diff --git a/src/main/java/io/k2dv/garden/discount/package-info.java b/src/main/java/io/k2dv/garden/discount/package-info.java new file mode 100644 index 0000000..eff6dfc --- /dev/null +++ b/src/main/java/io/k2dv/garden/discount/package-info.java @@ -0,0 +1,6 @@ +/** + * Discount module: manages coupon codes and automatic promotions with usage tracking. Supports + * fixed-amount, percentage, and free-shipping discount types with optional minimum order + * thresholds, date-validity windows, global usage caps, and company-scoped eligibility. + */ +package io.k2dv.garden.discount; diff --git a/src/main/java/io/k2dv/garden/discount/service/DiscountService.java b/src/main/java/io/k2dv/garden/discount/service/DiscountService.java index b928891..6cc1d49 100644 --- a/src/main/java/io/k2dv/garden/discount/service/DiscountService.java +++ b/src/main/java/io/k2dv/garden/discount/service/DiscountService.java @@ -26,23 +26,38 @@ import java.util.List; import java.util.UUID; +/** + * Manages the lifecycle of discount codes and automatic promotions, including creation, + * validation, and atomic redemption at checkout. Supports percentage, fixed-amount, and + * free-shipping discount types with optional minimum order thresholds, date windows, usage + * caps, and company-scoped eligibility. + */ @Service @RequiredArgsConstructor public class DiscountService { private final DiscountRepository discountRepo; + /** + * Returns a paginated list of discounts, optionally filtered by status, type, or other + * criteria defined in {@link io.k2dv.garden.discount.dto.DiscountFilter}. + */ @Transactional(readOnly = true) public PagedResult list(DiscountFilter filter, Pageable pageable) { return PagedResult.of(discountRepo.findAll(DiscountSpecification.toSpec(filter), pageable), DiscountResponse::from); } + /** Retrieves a single discount by its ID, throwing {@code NotFoundException} if absent. */ @Transactional(readOnly = true) public DiscountResponse getById(UUID id) { return DiscountResponse.from(findOrThrow(id)); } + /** + * Creates a new discount, enforcing that non-automatic promotions must have a unique code + * and that the active date range is valid. Codes are stored in uppercase. + */ @Transactional public DiscountResponse create(CreateDiscountRequest req) { if (req.startsAt() != null && req.endsAt() != null && !req.startsAt().isBefore(req.endsAt())) { @@ -68,6 +83,11 @@ public DiscountResponse create(CreateDiscountRequest req) { return DiscountResponse.from(discountRepo.save(d)); } + /** + * Updates mutable fields of an existing discount. Date-range consistency is re-validated + * against the merged effective dates, and code uniqueness is checked if the code is being + * changed. This operation is recorded in the audit log. + */ @Audited(entityType = "discount", entityId = "#id") @Transactional public DiscountResponse update(UUID id, UpdateDiscountRequest req) { @@ -97,6 +117,10 @@ public DiscountResponse update(UUID id, UpdateDiscountRequest req) { return DiscountResponse.from(discountRepo.save(d)); } + /** + * Permanently removes a discount. This operation is recorded in the audit log and cannot + * be undone; consider deactivating instead of deleting if historical orders reference the code. + */ @Audited(entityType = "discount", entityId = "#id") @Transactional public void delete(UUID id) { @@ -104,11 +128,19 @@ public void delete(UUID id) { discountRepo.delete(d); } + /** + * Checks whether a discount code is eligible for the given order amount without consuming + * a usage slot. Use this for real-time coupon validation in the checkout UI. + */ @Transactional(readOnly = true) public DiscountValidationResponse validate(String code, BigDecimal orderAmount) { return validate(code, orderAmount, null); } + /** + * Company-scoped variant of {@link #validate(String, BigDecimal)}: also enforces that + * company-restricted discounts may only be used by members of the matching company. + */ @Transactional(readOnly = true) public DiscountValidationResponse validate(String code, BigDecimal orderAmount, UUID companyId) { Discount d = discountRepo.findByCodeIgnoreCase(code).orElse(null); @@ -123,6 +155,11 @@ public DiscountValidationResponse validate(String code, BigDecimal orderAmount, return new DiscountValidationResponse(true, d.getCode(), d.getType(), d.getValue(), discountedAmount, null); } + /** + * Finds the automatic promotion that yields the highest discount for the given order, + * considering only currently active, company-eligible promotions. Returns empty if none + * apply. Does not consume any usage slots. + */ @Transactional(readOnly = true) public java.util.Optional findBestAutomatic(BigDecimal orderAmount, UUID companyId) { List candidates = discountRepo.findActiveAutomatic(Instant.now(), companyId); @@ -133,6 +170,11 @@ public java.util.Optional findBestAutomatic(BigDecimal orde .max(java.util.Comparator.comparing(DiscountApplication::discountedAmount)); } + /** + * Increments the usage counter for an automatic promotion and returns the computed + * discount amount. Intended for the order-placement flow after {@link #findBestAutomatic} + * has already selected the winning promotion. + */ @Transactional public DiscountApplication applyAutomatic(UUID discountId, BigDecimal orderAmount) { Discount d = discountRepo.findById(discountId) @@ -142,11 +184,21 @@ public DiscountApplication applyAutomatic(UUID discountId, BigDecimal orderAmoun calculateDiscount(d, orderAmount)); } + /** + * Validates and atomically redeems a discount code at checkout without company scoping. + * Delegates to the company-scoped overload with a null company ID. + */ @Transactional public DiscountApplication redeem(String code, BigDecimal orderAmount) { return redeem(code, orderAmount, null); } + /** + * Validates eligibility and atomically increments the usage counter for a coupon code, + * preventing over-redemption under concurrent checkout traffic. Throws + * {@code ValidationException} if the code is ineligible and {@code ConflictException} + * if the usage cap was reached between validation and the DB update. + */ @Transactional public DiscountApplication redeem(String code, BigDecimal orderAmount, UUID companyId) { Discount d = discountRepo.findByCodeIgnoreCaseForUpdate(code) diff --git a/src/main/java/io/k2dv/garden/discount/service/package-info.java b/src/main/java/io/k2dv/garden/discount/service/package-info.java new file mode 100644 index 0000000..9b6cac2 --- /dev/null +++ b/src/main/java/io/k2dv/garden/discount/service/package-info.java @@ -0,0 +1,6 @@ +/** + * Service layer for the discount module. Contains {@code DiscountService}, which handles + * coupon creation, eligibility validation, and atomic redemption at checkout to prevent + * over-use under concurrent traffic. + */ +package io.k2dv.garden.discount.service; diff --git a/src/main/java/io/k2dv/garden/fulfillment/package-info.java b/src/main/java/io/k2dv/garden/fulfillment/package-info.java new file mode 100644 index 0000000..8ab593b --- /dev/null +++ b/src/main/java/io/k2dv/garden/fulfillment/package-info.java @@ -0,0 +1,6 @@ +/** + * Fulfillment module: tracks shipments and fulfillment status per order, supporting partial + * fulfillment where items may ship in separate packages with independent carrier tracking numbers. + * Automatically re-derives the parent order status after each fulfillment change. + */ +package io.k2dv.garden.fulfillment; diff --git a/src/main/java/io/k2dv/garden/fulfillment/service/FulfillmentService.java b/src/main/java/io/k2dv/garden/fulfillment/service/FulfillmentService.java index af63f71..6b5a017 100644 --- a/src/main/java/io/k2dv/garden/fulfillment/service/FulfillmentService.java +++ b/src/main/java/io/k2dv/garden/fulfillment/service/FulfillmentService.java @@ -37,6 +37,12 @@ import java.util.UUID; import java.util.stream.Collectors; +/** + * Manages the shipment lifecycle for paid orders, supporting partial fulfillment where different + * items may ship in separate packages. Automatically re-derives the parent order status + * (PAID / PARTIALLY_FULFILLED / FULFILLED) after each change and dispatches shipping and delivery + * notifications to customers and outbound webhooks. + */ @Service @RequiredArgsConstructor public class FulfillmentService { @@ -52,6 +58,11 @@ public class FulfillmentService { private final OutboundWebhookService outboundWebhookService; private final NotificationPreferenceService notificationPreferenceService; + /** + * Records a new shipment against a paid order, validating that each requested line-item quantity + * does not exceed the ordered quantity minus what has already been fulfilled. Updates the parent + * order status after saving. + */ @Transactional public FulfillmentResponse create(UUID orderId, CreateFulfillmentRequest req, User admin) { Order order = orderRepo.findById(orderId) @@ -113,6 +124,11 @@ public FulfillmentResponse create(UUID orderId, CreateFulfillmentRequest req, Us return toResponse(f); } + /** + * Advances a fulfillment through its status machine (PENDING → SHIPPED → DELIVERED or CANCELLED) + * and patches mutable carrier tracking fields. Transitioning to SHIPPED or DELIVERED triggers + * customer notification emails and outbound webhook delivery. + */ @Transactional public FulfillmentResponse update(UUID orderId, UUID fulfillmentId, UpdateFulfillmentRequest req) { Fulfillment f = fulfillmentRepo.findByIdAndOrderId(fulfillmentId, orderId) @@ -155,6 +171,9 @@ public FulfillmentResponse update(UUID orderId, UUID fulfillmentId, UpdateFulfil return toResponse(f); } + /** + * Returns all fulfillments for an order with their line items, batch-fetched to avoid per-fulfillment queries. + */ @Transactional(readOnly = true) public List list(UUID orderId) { List fulfillments = fulfillmentRepo.findByOrderId(orderId); @@ -172,6 +191,9 @@ public List list(UUID orderId) { .toList(); } + /** + * Retrieves a single fulfillment record, verifying it belongs to the specified order. + */ @Transactional(readOnly = true) public FulfillmentResponse getById(UUID orderId, UUID fulfillmentId) { Fulfillment f = fulfillmentRepo.findByIdAndOrderId(fulfillmentId, orderId) diff --git a/src/main/java/io/k2dv/garden/fulfillment/service/package-info.java b/src/main/java/io/k2dv/garden/fulfillment/service/package-info.java new file mode 100644 index 0000000..b9a04c1 --- /dev/null +++ b/src/main/java/io/k2dv/garden/fulfillment/service/package-info.java @@ -0,0 +1,6 @@ +/** + * Service layer for the fulfillment module. {@code FulfillmentService} enforces the shipment + * status machine, prevents over-fulfillment, recalculates order status after each change, and + * dispatches customer shipping and delivery notifications via email and outbound webhooks. + */ +package io.k2dv.garden.fulfillment.service; diff --git a/src/main/java/io/k2dv/garden/giftcard/model/package-info.java b/src/main/java/io/k2dv/garden/giftcard/model/package-info.java new file mode 100644 index 0000000..88289df --- /dev/null +++ b/src/main/java/io/k2dv/garden/giftcard/model/package-info.java @@ -0,0 +1,6 @@ +/** + * JPA entities for the gift card module. {@code GiftCard} holds the card code, current and + * initial balance, expiry, and activation state; {@code GiftCardTransaction} records every + * credit and debit against a card as an append-only audit trail. + */ +package io.k2dv.garden.giftcard.model; diff --git a/src/main/java/io/k2dv/garden/giftcard/package-info.java b/src/main/java/io/k2dv/garden/giftcard/package-info.java new file mode 100644 index 0000000..8d3d35e --- /dev/null +++ b/src/main/java/io/k2dv/garden/giftcard/package-info.java @@ -0,0 +1,6 @@ +/** + * Gift card module: handles issuance, redemption, and per-card balance tracking. Gift cards + * can be applied at checkout to reduce the order total; partial redemption is supported, and + * every balance change is recorded as an immutable transaction for auditability. + */ +package io.k2dv.garden.giftcard; diff --git a/src/main/java/io/k2dv/garden/giftcard/service/GiftCardService.java b/src/main/java/io/k2dv/garden/giftcard/service/GiftCardService.java index 8506652..7fbe5a9 100644 --- a/src/main/java/io/k2dv/garden/giftcard/service/GiftCardService.java +++ b/src/main/java/io/k2dv/garden/giftcard/service/GiftCardService.java @@ -30,6 +30,12 @@ import java.util.List; import java.util.UUID; +/** + * Manages the full lifecycle of gift cards: issuance (with auto-generated or custom codes), + * balance tracking, administrative adjustments, and atomic redemption at checkout. Partial + * redemption is supported—only the amount needed to cover the order is deducted, leaving any + * remaining balance for future use. + */ @Service @RequiredArgsConstructor public class GiftCardService { @@ -40,6 +46,10 @@ public class GiftCardService { private final GiftCardRepository giftCardRepo; private final GiftCardTransactionRepository txRepo; + /** + * Returns a paginated list of gift cards, optionally filtered by active status or partial + * code match (case-insensitive). + */ @Transactional(readOnly = true) public PagedResult list(GiftCardFilter filter, Pageable pageable) { Specification spec = (root, query, cb) -> { @@ -56,11 +66,16 @@ public PagedResult list(GiftCardFilter filter, Pageable pageab return PagedResult.of(giftCardRepo.findAll(spec, pageable), GiftCardResponse::from); } + /** Retrieves a gift card by its internal ID, throwing {@code NotFoundException} if absent. */ @Transactional(readOnly = true) public GiftCardResponse getById(UUID id) { return GiftCardResponse.from(findOrThrow(id)); } + /** + * Returns the full chronological transaction history for a gift card, covering issuance + * credits, redemptions, and administrative adjustments. + */ @Transactional(readOnly = true) public List listTransactions(UUID id) { findOrThrow(id); @@ -68,6 +83,11 @@ public List listTransactions(UUID id) { .stream().map(GiftCardTransactionResponse::from).toList(); } + /** + * Issues a new gift card. If no code is provided, a secure random code in the format + * {@code GIFT-XXXX-XXXX-XXXX-XXXX} is generated. The initial and current balance are + * set to the same value; uniqueness of the code is enforced. + */ @Transactional public GiftCardResponse create(CreateGiftCardRequest req) { String code = req.code() != null && !req.code().isBlank() @@ -90,6 +110,10 @@ public GiftCardResponse create(CreateGiftCardRequest req) { return GiftCardResponse.from(giftCardRepo.save(g)); } + /** + * Updates administrative metadata on a gift card (expiry date, note, recipient email). + * Balance changes must go through {@link #addTransaction} to maintain an audit trail. + */ @Transactional public GiftCardResponse update(UUID id, UpdateGiftCardRequest req) { GiftCard g = findOrThrow(id); @@ -99,6 +123,11 @@ public GiftCardResponse update(UUID id, UpdateGiftCardRequest req) { return GiftCardResponse.from(giftCardRepo.save(g)); } + /** + * Deactivates a gift card and zeroes its balance, recording a compensating transaction + * so the balance reduction is visible in the transaction history. No-ops if the card is + * already inactive. + */ @Transactional public GiftCardResponse deactivate(UUID id) { GiftCard g = findOrThrow(id); @@ -113,6 +142,11 @@ public GiftCardResponse deactivate(UUID id) { return GiftCardResponse.from(giftCardRepo.save(g)); } + /** + * Posts a manual balance adjustment (positive to credit, negative to debit) to a gift + * card. Rejects the operation if the resulting balance would fall below zero, preserving + * the invariant that a card's balance is never negative. + */ @Transactional public GiftCardTransactionResponse addTransaction(UUID id, GiftCardTransactionRequest req) { GiftCard g = findOrThrow(id); @@ -126,6 +160,10 @@ public GiftCardTransactionResponse addTransaction(UUID id, GiftCardTransactionRe return GiftCardTransactionResponse.from(recordTransaction(g.getId(), req.delta(), null, req.note())); } + /** + * Returns the transaction history for a gift card looked up by its public code, useful + * for customer-facing balance-check flows where only the code is known. + */ @Transactional(readOnly = true) public List listTransactionsByCode(String code) { GiftCard g = giftCardRepo.findByCodeIgnoreCase(code) @@ -135,6 +173,11 @@ public List listTransactionsByCode(String code) { .stream().map(GiftCardTransactionResponse::from).toList(); } + /** + * Checks whether a gift card code is active, unexpired, and has a positive balance + * without touching the balance. Use this for real-time validation in the checkout UI + * before committing to a redemption. + */ @Transactional(readOnly = true) public GiftCardValidationResponse validate(String code) { GiftCard g = giftCardRepo.findByCodeIgnoreCase(code).orElse(null); @@ -144,6 +187,12 @@ public GiftCardValidationResponse validate(String code) { return new GiftCardValidationResponse(true, g.getCode(), g.getCurrentBalance(), g.getCurrency(), null); } + /** + * Applies a gift card to an order at checkout, debiting the lesser of the card's current + * balance and the order total. The debit is performed atomically to prevent double-spend + * under concurrent requests, and a transaction record is written against the order ID. + * Currency mismatch between the card and the order is rejected outright. + */ @Transactional public GiftCardApplication redeem(String code, BigDecimal orderAmount, UUID orderId, String orderCurrency) { GiftCard g = giftCardRepo.findByCodeIgnoreCase(code) diff --git a/src/main/java/io/k2dv/garden/giftcard/service/package-info.java b/src/main/java/io/k2dv/garden/giftcard/service/package-info.java new file mode 100644 index 0000000..362e5f4 --- /dev/null +++ b/src/main/java/io/k2dv/garden/giftcard/service/package-info.java @@ -0,0 +1,6 @@ +/** + * Service layer for the gift card module. Contains {@code GiftCardService}, which manages + * the full gift card lifecycle including secure code generation, administrative adjustments, + * and atomic balance deduction at checkout to prevent double-spend. + */ +package io.k2dv.garden.giftcard.service; diff --git a/src/main/java/io/k2dv/garden/iam/model/package-info.java b/src/main/java/io/k2dv/garden/iam/model/package-info.java new file mode 100644 index 0000000..e4ad753 --- /dev/null +++ b/src/main/java/io/k2dv/garden/iam/model/package-info.java @@ -0,0 +1,6 @@ +/** + * JPA entity types for the IAM module: {@code Role} (a named bundle of permissions) + * and {@code Permission} (a single capability string such as "order:read"). Roles are + * assigned to users via a many-to-many join managed by the User entity in the user module. + */ +package io.k2dv.garden.iam.model; diff --git a/src/main/java/io/k2dv/garden/iam/package-info.java b/src/main/java/io/k2dv/garden/iam/package-info.java new file mode 100644 index 0000000..32c7214 --- /dev/null +++ b/src/main/java/io/k2dv/garden/iam/package-info.java @@ -0,0 +1,7 @@ +/** + * IAM (Identity and Access Management) module: defines roles, permissions, and their + * assignments to users for role-based access control across the platform. Permission + * strings (e.g. "order:read", "product:write") are resolved by the IAM service and + * embedded in JWT claims at token-mint time by the authentication layer. + */ +package io.k2dv.garden.iam; diff --git a/src/main/java/io/k2dv/garden/iam/service/IamService.java b/src/main/java/io/k2dv/garden/iam/service/IamService.java index 73320f4..bc6f315 100644 --- a/src/main/java/io/k2dv/garden/iam/service/IamService.java +++ b/src/main/java/io/k2dv/garden/iam/service/IamService.java @@ -15,6 +15,12 @@ import java.util.List; import java.util.UUID; +/** + * Core IAM service responsible for resolving a user's effective permission set and + * managing role assignments. The {@link #loadPermissionsForUser} result is cached per user + * and embedded in JWT claims at token-mint time; cache entries are evicted whenever a + * role is assigned or removed so the next token always reflects the current state. + */ @Service @RequiredArgsConstructor public class IamService { @@ -23,6 +29,12 @@ public class IamService { private final RoleRepository roleRepo; private final PermissionRepository permissionRepo; + /** + * Returns the full list of permission strings (e.g. "order:read", "product:write") + * that are effective for this user. Users with the OWNER role receive every permission + * in the system without needing explicit role assignments. + * Results are cached under the "permissions" cache keyed by userId. + */ @Cacheable(value = "permissions", key = "#userId") @Transactional(readOnly = true) public List loadPermissionsForUser(UUID userId) { @@ -33,6 +45,10 @@ public List loadPermissionsForUser(UUID userId) { return userRepo.findPermissionNamesByUserId(userId); } + /** + * Grants a named role to a user and evicts their cached permissions so the change + * takes effect on the next token refresh. + */ @CacheEvict(value = "permissions", key = "#userId") @Transactional public void assignRoleByName(UUID userId, String roleName) { @@ -44,6 +60,10 @@ public void assignRoleByName(UUID userId, String roleName) { userRepo.save(user); } + /** + * Revokes a named role from a user and evicts their cached permissions so the + * reduced permission set is reflected on the next token refresh. + */ @CacheEvict(value = "permissions", key = "#userId") @Transactional public void removeRoleByName(UUID userId, String roleName) { diff --git a/src/main/java/io/k2dv/garden/iam/service/package-info.java b/src/main/java/io/k2dv/garden/iam/service/package-info.java new file mode 100644 index 0000000..662579d --- /dev/null +++ b/src/main/java/io/k2dv/garden/iam/service/package-info.java @@ -0,0 +1,6 @@ +/** + * Service layer for the IAM module, providing permission resolution with caching and + * role assignment operations. The primary consumer is the authentication token pipeline; + * the admin IAM service in {@code admin/iam} handles catalog management of roles and permissions. + */ +package io.k2dv.garden.iam.service; diff --git a/src/main/java/io/k2dv/garden/inventory/model/package-info.java b/src/main/java/io/k2dv/garden/inventory/model/package-info.java new file mode 100644 index 0000000..e6caedd --- /dev/null +++ b/src/main/java/io/k2dv/garden/inventory/model/package-info.java @@ -0,0 +1,7 @@ +/** + * JPA entity classes for the inventory domain. {@code InventoryItem} links a product variant to + * the inventory system; {@code InventoryLevel} holds the per-location stock counters + * ({@code quantityOnHand} and {@code quantityCommitted}); {@code InventoryTransaction} is the + * append-only audit ledger; and {@code Location} represents a physical or logical stock-holding site. + */ +package io.k2dv.garden.inventory.model; diff --git a/src/main/java/io/k2dv/garden/inventory/package-info.java b/src/main/java/io/k2dv/garden/inventory/package-info.java new file mode 100644 index 0000000..35312a4 --- /dev/null +++ b/src/main/java/io/k2dv/garden/inventory/package-info.java @@ -0,0 +1,8 @@ +/** + * Inventory module for tracking stock levels per variant and warehouse location. Implements a + * two-phase reservation model: stock is committed (reserved) on order placement, deducted from + * on-hand when payment is captured, and released back to available when an order is cancelled. + * Locations can be physical warehouses or logical fulfilment nodes; stock movements are recorded + * as immutable {@code InventoryTransaction} entries for audit purposes. + */ +package io.k2dv.garden.inventory; diff --git a/src/main/java/io/k2dv/garden/inventory/service/InventoryService.java b/src/main/java/io/k2dv/garden/inventory/service/InventoryService.java index 55e6144..b2bbd56 100644 --- a/src/main/java/io/k2dv/garden/inventory/service/InventoryService.java +++ b/src/main/java/io/k2dv/garden/inventory/service/InventoryService.java @@ -28,6 +28,11 @@ import java.util.UUID; import java.util.stream.Collectors; +/** + * Manages stock levels, reservations, and the sales ledger across one or more warehouse + * locations. Implements a two-phase commit model: stock is reserved on order placement, + * confirmed (deducted) on payment capture, and released back on cancellation. + */ @Service @RequiredArgsConstructor public class InventoryService { @@ -39,6 +44,10 @@ public class InventoryService { private final ProductVariantRepository variantRepo; private final ProductOptionRepository optionRepo; + /** + * Returns the stock levels for a variant across all locations it has been stocked at, + * including both on-hand quantity and the quantity currently committed to open orders. + */ @Transactional(readOnly = true) public List getLevels(UUID variantId) { InventoryItem item = findItemByVariant(variantId); @@ -47,6 +56,11 @@ public List getLevels(UUID variantId) { .toList(); } + /** + * Records a stock receipt at a specific location, creating the inventory level record if + * this is the first time stock has been received there. Also writes a RECEIVED transaction + * for audit purposes. + */ @Transactional public InventoryLevelResponse receiveStock(UUID variantId, ReceiveStockRequest req) { InventoryItem item = findItemByVariant(variantId); @@ -69,6 +83,12 @@ public InventoryLevelResponse receiveStock(UUID variantId, ReceiveStockRequest r return toLevelResponse(level); } + /** + * Applies a positive or negative stock correction (e.g., damage write-off, count + * reconciliation). RECEIVED and SOLD reasons are reserved for system use and will be + * rejected; use {@link #receiveStock} for inbound stock. Negative adjustments that would + * push on-hand below zero are blocked when the variant's inventory policy is DENY. + */ @Transactional public InventoryLevelResponse adjustStock(UUID variantId, AdjustStockRequest req) { if (req.reason() == InventoryTransactionReason.RECEIVED @@ -104,6 +124,10 @@ public InventoryLevelResponse adjustStock(UUID variantId, AdjustStockRequest req return toLevelResponse(level); } + /** + * Returns a paginated audit trail of all stock movements for a variant, optionally + * filtered to a single location. + */ @Transactional(readOnly = true) public PagedResult listTransactions( UUID variantId, UUID locationId, Pageable pageable) { @@ -114,6 +138,11 @@ public PagedResult listTransactions( return PagedResult.of(page, this::toTxnResponse); } + /** + * Updates fulfillment-related settings on a variant (fulfillment type, inventory policy, + * lead time), which control how the order management system handles this item at checkout + * and pick/pack time. + */ @Transactional public AdminVariantResponse updateVariantFulfillment(UUID variantId, UpdateVariantFulfillmentRequest req) { ProductVariant variant = variantRepo.findById(variantId) @@ -174,6 +203,11 @@ private AdminVariantResponse toVariantResponse(ProductVariant v) { v.getMinimumOrderQty(), v.getDeletedAt()); } + /** + * Reserves stock across locations on order placement, incrementing the committed quantity + * using a pessimistic lock to prevent overselling. Throws if total available stock + * (on-hand minus committed) is insufficient. + */ @Transactional public void reserveStock(UUID variantId, int quantity) { InventoryItem item = findItemByVariant(variantId); @@ -201,6 +235,10 @@ public void reserveStock(UUID variantId, int quantity) { } } + /** + * Releases a previously made stock reservation on order cancellation, decrementing the + * committed quantity so the stock becomes available to other orders again. + */ @Transactional public void releaseReservation(UUID variantId, int quantity) { InventoryItem item = findItemByVariant(variantId); @@ -218,6 +256,10 @@ public void releaseReservation(UUID variantId, int quantity) { } } + /** + * Confirms a sale on payment capture by deducting the quantity from both on-hand stock + * and the committed reservation, and writing a SOLD transaction for the audit ledger. + */ @Transactional public void confirmSale(UUID variantId, int quantity) { InventoryItem item = findItemByVariant(variantId); diff --git a/src/main/java/io/k2dv/garden/inventory/service/LocationService.java b/src/main/java/io/k2dv/garden/inventory/service/LocationService.java index df96b30..88e6c0f 100644 --- a/src/main/java/io/k2dv/garden/inventory/service/LocationService.java +++ b/src/main/java/io/k2dv/garden/inventory/service/LocationService.java @@ -13,12 +13,21 @@ import java.util.List; import java.util.UUID; +/** + * Manages physical or logical stock-holding locations (e.g., warehouses, retail stores) + * used by the inventory system to track per-location stock levels. Locations can be + * deactivated without deletion to preserve historical transaction data. + */ @Service @RequiredArgsConstructor public class LocationService { private final LocationRepository locationRepo; + /** + * Registers a new stock location that can subsequently receive inventory and be referenced + * in fulfillment workflows. + */ @Transactional public LocationResponse create(CreateLocationRequest req) { Location loc = new Location(); @@ -37,6 +46,10 @@ public LocationResponse get(UUID id) { return toResponse(findOrThrow(id)); } + /** + * Partially updates a location's name or address without affecting its active status or + * linked inventory levels. + */ @Transactional public LocationResponse update(UUID id, UpdateLocationRequest req) { Location loc = findOrThrow(id); @@ -45,12 +58,20 @@ public LocationResponse update(UUID id, UpdateLocationRequest req) { return toResponse(locationRepo.save(loc)); } + /** + * Marks a location as inactive so it no longer appears in receiving or fulfillment + * workflows, while preserving its inventory history. + */ @Transactional public void deactivate(UUID id) { Location loc = findOrThrow(id); loc.setActive(false); } + /** + * Re-enables a previously deactivated location so it can once again be used for receiving + * stock and fulfilling orders. + */ @Transactional public LocationResponse reactivate(UUID id) { Location loc = findOrThrow(id); diff --git a/src/main/java/io/k2dv/garden/inventory/service/package-info.java b/src/main/java/io/k2dv/garden/inventory/service/package-info.java new file mode 100644 index 0000000..95d61e8 --- /dev/null +++ b/src/main/java/io/k2dv/garden/inventory/service/package-info.java @@ -0,0 +1,7 @@ +/** + * Service layer for the inventory domain. {@code InventoryService} is the central coordinator for + * stock receipt, manual adjustments, reservations, and sale confirmation; it uses pessimistic + * locking to prevent overselling across concurrent requests. {@code LocationService} manages the + * warehouse and store locations that inventory levels are scoped to. + */ +package io.k2dv.garden.inventory.service; diff --git a/src/main/java/io/k2dv/garden/newsletter/package-info.java b/src/main/java/io/k2dv/garden/newsletter/package-info.java new file mode 100644 index 0000000..c3dc884 --- /dev/null +++ b/src/main/java/io/k2dv/garden/newsletter/package-info.java @@ -0,0 +1,6 @@ +/** + * Newsletter subscription and unsubscription lifecycle module. + * Collects email addresses from storefront sign-up forms, normalises them, and maintains + * an idempotent subscriber list suitable for export to external mailing platforms. + */ +package io.k2dv.garden.newsletter; diff --git a/src/main/java/io/k2dv/garden/newsletter/service/NewsletterService.java b/src/main/java/io/k2dv/garden/newsletter/service/NewsletterService.java index d292257..ae9fbf8 100644 --- a/src/main/java/io/k2dv/garden/newsletter/service/NewsletterService.java +++ b/src/main/java/io/k2dv/garden/newsletter/service/NewsletterService.java @@ -8,12 +8,22 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * Handles newsletter subscription sign-ups, normalising email addresses and + * enforcing idempotency so that re-subscribing an already-subscribed address + * is a no-op rather than an error. + */ @Service @RequiredArgsConstructor public class NewsletterService { private final NewsletterSubscriberRepository repo; + /** + * Subscribes an email address to the newsletter, creating a new subscriber record if + * this email has not been seen before. The response indicates whether the address was + * already subscribed, which callers can use to tailor confirmation messaging. + */ @Transactional public SubscribeResponse subscribe(SubscribeRequest req) { String email = req.email().trim().toLowerCase(); diff --git a/src/main/java/io/k2dv/garden/newsletter/service/package-info.java b/src/main/java/io/k2dv/garden/newsletter/service/package-info.java new file mode 100644 index 0000000..3db360f --- /dev/null +++ b/src/main/java/io/k2dv/garden/newsletter/service/package-info.java @@ -0,0 +1,5 @@ +/** + * Service layer for the newsletter module, encapsulating subscription business rules + * such as email normalisation and idempotent re-subscribe handling. + */ +package io.k2dv.garden.newsletter.service; diff --git a/src/main/java/io/k2dv/garden/notification/package-info.java b/src/main/java/io/k2dv/garden/notification/package-info.java new file mode 100644 index 0000000..0e8b31e --- /dev/null +++ b/src/main/java/io/k2dv/garden/notification/package-info.java @@ -0,0 +1,6 @@ +/** + * Per-user opt-in/opt-out settings for transactional email notification types. + * Every notification-sending service should consult {@code NotificationPreferenceService} + * before dispatching an email to honour the user's communication preferences. + */ +package io.k2dv.garden.notification; diff --git a/src/main/java/io/k2dv/garden/notification/service/NotificationPreferenceService.java b/src/main/java/io/k2dv/garden/notification/service/NotificationPreferenceService.java index b5876d3..caa8102 100644 --- a/src/main/java/io/k2dv/garden/notification/service/NotificationPreferenceService.java +++ b/src/main/java/io/k2dv/garden/notification/service/NotificationPreferenceService.java @@ -12,12 +12,21 @@ import java.util.*; import java.util.stream.Collectors; +/** + * Manages per-user notification opt-in/opt-out preferences for each {@link NotificationType}. + * All notification-sending code should call {@link #isEnabled(UUID, NotificationType)} before + * dispatching a transactional email, respecting any explicit opt-out stored here. + */ @Service @RequiredArgsConstructor public class NotificationPreferenceService { private final NotificationPreferenceRepository preferenceRepo; + /** + * Returns the full list of notification types with their current enabled state for a user, + * defaulting to {@code true} (opted-in) for any type that has no explicit preference row. + */ @Transactional(readOnly = true) public List getForUser(UUID userId) { Map saved = preferenceRepo.findByUserId(userId).stream() @@ -30,6 +39,10 @@ public List getForUser(UUID userId) { .toList(); } + /** + * Applies a partial update to the user's notification preferences, creating preference rows + * for types that have no prior setting and updating existing rows in place. + */ @Transactional public List updateForUser(UUID userId, UpdateNotificationPreferencesRequest req) { diff --git a/src/main/java/io/k2dv/garden/notification/service/package-info.java b/src/main/java/io/k2dv/garden/notification/service/package-info.java new file mode 100644 index 0000000..a6cb10e --- /dev/null +++ b/src/main/java/io/k2dv/garden/notification/service/package-info.java @@ -0,0 +1,5 @@ +/** + * Service layer for the notification module, providing preference lookup and bulk update + * operations that control which transactional emails each user receives. + */ +package io.k2dv.garden.notification.service; diff --git a/src/main/java/io/k2dv/garden/order/event/package-info.java b/src/main/java/io/k2dv/garden/order/event/package-info.java new file mode 100644 index 0000000..5b32f24 --- /dev/null +++ b/src/main/java/io/k2dv/garden/order/event/package-info.java @@ -0,0 +1,6 @@ +/** + * Spring application events fired by {@code OrderService} to trigger transactional outbox emails. + * {@code OrderConfirmedEvent} is published on successful payment; {@code OrderCancelledEvent} is + * published when an order is cancelled, both subject to the customer's notification preferences. + */ +package io.k2dv.garden.order.event; diff --git a/src/main/java/io/k2dv/garden/order/model/package-info.java b/src/main/java/io/k2dv/garden/order/model/package-info.java new file mode 100644 index 0000000..e13f0cb --- /dev/null +++ b/src/main/java/io/k2dv/garden/order/model/package-info.java @@ -0,0 +1,6 @@ +/** + * JPA entities for the order module. {@code Order} is the aggregate root tracking lifecycle + * status, totals, and payment references; {@code OrderItem} represents individual line items; + * {@code OrderEvent} provides the immutable audit timeline for each order. + */ +package io.k2dv.garden.order.model; diff --git a/src/main/java/io/k2dv/garden/order/package-info.java b/src/main/java/io/k2dv/garden/order/package-info.java new file mode 100644 index 0000000..08a67f8 --- /dev/null +++ b/src/main/java/io/k2dv/garden/order/package-info.java @@ -0,0 +1,6 @@ +/** + * Order module: covers the full order lifecycle from placement through payment, fulfillment, and + * refund. Handles both Stripe-based and net-terms (invoice) payment flows, B2B spend-limit approval + * gating, draft order management, and publishes domain events for transactional email notifications. + */ +package io.k2dv.garden.order; diff --git a/src/main/java/io/k2dv/garden/order/service/OrderEventService.java b/src/main/java/io/k2dv/garden/order/service/OrderEventService.java index 31b9be1..f5e6cc6 100644 --- a/src/main/java/io/k2dv/garden/order/service/OrderEventService.java +++ b/src/main/java/io/k2dv/garden/order/service/OrderEventService.java @@ -14,6 +14,11 @@ import java.util.Map; import java.util.UUID; +/** + * Manages the immutable, append-only event timeline attached to each order and fans out + * selected event types to registered outbound webhooks. Timeline entries serve as both an + * audit trail and the source of truth for the order activity feed shown in the admin console. + */ @Service @RequiredArgsConstructor public class OrderEventService { @@ -28,6 +33,10 @@ public class OrderEventService { OrderEventType.ORDER_REFUNDED, WebhookEventType.ORDER_REFUNDED ); + /** + * Appends a new event to the order timeline and, for event types mapped to a webhook, + * schedules outbound webhook delivery to all registered endpoints. + */ @Transactional public OrderEventResponse emit(UUID orderId, OrderEventType type, String message, UUID authorId, String authorName, Map metadata) { @@ -51,6 +60,10 @@ public OrderEventResponse emit(UUID orderId, OrderEventType type, String message return response; } + /** + * Returns all timeline events for an order in chronological order, used to render the + * activity feed in the order detail view. + */ @Transactional(readOnly = true) public List list(UUID orderId) { return eventRepo.findByOrderIdOrderByCreatedAtAsc(orderId) diff --git a/src/main/java/io/k2dv/garden/order/service/OrderService.java b/src/main/java/io/k2dv/garden/order/service/OrderService.java index fb6da5f..f483047 100644 --- a/src/main/java/io/k2dv/garden/order/service/OrderService.java +++ b/src/main/java/io/k2dv/garden/order/service/OrderService.java @@ -66,6 +66,12 @@ import java.util.UUID; import java.util.stream.Collectors; +/** + * Central service for the order lifecycle from creation through payment, cancellation, and refund. + * Coordinates inventory reservation and release, Stripe payment confirmation, domain event + * publication, and automatic tag application; supports both Stripe-based and net-terms (invoice) + * payment paths as well as B2B spend-limit approval gating. + */ @Service @RequiredArgsConstructor public class OrderService { @@ -86,6 +92,11 @@ public class OrderService { private final CompanyService companyService; private final NotificationPreferenceService notificationPreferenceService; + /** + * Creates a new order for an authenticated user from the given cart items, reserving inventory + * for each item. Overloads allow callers to progressively supply shipping, B2B company context, + * and a purchase-order number. + */ @Transactional public Order createFromCart(UUID userId, List cartItems) { return createFromCart(userId, cartItems, null, null, null, null); @@ -111,6 +122,10 @@ public Order createFromCart(UUID userId, UUID companyId, boolean taxExempt, List return buildOrder(userId, null, companyId, taxExempt, cartItems, shippingRateId, shippingCost, shippingAddress, poNumber); } + /** + * Creates a new order for an anonymous guest, reserving inventory for each item. A guest email + * is stored on the order so confirmation notifications can be delivered without an account. + */ @Transactional public Order createGuestOrder(String guestEmail, List cartItems, UUID shippingRateId, BigDecimal shippingCost, String shippingAddress) { @@ -200,6 +215,10 @@ private Order buildOrder(UUID userId, String guestEmail, UUID companyId, boolean return order; } + /** + * Converts an accepted quote into an order, reserving inventory for any variant-linked items. + * Quote items may carry custom pricing negotiated outside the standard price list. + */ @Transactional public Order createFromQuote(QuoteRequest quoteRequest, List quoteItems) { if (quoteItems.isEmpty()) { @@ -238,6 +257,10 @@ public Order createFromQuote(QuoteRequest quoteRequest, List quoteIte return order; } + /** + * Records an applied discount on the order and subtracts the discount amount from the + * running total; called by {@code PaymentService} after the discount code has been validated. + */ @Transactional public void applyDiscount(UUID orderId, UUID discountId, BigDecimal discountAmount) { Order order = orderRepo.findById(orderId) @@ -248,6 +271,10 @@ public void applyDiscount(UUID orderId, UUID discountId, BigDecimal discountAmou orderRepo.save(order); } + /** + * Records a gift-card redemption on the order and subtracts the applied amount from the + * running total; if the total reaches zero the order transitions to PAID immediately. + */ @Transactional public void applyGiftCard(UUID orderId, UUID giftCardId, BigDecimal giftCardAmount) { Order order = orderRepo.findById(orderId) @@ -258,6 +285,10 @@ public void applyGiftCard(UUID orderId, UUID giftCardId, BigDecimal giftCardAmou orderRepo.save(order); } + /** + * Moves the order to PENDING_APPROVAL, pausing the payment flow until a company owner or + * manager approves it; triggered when the order total exceeds the B2B user's spending limit. + */ @Transactional public void holdForApproval(UUID orderId) { Order order = orderRepo.findById(orderId) @@ -268,6 +299,10 @@ public void holdForApproval(UUID orderId) { "Order pending company approval — spending limit exceeded", null, "system", null); } + /** + * Records the approver identity and timestamp on the order, advancing it to PENDING_PAYMENT + * so the payment flow can resume. + */ @Transactional public void recordApproval(UUID orderId, UUID approverId) { Order order = orderRepo.findById(orderId) @@ -280,6 +315,10 @@ public void recordApproval(UUID orderId, UUID approverId) { "Order approved", approverId, null, null); } + /** + * Cancels an order that is awaiting B2B approval, releasing all inventory reservations. + * Only a company owner or manager may reject; a plain member will receive a FORBIDDEN error. + */ @Transactional public OrderResponse rejectApproval(UUID orderId, UUID rejectorId) { Order order = orderRepo.findById(orderId) @@ -302,6 +341,10 @@ public OrderResponse rejectApproval(UUID orderId, UUID rejectorId) { return toResponse(order); } + /** + * Returns a paged list of orders awaiting approval within the given company, intended for the + * B2B approver dashboard. + */ @Transactional(readOnly = true) public PagedResult listPendingApprovals(UUID companyId, Pageable pageable) { Specification spec = (root, query, cb) -> cb.and( @@ -316,6 +359,10 @@ public List getOrderItems(UUID orderId) { return orderItemRepo.findByOrderId(orderId); } + /** + * Emits an INVOICE_ISSUED timeline event and sends the order confirmation email for a B2B + * net-terms order, where payment is deferred rather than collected via Stripe immediately. + */ @Transactional public void notifyNetTermsPlaced(Order order) { orderEventService.emit(order.getId(), OrderEventType.INVOICE_ISSUED, @@ -324,6 +371,10 @@ public void notifyNetTermsPlaced(Order order) { if (order.getUserId() != null) autoTagService.applyOrderTags(order.getUserId()); } + /** + * Transitions the order to PAID after an invoice has been settled, confirming inventory sales + * without involving Stripe. Used for the net-terms payment path. + */ @Transactional public void markPaidFromInvoice(UUID orderId) { Order order = orderRepo.findById(orderId) @@ -337,6 +388,10 @@ public void markPaidFromInvoice(UUID orderId) { "Payment confirmed via invoice", null, "system", null); } + /** + * Transitions the order to PAID when the total has been fully covered by a gift card (no + * Stripe payment required); sends the confirmation email and applies automatic user tags. + */ @Transactional public void markPaidDirectly(UUID orderId) { Order order = orderRepo.findById(orderId) @@ -352,6 +407,11 @@ public void markPaidDirectly(UUID orderId) { if (order.getUserId() != null) autoTagService.applyOrderTags(order.getUserId()); } + /** + * Manual reconciliation fallback that polls Stripe for the current session status and drives + * the order to PAID or CANCELLED accordingly. Not transactional at this level because the + * Stripe network call must happen outside the database transaction. + */ // NOT @Transactional — Stripe call is outside transaction; sub-calls manage their own tx public OrderResponse syncPaymentFromStripe(UUID orderId) { Order order = getById(orderId); @@ -379,6 +439,10 @@ public OrderResponse syncPaymentFromStripe(UUID orderId) { return getOrderResponse(orderId); } + /** + * Stores the Stripe Checkout session ID on the order so that webhook callbacks can locate + * the order when Stripe posts the payment result. + */ @Transactional public void setStripeSession(UUID orderId, String stripeSessionId) { Order order = orderRepo.findById(orderId) @@ -387,6 +451,12 @@ public void setStripeSession(UUID orderId, String stripeSessionId) { orderRepo.save(order); } + /** + * Marks the order as PAID upon successful Stripe payment confirmation, converts inventory + * reservations to confirmed sales, emits a timeline event, sends the confirmation email, + * and applies user tags. Operation is idempotent — repeated calls for the same session are + * safely ignored. + */ @Transactional public void confirmPayment(String stripeSessionId, String stripePaymentIntentId) { confirmPayment(stripeSessionId, stripePaymentIntentId, null); @@ -415,6 +485,10 @@ public void confirmPayment(String stripeSessionId, String stripePaymentIntentId, }); } + /** + * Cancels the order associated with an expired Stripe session and releases all inventory + * reservations. Idempotent — already-cancelled orders are silently skipped. + */ @Transactional public void cancelBySession(String stripeSessionId) { orderRepo.findByStripeSessionId(stripeSessionId).ifPresent(order -> { @@ -431,6 +505,11 @@ public void cancelBySession(String stripeSessionId) { }); } + /** + * Admin-initiated cancellation that releases inventory and writes an audit-log entry via + * {@code @Audited}. Only orders in PENDING_PAYMENT or PENDING_APPROVAL status can be cancelled + * through this path; already-cancelled orders are silently skipped. + */ @Audited(entityType = "order", entityId = "#orderId") @Transactional public void cancelOrder(UUID orderId) { @@ -452,6 +531,10 @@ public void cancelOrder(UUID orderId) { orderEventService.emit(orderId, OrderEventType.ORDER_CANCELLED, "Order cancelled", null, "system", null); } + /** + * Customer-facing cancellation that releases inventory and, if the user has not opted out of + * notifications, fires an {@code OrderCancelledEvent} for transactional email delivery. + */ @Transactional public OrderResponse cancelAndReturn(UUID orderId) { Order order = orderRepo.findById(orderId) @@ -475,6 +558,11 @@ public OrderResponse cancelAndReturn(UUID orderId) { return toResponse(order); } + /** + * Cancels multiple orders in one operation, releasing inventory for each and sending + * cancellation emails to customers who have not opted out. Orders that are not in + * PENDING_PAYMENT or PAID status are silently skipped. + */ @Transactional public void bulkCancel(List ids) { List cancellable = orderRepo.findAllById(ids).stream() @@ -513,6 +601,10 @@ public OrderResponse getOrderResponse(UUID orderId) { return toResponse(order); } + /** + * Returns a filtered, paginated list of orders; used by both the admin console and customer + * order-history views depending on the filter criteria supplied. + */ @Transactional(readOnly = true) public PagedResult list(OrderFilter filter, Pageable pageable) { return PagedResult.of(orderRepo.findAll(buildSpec(filter), pageable), this::toResponse); @@ -520,6 +612,10 @@ public PagedResult list(OrderFilter filter, Pageable pageable) { public record CsvExportResult(String csv, boolean truncated) {} + /** + * Exports a filtered order list as a CSV string, capped at {@code maxRows} to prevent + * memory exhaustion. Returns a {@code truncated} flag when the result set was trimmed. + */ @Transactional(readOnly = true) public CsvExportResult exportCsv(OrderFilter filter, int maxRows) { List orders = orderRepo.findAll( @@ -616,6 +712,10 @@ private static String csvCell(Object value) { return s; } + /** + * Issues a full Stripe refund and marks the order REFUNDED; restricted to admin callers. + * Idempotent — already-refunded orders are returned unchanged without re-calling Stripe. + */ @Transactional public OrderResponse adminRefundOrder(UUID orderId) { Order order = orderRepo.findById(orderId) @@ -644,6 +744,10 @@ public OrderResponse adminRefundOrder(UUID orderId) { return toResponse(order); } + /** + * Allows admin users to patch mutable order fields: shipping address (blocked once shipped), + * admin notes, and purchase-order number. Note additions are appended to the order timeline. + */ @Transactional public OrderResponse updateOrder(UUID orderId, UpdateOrderRequest req) { Order order = orderRepo.findById(orderId) @@ -667,6 +771,10 @@ public OrderResponse updateOrder(UUID orderId, UpdateOrderRequest req) { return toResponse(orderRepo.save(order)); } + /** + * Customer-initiated full refund via Stripe; verifies the requesting user owns the order + * and writes an audit-log entry via {@code @Audited}. Idempotent for already-refunded orders. + */ @Audited(entityType = "order", entityId = "#orderId") @Transactional public OrderResponse refundOrder(UUID orderId, UUID requestingUserId) { @@ -700,6 +808,10 @@ public OrderResponse refundOrder(UUID orderId, UUID requestingUserId) { return toResponse(order); } + /** + * Creates a DRAFT order for admin use without reserving inventory or initiating payment; + * useful for manually constructing orders on behalf of a customer before finalising them. + */ @Transactional public OrderResponse createDraft(CreateDraftOrderRequest req) { if (req.userId() == null && (req.guestEmail() == null || req.guestEmail().isBlank())) { @@ -739,6 +851,10 @@ public OrderResponse createDraft(CreateDraftOrderRequest req) { return toResponse(order); } + /** + * Replaces the line items of a DRAFT order wholesale, recalculating the total; the draft must + * not yet have been submitted (status DRAFT), so no inventory changes are made at this point. + */ @Transactional public OrderResponse updateDraftItems(UUID orderId, java.util.List items) { Order order = orderRepo.findById(orderId) @@ -765,6 +881,10 @@ public OrderResponse updateDraftItems(UUID orderId, java.util.List metadata) { Order order = orderRepo.findById(orderId) diff --git a/src/main/java/io/k2dv/garden/order/service/ReturnRequestService.java b/src/main/java/io/k2dv/garden/order/service/ReturnRequestService.java index ed23c02..04aa226 100644 --- a/src/main/java/io/k2dv/garden/order/service/ReturnRequestService.java +++ b/src/main/java/io/k2dv/garden/order/service/ReturnRequestService.java @@ -21,6 +21,11 @@ import java.util.Set; import java.util.UUID; +/** + * Manages the full lifecycle of customer return requests from submission through staff review + * and final completion. When a return is approved with a REFUND resolution the Stripe refund + * is issued automatically; all state transitions are recorded on the order's event timeline. + */ @Service @RequiredArgsConstructor public class ReturnRequestService { @@ -38,6 +43,11 @@ public class ReturnRequestService { private static final Set OPEN_STATUSES = Set.of(ReturnRequestStatus.PENDING, ReturnRequestStatus.APPROVED); + /** + * Opens a new return request against a paid or fulfilled order; rejects the request if another + * open return already exists or the order is not in a returnable state. Defaults the resolution + * to REFUND when the caller does not specify one. + */ @Transactional public ReturnRequestResponse submit(UUID orderId, UUID userId, SubmitReturnRequest req) { Order order = requireOrder(orderId); @@ -69,6 +79,10 @@ public ReturnRequestResponse submit(UUID orderId, UUID userId, SubmitReturnReque return toResponse(rr, savedItems); } + /** + * Returns a paginated list of return requests belonging to the authenticated user, + * ordered by the repository default (typically newest first). + */ @Transactional(readOnly = true) public PagedResult listForUser(UUID userId, Pageable pageable) { return PagedResult.of( @@ -76,6 +90,10 @@ public PagedResult listForUser(UUID userId, Pageable page rr -> toResponse(rr, returnItemRepo.findByReturnRequestId(rr.getId()))); } + /** + * Returns a paginated, admin-facing list of all return requests; optionally filtered to a + * single status to support the staff review queue. + */ @Transactional(readOnly = true) public PagedResult listAll(ReturnRequestStatus status, Pageable pageable) { var page = status != null @@ -85,12 +103,20 @@ public PagedResult listAll(ReturnRequestStatus status, Pa rr -> toResponse(rr, returnItemRepo.findByReturnRequestId(rr.getId()))); } + /** + * Retrieves the full details of a return request by its ID; intended for admin use where + * no ownership check is required. + */ @Transactional(readOnly = true) public ReturnRequestResponse getById(UUID id) { ReturnRequest rr = requireReturnRequest(id); return toResponse(rr, returnItemRepo.findByReturnRequestId(id)); } + /** + * Retrieves a return request for the authenticated customer, throwing a not-found error (rather + * than forbidden) if the request belongs to a different user to avoid ID enumeration. + */ @Transactional(readOnly = true) public ReturnRequestResponse getByIdForUser(UUID id, UUID userId) { ReturnRequest rr = requireReturnRequest(id); @@ -100,6 +126,10 @@ public ReturnRequestResponse getByIdForUser(UUID id, UUID userId) { return toResponse(rr, returnItemRepo.findByReturnRequestId(id)); } + /** + * Staff approval of a pending return request; if the resolution is REFUND, the Stripe refund + * is issued atomically within the same transaction before the status is updated. + */ @Transactional public ReturnRequestResponse approve(UUID id, UUID staffId, ReviewReturnRequest req) { ReturnRequest rr = requireReturnRequest(id); @@ -124,6 +154,10 @@ public ReturnRequestResponse approve(UUID id, UUID staffId, ReviewReturnRequest return toResponse(rr, returnItemRepo.findByReturnRequestId(id)); } + /** + * Staff rejection of a pending return request; no Stripe refund is issued. The request moves + * to REJECTED and no further transitions are permitted. + */ @Transactional public ReturnRequestResponse reject(UUID id, UUID staffId, ReviewReturnRequest req) { ReturnRequest rr = requireReturnRequest(id); @@ -144,6 +178,10 @@ public ReturnRequestResponse reject(UUID id, UUID staffId, ReviewReturnRequest r return toResponse(rr, returnItemRepo.findByReturnRequestId(id)); } + /** + * Marks an approved return request as physically completed (e.g., item received back in + * warehouse); this is the terminal success state for the return workflow. + */ @Transactional public ReturnRequestResponse complete(UUID id, UUID staffId) { ReturnRequest rr = requireReturnRequest(id); diff --git a/src/main/java/io/k2dv/garden/order/service/package-info.java b/src/main/java/io/k2dv/garden/order/service/package-info.java new file mode 100644 index 0000000..327cc0d --- /dev/null +++ b/src/main/java/io/k2dv/garden/order/service/package-info.java @@ -0,0 +1,6 @@ +/** + * Service layer for the order module. {@code OrderService} is the central coordinator for order + * lifecycle transitions and inventory management; {@code OrderEventService} maintains the + * append-only event timeline; {@code ReturnRequestService} handles the customer return workflow. + */ +package io.k2dv.garden.order.service; diff --git a/src/main/java/io/k2dv/garden/order/template/service/OrderTemplateService.java b/src/main/java/io/k2dv/garden/order/template/service/OrderTemplateService.java index 72d9c35..90d1f10 100644 --- a/src/main/java/io/k2dv/garden/order/template/service/OrderTemplateService.java +++ b/src/main/java/io/k2dv/garden/order/template/service/OrderTemplateService.java @@ -27,6 +27,11 @@ import java.util.*; import java.util.stream.Collectors; +/** + * Allows authenticated users to save named lists of product variants as reusable order templates. + * Loading a template replaces the user's active cart contents with the template items, + * silently skipping any variants that are no longer available or whose products are inactive. + */ @Service @RequiredArgsConstructor public class OrderTemplateService { @@ -39,6 +44,9 @@ public class OrderTemplateService { private final ProductRepository productRepo; private final CartService cartService; + /** + * Creates a new named order template for the user with the specified variant and quantity list. + */ @Transactional public OrderTemplateResponse create(UUID userId, CreateOrderTemplateRequest req) { OrderTemplate template = new OrderTemplate(); @@ -58,6 +66,10 @@ public OrderTemplateResponse create(UUID userId, CreateOrderTemplateRequest req) return toResponse(template, items); } + /** + * Returns all templates owned by the user, newest first, with variant titles batch-fetched + * to avoid N+1 queries on the item list. + */ @Transactional(readOnly = true) public List listForUser(UUID userId) { List templates = templateRepo.findByUserIdOrderByCreatedAtDesc(userId); @@ -82,6 +94,9 @@ public List listForUser(UUID userId) { .toList(); } + /** + * Retrieves a single template, enforcing that it belongs to the requesting user. + */ @Transactional(readOnly = true) public OrderTemplateResponse getById(UUID userId, UUID templateId) { OrderTemplate template = requireOwned(userId, templateId); @@ -89,6 +104,9 @@ public OrderTemplateResponse getById(UUID userId, UUID templateId) { return toResponse(template, items); } + /** + * Permanently deletes a template and all its line items; enforces ownership before deletion. + */ @Transactional public void delete(UUID userId, UUID templateId) { requireOwned(userId, templateId); @@ -96,6 +114,11 @@ public void delete(UUID userId, UUID templateId) { templateRepo.deleteById(templateId); } + /** + * Replaces the user's active cart contents with the items from the named template, using current + * retail prices rather than any historical price stored in the template. Variants that have + * been deleted or whose products are no longer active are silently skipped. + */ @Transactional public CartResponse loadToCart(UUID userId, UUID templateId) { OrderTemplate template = requireOwned(userId, templateId); diff --git a/src/main/java/io/k2dv/garden/payment/gateway/package-info.java b/src/main/java/io/k2dv/garden/payment/gateway/package-info.java new file mode 100644 index 0000000..3bf9991 --- /dev/null +++ b/src/main/java/io/k2dv/garden/payment/gateway/package-info.java @@ -0,0 +1,6 @@ +/** + * Stripe gateway abstraction: a thin wrapper around the Stripe Java SDK that isolates all direct + * Stripe API calls behind an interface. The abstraction allows the gateway to be mocked in tests + * without requiring live Stripe credentials or network access. + */ +package io.k2dv.garden.payment.gateway; diff --git a/src/main/java/io/k2dv/garden/payment/package-info.java b/src/main/java/io/k2dv/garden/payment/package-info.java new file mode 100644 index 0000000..12decdc --- /dev/null +++ b/src/main/java/io/k2dv/garden/payment/package-info.java @@ -0,0 +1,6 @@ +/** + * Payment module: orchestrates Stripe Checkout session creation, inbound webhook processing, and + * refund issuance. Also handles gift-card redemption, automatic discount application, and the + * net-terms (credit account) payment path for B2B customers. + */ +package io.k2dv.garden.payment; diff --git a/src/main/java/io/k2dv/garden/payment/service/PaymentService.java b/src/main/java/io/k2dv/garden/payment/service/PaymentService.java index 7a1a54f..80fd06c 100644 --- a/src/main/java/io/k2dv/garden/payment/service/PaymentService.java +++ b/src/main/java/io/k2dv/garden/payment/service/PaymentService.java @@ -63,6 +63,12 @@ import java.util.UUID; import java.util.stream.Collectors; +/** + * Orchestrates the end-to-end payment flow: resolving shipping rates, creating orders, applying + * discounts and gift cards, and routing to Stripe Checkout, net-terms invoicing, or the B2B + * approval gate depending on company configuration. Also processes inbound Stripe webhooks with + * idempotency guarantees and exposes helpers for quote-based and post-approval checkout sessions. + */ @Service @RequiredArgsConstructor @Slf4j @@ -89,6 +95,12 @@ public class PaymentService { private static final ObjectMapper MAPPER = new ObjectMapper(); + /** + * Starts the authenticated-user checkout flow: validates the shipping address, applies any + * discount or gift-card code, checks B2B credit limits, and then routes to Stripe Checkout, + * net-terms invoicing, or the spending-limit approval gate as appropriate. Not transactional + * at this level because the Stripe network call must occur outside a database transaction. + */ // NOT @Transactional — Stripe call is outside transaction; each sub-call manages its own tx public CheckoutResponse initiateCheckout(UUID userId, String discountCode, String giftCardCode) { return initiateCheckout(userId, discountCode, giftCardCode, null, null); @@ -185,6 +197,11 @@ public CheckoutResponse initiateCheckout(UUID userId, String discountCode, Strin } } + /** + * Starts the anonymous guest checkout flow using an inline shipping address rather than a saved + * profile address. Rejects the request if the email is already registered to an account, + * directing the customer to log in instead. + */ public CheckoutResponse initiateGuestCheckout(String guestEmail, GuestAddressRequest guestAddress, UUID shippingRateId, String discountCode, String giftCardCode, UUID sessionId) { @@ -244,6 +261,11 @@ public CheckoutResponse initiateGuestCheckout(String guestEmail, GuestAddressReq } } + /** + * Creates a Stripe Checkout session for a previously accepted quote, using the quote's custom + * line-item descriptions and negotiated prices rather than the standard product catalog. + * Not transactional at this level because the Stripe network call occurs outside the database transaction. + */ // NOT @Transactional — Stripe call is outside transaction public CheckoutResponse createCheckoutSessionFromQuote(Order order, List items, QuoteRequest quote) { try { @@ -296,6 +318,10 @@ public CheckoutResponse createCheckoutSessionFromQuote(Order order, List items) { productRepo.findByIdAndDeletedAtIsNull(productId) diff --git a/src/main/java/io/k2dv/garden/product/service/ProductService.java b/src/main/java/io/k2dv/garden/product/service/ProductService.java index 483fff8..d8836ca 100644 --- a/src/main/java/io/k2dv/garden/product/service/ProductService.java +++ b/src/main/java/io/k2dv/garden/product/service/ProductService.java @@ -30,6 +30,11 @@ import java.util.UUID; import java.util.stream.Collectors; +/** + * Core service for managing the product catalog, including creation, status transitions, + * soft-delete lifecycle, and tag-driven collection sync. Serves both the admin back-office + * and the customer-facing storefront, enforcing B2B catalog visibility rules where applicable. + */ @Service @RequiredArgsConstructor public class ProductService { @@ -46,6 +51,10 @@ public class ProductService { private final ProductReviewService reviewService; private final CompanyProductCatalogRepository catalogRepo; + /** + * Creates a new product in DRAFT status, auto-generating a URL handle from the title + * if one is not supplied. Rejects the request if the handle is already taken by another active product. + */ @Transactional public AdminProductResponse create(CreateProductRequest req) { if (req.title() == null) { @@ -70,6 +79,10 @@ public AdminProductResponse create(CreateProductRequest req) { return toAdminResponse(saved); } + /** + * Fetches a single product for the admin view, including all variants, options, images, + * and resolved blob URLs. Throws {@code NotFoundException} if the product is soft-deleted. + */ @Transactional(readOnly = true) public AdminProductResponse getAdmin(UUID id) { Product p = productRepo.findByIdAndDeletedAtIsNull(id) @@ -77,12 +90,20 @@ public AdminProductResponse getAdmin(UUID id) { return toAdminResponse(p); } + /** + * Returns a paginated, filterable list of products for admin use, including soft-deleted + * products when the filter requests them. + */ @Transactional(readOnly = true) public PagedResult listAdmin(ProductFilterRequest filter, Pageable pageable) { Page page = productRepo.findAll(ProductSpecification.toSpec(filter), pageable); return PagedResult.of(page, this::toAdminResponse); } + /** + * Applies partial updates to a product's attributes. When tags are changed, triggers an + * automated collection membership sync so tag-based collections stay current. + */ @Transactional public AdminProductResponse update(UUID id, UpdateProductRequest req) { Product p = productRepo.findByIdAndDeletedAtIsNull(id) @@ -113,6 +134,10 @@ public AdminProductResponse update(UUID id, UpdateProductRequest req) { return toAdminResponse(saved); } + /** + * Transitions a product to a new publishing status. Archiving a product automatically + * removes it from all collections. This operation is recorded in the audit log. + */ @Audited(entityType = "product", entityId = "#id") @Transactional public AdminProductResponse changeStatus(UUID id, ProductStatusRequest req) { @@ -125,6 +150,10 @@ public AdminProductResponse changeStatus(UUID id, ProductStatusRequest req) { return toAdminResponse(productRepo.save(p)); } + /** + * Replaces the product's freeform metadata map, which is used to store arbitrary + * merchant-defined attributes not covered by the standard product schema. + */ @Transactional public AdminProductResponse updateMetadata(UUID id, Map metadata) { Product p = productRepo.findByIdAndDeletedAtIsNull(id) @@ -133,6 +162,11 @@ public AdminProductResponse updateMetadata(UUID id, Map metadata return toAdminResponse(productRepo.save(p)); } + /** + * Soft-deletes a product by stamping {@code deletedAt}, making it invisible to all + * queries that filter by {@code deletedAtIsNull}. Also removes the product from every + * collection. This operation is recorded in the audit log. + */ @Audited(entityType = "product", entityId = "#id") @Transactional public void softDelete(UUID id) { @@ -143,6 +177,11 @@ public void softDelete(UUID id) { productRepo.save(p); } + /** + * Changes the publishing status for a batch of products in a single transaction. + * Silently skips IDs that do not resolve to active products. Archiving removes each + * product from its collections. + */ @Transactional public void bulkChangeStatus(List ids, ProductStatus status) { List products = productRepo.findAllByIdInAndDeletedAtIsNull(ids); @@ -156,6 +195,10 @@ public void bulkChangeStatus(List ids, ProductStatus status) { productRepo.saveAll(products); } + /** + * Soft-deletes a batch of products in a single transaction, removing each from all + * collections before stamping the deletion timestamp. + */ @Transactional public void bulkDelete(List ids) { List products = productRepo.findAllByIdInAndDeletedAtIsNull(ids); @@ -167,6 +210,10 @@ public void bulkDelete(List ids) { productRepo.saveAll(products); } + /** + * Returns a customer-facing paginated product listing with price ranges and featured image + * URLs. Variant prices and images are batch-loaded to avoid N+1 queries. + */ @Transactional(readOnly = true) public PagedResult listStorefront(StorefrontProductFilterRequest filter, Pageable pageable) { Page page = productRepo.findAll(ProductSpecification.storefrontSpec(filter), pageable); @@ -216,6 +263,10 @@ public PagedResult listStorefront(StorefrontProductFilte }); } + /** + * Resolves a variant and its parent product by SKU, used by POS and fulfillment integrations + * to look up purchasable items by barcode. Only returns results for active, non-deleted products. + */ @Transactional(readOnly = true) public VariantLookupResponse lookupBySku(String sku) { ProductVariant variant = variantRepo.findBySkuIgnoreCaseAndDeletedAtIsNull(sku) @@ -240,6 +291,11 @@ public VariantLookupResponse lookupBySku(String sku) { ); } + /** + * Fetches a full product detail page by URL handle for the storefront. Products that belong + * to a B2B company-restricted catalog are hidden from shoppers who are not members of that + * company, surfacing as a 404 to avoid leaking catalog structure. + */ @Transactional(readOnly = true) public ProductDetailResponse getByHandle(String handle, UUID companyId) { Product p = productRepo.findByHandle(handle) diff --git a/src/main/java/io/k2dv/garden/product/service/VariantService.java b/src/main/java/io/k2dv/garden/product/service/VariantService.java index 22bf101..326cea1 100644 --- a/src/main/java/io/k2dv/garden/product/service/VariantService.java +++ b/src/main/java/io/k2dv/garden/product/service/VariantService.java @@ -28,6 +28,12 @@ import java.util.UUID; import java.util.stream.Collectors; +/** + * Manages product variants, which are the purchasable SKUs of a product distinguished by + * option value combinations (e.g., size + color). Automatically provisions an + * {@code InventoryItem} for each new variant and enforces pricing invariants such as + * compare-at price being strictly greater than the sale price. + */ @Service @RequiredArgsConstructor public class VariantService { @@ -38,6 +44,11 @@ public class VariantService { private final InventoryItemRepository inventoryRepo; private final ProductRepository productRepo; + /** + * Adds a new purchasable variant to an existing product, linking it to the supplied option + * values and auto-creating a corresponding {@code InventoryItem} for stock tracking. + * The variant title is derived from its option value labels (e.g., "Red / Large"). + */ @Transactional public AdminVariantResponse create(UUID productId, CreateVariantRequest req) { productRepo.findByIdAndDeletedAtIsNull(productId) @@ -83,6 +94,10 @@ public AdminVariantResponse create(UUID productId, CreateVariantRequest req) { return toResponse(v); } + /** + * Applies partial updates to a variant's pricing, SKU, weight, and fulfillment settings. + * Validates that the compare-at price, if set, remains strictly above the effective sale price. + */ @Transactional public AdminVariantResponse update(UUID productId, UUID variantId, UpdateVariantRequest req) { ProductVariant v = variantRepo.findByIdAndDeletedAtIsNull(variantId) @@ -107,6 +122,10 @@ public AdminVariantResponse update(UUID productId, UUID variantId, UpdateVariant return toResponse(variantRepo.save(v)); } + /** + * Soft-deletes a variant by stamping {@code deletedAt}, preserving its historical data for + * order references while hiding it from active product listings and the storefront. + */ @Transactional public void softDelete(UUID productId, UUID variantId) { ProductVariant v = variantRepo.findByIdAndDeletedAtIsNull(variantId) @@ -115,6 +134,10 @@ public void softDelete(UUID productId, UUID variantId) { v.setDeletedAt(Instant.now()); } + /** + * Returns the inventory items for all active variants of a product, providing the link + * between variants and the inventory tracking system. + */ @Transactional(readOnly = true) public List getInventoryForProduct(UUID productId) { List variants = variantRepo.findByProductIdAndDeletedAtIsNullOrderByCreatedAtAsc(productId); diff --git a/src/main/java/io/k2dv/garden/product/service/package-info.java b/src/main/java/io/k2dv/garden/product/service/package-info.java new file mode 100644 index 0000000..134de73 --- /dev/null +++ b/src/main/java/io/k2dv/garden/product/service/package-info.java @@ -0,0 +1,7 @@ +/** + * Service layer for the product catalog domain. {@code ProductService} is the primary entry point + * for product CRUD, status management, and B2B visibility rules. Specialised services handle + * variants ({@code VariantService}), option axes ({@code OptionService}), and gallery images + * ({@code ProductImageService}). + */ +package io.k2dv.garden.product.service; diff --git a/src/main/java/io/k2dv/garden/quote/model/package-info.java b/src/main/java/io/k2dv/garden/quote/model/package-info.java new file mode 100644 index 0000000..b191d56 --- /dev/null +++ b/src/main/java/io/k2dv/garden/quote/model/package-info.java @@ -0,0 +1,7 @@ +/** + * JPA entities for the quote module. {@code QuoteRequest} is the top-level negotiation + * record whose {@code QuoteStatus} transitions from PENDING through SENT to ACCEPTED or + * REJECTED; {@code QuoteItem} holds individual negotiated line items; {@code QuoteCart} and + * {@code QuoteCartItem} represent the pre-submission staging area. + */ +package io.k2dv.garden.quote.model; diff --git a/src/main/java/io/k2dv/garden/quote/package-info.java b/src/main/java/io/k2dv/garden/quote/package-info.java new file mode 100644 index 0000000..00e99ef --- /dev/null +++ b/src/main/java/io/k2dv/garden/quote/package-info.java @@ -0,0 +1,7 @@ +/** + * Quote module: B2B custom pricing flow that lets buyers assemble a quote cart, submit a + * formal quote request, and have sales staff negotiate line-item prices before the quote is + * accepted. Supports multi-level spend-approval workflows, PDF generation, and conversion to + * an order paid via Stripe or net-terms invoicing. + */ +package io.k2dv.garden.quote; diff --git a/src/main/java/io/k2dv/garden/quote/service/QuoteCartService.java b/src/main/java/io/k2dv/garden/quote/service/QuoteCartService.java index a24bbe3..c65267a 100644 --- a/src/main/java/io/k2dv/garden/quote/service/QuoteCartService.java +++ b/src/main/java/io/k2dv/garden/quote/service/QuoteCartService.java @@ -25,6 +25,12 @@ import java.util.UUID; import java.util.stream.Collectors; +/** + * Manages a user's quote cart—a staging area where B2B buyers assemble product variants + * before submitting a formal quote request. Unlike the regular shopping cart, the quote cart + * accepts any active variant (including price-on-request items) and is transitioned to + * SUBMITTED once {@link QuoteService#submit} converts it into a {@code QuoteRequest}. + */ @Service @RequiredArgsConstructor public class QuoteCartService { @@ -35,6 +41,10 @@ public class QuoteCartService { private final ProductRepository productRepo; private final ProductImageResolver imageResolver; + /** + * Returns the user's existing ACTIVE quote cart, or transparently creates one if none + * exists. This is the primary entry point for the quote cart UI. + */ @Transactional public QuoteCartResponse getOrCreateActiveCart(UUID userId) { QuoteCart cart = cartRepo.findByUserIdAndStatus(userId, QuoteCartStatus.ACTIVE) @@ -46,6 +56,10 @@ public QuoteCartResponse getOrCreateActiveCart(UUID userId) { return toResponse(cart); } + /** + * Removes all items from the user's active quote cart without deleting the cart itself, + * allowing the buyer to start fresh without re-triggering cart creation. + */ @Transactional public void clearCart(UUID userId) { cartRepo.findByUserIdAndStatus(userId, QuoteCartStatus.ACTIVE).ifPresent(cart -> { @@ -53,6 +67,11 @@ public void clearCart(UUID userId) { }); } + /** + * Adds a product variant to the active quote cart, incrementing the quantity if the + * variant is already present. Validates that the variant exists and has not been + * soft-deleted. + */ @Transactional public QuoteCartResponse addItem(UUID userId, AddQuoteCartItemRequest req) { QuoteCart cart = findActiveCartOrThrow(userId); @@ -78,6 +97,10 @@ public QuoteCartResponse addItem(UUID userId, AddQuoteCartItemRequest req) { return toResponse(cart); } + /** + * Replaces the quantity and note on an existing quote cart line item. Throws + * {@code NotFoundException} if the item does not belong to the user's active cart. + */ @Transactional public QuoteCartResponse updateItem(UUID userId, UUID itemId, UpdateQuoteCartItemRequest req) { QuoteCart cart = findActiveCartOrThrow(userId); @@ -89,6 +112,10 @@ public QuoteCartResponse updateItem(UUID userId, UUID itemId, UpdateQuoteCartIte return toResponse(cart); } + /** + * Deletes a specific line item from the user's active quote cart. Throws + * {@code NotFoundException} if the item does not belong to the active cart. + */ @Transactional public QuoteCartResponse removeItem(UUID userId, UUID itemId) { QuoteCart cart = findActiveCartOrThrow(userId); @@ -100,16 +127,28 @@ public QuoteCartResponse removeItem(UUID userId, UUID itemId) { // --- Internal API --- + /** + * Returns the user's active quote cart for internal callers (e.g., {@link QuoteService} + * during quote submission); throws {@code ValidationException} if none exists. + */ @Transactional(readOnly = true) public QuoteCart requireActiveCart(UUID userId) { return findActiveCartOrThrow(userId); } + /** + * Returns the raw line items for a given cart ID; used by {@link QuoteService} to copy + * cart contents into a new {@code QuoteRequest}. + */ @Transactional(readOnly = true) public List getCartItems(UUID cartId) { return itemRepo.findByQuoteCartId(cartId); } + /** + * Transitions the cart to SUBMITTED status after its contents have been converted into a + * {@code QuoteRequest}, preventing further modification through the cart API. + */ @Transactional public void markSubmitted(UUID cartId) { cartRepo.findById(cartId).ifPresent(cart -> { diff --git a/src/main/java/io/k2dv/garden/quote/service/QuotePdfService.java b/src/main/java/io/k2dv/garden/quote/service/QuotePdfService.java index 2fade1c..3636aad 100644 --- a/src/main/java/io/k2dv/garden/quote/service/QuotePdfService.java +++ b/src/main/java/io/k2dv/garden/quote/service/QuotePdfService.java @@ -18,6 +18,11 @@ import java.util.List; import java.util.stream.Collectors; +/** + * Generates a PDF document for a quote by rendering a Thymeleaf HTML template and converting + * it to PDF via OpenHTMLToPDF. The resulting bytes are returned to the caller for storage and + * email delivery; this service has no side effects of its own. + */ @Service @RequiredArgsConstructor public class QuotePdfService { @@ -27,6 +32,11 @@ public class QuotePdfService { private final TemplateEngine templateEngine; + /** + * Renders the {@code quote-template} Thymeleaf template with the given quote, line items, + * and company details, then converts the resulting HTML to a PDF byte array. Line totals + * and the grand total are computed here and injected into the template context. + */ public byte[] generate(QuoteRequest quote, List items, Company company) { List lineTotals = items.stream() .map(i -> i.getUnitPrice() != null diff --git a/src/main/java/io/k2dv/garden/quote/service/QuoteService.java b/src/main/java/io/k2dv/garden/quote/service/QuoteService.java index ef30f26..3b6eb62 100644 --- a/src/main/java/io/k2dv/garden/quote/service/QuoteService.java +++ b/src/main/java/io/k2dv/garden/quote/service/QuoteService.java @@ -50,6 +50,13 @@ import java.util.List; import java.util.UUID; +/** + * Orchestrates the B2B quote lifecycle from cart-based submission through multi-level approval, + * PDF generation, and final conversion to an order. Collaborates with {@link CompanyService}, + * {@link CompanyApprovalRuleService}, {@link OrderService}, {@link PaymentService}, and + * {@link QuotePdfService} to fulfil both the spend-limit approval path and rule-based + * multi-level approval flows before routing payment via Stripe or net-terms invoicing. + */ @Service @RequiredArgsConstructor public class QuoteService { @@ -76,6 +83,12 @@ public class QuoteService { private final StorageProperties storageProperties; private final AppProperties appProperties; + /** + * Converts the user's active quote cart into a {@code QuoteRequest}, pre-populating + * contract-list prices where a price-list agreement exists for the company. Marks the + * cart as SUBMITTED, then sends a confirmation email to the user and a new-request alert + * to the configured admin address. + */ @Transactional public QuoteRequestResponse submit(UUID userId, SubmitQuoteRequest req) { // Verify company exists, then membership @@ -138,6 +151,7 @@ public QuoteRequestResponse submit(UUID userId, SubmitQuoteRequest req) { return toResponse(quote); } + /** Returns all quotes submitted by the specified user, most recent first. */ @Transactional(readOnly = true) public PagedResult listForUser(UUID userId, Pageable pageable) { Specification spec = (root, query, cb) -> @@ -145,6 +159,10 @@ public PagedResult listForUser(UUID userId, Pageable pagea return PagedResult.of(quoteRepo.findAll(spec, pageable), this::toResponse); } + /** + * Returns quotes awaiting approval that belong to companies where the caller holds an + * OWNER or MANAGER role. Used to populate the manager's pending-approvals dashboard. + */ @Transactional(readOnly = true) public PagedResult listPendingApprovals(UUID userId, Pageable pageable) { List ownedCompanyIds = membershipRepo @@ -160,6 +178,10 @@ public PagedResult listPendingApprovals(UUID userId, Pagea return PagedResult.of(quoteRepo.findAll(spec, pageable), this::toResponse); } + /** + * Retrieves a quote for a customer, enforcing that the quote belongs to the requesting + * user. Throws {@code ForbiddenException} if the quote is owned by a different user. + */ @Transactional(readOnly = true) public QuoteRequestResponse getForUser(UUID quoteId, UUID userId) { QuoteRequest quote = quoteRepo.findById(quoteId) @@ -170,6 +192,11 @@ public QuoteRequestResponse getForUser(UUID quoteId, UUID userId) { return toResponse(quote); } + /** + * Streams the PDF bytes for a customer's own quote. The PDF must have been generated + * by a prior {@link #send} call; throws {@code NotFoundException} if the PDF blob has + * not yet been attached to the quote. + */ @Transactional(readOnly = true) public byte[] downloadPdf(UUID quoteId, UUID userId) { QuoteRequest quote = quoteRepo.findById(quoteId) @@ -180,6 +207,10 @@ public byte[] downloadPdf(UUID quoteId, UUID userId) { return fetchPdfBytes(quote); } + /** + * Admin-only variant of PDF download; bypasses ownership checks, allowing staff to + * retrieve the PDF for any quote regardless of the submitting user. + */ public byte[] downloadPdfAdmin(UUID quoteId) { QuoteRequest quote = quoteRepo.findById(quoteId) .orElseThrow(() -> new NotFoundException("QUOTE_NOT_FOUND", "Quote not found")); @@ -199,7 +230,13 @@ private byte[] fetchPdfBytes(QuoteRequest quote) { } } - // Accept: checks spending limit, then creates Order + Stripe session or routes for approval + /** + * Customer action: accepts a SENT quote, triggering either multi-level rule-based + * approval or spend-limit approval if configured for the company, or finalising the + * quote immediately by creating an order and a Stripe checkout session (or an invoice + * for net-terms accounts). Quotes that have passed their {@code expiresAt} are + * transitioned to EXPIRED and the acceptance is rejected. + */ @Transactional public QuoteAcceptResponse accept(UUID quoteId, UUID userId) { QuoteRequest quote = quoteRepo.findById(quoteId) @@ -245,7 +282,11 @@ public QuoteAcceptResponse accept(UUID quoteId, UUID userId) { return finalizeAcceptance(quote, items); } - // Approve a specific rule pendency (rule-based multi-level approval) + /** + * Records an individual approver's approval for a specific rule pendency in the + * multi-level approval chain. When all pendencies are resolved, the quote is finalised + * and the submitting user is notified by email. + */ @Transactional public QuoteAcceptResponse approvePendency(UUID quoteId, UUID approverId, UUID ruleId) { QuoteRequest quote = quoteRepo.findById(quoteId) @@ -268,7 +309,11 @@ public QuoteAcceptResponse approvePendency(UUID quoteId, UUID approverId, UUID r return new QuoteAcceptResponse(null, null, true, null); } - // Reject a specific rule pendency + /** + * Rejects a rule-based pendency, immediately moving the quote to REJECTED status and + * notifying the submitting user. Once rejected, no further approvals are possible on + * this quote. + */ @Transactional public QuoteRequestResponse rejectPendency(UUID quoteId, UUID approverId, UUID ruleId, String reason) { QuoteRequest quote = quoteRepo.findById(quoteId) @@ -286,6 +331,10 @@ public QuoteRequestResponse rejectPendency(UUID quoteId, UUID approverId, UUID r return response; } + /** + * Returns the current state of all approval pendencies for a quote. Accessible by the + * quote submitter or any OWNER/MANAGER of the associated company. + */ @Transactional(readOnly = true) public List listPendencies(UUID quoteId, UUID userId) { QuoteRequest quote = quoteRepo.findById(quoteId) @@ -297,8 +346,11 @@ public List listPendencies(UUID quoteId, UUID use return approvalRuleService.getPendencies(quoteId); } - // Approve: company OWNER/MANAGER approves a PENDING_APPROVAL quote (legacy spend-limit path). - // Only valid when there are no unresolved rule-based pendencies. + /** + * Legacy spend-limit approval path: a company OWNER or MANAGER approves a quote that + * exceeded a user's spending limit. Blocked if any rule-based pendencies are still + * unresolved—those must be actioned via {@link #approvePendency} first. + */ @Transactional public QuoteAcceptResponse approveSpend(UUID quoteId, UUID approverId) { QuoteRequest quote = quoteRepo.findById(quoteId) @@ -328,7 +380,11 @@ public QuoteAcceptResponse approveSpend(UUID quoteId, UUID approverId) { return response; } - // Reject approval: company OWNER rejects a PENDING_APPROVAL quote + /** + * Legacy spend-limit rejection path: a company OWNER or MANAGER rejects a + * PENDING_APPROVAL quote and notifies the submitting user. Requires OWNER/MANAGER role + * on the associated company. + */ @Transactional public QuoteRequestResponse rejectSpend(UUID quoteId, UUID approverId, String reason) { QuoteRequest quote = quoteRepo.findById(quoteId) @@ -379,6 +435,11 @@ private QuoteAcceptResponse finalizeAcceptance(QuoteRequest quote, List listAll(QuoteFilter filter, Pageable pageable) { Specification spec = (root, query, cb) -> { @@ -416,6 +481,7 @@ public PagedResult listAll(QuoteFilter filter, Pageable pa return PagedResult.of(quoteRepo.findAll(spec, pageable), this::toResponse); } + /** Admin endpoint: retrieves any quote by ID without ownership or role restrictions. */ @Transactional(readOnly = true) public QuoteRequestResponse getAdmin(UUID quoteId) { QuoteRequest quote = quoteRepo.findById(quoteId) @@ -423,6 +489,10 @@ public QuoteRequestResponse getAdmin(UUID quoteId) { return toResponse(quote); } + /** + * Assigns a staff member to a PENDING or ASSIGNED quote, transitioning it to ASSIGNED + * status so it appears in the assignee's work queue. + */ @Transactional public QuoteRequestResponse assign(UUID quoteId, AssignStaffRequest req) { QuoteRequest quote = quoteRepo.findById(quoteId) @@ -436,6 +506,10 @@ public QuoteRequestResponse assign(UUID quoteId, AssignStaffRequest req) { return toResponse(quoteRepo.save(quote)); } + /** + * Staff action: updates the quantity and negotiated unit price on a quote line item. + * Only permitted on quotes in an editable status (PENDING, ASSIGNED, DRAFT). + */ @Transactional public QuoteItemResponse updateItem(UUID quoteId, UUID itemId, UpdateQuoteItemRequest req) { requireEditableStatus(quoteId); @@ -446,6 +520,10 @@ public QuoteItemResponse updateItem(UUID quoteId, UUID itemId, UpdateQuoteItemRe return toItemResponse(itemRepo.save(item)); } + /** + * Staff action: appends a new line item (e.g. a custom service charge) to an editable + * quote. Only permitted on quotes in an editable status (PENDING, ASSIGNED, DRAFT). + */ @Transactional public QuoteItemResponse addItem(UUID quoteId, AddQuoteItemRequest req) { requireEditableStatus(quoteId); @@ -457,6 +535,10 @@ public QuoteItemResponse addItem(UUID quoteId, AddQuoteItemRequest req) { return toItemResponse(itemRepo.save(item)); } + /** + * Staff action: removes a line item from an editable quote. Only permitted on quotes in + * an editable status (PENDING, ASSIGNED, DRAFT). + */ @Transactional public void removeItem(UUID quoteId, UUID itemId) { requireEditableStatus(quoteId); @@ -465,6 +547,10 @@ public void removeItem(UUID quoteId, UUID itemId) { itemRepo.delete(item); } + /** + * Staff action: records internal notes on a quote visible only to staff; these notes are + * not exposed to the customer in the quote PDF or customer-facing API responses. + */ @Transactional public QuoteRequestResponse updateNotes(UUID quoteId, UpdateStaffNotesRequest req) { QuoteRequest quote = quoteRepo.findById(quoteId) @@ -473,7 +559,13 @@ public QuoteRequestResponse updateNotes(UUID quoteId, UpdateStaffNotesRequest re return toResponse(quoteRepo.save(quote)); } - // Send: generate PDF, email user, transition to SENT + /** + * Finalises the negotiated quote for customer review: validates that all line items have + * a unit price, generates the quote PDF via {@link QuotePdfService}, uploads it to + * private blob storage, transitions the quote to SENT, and emails the PDF to the customer + * (if email notifications are enabled for them). Only PENDING, ASSIGNED, or DRAFT quotes + * may be sent. + */ @Transactional public QuoteRequestResponse send(UUID quoteId, SendQuoteRequest req) { QuoteRequest quote = loadForSend(quoteId); @@ -521,6 +613,10 @@ public QuoteRequestResponse send(UUID quoteId, SendQuoteRequest req) { return toResponse(quote); } + /** + * Admin action: cancels any quote that has not yet reached a terminal status (ACCEPTED, + * PAID, REJECTED, EXPIRED, or CANCELLED). + */ @Transactional public QuoteRequestResponse cancel(UUID quoteId, String reason) { QuoteRequest quote = quoteRepo.findById(quoteId) @@ -536,6 +632,11 @@ public QuoteRequestResponse cancel(UUID quoteId, String reason) { return toResponse(quoteRepo.save(quote)); } + /** + * Customer action: cancels the user's own quote before it reaches a terminal status. + * Enforces ownership; throws {@code ForbiddenException} if the quote belongs to a + * different user. + */ @Transactional public QuoteRequestResponse cancelForUser(UUID quoteId, UUID userId, String reason) { QuoteRequest quote = quoteRepo.findById(quoteId) diff --git a/src/main/java/io/k2dv/garden/quote/service/package-info.java b/src/main/java/io/k2dv/garden/quote/service/package-info.java new file mode 100644 index 0000000..5c9dc22 --- /dev/null +++ b/src/main/java/io/k2dv/garden/quote/service/package-info.java @@ -0,0 +1,7 @@ +/** + * Service layer for the quote module. {@code QuoteService} orchestrates the full quote + * lifecycle (submission through order conversion); {@code QuoteCartService} manages the + * staging cart B2B buyers use before submission; {@code QuotePdfService} generates the + * customer-facing PDF document from a Thymeleaf template. + */ +package io.k2dv.garden.quote.service; diff --git a/src/main/java/io/k2dv/garden/recommendation/package-info.java b/src/main/java/io/k2dv/garden/recommendation/package-info.java new file mode 100644 index 0000000..666b30f --- /dev/null +++ b/src/main/java/io/k2dv/garden/recommendation/package-info.java @@ -0,0 +1,6 @@ +/** + * Product recommendation module that surfaces related products based on tag overlap + * with a source product. Results are used for "You may also like" carousels on product + * detail pages. + */ +package io.k2dv.garden.recommendation; diff --git a/src/main/java/io/k2dv/garden/recommendation/service/RecommendationService.java b/src/main/java/io/k2dv/garden/recommendation/service/RecommendationService.java index c02b6d3..b89e291 100644 --- a/src/main/java/io/k2dv/garden/recommendation/service/RecommendationService.java +++ b/src/main/java/io/k2dv/garden/recommendation/service/RecommendationService.java @@ -23,6 +23,11 @@ import java.util.UUID; import java.util.stream.Collectors; +/** + * Generates product recommendations based on tag overlap with a source product. + * Results are capped at a maximum of 12 items to keep response payloads practical for + * carousel widgets on product detail pages. + */ @Service @RequiredArgsConstructor public class RecommendationService { @@ -33,6 +38,11 @@ public class RecommendationService { private final BlobObjectRepository blobRepo; private final StorageService storageService; + /** + * Finds products that share the most tags with the product identified by {@code handle}, + * ranked by tag-overlap score. The result set is capped at {@code min(limit, 12)} entries + * to prevent oversized payloads regardless of the caller's requested limit. + */ @Transactional(readOnly = true) public List findRelated(String handle, int limit) { Product source = productRepo.findByHandle(handle) diff --git a/src/main/java/io/k2dv/garden/recommendation/service/package-info.java b/src/main/java/io/k2dv/garden/recommendation/service/package-info.java new file mode 100644 index 0000000..cf3af07 --- /dev/null +++ b/src/main/java/io/k2dv/garden/recommendation/service/package-info.java @@ -0,0 +1,5 @@ +/** + * Service layer for the recommendation module, executing tag-overlap queries and + * enriching candidate products with variant price ranges and image URLs for display. + */ +package io.k2dv.garden.recommendation.service; diff --git a/src/main/java/io/k2dv/garden/review/package-info.java b/src/main/java/io/k2dv/garden/review/package-info.java new file mode 100644 index 0000000..f386f44 --- /dev/null +++ b/src/main/java/io/k2dv/garden/review/package-info.java @@ -0,0 +1,6 @@ +/** + * Product reviews and ratings module with admin moderation support. + * Customers may submit one review per product; reviews enter a pending state and must be + * approved by an admin before they appear on the storefront. + */ +package io.k2dv.garden.review; diff --git a/src/main/java/io/k2dv/garden/review/service/ProductReviewService.java b/src/main/java/io/k2dv/garden/review/service/ProductReviewService.java index d9fd413..f3ed893 100644 --- a/src/main/java/io/k2dv/garden/review/service/ProductReviewService.java +++ b/src/main/java/io/k2dv/garden/review/service/ProductReviewService.java @@ -28,6 +28,12 @@ import java.util.UUID; import java.util.stream.Collectors; +/** + * Handles customer-submitted product reviews including submission, storefront display, + * and admin moderation (status transitions between pending, published, and rejected). + * Enforces a one-review-per-user-per-product rule and partially anonymises reviewer names + * when surfacing reviews to the storefront. + */ @Service @RequiredArgsConstructor public class ProductReviewService { @@ -36,6 +42,11 @@ public class ProductReviewService { private final ProductRepository productRepo; private final UserRepository userRepo; + /** + * Submits a new review for an active product. Reviews start in a pending moderation state + * and are not shown to other shoppers until an admin publishes them. + * Throws {@link io.k2dv.garden.shared.exception.ConflictException} if the user has already reviewed this product. + */ @Transactional public ReviewResponse createReview(UUID productId, UUID userId, CreateReviewRequest req) { productRepo.findByIdAndDeletedAtIsNull(productId) @@ -59,6 +70,10 @@ public ReviewResponse createReview(UUID productId, UUID userId, CreateReviewRequ return toResponse(saved, user.getFirstName() + " " + user.getLastName().charAt(0) + "."); } + /** + * Returns paginated published reviews for a product, with reviewer names partially + * anonymised (first name + last initial) to protect customer privacy on the storefront. + */ @Transactional(readOnly = true) public PagedResult listReviews(UUID productId, Pageable pageable) { productRepo.findByIdAndDeletedAtIsNull(productId) @@ -82,6 +97,10 @@ public PagedResult listReviews(UUID productId, Pageable pageable }); } + /** + * Returns the aggregate rating (average rounded to one decimal place) and published review + * count for a product, used to power the star-rating widget on product detail pages. + */ @Transactional(readOnly = true) public ReviewSummaryResponse getReviewSummary(UUID productId) { Double avg = reviewRepo.findAverageRatingByProductId(productId); @@ -92,6 +111,10 @@ public ReviewSummaryResponse getReviewSummary(UUID productId) { return new ReviewSummaryResponse(averageRating, count); } + /** + * Admin operation to approve or reject a pending review, making it immediately visible + * or hiding it from the storefront based on the requested status. + */ @Transactional public ReviewResponse updateStatus(UUID reviewId, UpdateReviewStatusRequest req) { ProductReview review = reviewRepo.findById(reviewId) diff --git a/src/main/java/io/k2dv/garden/review/service/package-info.java b/src/main/java/io/k2dv/garden/review/service/package-info.java new file mode 100644 index 0000000..3b9e019 --- /dev/null +++ b/src/main/java/io/k2dv/garden/review/service/package-info.java @@ -0,0 +1,5 @@ +/** + * Service layer for the review module, enforcing the one-review-per-user rule, computing + * aggregate rating summaries, and providing admin status-transition operations. + */ +package io.k2dv.garden.review.service; diff --git a/src/main/java/io/k2dv/garden/scheduler/package-info.java b/src/main/java/io/k2dv/garden/scheduler/package-info.java new file mode 100644 index 0000000..43997fd --- /dev/null +++ b/src/main/java/io/k2dv/garden/scheduler/package-info.java @@ -0,0 +1,6 @@ +/** + * Scheduled cron jobs for background maintenance tasks including payment reconciliation, + * expiry cleanup (gift cards, quotes, discounts), and periodic housekeeping operations. + * All jobs use ShedLock to prevent concurrent execution across multiple application instances. + */ +package io.k2dv.garden.scheduler; diff --git a/src/main/java/io/k2dv/garden/search/package-info.java b/src/main/java/io/k2dv/garden/search/package-info.java new file mode 100644 index 0000000..be7086a --- /dev/null +++ b/src/main/java/io/k2dv/garden/search/package-info.java @@ -0,0 +1,6 @@ +/** + * Full-text and faceted search across products, collections, articles, and static pages. + * Queries are executed against database full-text indexes and composed into a single + * multi-bucket response so the storefront can display blended search results. + */ +package io.k2dv.garden.search; diff --git a/src/main/java/io/k2dv/garden/search/service/SearchService.java b/src/main/java/io/k2dv/garden/search/service/SearchService.java index f1ee7db..3c4934e 100644 --- a/src/main/java/io/k2dv/garden/search/service/SearchService.java +++ b/src/main/java/io/k2dv/garden/search/service/SearchService.java @@ -36,6 +36,12 @@ import java.util.UUID; import java.util.stream.Collectors; +/** + * Unified search service that queries products, collections, articles, and static pages + * in a single request using database full-text search. + * Callers specify which content types to include via the {@code types} filter so that + * unused result buckets are not fetched. + */ @Service @RequiredArgsConstructor public class SearchService { @@ -50,6 +56,10 @@ public class SearchService { private final BlobObjectRepository blobRepo; private final StorageService storageService; + /** + * Executes a cross-entity search against the requested content types and returns + * a composite response where each bucket is {@code null} when its type was not requested. + */ @Transactional(readOnly = true) public SearchResponse search(String q, Set types, Pageable pageable) { String term = q.trim(); diff --git a/src/main/java/io/k2dv/garden/search/service/package-info.java b/src/main/java/io/k2dv/garden/search/service/package-info.java new file mode 100644 index 0000000..70de983 --- /dev/null +++ b/src/main/java/io/k2dv/garden/search/service/package-info.java @@ -0,0 +1,6 @@ +/** + * Service layer for the search module, responsible for dispatching full-text queries + * to the appropriate repositories and assembling the composite {@code SearchResponse}. + * Each content-type bucket is fetched only when explicitly requested by the caller. + */ +package io.k2dv.garden.search.service; diff --git a/src/main/java/io/k2dv/garden/shared/dto/package-info.java b/src/main/java/io/k2dv/garden/shared/dto/package-info.java new file mode 100644 index 0000000..bebd16c --- /dev/null +++ b/src/main/java/io/k2dv/garden/shared/dto/package-info.java @@ -0,0 +1,5 @@ +/** + * Shared DTO types used by multiple modules, primarily {@code PagedResult} which wraps + * Spring Data {@code Page} objects into a consistent pagination envelope for all API responses. + */ +package io.k2dv.garden.shared.dto; diff --git a/src/main/java/io/k2dv/garden/shared/exception/package-info.java b/src/main/java/io/k2dv/garden/shared/exception/package-info.java new file mode 100644 index 0000000..c5dbea3 --- /dev/null +++ b/src/main/java/io/k2dv/garden/shared/exception/package-info.java @@ -0,0 +1,6 @@ +/** + * Application exception types used across all modules: {@code NotFoundException}, + * {@code ConflictException}, {@code ValidationException}, and related base classes. + * Controllers map these to appropriate HTTP status codes via a global exception handler. + */ +package io.k2dv.garden.shared.exception; diff --git a/src/main/java/io/k2dv/garden/shared/package-info.java b/src/main/java/io/k2dv/garden/shared/package-info.java new file mode 100644 index 0000000..a6f4aa7 --- /dev/null +++ b/src/main/java/io/k2dv/garden/shared/package-info.java @@ -0,0 +1,6 @@ +/** + * Common cross-cutting types shared across all modules, including exception hierarchy, + * the generic {@code PagedResult} DTO, and validation helpers. + * Nothing in this package should depend on any domain-specific module. + */ +package io.k2dv.garden.shared; diff --git a/src/main/java/io/k2dv/garden/shipping/model/package-info.java b/src/main/java/io/k2dv/garden/shipping/model/package-info.java new file mode 100644 index 0000000..4be084d --- /dev/null +++ b/src/main/java/io/k2dv/garden/shipping/model/package-info.java @@ -0,0 +1,7 @@ +/** + * JPA entities for the shipping module. {@code ShippingZone} groups country/province + * combinations into a named delivery region; {@code ShippingRate} defines a named rate + * within a zone, including its price, weight bounds, minimum order amount, carrier, and + * estimated delivery window. + */ +package io.k2dv.garden.shipping.model; diff --git a/src/main/java/io/k2dv/garden/shipping/package-info.java b/src/main/java/io/k2dv/garden/shipping/package-info.java new file mode 100644 index 0000000..739cb30 --- /dev/null +++ b/src/main/java/io/k2dv/garden/shipping/package-info.java @@ -0,0 +1,7 @@ +/** + * Shipping module: manages shipping zones (grouped by country/province) and their associated + * rates (flat, weight-banded, or minimum-order-gated). At checkout, eligible rates are + * resolved for the buyer's delivery address and order value; a chosen rate is re-validated + * at order placement to guard against stale cart state. + */ +package io.k2dv.garden.shipping; diff --git a/src/main/java/io/k2dv/garden/shipping/service/ShippingService.java b/src/main/java/io/k2dv/garden/shipping/service/ShippingService.java index d680cc7..c204f01 100644 --- a/src/main/java/io/k2dv/garden/shipping/service/ShippingService.java +++ b/src/main/java/io/k2dv/garden/shipping/service/ShippingService.java @@ -24,6 +24,12 @@ import java.util.List; import java.util.UUID; +/** + * Manages shipping zones and their associated rates for the storefront checkout flow. Admins + * define zones by country/province and attach named rates (flat, weight-banded, or + * minimum-order-gated) to each zone; at checkout, {@link #findRatesForAddress} resolves the + * eligible rates for a buyer's delivery address and order value. + */ @Service @RequiredArgsConstructor public class ShippingService { @@ -33,16 +39,22 @@ public class ShippingService { // ---- Zones ---- + /** Returns a paginated list of all shipping zones. */ @Transactional(readOnly = true) public PagedResult listZones(Pageable pageable) { return PagedResult.of(zoneRepo.findAll(pageable), ShippingZoneResponse::from); } + /** Retrieves a single shipping zone by ID, throwing {@code NotFoundException} if absent. */ @Transactional(readOnly = true) public ShippingZoneResponse getZone(UUID id) { return ShippingZoneResponse.from(findZoneOrThrow(id)); } + /** + * Creates a new shipping zone covering the specified countries and optional provinces. + * Country codes are normalised to uppercase ISO-3166-1 alpha-2 format before persistence. + */ @Transactional public ShippingZoneResponse createZone(CreateShippingZoneRequest req) { ShippingZone z = new ShippingZone(); @@ -53,6 +65,10 @@ public ShippingZoneResponse createZone(CreateShippingZoneRequest req) { return ShippingZoneResponse.from(zoneRepo.save(z)); } + /** + * Updates mutable fields of an existing shipping zone (name, description, country codes, + * provinces, active flag). Only non-null request fields are applied. + */ @Transactional public ShippingZoneResponse updateZone(UUID id, UpdateShippingZoneRequest req) { ShippingZone z = findZoneOrThrow(id); @@ -64,6 +80,10 @@ public ShippingZoneResponse updateZone(UUID id, UpdateShippingZoneRequest req) { return ShippingZoneResponse.from(zoneRepo.save(z)); } + /** + * Permanently deletes a shipping zone and all of its associated rates. Consider + * deactivating the zone instead if historical order records reference these rates. + */ @Transactional public void deleteZone(UUID id) { ShippingZone z = findZoneOrThrow(id); @@ -73,6 +93,7 @@ public void deleteZone(UUID id) { // ---- Rates ---- + /** Returns all rates defined for the given zone, throwing {@code NotFoundException} if the zone is absent. */ @Transactional(readOnly = true) public List listRates(UUID zoneId) { findZoneOrThrow(zoneId); @@ -80,11 +101,16 @@ public List listRates(UUID zoneId) { .map(ShippingRateResponse::from).toList(); } + /** Retrieves a single rate, scoped to its parent zone for safety. */ @Transactional(readOnly = true) public ShippingRateResponse getRate(UUID zoneId, UUID rateId) { return ShippingRateResponse.from(findRateOrThrow(zoneId, rateId)); } + /** + * Adds a new rate to an existing zone. Rates can encode flat pricing, weight-band limits + * ({@code minWeightGrams}/{@code maxWeightGrams}), and a minimum order amount threshold. + */ @Transactional public ShippingRateResponse createRate(UUID zoneId, CreateShippingRateRequest req) { findZoneOrThrow(zoneId); @@ -101,6 +127,10 @@ public ShippingRateResponse createRate(UUID zoneId, CreateShippingRateRequest re return ShippingRateResponse.from(rateRepo.save(r)); } + /** + * Updates mutable fields of an existing rate within a zone. Only non-null fields in the + * request are applied; use {@code isActive = false} to hide a rate without deleting it. + */ @Transactional public ShippingRateResponse updateRate(UUID zoneId, UUID rateId, UpdateShippingRateRequest req) { ShippingRate r = findRateOrThrow(zoneId, rateId); @@ -116,6 +146,7 @@ public ShippingRateResponse updateRate(UUID zoneId, UUID rateId, UpdateShippingR return ShippingRateResponse.from(rateRepo.save(r)); } + /** Permanently removes a shipping rate from a zone. */ @Transactional public void deleteRate(UUID zoneId, UUID rateId) { ShippingRate r = findRateOrThrow(zoneId, rateId); @@ -124,6 +155,11 @@ public void deleteRate(UUID zoneId, UUID rateId) { // ---- Storefront ---- + /** + * Confirms that a previously selected shipping rate is still valid for the given delivery + * address at order placement time. Throws {@code ValidationException} if the rate's parent + * zone no longer covers the destination country/province, guarding against stale cart state. + */ @Transactional(readOnly = true) public void validateRateForAddress(UUID rateId, String country, String province) { String normalizedCountry = CountryCode.normalize(country); @@ -135,6 +171,11 @@ public void validateRateForAddress(UUID rateId, String country, String province) } } + /** + * Resolves all active shipping rates applicable to the given destination and order value, + * sorted by price ascending so the checkout UI can present options cheapest-first. Weight- + * based filtering is noted but not yet enforced (cart weight calculation is pending). + */ @Transactional(readOnly = true) public List findRatesForAddress(String country, String province, BigDecimal orderAmount) { diff --git a/src/main/java/io/k2dv/garden/shipping/service/package-info.java b/src/main/java/io/k2dv/garden/shipping/service/package-info.java new file mode 100644 index 0000000..16a047b --- /dev/null +++ b/src/main/java/io/k2dv/garden/shipping/service/package-info.java @@ -0,0 +1,6 @@ +/** + * Service layer for the shipping module. Contains {@code ShippingService}, which provides + * admin CRUD for zones and rates as well as the storefront-facing rate-resolution and + * rate-validation logic used during checkout. + */ +package io.k2dv.garden.shipping.service; diff --git a/src/main/java/io/k2dv/garden/stats/package-info.java b/src/main/java/io/k2dv/garden/stats/package-info.java new file mode 100644 index 0000000..6d484f4 --- /dev/null +++ b/src/main/java/io/k2dv/garden/stats/package-info.java @@ -0,0 +1,6 @@ +/** + * Analytics and reporting module that powers the admin dashboard with revenue summaries, + * order counts, average order value, top products, and top customers. + * All aggregations target only paid orders to reflect realised revenue. + */ +package io.k2dv.garden.stats; diff --git a/src/main/java/io/k2dv/garden/stats/service/StatsService.java b/src/main/java/io/k2dv/garden/stats/service/StatsService.java index 9ff9e51..0ca3db1 100644 --- a/src/main/java/io/k2dv/garden/stats/service/StatsService.java +++ b/src/main/java/io/k2dv/garden/stats/service/StatsService.java @@ -17,6 +17,11 @@ import java.util.List; import java.util.UUID; +/** + * Provides aggregated analytics for the admin dashboard, including revenue summaries, + * order counts, average order value, and new customer acquisition for a given date window. + * All queries target only paid orders to exclude cancelled and pending revenue. + */ @Service @RequiredArgsConstructor public class StatsService { @@ -24,6 +29,11 @@ public class StatsService { private final OrderRepository orderRepo; private final UserRepository userRepo; + /** + * Returns high-level KPIs for the given time window: total revenue, order count, + * average order value, and new registrations. Throws a {@code ValidationException} + * if {@code from} is after {@code to}. + */ @Transactional(readOnly = true) public StatsResponse getStats(Instant from, Instant to) { if (from.isAfter(to)) { @@ -43,6 +53,10 @@ public StatsResponse getStats(Instant from, Instant to) { return new StatsResponse(from, to, orderCount, totalRevenue, averageOrderValue, newCustomerCount); } + /** + * Returns daily revenue and order-count data points for the given window, + * suitable for rendering a time-series chart on the admin dashboard. + */ @Transactional(readOnly = true) public List getTimeSeries(Instant from, Instant to) { return orderRepo.findRevenueTimeSeries(from, to).stream() @@ -54,6 +68,10 @@ public List getTimeSeries(Instant from, Instant to) { .toList(); } + /** + * Returns the best-selling products by units sold within the date range, + * capped at {@code limit} entries, for the admin "Top Products" leaderboard. + */ @Transactional(readOnly = true) public List getTopProducts(Instant from, Instant to, int limit) { return orderRepo.findTopProducts(from, to, limit).stream() @@ -67,6 +85,10 @@ public List getTopProducts(Instant from, Instant to, int limit) .toList(); } + /** + * Returns the highest-spending customers by total revenue within the date range, + * capped at {@code limit} entries, for the admin "Top Customers" leaderboard. + */ @Transactional(readOnly = true) public List getTopCustomers(Instant from, Instant to, int limit) { return orderRepo.findTopCustomers(from, to, limit).stream() diff --git a/src/main/java/io/k2dv/garden/stats/service/package-info.java b/src/main/java/io/k2dv/garden/stats/service/package-info.java new file mode 100644 index 0000000..6d507ec --- /dev/null +++ b/src/main/java/io/k2dv/garden/stats/service/package-info.java @@ -0,0 +1,5 @@ +/** + * Service layer for the stats module, executing aggregate queries against the order and + * user repositories and projecting raw database rows into typed DTOs for the dashboard. + */ +package io.k2dv.garden.stats.service; diff --git a/src/main/java/io/k2dv/garden/user/model/package-info.java b/src/main/java/io/k2dv/garden/user/model/package-info.java new file mode 100644 index 0000000..7453f69 --- /dev/null +++ b/src/main/java/io/k2dv/garden/user/model/package-info.java @@ -0,0 +1,7 @@ +/** + * JPA entities for the user module. The central {@code User} entity holds core identity + * fields (email, name, phone, email-verified timestamp, status) shared across the platform. + * The {@code Address} entity represents a user's saved shipping or billing address. + * The {@code UserStatus} enum captures the three-state lifecycle of a user account. + */ +package io.k2dv.garden.user.model; diff --git a/src/main/java/io/k2dv/garden/user/package-info.java b/src/main/java/io/k2dv/garden/user/package-info.java new file mode 100644 index 0000000..8c6a141 --- /dev/null +++ b/src/main/java/io/k2dv/garden/user/package-info.java @@ -0,0 +1,8 @@ +/** + * The user module defines the canonical {@code User} entity and its supporting value types. + * It is the shared foundation that other modules (auth, account, IAM, orders) build upon + * to reference an authenticated principal. User lifecycle state is expressed through the + * {@code UserStatus} enum: {@code UNVERIFIED} on registration, {@code ACTIVE} after email + * confirmation, and {@code SUSPENDED} when administrative action has restricted access. + */ +package io.k2dv.garden.user; diff --git a/src/main/java/io/k2dv/garden/user/repository/package-info.java b/src/main/java/io/k2dv/garden/user/repository/package-info.java new file mode 100644 index 0000000..1f2ac5d --- /dev/null +++ b/src/main/java/io/k2dv/garden/user/repository/package-info.java @@ -0,0 +1,7 @@ +/** + * Spring Data JPA repositories for the user module. {@code UserRepository} provides + * core user lookups (by ID, by email, existence checks) consumed by most other modules. + * {@code AddressRepository} handles address-book persistence, including queries for + * ownership verification and default-address management. + */ +package io.k2dv.garden.user.repository; diff --git a/src/main/java/io/k2dv/garden/webhook/package-info.java b/src/main/java/io/k2dv/garden/webhook/package-info.java new file mode 100644 index 0000000..a8d80b1 --- /dev/null +++ b/src/main/java/io/k2dv/garden/webhook/package-info.java @@ -0,0 +1,7 @@ +/** + * Outbound webhook delivery module that notifies subscriber endpoints about platform events + * such as order placed, payment confirmed, and fulfilment updates. + * Endpoint registration is managed separately from payload dispatch, which runs on a + * scheduled poller with exponential retry back-off. + */ +package io.k2dv.garden.webhook; diff --git a/src/main/java/io/k2dv/garden/webhook/service/OutboundWebhookService.java b/src/main/java/io/k2dv/garden/webhook/service/OutboundWebhookService.java index c16561f..f673c4c 100644 --- a/src/main/java/io/k2dv/garden/webhook/service/OutboundWebhookService.java +++ b/src/main/java/io/k2dv/garden/webhook/service/OutboundWebhookService.java @@ -20,6 +20,11 @@ import java.util.Map; import java.util.UUID; +/** + * Manages the lifecycle of outbound webhook endpoint registrations and provides + * the entry point for enqueuing delivery records when a platform event occurs. + * Actual HTTP dispatch is performed by {@link WebhookDispatchService}. + */ @Service @RequiredArgsConstructor public class OutboundWebhookService { @@ -27,6 +32,9 @@ public class OutboundWebhookService { private final WebhookEndpointRepository endpointRepo; private final WebhookDeliveryRepository deliveryRepo; + /** + * Registers a new webhook endpoint with its target URL, shared secret, and subscribed event types. + */ @Transactional public WebhookEndpointResponse create(CreateWebhookEndpointRequest req) { WebhookEndpoint e = new WebhookEndpoint(); @@ -47,6 +55,10 @@ public WebhookEndpointResponse getById(UUID id) { return WebhookEndpointResponse.from(require(id)); } + /** + * Partially updates a webhook endpoint; only non-null fields in the request are applied, + * allowing callers to toggle active state or rotate the signing secret independently. + */ @Transactional public WebhookEndpointResponse update(UUID id, UpdateWebhookEndpointRequest req) { WebhookEndpoint e = require(id); @@ -64,6 +76,10 @@ public void delete(UUID id) { endpointRepo.deleteById(id); } + /** + * Returns paginated delivery history for a specific endpoint, allowing operators + * to inspect past attempts, HTTP status codes, and response bodies. + */ @Transactional(readOnly = true) public PagedResult listDeliveries(UUID endpointId, Pageable pageable) { require(endpointId); diff --git a/src/main/java/io/k2dv/garden/webhook/service/WebhookDispatchService.java b/src/main/java/io/k2dv/garden/webhook/service/WebhookDispatchService.java index 09d485f..6e5e139 100644 --- a/src/main/java/io/k2dv/garden/webhook/service/WebhookDispatchService.java +++ b/src/main/java/io/k2dv/garden/webhook/service/WebhookDispatchService.java @@ -29,6 +29,12 @@ import java.util.List; import java.util.Map; +/** + * Polls for pending webhook deliveries every 30 seconds and dispatches them via HTTP POST + * to the registered endpoint URLs, signing each payload with HMAC-SHA256. + * Failed deliveries are retried up to five times with exponential back-off (1 min → 1 day); + * ShedLock prevents concurrent dispatch across multiple application instances. + */ @Service @RequiredArgsConstructor @Slf4j @@ -48,12 +54,20 @@ public class WebhookDispatchService { .connectTimeout(Duration.ofSeconds(10)) .build(); + /** + * Scheduled entry point that runs every 30 seconds under a distributed ShedLock + * to pick up and deliver any queued webhook payloads. + */ @Scheduled(fixedDelay = 30_000) @SchedulerLock(name = "webhookDispatch", lockAtMostFor = "PT25S", lockAtLeastFor = "PT5S") public void dispatchPending() { doDispatch(); } + /** + * Loads all dispatchable deliveries and attempts each one within the current transaction. + * Exposed as a public method to allow direct invocation in tests without the scheduler. + */ @Transactional public void doDispatch() { List deliveries = deliveryRepo.findDispatchable(Instant.now()); diff --git a/src/main/java/io/k2dv/garden/webhook/service/package-info.java b/src/main/java/io/k2dv/garden/webhook/service/package-info.java new file mode 100644 index 0000000..a209bf3 --- /dev/null +++ b/src/main/java/io/k2dv/garden/webhook/service/package-info.java @@ -0,0 +1,6 @@ +/** + * Service layer for the webhook module, containing {@code OutboundWebhookService} for + * endpoint CRUD and delivery scheduling, and {@code WebhookDispatchService} for the + * scheduled HTTP dispatch loop with HMAC-SHA256 payload signing and retry logic. + */ +package io.k2dv.garden.webhook.service; diff --git a/src/main/java/io/k2dv/garden/wishlist/package-info.java b/src/main/java/io/k2dv/garden/wishlist/package-info.java new file mode 100644 index 0000000..8de5dfa --- /dev/null +++ b/src/main/java/io/k2dv/garden/wishlist/package-info.java @@ -0,0 +1,6 @@ +/** + * User wishlist module that lets shoppers save product variants for later purchase. + * A wishlist is created on demand for each user and persisted so saved items survive + * across sessions and devices. + */ +package io.k2dv.garden.wishlist; diff --git a/src/main/java/io/k2dv/garden/wishlist/service/WishlistService.java b/src/main/java/io/k2dv/garden/wishlist/service/WishlistService.java index 7ba879e..89a6461 100644 --- a/src/main/java/io/k2dv/garden/wishlist/service/WishlistService.java +++ b/src/main/java/io/k2dv/garden/wishlist/service/WishlistService.java @@ -31,6 +31,11 @@ import java.util.UUID; import java.util.stream.Collectors; +/** + * Manages each user's wishlist — a persistent list of product variants the shopper intends + * to purchase later. A wishlist is created on demand when the first item is added, so callers + * never need to provision one explicitly. + */ @Service @RequiredArgsConstructor public class WishlistService { @@ -43,6 +48,10 @@ public class WishlistService { private final BlobObjectRepository blobRepo; private final StorageService storageService; + /** + * Returns the user's wishlist including enriched product details (images, price range). + * Returns an empty response with a null wishlist ID if the user has not yet added any items. + */ @Transactional(readOnly = true) public WishlistResponse getWishlist(UUID userId) { Optional wishlist = wishlistRepo.findByUserId(userId); @@ -53,6 +62,11 @@ public WishlistResponse getWishlist(UUID userId) { return new WishlistResponse(wishlist.get().getId(), toItemResponses(items)); } + /** + * Adds an active product to the user's wishlist, lazily creating the wishlist if necessary. + * Throws {@link io.k2dv.garden.shared.exception.ConflictException} if the product is already on the list + * and {@link io.k2dv.garden.shared.exception.NotFoundException} if the product does not exist or is inactive. + */ @Transactional public WishlistResponse addItem(UUID userId, UUID productId) { productRepo.findByIdAndDeletedAtIsNull(productId) @@ -78,6 +92,10 @@ public WishlistResponse addItem(UUID userId, UUID productId) { return new WishlistResponse(wishlist.getId(), toItemResponses(items)); } + /** + * Removes a product from the user's wishlist and returns the updated list. + * Throws {@link io.k2dv.garden.shared.exception.NotFoundException} if the user has no wishlist. + */ @Transactional public WishlistResponse removeItem(UUID userId, UUID productId) { Wishlist wishlist = wishlistRepo.findByUserId(userId) diff --git a/src/main/java/io/k2dv/garden/wishlist/service/package-info.java b/src/main/java/io/k2dv/garden/wishlist/service/package-info.java new file mode 100644 index 0000000..c7f27d3 --- /dev/null +++ b/src/main/java/io/k2dv/garden/wishlist/service/package-info.java @@ -0,0 +1,5 @@ +/** + * Service layer for the wishlist module, handling lazy wishlist creation, item addition + * with duplicate prevention, removal, and enriched product detail resolution for display. + */ +package io.k2dv.garden.wishlist.service; From ee54013733615322792956468dbfbd060daa11bb Mon Sep 17 00:00:00 2001 From: tkahng Date: Tue, 26 May 2026 19:05:16 -0700 Subject: [PATCH 2/2] feat: add docs --- docs/1-intro.md | 5 - docs/README.md | 34 ++ docs/api-conventions.md | 186 +++++++++++ docs/architecture.md | 192 +++++++++++ docs/b2b-spec.md | 592 --------------------------------- docs/database.md | 181 ++++++++++ docs/deployment.md | 180 ++++++++++ docs/getting-started.md | 143 ++++++++ docs/security.md | 193 +++++++++++ docs/specs/00-overview.md | 91 +++++ docs/specs/01-auth.md | 187 +++++++++++ docs/specs/02-catalog.md | 203 +++++++++++ docs/specs/03-cart-checkout.md | 233 +++++++++++++ docs/specs/04-order.md | 193 +++++++++++ docs/specs/05-b2b.md | 126 +++++++ docs/specs/06-inventory.md | 115 +++++++ docs/specs/07-account.md | 72 ++++ docs/specs/08-content.md | 170 ++++++++++ docs/specs/09-infra.md | 174 ++++++++++ docs/testing.md | 188 ++++++++--- 20 files changed, 2816 insertions(+), 642 deletions(-) delete mode 100644 docs/1-intro.md create mode 100644 docs/README.md create mode 100644 docs/api-conventions.md create mode 100644 docs/architecture.md delete mode 100644 docs/b2b-spec.md create mode 100644 docs/database.md create mode 100644 docs/deployment.md create mode 100644 docs/getting-started.md create mode 100644 docs/security.md create mode 100644 docs/specs/00-overview.md create mode 100644 docs/specs/01-auth.md create mode 100644 docs/specs/02-catalog.md create mode 100644 docs/specs/03-cart-checkout.md create mode 100644 docs/specs/04-order.md create mode 100644 docs/specs/05-b2b.md create mode 100644 docs/specs/06-inventory.md create mode 100644 docs/specs/07-account.md create mode 100644 docs/specs/08-content.md create mode 100644 docs/specs/09-infra.md diff --git a/docs/1-intro.md b/docs/1-intro.md deleted file mode 100644 index e89f0ec..0000000 --- a/docs/1-intro.md +++ /dev/null @@ -1,5 +0,0 @@ -# Introdoction to the Garden - -Welcome to the garden, a spring boot project for a e-commerce website. The Garden is specialized in landscape products vendor, such as beautiful planters and green roof trays and edging. - -We will consider what this vendor sells to be changing in the future, therefore we will target Shopify as a guide, and aim to implement a Shopify-like service. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..f956256 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,34 @@ +# Garden — Backend Documentation + +Garden is a Shopify-like e-commerce backend for a landscape products vendor. It serves both B2C (storefront) and B2B (companies, quotes, net-terms invoicing) purchase flows. + +--- + +## Documentation Index + +| Doc | Contents | +|---|---| +| [Getting Started](getting-started.md) | Prerequisites, local setup, running the app | +| [Architecture](architecture.md) | System design, module map, technology choices | +| [API Conventions](api-conventions.md) | REST patterns, auth headers, pagination, error format | +| [Security](security.md) | JWT, OAuth2, roles, permissions, impersonation | +| [Database](database.md) | Schema map, Flyway migrations, entity conventions | +| [Testing](testing.md) | Test harness, running tests, integration test setup | +| [Deployment](deployment.md) | Docker, CI/CD, environment variables reference | + +## Specs Index + +Detailed per-module specs live in [`specs/`](specs/): + +| Spec | Covers | +|---|---| +| [specs/00-overview.md](specs/00-overview.md) | Module table, DB schema map, security model | +| [specs/01-auth.md](specs/01-auth.md) | Auth, IAM, admin user management | +| [specs/02-catalog.md](specs/02-catalog.md) | Products, collections, reviews | +| [specs/03-cart-checkout.md](specs/03-cart-checkout.md) | Cart, payment, discounts, gift cards, shipping | +| [specs/04-order.md](specs/04-order.md) | Orders, fulfillment, returns | +| [specs/05-b2b.md](specs/05-b2b.md) | B2B companies, quotes, invoices, credit accounts | +| [specs/06-inventory.md](specs/06-inventory.md) | Inventory items, levels, locations, transactions | +| [specs/07-account.md](specs/07-account.md) | Profile, addresses, wishlist, notification prefs | +| [specs/08-content.md](specs/08-content.md) | Blog, articles, pages, search, recommendations | +| [specs/09-infra.md](specs/09-infra.md) | Blob storage, webhooks, stats, audit, schedulers | diff --git a/docs/api-conventions.md b/docs/api-conventions.md new file mode 100644 index 0000000..f45464c --- /dev/null +++ b/docs/api-conventions.md @@ -0,0 +1,186 @@ +# API Conventions + +All endpoints are under `/api/v1/`. The API is JSON-only (`Content-Type: application/json`) except for file upload endpoints which use `multipart/form-data`. + +--- + +## Base URL + +| Environment | Base URL | +|---|---| +| Local dev | `http://localhost:8080/api/v1` | +| Production | configured via reverse proxy | + +--- + +## Authentication + +Almost all non-public endpoints require a JWT access token. Pass it as a Bearer token: + +``` +Authorization: Bearer +``` + +Tokens are obtained from `/api/v1/auth/login` or `/api/v1/auth/register`. They expire after **15 minutes**. Use `/api/v1/auth/refresh` with the refresh token to get a new access token. + +Guest endpoints (cart, checkout) use an `X-Guest-Session` header instead: + +``` +X-Guest-Session: +``` + +The client generates this UUID and persists it locally (e.g., in localStorage). + +--- + +## Response Envelope + +All responses are wrapped in `ApiResponse`: + +```json +{ + "data": { ... }, + "message": null +} +``` + +Error responses: + +```json +{ + "data": null, + "message": "Human-readable error description" +} +``` + +The HTTP status code carries the semantic meaning. The `message` field is for display to users or developers. + +--- + +## Pagination + +Paginated endpoints return `PagedResult`: + +```json +{ + "content": [ ... ], + "page": 0, + "size": 20, + "totalElements": 143, + "totalPages": 8, + "last": false +} +``` + +Query parameters: +- `page` — zero-indexed (default `0`) +- `size` — items per page (default varies by endpoint, usually `20`) + +--- + +## HTTP Status Codes + +| Code | Meaning | +|---|---| +| 200 | OK — successful GET, PUT, POST returning data | +| 201 | Created — resource created (rare; most POSTs return 200) | +| 204 | No Content — successful DELETE or action with no body | +| 400 | Bad Request — validation failure; `message` explains what failed | +| 401 | Unauthorized — missing or invalid JWT | +| 403 | Forbidden — valid JWT but insufficient permissions | +| 404 | Not Found — resource doesn't exist | +| 409 | Conflict — business rule violation (duplicate, state mismatch) | +| 422 | Unprocessable Entity — request is structurally valid but logically invalid | +| 429 | Too Many Requests — rate limit exceeded | +| 500 | Internal Server Error — unexpected server failure | + +--- + +## Permission-Gated Endpoints + +Admin endpoints check permissions via `@HasPermission("resource:action")`. The permission name is listed in the API tables in the spec files. Permissions are assigned to IAM roles; roles are assigned to users. + +The built-in roles and their permissions: + +| Role | What it can do | +|---|---| +| CUSTOMER | Storefront only — no admin access | +| STAFF | Read-only admin access across most resources | +| MANAGER | Full admin access except IAM management | +| OWNER | Full access including IAM | + +Permission names follow the pattern `resource:action` where action is one of `read`, `write`, `delete`, `manage`. + +--- + +## Rate Limiting + +- **Global:** per-IP rate limit applied by `ApiRateLimitFilter` to all API endpoints +- **Login:** additional per-email rate limit (1 attempt/minute) applied by `LoginRateLimiter` to `POST /auth/request-password-reset` + +Clients that exceed limits receive `429 Too Many Requests`. + +--- + +## Filtering and Sorting + +Admin list endpoints accept query parameters for filtering. Common patterns: + +``` +GET /api/v1/admin/orders?status=PAID&userId=&page=0&size=20 +GET /api/v1/admin/users?email=foo@example.com&status=ACTIVE +GET /api/v1/admin/products?titleContains=planter&status=ACTIVE +``` + +Sorting is endpoint-specific and documented in the specs. Where supported, the `sortBy` parameter accepts field names. + +--- + +## Storefront vs Admin Split + +Endpoints are split into two audiences: + +| Audience | Path prefix | Auth | +|---|---|---| +| Storefront (customer-facing) | `/api/v1/*` | JWT (`@Authenticated`) or no auth | +| Admin (staff-facing) | `/api/v1/admin/*` | JWT + permission (`@HasPermission`) | + +A logged-in customer cannot access admin endpoints even with a valid JWT — the permission check blocks them. + +--- + +## Idempotency + +Stripe webhook processing is idempotent via the `ProcessedStripeEvent` table (V59 migration). Other write endpoints are not idempotent by default — clients should not retry POST requests on network failure without checking the current state first. + +--- + +## Dates and Times + +All timestamps are ISO-8601 UTC strings in request and response bodies: + +``` +"createdAt": "2026-05-26T10:30:00Z" +``` + +The database stores all timestamps in UTC (`UTC` timezone set on the JDBC driver). The JVM runs in UTC. + +--- + +## Currency + +Monetary amounts are `NUMERIC(19,4)` in the database — returned as strings in JSON to avoid floating-point precision loss. The currency field defaults to `"usd"` (lowercase ISO-4217). + +--- + +## File Uploads + +Blob upload: `POST /api/v1/blobs` — `multipart/form-data`, field name `file`. Max file size 20 MB, max request 25 MB. + +The response includes a `key` field. Use the key to reference the blob from other entities (e.g., `featuredImageId` on a product). + +--- + +## Versioning + +The API is currently at v1 (`/api/v1/`). There is no v2. Breaking changes require coordination across the frontend apps. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..6cbd319 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,192 @@ +# Architecture + +## Overview + +Garden is a monolithic Spring Boot REST API. Everything lives in a single deployable JAR. There is no microservices split — modules are separated by Java package, not by process. + +``` +┌─────────────────────────────────────────────────┐ +│ Clients │ +│ garden-web (Vite/React) garden-admin (Vite) │ +└───────────────────┬─────────────────────────────┘ + │ HTTPS +┌───────────────────▼─────────────────────────────┐ +│ Garden API (Spring Boot) │ +│ localhost:8080 / :80 │ +│ │ +│ Auth · IAM · Catalog · Cart · Order · B2B │ +│ Payment · Inventory · Fulfillment · Content │ +│ Webhooks · Stats · Audit · Schedulers │ +└───────┬───────────────────────────┬──────────────┘ + │ │ +┌───────▼──────┐ ┌──────────▼────────────┐ +│ PostgreSQL │ │ S3 / Cloudflare R2 │ +│ (all state) │ │ (blobs/files) │ +└──────────────┘ └────────────────────────┘ + │ +┌───────▼──────┐ +│ Stripe │ +│ (payments) │ +└──────────────┘ +``` + +--- + +## Technology Stack + +| Layer | Technology | Notes | +|---|---|---| +| Language | Java 26 | | +| Framework | Spring Boot 4.0.4 | Spring MVC (not WebFlux) | +| Persistence | Spring Data JPA + Hibernate | Postgres-only; no JPQL dialects outside of Postgres | +| Schema management | Flyway | DDL never auto-generated by Hibernate | +| Security | Spring Security | OAuth2 resource server (JWT RS256) + OAuth2 client (Google) | +| Auth tokens | Nimbus JOSE + JWT | RS256 access tokens; opaque refresh tokens stored hashed | +| Payments | Stripe Java SDK | Checkout sessions + webhook verification | +| File storage | AWS SDK v2 (S3 client) | Works against any S3-compatible endpoint (MinIO, Cloudflare R2) | +| Email | Spring Mail (SMTP) | Thymeleaf HTML templates | +| PDF generation | openhtmltopdf | Quote PDFs from Thymeleaf templates | +| Scheduling | Spring `@Scheduled` + ShedLock | Distributed lock prevents duplicate execution across nodes | +| Caching | Caffeine | In-process; used for IAM permission lookup | +| Build | Maven Wrapper (`./mvnw`) | | +| Containerization | Docker (multi-stage) | `eclipse-temurin:26-jdk` | + +--- + +## Package Structure + +All code lives under `io.k2dv.garden`. Each module follows the same internal structure: + +``` +io.k2dv.garden.{module}/ + controller/ ← REST endpoints + service/ ← business logic + model/ ← JPA entities + repository/ ← Spring Data JPA interfaces + dto/ ← request/response records + specification/ ← JPA Specification classes (for filtered queries) +``` + +Modules do not call each other's repositories directly — they go through services. Cross-module dependencies are kept to a minimum and flow in predictable directions. + +--- + +## Module Map + +``` +auth ──────────────────────────────────────────────────────┐ + Identity · RefreshToken · Token · ImpersonationToken │ + │ +iam ──────────────────────────────────────────────────────►│ + Role · Permission │ auth.users + │ +user/account ────────────────────────────────────────────►─┘ + User · Address (shared table) + +product ──────────────────────────────────────────────────►─ catalog schema + Product · ProductVariant · ProductOption · ProductImage + +collection ───────────────────────────────────────────────►─ catalog schema + Collection · CollectionRule · CollectionProduct + +inventory ────────────────────────────────────────────────►─ inventory schema + InventoryItem · InventoryLevel · InventoryTransaction · Location + +cart ─────────────────────────────────────────────────────►─ checkout schema + Cart · CartItem + +payment ──────────────────────────────────────────────────►─ Stripe + checkout schema + ProcessedStripeEvent (idempotency log) + +order ────────────────────────────────────────────────────►─ checkout schema + Order · OrderItem · OrderEvent · ReturnRequest + +fulfillment ──────────────────────────────────────────────►─ checkout schema + Fulfillment · FulfillmentItem + +discount ─────────────────────────────────────────────────►─ checkout schema +giftcard ─────────────────────────────────────────────────►─ checkout schema +shipping ─────────────────────────────────────────────────►─ shipping schema + +b2b ──────────────────────────────────────────────────────►─ b2b schema + Company · CompanyMembership · CreditAccount · Invoice + +quote ────────────────────────────────────────────────────►─ quote schema + QuoteRequest · QuoteItem · QuoteCart + +content ──────────────────────────────────────────────────►─ content schema + Article · SitePage · Blog + +blob ─────────────────────────────────────────────────────►─ storage schema + S3 +webhook ──────────────────────────────────────────────────►─ webhook schema +audit ────────────────────────────────────────────────────►─ shared schema +``` + +--- + +## Key Design Decisions + +### Monolith over microservices +All modules deploy together. This simplifies transactions (no distributed transactions needed), reduces operational overhead, and keeps the codebase navigable for a small team. + +### Postgres as the only database +All state lives in Postgres. No Redis, no Elasticsearch, no separate cache service. Full-text search uses Postgres `tsvector` (V62 migration). In-process Caffeine handles the one cache worth having (IAM permissions). + +### Flyway owns the schema — Hibernate never generates DDL +`spring.jpa.hibernate.ddl-auto=validate` ensures Hibernate only validates that entity mappings match the database. All schema changes go through versioned SQL migration files. This gives a clear, auditable history of every schema change. + +### UUID v7 primary keys +All entities use UUID v7 (time-ordered). This avoids the index fragmentation problem of random UUID v4 while keeping PKs as UUIDs. Generated in Java via a utility in `shared`. + +### Soft deletes for catalog entities +Products, variants, collections, articles, and pages have a `deletedAt` column. Repositories filter these out by default. Hard deletes are not used because order history must remain consistent even after a product is removed from the catalog. + +### ShedLock for schedulers +The app may run as multiple instances (Dokploy deployment). ShedLock ensures that background jobs (abandoned cart reminders, quote expiry, payment reconciliation) run on exactly one instance at a time, preventing duplicate emails and duplicate Stripe calls. + +### Optimistic locking on Orders +`Order.lockVersion` uses JPA `@Version`. Concurrent updates (e.g., two fulfillment creates) throw `OptimisticLockException` rather than silently overwriting each other. + +### Stripe checkout sessions (not Payment Intents directly) +All payments go through Stripe Checkout (hosted payment page). This avoids PCI scope creep — the app never touches raw card numbers. The `PaymentReconciliationScheduler` runs every 10 minutes to catch payments where the Stripe webhook was delayed or lost. + +--- + +## Request Lifecycle + +``` +HTTP Request + → MdcLoggingFilter (injects requestId + userId into MDC for log correlation) + → ApiRateLimitFilter (per-IP rate limit; login endpoint also has per-email limit) + → Spring Security filter chain + → JwtAuthenticationFilter (validates RS256 JWT, loads permissions from claims) + → ImpersonationTokenValidationFilter (handles impersonation tokens) + → DispatcherServlet + → Controller (@RestController) + → @Authenticated / @HasPermission check (method-level annotations) + → @CurrentUser resolution (CurrentUserArgumentResolver) + → Service (business logic, @Transactional) + → Repository (Spring Data JPA) + → Database (Postgres) + → GlobalExceptionHandler (maps exceptions → HTTP status + ApiResponse error body) +``` + +--- + +## Event Flow (Order Confirmation) + +``` +Stripe webhook ──► WebhookController.stripeWebhook() + │ + ▼ + PaymentService.handleStripeEvent() + │ (idempotency check via ProcessedStripeEvent) + ▼ + Order status → PAID + │ + ├──► OrderEventService.record(PAYMENT_CONFIRMED) + ├──► InventoryService.fulfill() + ├──► EmailService.sendOrderConfirmation() + ├──► OutboundWebhookService.dispatch(ORDER_PAID) + └──► AutoTagService.applyOrderTags(userId) +``` diff --git a/docs/b2b-spec.md b/docs/b2b-spec.md deleted file mode 100644 index 0c5cf58..0000000 --- a/docs/b2b-spec.md +++ /dev/null @@ -1,592 +0,0 @@ -# B2B Backend Spec - -**Status:** Retroactive — documents what is built and what remains -**Last updated:** 2026-04-19 -**Package root:** `io.k2dv.garden` -**DB schemas:** `b2b`, `quote` -**Flyway migrations:** V15, V16, V18, V28, V29, V30, V32 - ---- - -## Overview - -The B2B system lets verified companies buy on negotiated terms. The core path is: - -``` -Company created → price lists configured → buyer builds quote cart -→ submits quote request → admin prices items → admin sends quote -→ buyer accepts → order + invoice created → admin records payments -``` - -Net terms (credit accounts) let qualifying companies accept quotes without upfront payment. The invoice draws on a credit line and is settled manually by the admin recording wire transfers or cheques. - ---- - -## Database Schema - -All B2B tables live in the `b2b` Postgres schema. Quote tables live in the `quote` schema. - -### b2b schema tables - -| Table | Created | Purpose | -|---|---|---| -| `companies` | V15 | Organisation entity | -| `company_memberships` | V15 | User↔company with role + spending limit | -| `company_invitations` | V32 | Email invitations with UUID token | -| `price_lists` | V28 | Named contract price sets per company | -| `price_list_entries` | V28 | Per-variant custom prices with quantity tiers | -| `credit_accounts` | V30 | Net-terms credit line (one per company) | -| `invoices` | V30 | B2B invoices (one per order) | -| `invoice_payments` | V30 | Immutable payment records against an invoice | - -### quote schema tables - -| Table | Created | Purpose | -|---|---|---| -| `quote_carts` | V16 | Active/submitted quote cart per user | -| `quote_cart_items` | V16 | Items in a cart (variantId + qty + note) | -| `quote_requests` | V16 | Submitted quote (10-state lifecycle) | -| `quote_items` | V16 | Line items on a quote (description + pricing) | - ---- - -## Entities - -### Company -`b2b.companies` — `io.k2dv.garden.b2b.model.Company` - -| Field | Column | Constraint | Notes | -|---|---|---|---| -| id | id | PK, UUID | from BaseEntity | -| name | name | NOT NULL | | -| taxId | tax_id | nullable | VAT / EIN | -| phone | phone | nullable | | -| billingAddressLine1 | billing_address_line1 | nullable | | -| billingAddressLine2 | billing_address_line2 | nullable | | -| billingCity | billing_city | nullable | | -| billingState | billing_state | nullable | | -| billingPostalCode | billing_postal_code | nullable | | -| billingCountry | billing_country | nullable | | -| createdAt / updatedAt | | | from BaseEntity | - -**Create:** `@NotBlank name` required. All address fields optional. -**Update:** same shape as create (`UpdateCompanyRequest`). - ---- - -### CompanyMembership -`b2b.company_memberships` — `io.k2dv.garden.b2b.model.CompanyMembership` - -| Field | Column | Constraint | -|---|---|---| -| companyId | company_id | NOT NULL, FK | -| userId | user_id | NOT NULL, FK | -| role | role | NOT NULL, ENUM (default MEMBER) | -| spendingLimit | spending_limit | nullable, NUMERIC(19,4), CHECK > 0 | - -**Unique index:** `(company_id, user_id)` — a user can only have one membership per company. - -**Roles:** -- `OWNER` — full control, set on company creation; cannot be reassigned via invitation -- `MANAGER` — can invite members, approve spending, manage price lists -- `MEMBER` — can submit quotes; optionally subject to a spending limit - ---- - -### CompanyInvitation -`b2b.company_invitations` — `io.k2dv.garden.b2b.model.CompanyInvitation` - -| Field | Column | Constraint | Notes | -|---|---|---|---| -| companyId | company_id | NOT NULL, FK | | -| email | email | NOT NULL | | -| role | role | NOT NULL, ENUM | MANAGER or MEMBER only — OWNER cannot be invited | -| spendingLimit | spending_limit | nullable | pre-set on invitation | -| token | token | NOT NULL, UNIQUE | UUID used in accept URL | -| invitedBy | invited_by | NOT NULL, FK | user who sent it | -| status | status | NOT NULL, ENUM (default PENDING) | | -| expiresAt | expires_at | NOT NULL | set to now + 7 days | - -**Statuses:** `PENDING` · `ACCEPTED` · `CANCELLED` · `EXPIRED` - -**Business rules:** -- Only OWNER or MANAGER can invite -- Cannot invite if a PENDING invitation already exists for that email+company (duplicate check: `existsByCompanyIdAndEmailAndStatus`) -- Accepting validates that the accepting user's email matches the invitation email -- Accepting a token that is expired throws an error - ---- - -### CreditAccount -`b2b.credit_accounts` — `io.k2dv.garden.b2b.model.CreditAccount` - -| Field | Column | Constraint | Default | -|---|---|---|---| -| companyId | company_id | NOT NULL, UNIQUE FK | | -| creditLimit | credit_limit | NOT NULL, NUMERIC(19,4) | | -| paymentTermsDays | payment_terms_days | NOT NULL | 30 | -| currency | currency | NOT NULL | USD | - -**One per company** — the `company_id` column has a unique constraint. -Currency is set at creation and cannot be changed afterward. - -**Computed fields** (not persisted): -- `outstandingBalance` — JPQL aggregate: `SUM(totalAmount - paidAmount)` for invoices with status IN (`ISSUED`, `PARTIAL`, `OVERDUE`) -- `availableCredit` = `creditLimit − outstandingBalance` - -**Business rules:** -- `@Positive creditLimit` required on create -- `paymentTermsDays` defaults to 30 if not supplied -- `delete` removes the credit account; outstanding invoices are not automatically voided (server enforces nothing — caller must verify balance is clear) - ---- - -### PriceList -`b2b.price_lists` — `io.k2dv.garden.b2b.model.PriceList` - -| Field | Column | Constraint | Default | -|---|---|---|---| -| companyId | company_id | NOT NULL, FK | | -| name | name | NOT NULL | | -| currency | currency | NOT NULL | USD | -| priority | priority | NOT NULL | 0 | -| startsAt | starts_at | nullable | no restriction | -| endsAt | ends_at | nullable | no expiry | - -A price list without `startsAt`/`endsAt` is always active. - ---- - -### PriceListEntry -`b2b.price_list_entries` — `io.k2dv.garden.b2b.model.PriceListEntry` - -| Field | Column | Constraint | Default | -|---|---|---|---| -| priceListId | price_list_id | NOT NULL, FK | | -| variantId | variant_id | NOT NULL, FK | | -| price | price | NOT NULL, NUMERIC(19,4) | | -| minQty | min_qty | NOT NULL | 1 | - -**Unique index:** `(price_list_id, variant_id, min_qty)` — enables quantity-tiered pricing. -`@PositiveOrZero price`, `@Min(1) minQty`. - -**Upsert semantics:** `upsertEntry` finds by `(priceListId, variantId, minQty)` — updates price if found, creates new entry otherwise. Each `PUT /entries/{variantId}` targets one tier. -**Delete semantics:** `deleteEntry` removes **all** entries for a variant across all quantity tiers, not just one tier. - ---- - -### Price Resolution Algorithm - -`PriceListService.resolvePrice(companyId, variantId, qty)`: - -1. Load active price lists for the company: `startsAt <= now AND endsAt > now` (or null bounds), ordered by `priority DESC` -2. Query `PriceListEntry` candidates: `priceListId IN activeListIds AND variantId = ? AND minQty <= qty`, ordered by `minQty DESC` -3. `pickBestEntry`: iterate active lists by priority; for each list find the highest `minQty` candidate — first match wins -4. If no contract price found, fall back to the variant's default `price` field - -Returns: `ResolvedPriceResponse { price, isContractPrice }`. - -This is also called at quote-submission time to pre-populate item prices. - ---- - -### Invoice -`b2b.invoices` — `io.k2dv.garden.b2b.model.Invoice` - -| Field | Column | Constraint | Default | -|---|---|---|---| -| companyId | company_id | NOT NULL, FK | | -| orderId | order_id | NOT NULL, UNIQUE FK | one invoice per order | -| quoteId | quote_id | nullable, FK | link back to source quote | -| status | status | NOT NULL, ENUM | ISSUED | -| totalAmount | total_amount | NOT NULL, NUMERIC(19,4) | | -| paidAmount | paid_amount | NOT NULL, NUMERIC(19,4) | 0 | -| currency | currency | NOT NULL | USD | -| issuedAt | issued_at | NOT NULL | | -| dueAt | due_at | NOT NULL | issuedAt + paymentTermsDays | - -**Statuses:** `ISSUED` → `PARTIAL` → `PAID` (side paths: `OVERDUE`, `VOID`) - -**Status transitions:** - -| From | Action | To | Side effect | -|---|---|---|---| -| ISSUED / PARTIAL / OVERDUE | recordPayment (partial) | PARTIAL | paidAmount updated | -| ISSUED / PARTIAL / OVERDUE | recordPayment (full) | PAID | Order set to PAID | -| ISSUED / PARTIAL | markOverdue | OVERDUE | — | -| ISSUED / INVOICED (order) | voidInvoice | VOID | Order cancelled if INVOICED | - -**recordPayment validation:** `amount` must be `> 0` and `<= outstandingAmount` (otherwise throws). - ---- - -### InvoicePayment -`b2b.invoice_payments` — `io.k2dv.garden.b2b.model.InvoicePayment` - -Extends `ImmutableBaseEntity` — no `updatedAt`, records are never modified after creation. - -| Field | Notes | -|---|---| -| invoiceId | FK to invoice | -| amount | NOT NULL, positive | -| paymentReference | wire ref, cheque #, etc. | -| notes | free text | -| paidAt | when the payment was actually made (not when recorded) | - ---- - -### QuoteRequest -`quote.quote_requests` — `io.k2dv.garden.quote.model.QuoteRequest` - -| Field | Column | Constraint | -|---|---|---| -| userId | user_id | NOT NULL, FK | -| companyId | company_id | NOT NULL, FK | -| assignedStaffId | assigned_staff_id | nullable | -| status | status | NOT NULL, ENUM (default PENDING) | -| deliveryAddressLine1 | delivery_address_line1 | NOT NULL | -| deliveryAddressLine2 | delivery_address_line2 | nullable | -| deliveryCity | delivery_city | NOT NULL | -| deliveryState | delivery_state | nullable | -| deliveryPostalCode | delivery_postal_code | NOT NULL | -| deliveryCountry | delivery_country | NOT NULL | -| shippingRequirements | shipping_requirements | nullable | -| customerNotes | customer_notes | nullable | -| staffNotes | staff_notes | nullable | -| expiresAt | expires_at | nullable; `@Future` enforced on send | -| pdfBlobId | pdf_blob_id | nullable; set when PDF is generated | -| orderId | order_id | nullable; set on acceptance | -| approverId | approver_id | nullable; set when manager approves | -| approvedAt | approved_at | nullable | - -**Quote statuses:** - -``` -PENDING ──► ASSIGNED ──► DRAFT ──► SENT ──► ACCEPTED ──► PAID - │ │ │ │ - └────────────┴───────────┴──► CANCELLED - │ - SENT ──► EXPIRED (scheduled) - SENT ──► REJECTED (buyer) - ACCEPTED ──► PENDING_APPROVAL (spending limit) - PENDING_APPROVAL ──► ACCEPTED (manager approves) - PENDING_APPROVAL ──► REJECTED (manager rejects) -``` - -**Editable states** (items/notes can be changed): PENDING, ASSIGNED, DRAFT -**Sendable states** (can call `/send`): PENDING, ASSIGNED, DRAFT -**Cancellable states** (admin or buyer): PENDING, ASSIGNED, DRAFT, SENT -**Terminal states** (no further mutations): ACCEPTED, PAID, REJECTED, EXPIRED, CANCELLED, PENDING_APPROVAL - ---- - -### QuoteItem -`quote.quote_items` — `io.k2dv.garden.quote.model.QuoteItem` - -| Field | Notes | -|---|---| -| quoteRequestId | FK | -| variantId | nullable — custom line items may not map to a variant | -| description | NOT NULL | -| quantity | NOT NULL | -| unitPrice | nullable — null means "pending pricing" (buyer sees placeholder) | - -**Send validation:** all items must have a non-null `unitPrice` before the quote can be sent. -**Submit pre-population:** on quote submission, `PriceListService.resolvePrice` is called for each cart item that has a `variantId`; the resolved price is stored as `unitPrice` on the quote item. - ---- - -### QuoteCart -`quote.quote_carts` — `io.k2dv.garden.quote.model.QuoteCart` - -| Field | Notes | -|---|---| -| userId | NOT NULL, FK | -| status | ACTIVE or SUBMITTED | - -**One active cart per user** — DB unique partial index on `(user_id)` where `status = 'ACTIVE'`. -`getOrCreateActiveCart` lazily creates a cart on first use. -On quote submission the cart transitions to `SUBMITTED`; a new `ACTIVE` cart is created on the next `addItem` call. - ---- - -## Service Business Logic - -### CompanyService - -- **create:** creates company, immediately creates an OWNER membership for the creating user -- **getById:** verifies the requesting user is a member before returning (`requireMemberAccess`) -- **update:** only OWNER can update company details -- **addMember:** OWNER or MANAGER only; adds membership directly (no invitation); looks user up by email -- **removeMember:** OWNER or MANAGER only; cannot remove the OWNER -- **updateMemberRole:** OWNER or MANAGER only; cannot change OWNER role -- **updateSpendingLimit:** OWNER or MANAGER only; sets/clears spending limit on a membership -- **requireMemberAccess:** throws 403 if the user has no membership in the company - -### CompanyInvitationService - -- **invite:** requires OWNER or MANAGER; `role` cannot be OWNER; checks for duplicate PENDING invitation; sets `expiresAt = now + 7 days`; sends invitation email -- **accept:** validates token exists and is PENDING; checks `expiresAt`; validates accepting user's email matches invitation email; creates membership; marks invitation ACCEPTED -- **cancel:** requires OWNER or MANAGER; only PENDING invitations can be cancelled -- **listPending:** returns invitations with status PENDING for a company; requires membership - -### CreditAccountService - -- **create:** validates company exists; only one credit account per company (unique constraint enforced by DB) -- **getByCompany:** computes `outstandingBalance` via `InvoiceRepository.computeOutstandingBalance`; computes `availableCredit = creditLimit - outstandingBalance` -- **update:** updates `creditLimit` and `paymentTermsDays`; currency is not updatable - -### PriceListService - -- **listByCompany:** ordered by `priority DESC` -- **listEntries:** ordered by `minQty ASC` -- **upsertEntry:** uses `findByPriceListIdAndVariantIdAndMinQty` — updates existing or creates new -- **deleteEntry:** `deleteByPriceListIdAndVariantId` removes ALL tiers for a variant, not just one - -### InvoiceService - -- **createFromOrder:** called internally by `QuoteService.finalizeAcceptance`; `dueAt = issuedAt + paymentTermsDays days`; sets Order status to INVOICED -- **recordPayment:** validates `amount <= outstanding`; creates `InvoicePayment`; updates `paidAmount`; if `paidAmount >= totalAmount` → PAID and sets Order to PAID; else → PARTIAL -- **voidInvoice:** sets status to VOID; if Order is still INVOICED, cancels it - -### QuoteService - -**submit:** -1. Verifies `companyId` membership for the user -2. Loads the user's ACTIVE cart; throws if empty -3. Creates `QuoteRequest` with delivery address from the request -4. For each cart item: calls `resolvePrice(companyId, variantId, qty)` to pre-populate `unitPrice` -5. Marks cart SUBMITTED -6. Sends confirmation email to user + notification to configured admin email - -**send:** -1. Quote must be in PENDING / ASSIGNED / DRAFT -2. All `QuoteItem.unitPrice` must be non-null (throws if any are null) -3. Generates PDF via `QuotePdfService` (Thymeleaf + openhtmltopdf), uploads to blob storage, stores `pdfBlobId` -4. Sends email to buyer with PDF attachment -5. Transitions to SENT, sets `expiresAt` - -**accept (buyer):** -1. Quote must be SENT -2. Checks `expiresAt` — throws if expired -3. **Spending limit check:** if the membership has a `spendingLimit` and quote total > spendingLimit → transitions to PENDING_APPROVAL, returns `{ pendingApproval: true }` -4. Otherwise calls `finalizeAcceptance` - -**finalizeAcceptance:** -1. Creates an Order from the quote items -2. Checks if company has a credit account - - If yes: checks `availableCredit >= totalAmount` (throws if insufficient); calls `InvoiceService.createFromOrder`; returns `{ invoiceId }` - - If no: creates Stripe checkout session; returns `{ checkoutUrl }` -3. Sets `QuoteRequest.orderId`, transitions to ACCEPTED - -**approveSpend (manager):** -1. Quote must be PENDING_APPROVAL -2. Requesting user must be OWNER or MANAGER of the company -3. Sets `approverId`, `approvedAt`; calls `finalizeAcceptance` - -**rejectSpend (manager):** -1. Quote must be PENDING_APPROVAL -2. Requesting user must be OWNER or MANAGER -3. Transitions to REJECTED - -**assign:** transitions PENDING → ASSIGNED; sets `assignedStaffId` - -**Scheduled expiry** (bulk): `QuoteRequestRepository.expireByStatus(SENT, EXPIRED, now)` — updates all SENT quotes where `expiresAt < now`. Caller is a scheduled job (not shown in source read, assumed to exist). - ---- - -## API Reference - -### Storefront — Company (`/api/v1/companies`) - -All endpoints require `@Authenticated`. - -| Method | Path | Permission | Description | -|---|---|---|---| -| POST | `/` | any user | Create company; caller becomes OWNER | -| GET | `/` | any user | List companies the caller belongs to | -| GET | `/{id}` | member | Get company (membership required) | -| PUT | `/{id}` | OWNER | Update company details | -| GET | `/{id}/members` | member | List all members | -| POST | `/{id}/members` | OWNER/MANAGER | Add member directly by email | -| DELETE | `/{id}/members/{userId}` | OWNER/MANAGER | Remove member | -| PUT | `/{id}/members/{userId}/role` | OWNER/MANAGER | Change member role | -| PUT | `/{id}/members/{userId}/spending-limit` | OWNER/MANAGER | Set spending limit | -| GET | `/{id}/invitations` | OWNER/MANAGER | List pending invitations | -| POST | `/{id}/invitations` | OWNER/MANAGER | Send invitation | -| DELETE | `/{id}/invitations/{invId}` | OWNER/MANAGER | Cancel invitation | -| GET | `/{id}/invoices` | member | List company invoices | -| GET | `/{id}/price-lists` | member | List company price lists | -| GET | `/{id}/price-lists/{plId}/entries` | member | List entries (with product details) | -| GET | `/{id}/price` | member | Resolve best price for a variant+qty | - -### Storefront — Invitations (`/api/v1/invitations`) - -| Method | Path | Auth | Description | -|---|---|---|---| -| GET | `/{token}` | none | Look up invitation by token (for accept page) | -| POST | `/{token}/accept` | `@Authenticated` | Accept invitation; email must match | - -### Storefront — Quote Cart (`/api/v1/quote-cart`) - -| Method | Path | Description | -|---|---|---| -| GET | `/` | Get (or create) active cart | -| DELETE | `/` | Clear all items from active cart | -| POST | `/items` | Add item (upserts if variant already in cart) | -| PUT | `/items/{itemId}` | Update quantity and/or note | -| DELETE | `/items/{itemId}` | Remove item | - -### Storefront — Quotes (`/api/v1/quotes`) - -| Method | Path | Description | -|---|---|---| -| POST | `/` | Submit quote from active cart | -| GET | `/` | List caller's quotes (paginated) | -| GET | `/pending-approvals` | List PENDING_APPROVAL quotes for OWNER/MANAGER | -| GET | `/{id}` | Get quote (ownership check) | -| POST | `/{id}/accept` | Accept SENT quote | -| POST | `/{id}/reject` | Reject SENT quote | -| POST | `/{id}/cancel` | Cancel quote (PENDING/ASSIGNED/DRAFT/SENT) | -| POST | `/{id}/approve` | Manager approves PENDING_APPROVAL quote | -| POST | `/{id}/reject-approval` | Manager rejects PENDING_APPROVAL quote | -| GET | `/{id}/pdf` | Download quote PDF (ownership check) | - -### Admin — Price Lists (`/api/v1/admin/price-lists`) - -Permission required per endpoint: - -| Method | Path | Permission | -|---|---|---| -| POST | `/` | `price_list:write` | -| GET | `/` | `price_list:read` — **requires `?companyId=` query param** | -| GET | `/{id}` | `price_list:read` | -| PUT | `/{id}` | `price_list:write` | -| DELETE | `/{id}` | `price_list:delete` | -| GET | `/{id}/entries` | `price_list:read` | -| PUT | `/{id}/entries/{variantId}` | `price_list:write` | -| DELETE | `/{id}/entries/{variantId}` | `price_list:delete` | - -### Admin — Credit Accounts (`/api/v1/admin/credit-accounts`) - -| Method | Path | Permission | -|---|---|---| -| POST | `/` | `credit_account:write` | -| GET | `/company/{companyId}` | `credit_account:read` | -| PUT | `/company/{companyId}` | `credit_account:write` | -| DELETE | `/company/{companyId}` | `credit_account:write` | - -### Admin — Invoices (`/api/v1/admin/invoices`) - -| Method | Path | Permission | Notes | -|---|---|---|---| -| GET | `/` | `invoice:read` | Filterable: `companyId`, `status`; paginated | -| GET | `/{id}` | `invoice:read` | | -| POST | `/{id}/payments` | `invoice:write` | Record a payment | -| POST | `/{id}/overdue` | `invoice:write` | Mark overdue | -| DELETE | `/{id}` | `invoice:delete` | Void invoice | - -### Admin — Quotes (`/api/v1/admin/quotes`) - -| Method | Path | Permission | Notes | -|---|---|---|---| -| GET | `/` | `quote:read` | Filter: `status`, `companyId`, `assignedStaffId` | -| GET | `/{id}` | `quote:read` | | -| POST | `/{id}/assign` | `quote:write` | Set staff | -| POST | `/{id}/items` | `quote:write` | Add line item | -| PUT | `/{id}/items/{itemId}` | `quote:write` | Update qty + price | -| DELETE | `/{id}/items/{itemId}` | `quote:write` | Remove item | -| PUT | `/{id}/notes` | `quote:write` | Update staff notes | -| POST | `/{id}/send` | `quote:write` | Send to buyer; requires all items priced | -| POST | `/{id}/cancel` | `quote:write` | Cancel quote | - ---- - -## Permissions Matrix - -Permissions are seeded by Flyway migrations and assigned to IAM roles. - -| Permission | STAFF | MANAGER | OWNER | -|---|---|---|---| -| `quote:read` | ✓ | ✓ | ✓ | -| `quote:write` | ✓ | ✓ | ✓ | -| `price_list:read` | ✓ | ✓ | ✓ | -| `price_list:write` | | ✓ | ✓ | -| `price_list:delete` | | ✓ | ✓ | -| `credit_account:read` | ✓ | ✓ | ✓ | -| `credit_account:write` | | ✓ | ✓ | -| `invoice:read` | ✓ | ✓ | ✓ | -| `invoice:write` | | ✓ | ✓ | -| `invoice:delete` | | ✓ | ✓ | - -*MANAGER/OWNER here refer to admin IAM roles (staff-side), not company membership roles.* - ---- - -## PDF Generation - -`QuotePdfService.generate(QuoteRequest, List, Company)`: - -- Template: `quote-template` (Thymeleaf HTML) -- Renderer: openhtmltopdf -- Calculates: line totals (`qty × unitPrice`), grand total -- Date format: UTC `yyyy-MM-dd` -- Output: `byte[]`; uploaded to blob storage by `QuoteService.send`; `pdfBlobId` stored on `QuoteRequest` - ---- - -## Scheduled / Background Work - -The following bulk-update queries exist but their scheduler wiring is not covered by this audit: - -| Query | Repository method | Trigger | -|---|---|---| -| Expire SENT quotes past `expiresAt` | `QuoteRequestRepository.expireByStatus(SENT, EXPIRED, now)` | Scheduled job (assumed) | -| Bulk-mark invoices overdue past `dueAt` | `InvoiceRepository.markOverduePastDue(now)` | Scheduled job (assumed) | - ---- - -## Validation Summary - -| DTO | Key constraints | -|---|---| -| `CreateCompanyRequest` | `@NotBlank name` | -| `AddMemberRequest` | `@Email @NotBlank email`, `@Positive spendingLimit` (optional) | -| `CreateInvitationRequest` | `@Email @NotBlank email`; role cannot be OWNER | -| `CreateCreditAccountRequest` | `@NotNull companyId`, `@Positive creditLimit` | -| `UpdateCreditAccountRequest` | `@Positive creditLimit` | -| `CreatePriceListRequest` | `@NotNull companyId`, `@NotBlank name` | -| `UpsertPriceListEntryRequest` | `@PositiveOrZero price`, `@Min(1) minQty` | -| `RecordPaymentRequest` | `@Positive amount`; server also validates `amount <= outstanding` | -| `SubmitQuoteRequest` | `@NotNull companyId`; all delivery fields required except line2/state | -| `SendQuoteRequest` | `@NotNull @Future expiresAt`; all items must have `unitPrice` (service check) | -| `AddQuoteItemRequest` | `@NotBlank description`, `@Min(1) quantity`, `@NotNull unitPrice` | - ---- - -## Known Gaps and Open Items - -### Missing features - -| # | Area | Description | -|---|---|---| -| 1 | Companies | No admin endpoint to list/search companies (only storefront `GET /companies` which scopes to the caller's memberships) | -| 2 | Companies | No admin endpoint to create companies on behalf of a customer | -| 3 | Invoices | No admin endpoint to create invoices manually (invoices are only auto-created via `finalizeAcceptance`) | -| 4 | Invoices | `markOverduePastDue` bulk query exists but no scheduled `@Scheduled` annotation found; overdue marking may require a manual admin call per invoice | -| 5 | Quotes | `expireByStatus` bulk query exists but scheduler wiring not confirmed | -| 6 | Price Lists | `deleteEntry` removes ALL tiers for a variant, not a single tier — no way to delete just one tier of a tiered entry | -| 7 | Invitations | No re-send endpoint; cancelled invitations require a new invite | -| 8 | Credit | No guard preventing deletion of a credit account with outstanding invoices | -| 9 | Quotes | `AddQuoteItemRequest` requires `@NotNull unitPrice` — admin cannot add an "unpriced" item (unitPrice must be provided upfront) | -| 10 | Quotes | No endpoint to clone an existing quote | - -### Hardening / correctness - -| # | Description | -|---|---| -| 11 | `voidInvoice` cancels the linked order only when order status is INVOICED; if the order has already moved to another state, it is left as-is with no error | -| 12 | `recordPayment` does not guard against concurrent payments exceeding the outstanding balance (no optimistic locking or database-level lock on the invoice row) | -| 13 | `resolvePrice` falls back to `variant.price` if no contract entry matches, but there is no explicit handling for the case where `variant.price` is also null | -| 14 | Quote submission pre-populates `unitPrice` from price lists, but admin can overwrite it afterward with any value — there is no audit trail of the original contract price vs. manually entered price | -| 15 | `CompanyInvitationService.accept` validates the accepting user's email matches the invitation email, but a user can change their email after an invitation is sent — no re-validation on send | diff --git a/docs/database.md b/docs/database.md new file mode 100644 index 0000000..937d445 --- /dev/null +++ b/docs/database.md @@ -0,0 +1,181 @@ +# Database + +## Overview + +Garden uses a single PostgreSQL 17 instance. The schema is divided into multiple Postgres schemas (namespaces) — one per domain area. All DDL is managed by Flyway; Hibernate never generates or alters tables. + +--- + +## Postgres Schemas + +| Schema | Domain | +|---|---| +| `auth` | Users, addresses, identities, tokens, roles, permissions, notification preferences | +| `catalog` | Products, variants, options, images, collections, collection rules, reviews, wishlists | +| `checkout` | Carts, orders, order items, order events, fulfillments, discounts, gift cards, order templates | +| `b2b` | Companies, memberships, invitations, price lists, credit accounts, invoices, invoice payments | +| `quote` | Quote carts, quote cart items, quote requests, quote items | +| `inventory` | Inventory items, levels, transactions, locations | +| `shipping` | Shipping zones, shipping rates | +| `content` | Blogs, articles, article images, pages, content tags | +| `payment` | Processed Stripe event idempotency log | +| `storage` | Blob object metadata | +| `webhook` | Outbound webhook endpoints, delivery records | +| `marketing` | Newsletter subscribers | +| `shared` | Audit log | + +--- + +## Flyway Migrations + +Migration files live in: +``` +src/main/resources/db/migration/V{N}__{description}.sql +``` + +Test-only migrations: +``` +src/test/resources/db/testmigration/V9999__test_probe_entity.sql +``` + +### Current migration sequence (as of V74) + +| Version | Description | +|---|---| +| V1 | Utility functions (UUID v7 generator) | +| V2 | Users, addresses | +| V3 | Identities (OAuth + credentials) | +| V4 | Tokens (one-time tokens) | +| V6 | IAM seed — roles, permissions, role-permission assignments | +| V7 | Add updatedAt to tokens | +| V8 | Blob objects | +| V9 | Products, variants, options, images, tags | +| V10 | Content (blogs, articles, pages, tags) | +| V11 | Pages handle unique index | +| V12 | Inventory (items, levels, transactions, locations) | +| V13 | Collections, collection rules, products | +| V14 | Checkout (carts, orders, order items) | +| V15 | B2B — companies, memberships | +| V16 | Quote — carts, cart items, requests, items | +| V17 | Nullable order item variant (custom line items) | +| V18 | Quote PAID status | +| V19 | Seed admin permissions | +| V20 | Discounts | +| V21 | Shipping zones, rates | +| V22 | Fulfillments, fulfillment items | +| V23 | Order events | +| V24 | Gift cards, gift card transactions | +| V25 | Admin notes and tags on users | +| V26 | Order admin fields | +| V27 | Blob asset library fields (alt, width, height, folder) | +| V28 | B2B price lists, price list entries | +| V29 | Spending limits on company memberships | +| V30 | Net terms — credit accounts, invoices, invoice payments | +| V31 | MANAGER role on memberships | +| V32 | Company invitations | +| V33 | Guest checkout columns, shipping on orders | +| V34 | Product reviews | +| V35 | Wishlists | +| V36 | Automation columns (abandoned cart, low stock alerts) | +| V37 | SEO meta fields on products, collections, articles, pages | +| V38 | Tax amount on orders | +| V39 | Cart company context | +| V40 | Outbound webhooks — endpoints, deliveries | +| V41 | PO number on orders | +| V42 | Discount company scope | +| V43 | Tax exemption and order company context | +| V44 | Invoice payment method tracking | +| V45 | Metadata JSONB on products and users | +| V59 | Stripe event idempotency table | +| V60 | ShedLock table | +| V61 | Rate limit attempts table | +| V62 | Full-text search tsvector columns + GIN indexes | +| V63 | Audit log table | +| V64 | Audit log read permission | +| V65 | Normalize country codes to ISO-3166-1 alpha-2 | +| V66 | Normalize spaced USA zone codes | +| V67 | Add rejection reason to quotes | +| V68 | Optimistic lock version on orders | +| V69 | Company approval rules | +| V70 | Company departments | +| V71 | Impersonation tokens | +| V72 | Fix audit and approval constraints | +| V73 | Refresh token rotation (new auth.refresh_tokens table) | +| V74 | Composite indexes on orders and carts | + +--- + +## Entity Conventions + +### BaseEntity + +All mutable entities extend `BaseEntity`: +- `id` — UUID v7 (generated by `generate_ulid_uuid()` Postgres function from V1) +- `createdAt` — set on insert, never updated +- `updatedAt` — set on insert, updated on every write + +UUID v7 is time-ordered. Inserts are sequential, which keeps B-tree indexes compact. + +### ImmutableBaseEntity + +For records that are never modified after creation (payments, audit records, transactions): +- `id` — UUID v7 +- `createdAt` — set on insert + +No `updatedAt` column. No JPA `@PreUpdate` hook. + +### Soft Deletes + +Catalog entities (products, variants, collections, articles, pages) have a `deletedAt` column. Repositories use `@Query` or `Specification` to filter `WHERE deleted_at IS NULL` by default. + +Hard deletes are reserved for administrative cleanup only. This preserves historical order and quote line items. + +### Optimistic Locking + +`Order` has a `lockVersion` column mapped with JPA `@Version`. Concurrent writes throw `OptimisticLockException` (HTTP 409 Conflict via `GlobalExceptionHandler`). + +--- + +## Full-Text Search + +V62 adds `tsvector` generated columns and `GIN` indexes to: +- `catalog.products` (title + description + handle) +- `catalog.collections` (title + description) +- `content.articles` (title + body) +- `content.pages` (title + body) + +`SearchService` queries these directly with `@@` operators. No external search engine is needed. + +--- + +## Connection Configuration + +| Profile | URL | +|---|---| +| local | `jdbc:postgresql://localhost:5432/garden` | +| Docker Compose (app container) | `jdbc:postgresql://postgres:5432/garden` | +| production | `${DB_URL}` env var | + +Connection pool: HikariCP (Spring Boot default). Batch size set to 50 for bulk writes. + +--- + +## Working with Migrations + +### Adding a new migration + +1. Find the highest existing version: `ls src/main/resources/db/migration/ | sort -t_ -k1 -V | tail -5` +2. Create `V{N+1}__{short_description}.sql` +3. Write idempotent SQL where possible (`CREATE TABLE IF NOT EXISTS`, `DO $$ BEGIN ... EXCEPTION WHEN duplicate_column THEN ... END $$`) +4. Start the app — Flyway applies it automatically +5. Update the corresponding JPA entity to match + +### Never edit a committed migration + +Flyway checksums every migration file. Editing one after it has been applied anywhere (local, CI, production) will cause all future startups to fail with a checksum mismatch. If a migration has a mistake and has not yet reached production, add a corrective `V{N+2}__fix_...sql` migration. + +### Seed data + +Seed data lives in migration files (e.g., V6 seeds IAM roles and permissions, V19 seeds admin permissions). This ensures every environment — including CI — starts with the correct baseline. + +The superuser (`owner@garden.local`) is seeded at application startup by `SuperUserCommand`, not by Flyway, so that credentials can be overridden via environment variables. diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..d0f384a --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,180 @@ +# Deployment + +## Overview + +The app is deployed as a Docker container image pushed to GitHub Container Registry (`ghcr.io`) and deployed to a Dokploy instance. The pipeline is fully automated via GitHub Actions on every push to `main`. + +--- + +## CI/CD Pipeline + +### CI (`.github/workflows/ci.yml`) — runs on every PR to `main` + +``` +pull_request → main + ├── Unit Tests (./mvnw test -Dtest="**/*Test,**/*Tests") + ├── Integration Tests (./mvnw test -Dtest="**/*IT,!DevDataSeederIT") + │ └── (runs after unit tests pass) + └── Seeder Tests (./mvnw test -Dtest="DevDataSeederIT") + └── (runs after unit tests pass, parallel with integration) +``` + +All three jobs must pass before a PR can be merged. + +### Deploy (`.github/workflows/deploy.yml`) — runs on every push to `main` + +``` +push → main + ├── Build & Push Image + │ ├── docker buildx build --platform linux/amd64 + │ ├── push → ghcr.io/{owner}/{repo}:latest + │ └── (uses GitHub Actions cache for layer reuse) + └── Deploy to Dokploy + └── POST Dokploy API → triggers pull + restart of the container +``` + +Required GitHub secrets: +- `DOKPLOY_HOST` — Dokploy instance URL +- `DOKPLOY_AUTH_TOKEN` — API token +- `DOKPLOY_APPLICATION_ID` — app identifier in Dokploy + +--- + +## Docker + +### Build image locally + +```bash +docker build -t garden:local . +``` + +### Run image locally (requires external Postgres + MinIO) + +```bash +docker run -p 8080:8080 \ + -e SPRING_PROFILES_ACTIVE=prod \ + -e DB_URL=jdbc:postgresql://host.docker.internal:5432/garden \ + -e DB_USERNAME=garden \ + -e DB_PASSWORD=garden \ + -e JWT_PRIVATE_KEY=... \ + -e JWT_PUBLIC_KEY=... \ + -e STRIPE_SECRET_KEY=sk_... \ + -e STRIPE_WEBHOOK_SECRET=whsec_... \ + -e STORAGE_ENDPOINT=... \ + -e STORAGE_BUCKET=garden \ + -e STORAGE_PRIVATE_BUCKET=garden-private \ + -e STORAGE_ACCESS_KEY=... \ + -e STORAGE_SECRET_KEY=... \ + -e STORAGE_BASE_URL=... \ + garden:local +``` + +### Full local stack (app + Postgres + MinIO) + +```bash +docker compose up +``` + +The `docker-compose.yaml` in the project root is for local development only. It sets `SPRING_PROFILES_ACTIVE=local` and uses hardcoded dev credentials. + +--- + +## Environment Variables Reference + +### Required in production + +| Variable | Description | +|---|---| +| `SPRING_PROFILES_ACTIVE` | Set to `prod` | +| `DB_URL` | JDBC URL — `jdbc:postgresql://host:5432/dbname` | +| `DB_USERNAME` | Database username | +| `DB_PASSWORD` | Database password | +| `JWT_PRIVATE_KEY` | Base64-encoded PKCS8 DER private key (RSA-2048) | +| `JWT_PUBLIC_KEY` | Base64-encoded X.509 DER public key | +| `STRIPE_SECRET_KEY` | Stripe secret key (`sk_live_...`) | +| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret (`whsec_...`) | +| `STORAGE_ENDPOINT` | S3-compatible endpoint URL (e.g. Cloudflare R2) | +| `STORAGE_BUCKET` | Public bucket name | +| `STORAGE_PRIVATE_BUCKET` | Private bucket name | +| `STORAGE_ACCESS_KEY` | S3 access key | +| `STORAGE_SECRET_KEY` | S3 secret key | +| `STORAGE_BASE_URL` | Public base URL for served files | +| `MAIL_HOST` | SMTP host | +| `MAIL_PORT` | SMTP port (e.g. 587) | +| `MAIL_USERNAME` | SMTP username | +| `MAIL_PASSWORD` | SMTP password / app password | +| `GOOGLE_CLIENT_ID` | Google OAuth2 client ID | +| `GOOGLE_CLIENT_SECRET` | Google OAuth2 client secret | +| `FRONTEND_URL` | Frontend HTTPS origin — used for OAuth2 redirect and CORS | + +### Optional + +| Variable | Default | Description | +|---|---|---| +| `SUPERUSER_EMAIL` | `owner@garden.local` | Superuser email seeded on first startup | +| `SUPERUSER_PASSWORD` | `changeme` | Superuser password — **change this in production** | +| `ADMIN_NOTIFICATION_EMAIL` | (empty) | Email to notify on new quote submissions | +| `STRIPE_AUTOMATIC_TAX_ENABLED` | `false` | Enable Stripe Tax (requires activation on Stripe account) | +| `MAIL_SMTP_AUTH` | `true` (prod) | SMTP authentication | +| `MAIL_SMTP_STARTTLS` | `true` (prod) | SMTP STARTTLS | + +### Generating RSA keys for production + +```bash +# Private key (PKCS8 DER, base64) +openssl genrsa 2048 | openssl pkcs8 -topk8 -nocrypt -outform DER | base64 | tr -d '\n' + +# Public key (X.509 DER, base64) — run from the same private key +# (pipe the private key into both commands, or save to a temp file) +openssl genrsa 2048 > /tmp/key.pem +openssl pkcs8 -topk8 -nocrypt -outform DER -in /tmp/key.pem | base64 | tr -d '\n' # → JWT_PRIVATE_KEY +openssl rsa -pubout -outform DER -in /tmp/key.pem | base64 | tr -d '\n' # → JWT_PUBLIC_KEY +rm /tmp/key.pem +``` + +The default keys bundled in `application.properties` are **development-only** and must not be used in production. + +--- + +## Health Check + +```bash +curl https://your-domain.com/actuator/health +# → {"status":"UP"} +``` + +Prometheus metrics (if scraping is configured): +``` +GET /actuator/prometheus +``` + +--- + +## Profiles + +| Profile | When used | Key differences | +|---|---|---| +| `local` | Local development | Dev JWT keys, MinIO, no-op mail, Swagger UI enabled | +| `demo` | Demo/staging | Production-like config, Swagger UI enabled | +| `prod` | Production | Swagger disabled, all credentials via env vars | +| `test` | CI / integration tests | Testcontainers Postgres, no real external services | + +Activate via `SPRING_PROFILES_ACTIVE=prod` or `-Dspring-boot.run.profiles=local`. + +--- + +## Deployment Platform + +The app is deployed via **Dokploy** (self-hosted PaaS). Dokploy pulls the Docker image from GHCR and manages container lifecycle, environment variables, and routing. + +The deploy workflow calls the Dokploy API to trigger a redeploy after each successful image push. Zero-downtime deploys depend on Dokploy's rolling restart configuration. + +--- + +## Storage: Cloudflare R2 + +Production uses Cloudflare R2 (S3-compatible). Two buckets: +- `STORAGE_BUCKET` (e.g. `garden`) — public; files served via `STORAGE_BASE_URL` +- `STORAGE_PRIVATE_BUCKET` (e.g. `garden-private`) — not publicly accessible; presigned URLs used for access + +The `S3StorageService` implementation uses AWS SDK v2 with a custom `S3Configuration` that overrides the endpoint URL, making it compatible with any S3-compatible service. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..2438474 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,143 @@ +# Getting Started + +## Prerequisites + +| Tool | Version | Notes | +|---|---|---| +| Java (Temurin) | 26 | `sdk install java 26-tem` via SDKMAN, or download from adoptium.net | +| Docker + Docker Compose | any recent | required for local Postgres + MinIO | +| Maven | bundled | use `./mvnw` — do not install separately | + +--- + +## 1. Clone and configure + +```bash +git clone +cd garden +cp .env.example .env +``` + +The `.env` file is read automatically when running with the `local` Spring profile (the default for local dev). The keys in `.env.example` are commented where optional. For basic local development you only need to set: + +- `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` — only if you need Google OAuth2 login +- Everything else works with the defaults (local dev JWT keys, MinIO, no real mail) + +--- + +## 2. Start infrastructure + +```bash +docker compose up -d +``` + +This starts: +- **Postgres 17** on `localhost:5432` — database `garden`, user/password `garden` +- **MinIO** on `localhost:9000` (API) and `localhost:9001` (console) — credentials `minioadmin/minioadmin` +- **minio-init** — one-off container that creates the `garden` and `garden-private` buckets + +Wait for healthy status before starting the app: + +```bash +docker compose ps +``` + +--- + +## 3. Run the application + +```bash +./mvnw spring-boot:run -Dspring-boot.run.profiles=local +``` + +The app starts on `http://localhost:8080`. + +Flyway runs all migrations automatically on startup. The schema is created fresh on the first run. Subsequent starts apply only new migrations. + +--- + +## 4. Seed a superuser + +On first startup, a superuser is created automatically via `SuperUserCommand`: + +| Field | Default value | +|---|---| +| Email | `owner@garden.local` | +| Password | `changeme` | + +Override via `.env`: +``` +SUPERUSER_EMAIL=you@example.com +SUPERUSER_PASSWORD=yourpassword +``` + +The superuser has the `OWNER` role and all permissions. Use it to log in to the admin API. + +--- + +## 5. Verify it's running + +```bash +curl http://localhost:8080/actuator/health +# → {"status":"UP"} + +curl -X POST http://localhost:8080/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"owner@garden.local","password":"changeme"}' +# → {"accessToken":"...","refreshToken":"..."} +``` + +--- + +## 6. OpenAPI / Swagger UI + +Swagger UI is available in local and demo profiles: + +``` +http://localhost:8080/swagger-ui.html +http://localhost:8080/v3/api-docs +``` + +Swagger is **disabled in production** (`application-prod.properties` sets `springdoc.api-docs.enabled=false`). + +--- + +## Development Workflow + +### Making schema changes + +Add a new Flyway migration file: + +``` +src/main/resources/db/migration/V{N+1}__{short_description}.sql +``` + +Rules: +- Never edit an existing migration — Flyway checksums them and will reject changes +- Version numbers must be strictly increasing +- Snake-case description after `__` + +### Running the app without Docker + +If you have a local Postgres instance, override the datasource in `.env`: + +``` +SPRING_DATASOURCE_URL=jdbc:postgresql://localhost:5432/yourdb +SPRING_DATASOURCE_USERNAME=youruser +SPRING_DATASOURCE_PASSWORD=yourpassword +``` + +For storage, set `STORAGE_ENDPOINT` to point at any S3-compatible service. + +### Hot reload + +Spring DevTools is on the classpath and enabled in local profile. Save a file → the app reloads automatically (JVM context restart, not full restart — fast). + +### Useful endpoints + +| URL | Purpose | +|---|---| +| `GET /actuator/health` | Health check | +| `GET /actuator/prometheus` | Prometheus metrics scrape | +| `GET /swagger-ui.html` | Interactive API docs (local/demo only) | +| `localhost:9001` | MinIO console — browse uploaded files | diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..c702910 --- /dev/null +++ b/docs/security.md @@ -0,0 +1,193 @@ +# Security + +## Overview + +Garden uses JWT-based stateless auth for API access. Access tokens are short-lived RS256 JWTs. Refresh tokens are opaque, stored hashed in Postgres, and rotated on every use. + +--- + +## Authentication Flows + +### Email/Password (CREDENTIALS) + +``` +POST /api/v1/auth/register + → creates User (UNVERIFIED) + Identity (CREDENTIALS) + → assigns CUSTOMER role + → sends EMAIL_VERIFICATION token (24h TTL) via email + → returns { accessToken, refreshToken } + +POST /api/v1/auth/login + → validates email + passwordHash + → rejects if UserStatus == SUSPENDED + → returns { accessToken, refreshToken } +``` + +### Google OAuth2 + +``` +GET /oauth2/authorization/google + → Spring Security redirects to Google consent screen + +GET /login/oauth2/code/google (Google callback) + → OAuth2SuccessHandler runs: + → upserts User (created or found by email) + → upserts Identity (GOOGLE provider + accountId) + → mints token pair + → redirects to {FRONTEND_URL}/auth/callback?accessToken=...&refreshToken=... +``` + +### Email Verification + +``` +GET /api/v1/auth/verify-email?token={rawToken} + → validates TOKEN record (type=EMAIL_VERIFICATION, not expired) + → sets User.emailVerifiedAt = now + → sets User.status = ACTIVE + → deletes token record (consumed) +``` + +### Password Reset + +``` +POST /api/v1/auth/request-password-reset { email } + → rate-limited: 1 request/min per email address + → sends PASSWORD_RESET token (24h TTL) via email + → response is always 204 (enumeration prevention) + +POST /api/v1/auth/confirm-password-reset/{rawToken} { newPassword } + → validates TOKEN record + → updates passwordHash on Identity + → deletes token record +``` + +--- + +## Access Tokens + +**Format:** RS256-signed JWT (Nimbus JOSE + JWT) +**TTL:** 15 minutes +**Claims:** + +| Claim | Value | +|---|---| +| `sub` | user UUID | +| `email` | user email | +| `permissions` | string array e.g. `["product:read","order:write"]` | +| `impersonatedBy` | admin UUID (impersonation tokens only) | + +The public key is injected at startup via `app.jwt.public-key` (base64 DER). Spring Security's OAuth2 resource server validates every request automatically. + +--- + +## Refresh Tokens + +**Format:** opaque UUID (raw value returned to client once) +**Storage:** SHA-256 hash stored in `auth.refresh_tokens` +**TTL:** 30 days + +### Token Rotation + +Every `POST /api/v1/auth/refresh` call: +1. Validates the presented token hash exists and is not revoked +2. Issues a new refresh token +3. Sets `revokedAt` on the old token; sets `replacedByToken` to the new raw value (for chain audit) +4. Returns the new token pair + +### Replay Detection + +If a **revoked** refresh token is presented (e.g., a stolen token used after it was already rotated): +- The entire token family is revoked (all tokens in the `replacedByToken` chain) +- The attacker and the legitimate user are both logged out +- No silent failure + +--- + +## One-Time Tokens + +Used for email verification and password reset. Stored in `auth.tokens`. + +| Type | TTL | +|---|---| +| `EMAIL_VERIFICATION` | 24 hours | +| `PASSWORD_RESET` | 24 hours | + +All one-time tokens are: +- Generated as a UUID +- Stored as SHA-256 hash (raw value emailed to user) +- Deleted on successful consumption (`validateAndConsume` is atomic validate + delete) + +--- + +## Roles and Permissions + +### IAM Model + +``` +User ──(many-to-many)──► Role ──(many-to-many)──► Permission +``` + +Permissions have a `resource:action` name (e.g., `product:write`, `order:read`). + +### Built-in Roles + +These roles are seeded by Flyway migrations and cannot be deleted via the API: + +| Role | Description | +|---|---| +| `CUSTOMER` | Default role assigned on registration. Storefront access only. | +| `STAFF` | Admin read access. Can view orders, products, users but not modify. | +| `MANAGER` | Full admin access. Cannot manage IAM (roles/permissions). | +| `OWNER` | Full access to everything including IAM. Gets all permissions implicitly. | + +### `OWNER` shortcut + +`IamService.loadPermissionsForUser(userId)` checks if the user has the `OWNER` role. If yes, all existing permissions are returned — no need to enumerate individual assignments. This is cached in Caffeine (10-minute TTL, max 1000 entries). + +### Custom Roles + +New roles can be created via `POST /api/v1/admin/iam/roles`. Permissions are assigned to roles, and roles are assigned to users via the admin user endpoints. The cache is evicted on every role assignment change. + +--- + +## Method-Level Authorization + +Two custom annotations enforce auth at the controller method level: + +**`@Authenticated`** — requires a valid JWT (any user). Applied to storefront endpoints that need identity but not a specific permission. + +**`@HasPermission("resource:action")`** — requires a valid JWT where the `permissions` claim contains the named permission. Applied to all admin endpoints. + +These are checked by Spring AOP / Spring Security. A valid JWT without the required permission returns `403 Forbidden`. + +--- + +## Impersonation + +Admin users (MANAGER or OWNER) can impersonate a customer for debugging: + +``` +POST /api/v1/admin/users/{targetUserId}/impersonate + → validates caller has user:write permission + → validates target is not STAFF/MANAGER/OWNER (staff cannot be impersonated) + → creates ImpersonationToken (30 min TTL, SHA-256 hashed) + → returns { impersonationToken, targetUserId } +``` + +The impersonation token is a special short-lived JWT with an additional `impersonatedBy` claim. `ImpersonationTokenValidationFilter` intercepts requests with this token and: +- Records `usedAt` on first use (single use) +- Rejects if already used or expired + +Impersonation is fully audited via the `@Audited` aspect. + +--- + +## Security Hardening Notes + +- Passwords: bcrypt hashed via Spring Security `PasswordEncoder` +- SQL injection: not possible — all queries use JPA/Spring Data (parameterized) +- CORS: configured to allow the frontend origins only +- HTTPS: enforced at the reverse proxy level (Dokploy/Caddy); `server.forward-headers-strategy=framework` trusts `X-Forwarded-Proto` +- File upload: size capped at 20 MB per file, 25 MB per request +- Rate limiting: per-IP global; per-email for password reset +- Stripe webhook validation: `WebhookController` verifies the `Stripe-Signature` header using the webhook secret before processing any event diff --git a/docs/specs/00-overview.md b/docs/specs/00-overview.md new file mode 100644 index 0000000..8c9c826 --- /dev/null +++ b/docs/specs/00-overview.md @@ -0,0 +1,91 @@ +# Garden — System Overview + +**Status:** Current +**Last updated:** 2026-05-26 +**Package root:** `io.k2dv.garden` +**Stack:** Spring Boot 4.0.4 · Java 26 · PostgreSQL · Flyway · Spring Security (OAuth2 + JWT RS256) · Stripe · AWS S3 · ShedLock + +--- + +## What it is + +Garden is a Shopify-like e-commerce backend for a landscape products vendor (planters, green roof trays, edging). It supports both B2C and B2B purchase flows. + +--- + +## Modules + +| Module | Package | Description | +|---|---|---| +| Auth | `auth` | JWT auth, OAuth2 (Google), token rotation, impersonation | +| IAM | `iam`, `admin.iam` | Roles, permissions, admin RBAC | +| Account | `account` | Profile, saved addresses | +| User | `user` | User entity, status, tags, metadata | +| Product | `product` | Catalog — products, variants, options, images, tags | +| Collection | `collection` | Manual and automated collections | +| Cart | `cart` | Storefront and guest carts | +| Checkout / Payment | `payment` | Stripe checkout, webhook reconciliation | +| Order | `order` | Order lifecycle, events, returns | +| Fulfillment | `fulfillment` | Shipping, tracking, line-item fulfillment | +| Discount | `discount` | Codes and automatic discounts | +| Gift Card | `giftcard` | Issuance and redemption | +| B2B | `b2b`, `quote` | Companies, price lists, quotes, invoices, credit accounts | +| Inventory | `inventory` | Inventory items, levels, locations, transactions | +| Shipping | `shipping` | Zones, rates, carrier rules | +| Review | `review` | Product reviews with verification | +| Wishlist | `wishlist` | Per-user saved variants | +| Notification | `notification` | Per-user notification preferences | +| Content | `content` | Blogs, articles, pages | +| Search | `search` | Full-text across products, collections, articles, pages | +| Recommendation | `recommendation` | Tag-overlap related products | +| Newsletter | `newsletter` | Email opt-in / opt-out | +| Blob | `blob` | File upload (S3-backed), signed URLs | +| Webhook | `webhook` | Outbound webhooks with retry | +| Stats | `stats` | Admin revenue and customer analytics | +| Audit | `audit` | Admin audit log via `@Audited` AOP | +| Scheduler | `scheduler` | ShedLock-managed background jobs | +| Automation | `automation` | Auto-tagging users based on order behavior | + +--- + +## Database Schemas + +| Postgres Schema | Modules | +|---|---| +| `auth` | users, addresses, identities, refresh_tokens, tokens, impersonation_tokens, roles, permissions, notification_preferences | +| `catalog` | products, product_variants, product_options, product_option_values, product_images, product_tags, collections, collection_rules, product_reviews, wishlists | +| `checkout` | carts, cart_items, orders, order_items, order_events, fulfillments, fulfillment_items, discounts, gift_cards, gift_card_transactions, order_templates | +| `b2b` | companies, company_memberships, company_invitations, price_lists, price_list_entries, credit_accounts, invoices, invoice_payments | +| `quote` | quote_carts, quote_cart_items, quote_requests, quote_items | +| `inventory` | inventory_items, inventory_levels, inventory_transactions, locations | +| `shipping` | shipping_zones, shipping_rates | +| `content` | blogs, articles, article_images, pages, content_tags | +| `payment` | processed_stripe_events | +| `storage` | blob_objects | +| `webhook` | endpoints, deliveries | +| `marketing` | newsletter_subscribers | +| `shared` | audit_log | + +--- + +## Security Model + +- **JWT RS256** access tokens issued by Garden; short-lived +- **Refresh tokens** — opaque UUID, SHA-256 hashed at rest; rotation with replay detection (compromised token revokes entire family) +- **OAuth2** — Google provider via Spring Security OAuth2 client; `OAuth2SuccessHandler` upserts user and identity records +- **`@Authenticated`** — method-level annotation requiring a valid JWT +- **`@HasPermission("resource:action")`** — permission check against claims loaded from IAM +- **Impersonation** — admin mints a one-use 30-minute token; cannot impersonate STAFF/MANAGER/OWNER + +--- + +## Shared Infrastructure + +- **`BaseEntity`** — UUID v7 PK, `createdAt`, `updatedAt` (all mutable entities) +- **`ImmutableBaseEntity`** — UUID v7 PK, `createdAt` only (payments, transactions, audit records) +- **`ApiResponse`** — standard response wrapper +- **`PagedResult`** — paginated list response +- **`GlobalExceptionHandler`** — maps domain exceptions to HTTP status codes +- **`ApiRateLimitFilter`** — per-IP rate limiting; login endpoint has additional `LoginRateLimiter` (1/min per email) +- **`MdcLoggingFilter`** — injects `requestId`, `userId` into MDC per request +- **`CurrentUserArgumentResolver`** — resolves `@CurrentUser` parameter in controllers diff --git a/docs/specs/01-auth.md b/docs/specs/01-auth.md new file mode 100644 index 0000000..c348465 --- /dev/null +++ b/docs/specs/01-auth.md @@ -0,0 +1,187 @@ +# Auth & IAM Spec + +**Status:** Current +**Last updated:** 2026-05-26 +**Packages:** `io.k2dv.garden.auth`, `io.k2dv.garden.iam`, `io.k2dv.garden.admin.iam` + +--- + +## Entities + +### User +`auth.users` — `io.k2dv.garden.user.model.User` + +| Field | Type | Constraint | +|---|---|---| +| email | String | unique, not null | +| firstName, lastName | String | not null | +| phone | String | nullable | +| status | UserStatus | not null | +| emailVerifiedAt | Instant | nullable | +| adminNotes | text | nullable | +| tags | text[] | nullable | +| metadata | jsonb | nullable | +| roles | ManyToMany → Role | lazy | + +**UserStatus enum:** `UNVERIFIED` · `ACTIVE` · `SUSPENDED` + +--- + +### Identity +`auth.identities` — `io.k2dv.garden.auth.model.Identity` + +| Field | Constraint | +|---|---| +| userId | not null, FK | +| provider | IdentityProvider enum, not null | +| accountId | not null, unique with provider | +| passwordHash | nullable (CREDENTIALS only) | +| accessToken, refreshToken, idToken | nullable (OAuth only) | +| expiresAt | Instant, nullable | + +**IdentityProvider enum:** `CREDENTIALS` · `GOOGLE` + +--- + +### RefreshToken +`auth.refresh_tokens` — `io.k2dv.garden.auth.model.RefreshToken` + +| Field | Constraint | Notes | +|---|---|---| +| tokenHash | unique, not null | SHA-256 hex of raw token | +| userId | not null | | +| expiresAt | not null | | +| revokedAt | nullable | set on consumption or revocation | +| replacedByToken | nullable | raw value kept for chain audit | + +**Token rotation:** on use, old token is revoked and `replacedByToken` is set. If a revoked token is presented again, the entire family is revoked (replay detection). + +--- + +### Token (one-time tokens) +`auth.tokens` — `io.k2dv.garden.auth.model.Token` + +| Field | Constraint | +|---|---| +| userId | not null | +| type | TokenType enum, not null | +| tokenHash | unique, not null | +| expiresAt | not null | + +**TokenType enum:** `REFRESH_TOKEN` · `EMAIL_VERIFICATION` · `PASSWORD_RESET` + +--- + +### ImpersonationToken +`auth.impersonation_tokens` + +| Field | Notes | +|---|---| +| targetUserId | who is being impersonated | +| adminUserId | who initiated | +| tokenHash | SHA-256 hex, unique | +| expiresAt | now + 30 min | +| usedAt | recorded on first use | + +**Rules:** Cannot impersonate STAFF/MANAGER/OWNER. Token is single-use (usedAt set on first check). + +--- + +### Role & Permission +`auth.roles`, `auth.permissions` + +| Entity | Key Fields | +|---|---| +| Role | name (unique), description, permissions (ManyToMany) | +| Permission | name (unique), resource (String), action (String) | + +**Predefined roles (cannot be deleted):** `CUSTOMER` · `STAFF` · `MANAGER` · `OWNER` + +--- + +### Address +`auth.addresses` + +| Field | Notes | +|---|---| +| userId | FK, not null | +| firstName, lastName, company | | +| address1, address2, city, province, zip, country | | +| isDefault | boolean | + +--- + +## API — Auth (`/api/v1/auth`) + +| Method | Path | Auth | Description | +|---|---|---|---| +| POST | `/register` | none | Creates user, assigns CUSTOMER role, sends email verification | +| POST | `/login` | none | Validates credentials, returns token pair | +| POST | `/refresh` | none | Rotates refresh token, returns new token pair | +| POST | `/logout` | none | Revokes refresh token (idempotent) | +| GET | `/verify-email?token=` | none | Consumes EMAIL_VERIFICATION token, activates user | +| POST | `/resend-verification` | none | Resends verification email (silent if not found) | +| POST | `/request-password-reset` | none | Rate-limited (1/min/email); sends reset token | +| POST | `/confirm-password-reset/{token}` | none | Consumes PASSWORD_RESET token, updates password | +| GET | `/check-email?email=` | none | Returns `{ exists: Boolean }` | +| POST | `/update-password` | `@Authenticated` | Validates current password, sets new | + +--- + +## API — Admin IAM (`/api/v1/admin/iam`) + +All require `@HasPermission("iam:manage")`. + +| Method | Path | Description | +|---|---|---| +| GET | `/roles` | List all roles | +| POST | `/roles` | Create role | +| PUT | `/roles/{id}` | Update role name/description | +| DELETE | `/roles/{id}` | Delete role (predefined roles blocked) | +| GET | `/permissions` | List all permissions | +| POST | `/roles/{id}/permissions` | Assign permission to role | +| DELETE | `/roles/{id}/permissions/{permissionId}` | Remove permission from role | + +--- + +## API — Admin Users (`/api/v1/admin/users`) + +| Method | Path | Permission | Description | +|---|---|---|---| +| GET | `/` | `user:read` | List/search users (filterable: email, status, tags, name) | +| GET | `/{id}` | `user:read` | Get user | +| PUT | `/{id}` | `user:write` | Update user fields | +| PUT | `/{id}/notes` | `user:write` | Update admin notes | +| PUT | `/{id}/tags` | `user:write` | Update tags array | +| POST | `/{id}/roles` | `user:write` | Assign role | +| DELETE | `/{id}/roles/{roleId}` | `user:write` | Remove role | +| POST | `/{id}/impersonate` | `user:write` | Mint impersonation token | + +--- + +## Service Logic + +### AuthService +- **register:** creates user (UNVERIFIED), creates CREDENTIALS identity, assigns CUSTOMER role, sends EMAIL_VERIFICATION token +- **login:** validates password hash, checks status (SUSPENDED → reject), checks emailVerifiedAt (warns if unverified), mints token pair +- **refresh:** delegates to TokenService.rotateRefreshToken, reloads permissions, mints new access token +- **verifyEmail:** TokenService.validateAndConsume(EMAIL_VERIFICATION), sets emailVerifiedAt, status → ACTIVE +- **requestPasswordReset:** rate-limited per email via LoginRateLimiter; silent response (enumeration prevention) +- **confirmPasswordReset:** TokenService.validateAndConsume(PASSWORD_RESET), updates passwordHash on identity + +### JwtService +- Access tokens: RS256-signed, claims include `email`, `permissions[]`, `sub` (userId) +- Impersonation tokens: additional `impersonatedBy` claim (adminUserId) +- TTL is configurable via application properties + +### TokenService +- All tokens stored as SHA-256 hash; raw token returned to caller once +- `validateAndConsume`: atomic validate + delete +- `rotateRefreshToken`: issues replacement, revokes prior, records `replacedByToken`; replay triggers full-family revocation + +### IamService +- `loadPermissionsForUser(userId)`: cached per user; OWNER role returns all permissions +- Cache evicted on role assignment/removal + +### OAuth2 (Google) +- `OAuth2SuccessHandler`: on successful OAuth2 login, upserts User and Identity records; mints token pair; redirects with tokens in query params diff --git a/docs/specs/02-catalog.md b/docs/specs/02-catalog.md new file mode 100644 index 0000000..7328523 --- /dev/null +++ b/docs/specs/02-catalog.md @@ -0,0 +1,203 @@ +# Catalog Spec — Products, Collections, Reviews + +**Status:** Current +**Last updated:** 2026-05-26 +**Packages:** `io.k2dv.garden.product`, `io.k2dv.garden.collection`, `io.k2dv.garden.review` + +--- + +## Products + +### Product +`catalog.products` — `io.k2dv.garden.product.model.Product` + +| Field | Constraint | Notes | +|---|---|---| +| title | not null | | +| handle | unique, not null | URL slug | +| description | text, nullable | | +| vendor | nullable | | +| productType | nullable | | +| status | ProductStatus, not null | | +| featuredImageId | UUID, nullable | FK to blob_objects | +| metaTitle, metaDescription | nullable | SEO | +| metadata | jsonb, nullable | | +| deletedAt | Instant, nullable | soft delete | +| tags | ManyToMany → ProductTag | | + +**ProductStatus enum:** `DRAFT` · `ACTIVE` · `ARCHIVED` + +--- + +### ProductVariant +`catalog.product_variants` — `io.k2dv.garden.product.model.ProductVariant` + +| Field | Constraint | Notes | +|---|---|---| +| productId | not null, FK | | +| title | not null | | +| sku | unique, nullable | | +| barcode | nullable | | +| fulfillmentType | FulfillmentType, not null | | +| inventoryPolicy | InventoryPolicy, not null | | +| leadTimeDays | nullable | for PRE_ORDER/MADE_TO_ORDER | +| price | NUMERIC(19,4), not null | | +| compareAtPrice | NUMERIC(19,4), nullable | | +| weight | nullable | | +| weightUnit | nullable | | +| minimumOrderQty | not null, default 1 | | +| deletedAt | Instant, nullable | soft delete | +| optionValues | ManyToMany → ProductOptionValue | | + +**FulfillmentType enum:** `IN_STOCK` · `PRE_ORDER` · `MADE_TO_ORDER` +**InventoryPolicy enum:** `DENY` · `CONTINUE` + +--- + +### ProductOption, ProductOptionValue +`catalog.product_options`, `catalog.product_option_values` + +- Option: `productId`, `name`, `position` +- OptionValue: `optionId`, `value` + +--- + +### ProductImage +`catalog.product_images` — `io.k2dv.garden.product.model.ProductImage` + +- `productId`, `blobId`, `position`, `alt` + +--- + +### ProductTag +`catalog.product_tags` — shared tag entity linked to products via join table. + +--- + +## API — Storefront Products (`/api/v1/products`) + +No auth required unless noted. + +| Method | Path | Description | +|---|---|---| +| GET | `/` | List active products (paginated). Filters: `titleContains`, `vendor`, `productType`, `sortBy`, `companyId` | +| GET | `/{handle}` | Get product by handle. `companyId` optional — resolves B2B prices if provided | +| GET | `/variants/lookup?sku=` | Lookup variant by SKU | +| GET | `/{handle}/tiers` | B2B price tier list (`@Authenticated`, B2B context) | + +--- + +## API — Admin Products (`/api/v1/admin/products`) + +| Method | Path | Permission | Description | +|---|---|---|---| +| GET | `/` | `product:read` | List products (filterable, paginated) | +| POST | `/` | `product:write` | Create product | +| GET | `/{id}` | `product:read` | Get product | +| PUT | `/{id}` | `product:write` | Update product | +| DELETE | `/{id}` | `product:delete` | Soft-delete product | +| POST | `/{id}/variants` | `product:write` | Add variant | +| PUT | `/{id}/variants/{variantId}` | `product:write` | Update variant | +| DELETE | `/{id}/variants/{variantId}` | `product:delete` | Soft-delete variant | +| POST | `/{id}/images` | `product:write` | Upload image (blob) | +| PUT | `/{id}/images/reorder` | `product:write` | Reorder images | +| DELETE | `/{id}/images/{imageId}` | `product:delete` | Delete image | +| GET | `/{id}/options` | `product:read` | List options | +| POST | `/{id}/options` | `product:write` | Add option | +| PUT | `/{id}/options/{optionId}` | `product:write` | Update option | +| DELETE | `/{id}/options/{optionId}` | `product:delete` | Delete option | +| POST | `/{id}/tags` | `product:write` | Add tag | +| DELETE | `/{id}/tags/{tag}` | `product:delete` | Remove tag | + +--- + +## Collections + +### Collection +`catalog.collections` — `io.k2dv.garden.collection.model.Collection` + +| Field | Constraint | Notes | +|---|---|---| +| title | not null | | +| handle | unique, not null | | +| description | text, nullable | | +| collectionType | CollectionType, not null | MANUAL or AUTOMATED | +| status | CollectionStatus, not null | | +| featuredImageId | UUID, nullable | | +| disjunctive | boolean | false = AND rules, true = OR rules | +| metaTitle, metaDescription | nullable | | +| deletedAt | soft delete | | + +**CollectionType enum:** `MANUAL` · `AUTOMATED` +**CollectionStatus enum:** `DRAFT` · `PUBLISHED` · `ARCHIVED` + +--- + +### CollectionRule +`catalog.collection_rules` + +- `collectionId`, `field` (CollectionRuleField), `operator` (CollectionRuleOperator), `value` + +AUTOMATED collections re-evaluate membership when products are saved. + +--- + +## API — Collections + +*Admin (`/api/v1/admin/collections`):* + +| Method | Path | Permission | Description | +|---|---|---|---| +| GET | `/` | `collection:read` | List collections | +| POST | `/` | `collection:write` | Create | +| GET | `/{id}` | `collection:read` | Get | +| PUT | `/{id}` | `collection:write` | Update | +| DELETE | `/{id}` | `collection:delete` | Soft-delete | +| POST | `/{id}/products` | `collection:write` | Add product (MANUAL only) | +| DELETE | `/{id}/products/{productId}` | `collection:delete` | Remove product (MANUAL only) | + +*Storefront (`/api/v1/collections`, no auth):* + +| Method | Path | Description | +|---|---|---| +| GET | `/` | List published collections | +| GET | `/{handle}` | Get published collection with products | + +--- + +## Reviews + +### ProductReview +`catalog.product_reviews` — `io.k2dv.garden.review.model.ProductReview` + +| Field | Constraint | Notes | +|---|---|---| +| productId | not null | | +| userId | not null | | +| rating | short, not null | 1–5 | +| title | nullable | | +| body | text, nullable | | +| verifiedPurchase | boolean, default false | set if user has a paid order containing this product | +| status | ReviewStatus, not null | | + +**ReviewStatus enum:** `PUBLISHED` · `HIDDEN` + +--- + +## API — Reviews + +*Storefront (`/api/v1/reviews`, `@Authenticated` for write):* + +| Method | Path | Auth | Description | +|---|---|---|---| +| GET | `/products/{handle}` | none | List published reviews for product | +| POST | `/products/{handle}` | `@Authenticated` | Submit review; verifiedPurchase auto-set | + +*Admin (`/api/v1/admin/reviews`):* + +| Method | Path | Permission | Description | +|---|---|---|---| +| GET | `/` | `review:read` | List all reviews (filterable by status, productId) | +| PUT | `/{id}/publish` | `review:write` | Set PUBLISHED | +| PUT | `/{id}/hide` | `review:write` | Set HIDDEN | +| DELETE | `/{id}` | `review:delete` | Delete review | diff --git a/docs/specs/03-cart-checkout.md b/docs/specs/03-cart-checkout.md new file mode 100644 index 0000000..0a8a2de --- /dev/null +++ b/docs/specs/03-cart-checkout.md @@ -0,0 +1,233 @@ +# Cart & Checkout Spec + +**Status:** Current +**Last updated:** 2026-05-26 +**Packages:** `io.k2dv.garden.cart`, `io.k2dv.garden.payment`, `io.k2dv.garden.discount`, `io.k2dv.garden.giftcard`, `io.k2dv.garden.shipping` + +--- + +## Cart + +### Cart +`checkout.carts` — `io.k2dv.garden.cart.model.Cart` + +| Field | Constraint | Notes | +|---|---|---| +| userId | UUID, nullable | null for guest carts | +| sessionId | UUID, unique | guest identifier | +| status | CartStatus, not null | | +| companyId | UUID, nullable | B2B context | +| abandonedReminderSentAt | Instant, nullable | set by automation scheduler | + +**CartStatus enum:** `ACTIVE` · `CHECKED_OUT` · `ABANDONED` + +--- + +### CartItem +`checkout.cart_items` + +| Field | Constraint | +|---|---| +| cartId | not null, FK | +| variantId | not null, FK | +| quantity | int, not null | +| unitPrice | NUMERIC(19,4), not null | + +--- + +## API — Cart (`/api/v1/cart`, `@Authenticated`) + +| Method | Path | Description | +|---|---|---| +| GET | `/` | Get or create active cart | +| DELETE | `/` | Clear all items | +| POST | `/items` | Add item; upserts if variant already present | +| PUT | `/items/{itemId}` | Update quantity | +| DELETE | `/items/{itemId}` | Remove item | +| PUT | `/company` | Set B2B company context (re-prices items) | +| DELETE | `/company` | Clear company context | +| POST | `/items/batch` | Bulk add items (CSV upload supported) | + +## API — Guest Cart (`/api/v1/guest/cart`) + +Same shape as cart; identified by `X-Guest-Session` header (UUID). + +--- + +## CartService Logic + +- `getOrCreateActiveCart(userId)` — lazy creates on first call +- `setCompanyContext(userId, companyId)` — validates membership, re-prices all items via `PriceListService.resolvePrice` +- `addItem` — validates: product ACTIVE, variant not deleted, inventory policy (DENY blocks if quantity < requested) +- On checkout: cart status → CHECKED_OUT + +--- + +## Checkout / Payment + +### ProcessedStripeEvent +`payment.processed_stripe_events` — idempotency record; prevents duplicate Stripe webhook processing. + +--- + +## API — Checkout (`/api/v1/checkout`) + +| Method | Path | Auth | Description | +|---|---|---|---| +| POST | `/` | `@Authenticated` | Initiate checkout | +| POST | `/guest` | none (`X-Guest-Session`) | Guest checkout | +| GET | `/return?session_id=` | none | Verify payment return from Stripe | + +**CheckoutRequest fields:** `discountCode`, `giftCardCode`, `shippingRateId`, `poNumber` + +**CheckoutResponse:** `{ sessionId, checkoutUrl }` + +--- + +## PaymentService Logic + +**initiateCheckout:** +1. Load ACTIVE cart (throws if empty) +2. Apply discount code (if supplied) → validates active, dates, minOrder, maxUses +3. Apply gift card (if supplied) → validates active, balance +4. Resolve shipping rate → `ShippingService.calculateShipping(rateId, orderAmount)` +5. Calculate tax (if not tax-exempt company) +6. Create draft `Order` +7. `StripeGateway.createCheckoutSession()` → returns URL +8. Returns `checkoutUrl + sessionId` + +**verifyReturn(sessionId):** +1. Lookup Order by stripeSessionId +2. Verify Stripe session status is `complete` +3. Order status → PAID +4. Publish `OrderConfirmedEvent` (triggers fulfillment, email) + +**Stripe webhook handler:** +- Idempotent via `ProcessedStripeEvent` record +- Handles: `checkout.session.completed`, `payment_intent.payment_failed` + +**PaymentReconciliationScheduler** (see `08-infra.md`): polls Stripe for stale PENDING_PAYMENT orders every 10 minutes. + +--- + +## Discounts + +### Discount +`checkout.discounts` — `io.k2dv.garden.discount.model.Discount` + +| Field | Constraint | Notes | +|---|---|---| +| code | nullable | null for automatic discounts | +| automatic | boolean | if true, applied without code | +| type | DiscountType, not null | | +| value | NUMERIC, not null | | +| minOrderAmount | NUMERIC, nullable | | +| maxUses | int, nullable | null = unlimited | +| usedCount | int, default 0 | | +| startsAt, endsAt | Instant, nullable | | +| isActive | boolean | | +| companyId | UUID, nullable | B2B-only discount | + +**DiscountType enum:** `PERCENTAGE` · `FIXED_AMOUNT` · `FREE_SHIPPING` + +--- + +## API — Admin Discounts (`/api/v1/admin/discounts`) + +| Method | Path | Permission | Description | +|---|---|---|---| +| GET | `/` | `discount:read` | List (filter: type, isActive, codeContains, companyId) | +| GET | `/{id}` | `discount:read` | Get | +| POST | `/` | `discount:write` | Create | +| PUT | `/{id}` | `discount:write` | Update | +| DELETE | `/{id}` | `discount:delete` | Delete | + +**DiscountService.validateAndApply(code, orderAmount):** checks active, date range, minOrderAmount, maxUses. Returns `DiscountApplication { appliedAmount }`. + +--- + +## Gift Cards + +### GiftCard +`checkout.gift_cards` + +| Field | Constraint | Notes | +|---|---|---| +| code | not null | redemption code | +| initialBalance | NUMERIC, not null | | +| currentBalance | NUMERIC, not null | decremented on redemption | +| currency | default "usd" | | +| isActive | boolean, default true | | +| expiresAt | Instant, nullable | | +| note | text, nullable | | +| purchaserUserId | UUID, nullable | | +| recipientEmail | nullable | | + +### GiftCardTransaction +Immutable record per redemption — `giftCardId`, `orderId`, `amount`, `createdAt`. + +--- + +## API — Gift Cards + +*Storefront (`/api/v1/gift-cards`, `@Authenticated`):* + +| Method | Path | Description | +|---|---|---| +| GET | `/balance?code=` | Check balance | +| POST | `/redeem` | Apply to cart/checkout | + +*Admin (`/api/v1/admin/gift-cards`):* + +| Method | Path | Permission | Description | +|---|---|---|---| +| GET | `/` | `gift_card:read` | List | +| POST | `/` | `gift_card:write` | Create/issue | +| PUT | `/{id}` | `gift_card:write` | Update (note, active, expiry) | +| DELETE | `/{id}` | `gift_card:delete` | Deactivate | + +--- + +## Shipping + +### ShippingZone +`shipping.shipping_zones` — `name`, `countries` (text array) + +### ShippingRate +`shipping.shipping_rates` + +| Field | Notes | +|---|---| +| zoneId | FK | +| name | not null | +| price | NUMERIC(19,4), not null | +| minWeightGrams, maxWeightGrams | nullable — weight-based filtering | +| minOrderAmount | nullable — threshold-based | +| estimatedDaysMin, estimatedDaysMax | nullable | +| carrier | nullable | +| isActive | boolean | + +--- + +## API — Shipping + +*Storefront (`/api/v1/shipping`, no auth):* + +| Method | Path | Description | +|---|---|---| +| GET | `/rates?country=&weight=&orderAmount=` | List applicable rates | + +*Admin (`/api/v1/admin/shipping`):* + +| Method | Path | Permission | Description | +|---|---|---|---| +| GET | `/zones` | `shipping:read` | List zones | +| POST | `/zones` | `shipping:write` | Create zone | +| PUT | `/zones/{id}` | `shipping:write` | Update zone | +| DELETE | `/zones/{id}` | `shipping:delete` | Delete zone | +| GET | `/zones/{id}/rates` | `shipping:read` | List rates | +| POST | `/zones/{id}/rates` | `shipping:write` | Create rate | +| PUT | `/rates/{id}` | `shipping:write` | Update rate | +| DELETE | `/rates/{id}` | `shipping:delete` | Delete rate | + +**ShippingService.getAvailableRates(address, weightGrams, orderAmount):** filters zones by country match, rates by weight range + minOrderAmount, returns active rates. diff --git a/docs/specs/04-order.md b/docs/specs/04-order.md new file mode 100644 index 0000000..ac77ed4 --- /dev/null +++ b/docs/specs/04-order.md @@ -0,0 +1,193 @@ +# Order & Fulfillment Spec + +**Status:** Current +**Last updated:** 2026-05-26 +**Packages:** `io.k2dv.garden.order`, `io.k2dv.garden.fulfillment` + +--- + +## Order + +### Order +`checkout.orders` — `io.k2dv.garden.order.model.Order` + +| Field | Constraint | Notes | +|---|---|---| +| userId | UUID, nullable | null for guest orders | +| guestEmail | nullable | set for guest orders | +| status | OrderStatus, not null | | +| shippingAddress | jsonb, nullable | embedded delivery address | +| shippingRateId | UUID, nullable | | +| shippingCost | NUMERIC(19,4) | | +| totalAmount | NUMERIC(19,4), not null | | +| currency | String, default "usd" | | +| taxAmount | NUMERIC(19,4) | | +| taxExempt | boolean | | +| discountId | UUID, nullable | | +| discountAmount | NUMERIC(19,4) | | +| giftCardId | UUID, nullable | | +| giftCardAmount | NUMERIC(19,4) | | +| stripeSessionId | nullable | | +| stripePaymentIntentId | nullable | | +| poNumber | nullable | B2B purchase order number | +| companyId | UUID, nullable | B2B company | +| adminNotes | text, nullable | | +| lockVersion | int | optimistic lock | + +**OrderStatus enum:** +`DRAFT` · `PENDING_PAYMENT` · `PENDING_APPROVAL` · `PAID` · `CANCELLED` · `REFUNDED` · `PARTIALLY_FULFILLED` · `FULFILLED` · `INVOICED` + +--- + +### OrderItem +`checkout.order_items` + +| Field | Constraint | +|---|---| +| orderId | not null, FK | +| variantId | not null, FK | +| quantity | int, not null | +| unitPrice | NUMERIC(19,4), not null | + +--- + +### OrderEvent +`checkout.order_events` — immutable audit trail per order. + +| Field | Notes | +|---|---| +| orderId | FK | +| type | OrderEventType enum | +| note | text, nullable | +| metadata | jsonb, nullable | +| createdBy | UUID, nullable | + +**OrderEventType enum:** +`ORDER_PLACED` · `PAYMENT_CONFIRMED` · `ORDER_CANCELLED` · `ORDER_REFUNDED` · `ADMIN_REFUND_ISSUED` · `DISCOUNT_APPLIED` · `GIFT_CARD_APPLIED` · `FULFILLMENT_CREATED` · `FULFILLMENT_UPDATED` · `NOTE_ADDED` · `INVOICE_ISSUED` · `ORDER_APPROVAL_REQUESTED` · `ORDER_APPROVAL_APPROVED` · `ORDER_APPROVAL_REJECTED` · `RETURN_REQUESTED` · `RETURN_APPROVED` · `RETURN_REJECTED` · `RETURN_COMPLETED` + +--- + +### ReturnRequest +`checkout.return_requests` + +| Field | Constraint | Notes | +|---|---|---| +| orderId | not null, FK | | +| userId | not null | | +| status | ReturnRequestStatus, not null | | +| reason | ReturnReason, not null | | +| resolution | ReturnResolution, nullable | set on approval | +| adminNotes | text, nullable | | + +**ReturnRequestStatus enum:** `PENDING` · `APPROVED` · `REJECTED` · `COMPLETED` +**ReturnReason enum:** `DEFECTIVE` · `WRONG_ITEM` · `NOT_AS_DESCRIBED` · `CHANGED_MIND` · `OTHER` +**ReturnResolution enum:** `REFUND` · `REPLACEMENT` · `STORE_CREDIT` + +--- + +### OrderTemplate +`checkout.order_templates` — saved reusable order configurations per user. + +--- + +## API — Storefront Orders (`/api/v1/storefront/orders`, `@Authenticated`) + +| Method | Path | Description | +|---|---|---| +| GET | `/` | List caller's orders (paginated) | +| GET | `/{id}` | Get order (ownership verified) | +| PUT | `/{id}/cancel` | Cancel order (allowed in PENDING_PAYMENT) | +| POST | `/{id}/returns` | Submit return request | +| GET | `/{id}/returns` | List return requests for order | +| GET | `/{id}/fulfillments` | List fulfillments | + +--- + +## API — Admin Orders (`/api/v1/admin/orders`) + +| Method | Path | Permission | Description | +|---|---|---|---| +| GET | `/` | `order:read` | List orders (filter: status, userId, companyId, date range) | +| GET | `/{id}` | `order:read` | Get order with items + events | +| PUT | `/{id}` | `order:write` | Update order (adminNotes, poNumber) | +| POST | `/{id}/cancel` | `order:write` | Cancel order | +| POST | `/{id}/refund` | `order:write` | Issue refund via Stripe | +| POST | `/{id}/events` | `order:write` | Add note event | +| GET | `/{id}/events` | `order:read` | List order events | + +--- + +## API — Admin Returns (`/api/v1/admin/returns`) + +| Method | Path | Permission | Description | +|---|---|---|---| +| GET | `/` | `order:read` | List return requests (filterable) | +| GET | `/{id}` | `order:read` | Get return request | +| POST | `/{id}/approve` | `order:write` | Approve return (set resolution) | +| POST | `/{id}/reject` | `order:write` | Reject return | +| POST | `/{id}/complete` | `order:write` | Mark complete (return received) | + +--- + +## OrderService Logic + +- **createFromCart:** validates inventory, applies discount + gift card + tax + shipping; creates Order in PENDING_PAYMENT; reserves inventory; clears cart +- **confirmPayment:** Stripe webhook or return verification → PAID; records payment event; publishes `OrderConfirmedEvent`; runs `AutoTagService.applyOrderTags` +- **cancel:** allowed if PENDING_PAYMENT or PAID (pre-fulfillment); releases inventory; publishes `OrderCancelledEvent` +- **refund:** calls Stripe refund API; status → REFUNDED; records event +- Optimistic locking via `lockVersion` on Order + +--- + +## Fulfillment + +### Fulfillment +`checkout.fulfillments` + +| Field | Constraint | Notes | +|---|---|---| +| orderId | not null, FK | | +| status | FulfillmentStatus, not null | | +| trackingNumber | nullable | | +| trackingCompany | nullable | | +| trackingUrl | nullable | | +| note | text, nullable | | + +**FulfillmentStatus enum:** `PENDING` · `SHIPPED` · `DELIVERED` · `CANCELLED` + +--- + +### FulfillmentItem +`checkout.fulfillment_items` + +- `fulfillmentId`, `orderItemId`, `quantity` + +--- + +## API — Admin Fulfillments (`/api/v1/admin/fulfillments`) + +| Method | Path | Permission | Description | +|---|---|---|---| +| POST | `/orders/{orderId}/fulfillments` | `fulfillment:write` | Create fulfillment | +| PUT | `/{id}` | `fulfillment:write` | Update tracking info / status | +| DELETE | `/{id}` | `fulfillment:delete` | Cancel fulfillment (returns inventory) | + +--- + +## FulfillmentService Logic + +- **create:** validates order not PENDING_PAYMENT/CANCELLED/REFUNDED; validates items belong to order; prevents over-fulfillment (quantity check against already-fulfilled qty); decrements `quantityOnHand`; transitions Order to PARTIALLY_FULFILLED or FULFILLED +- **update:** updates tracking fields; SHIPPED triggers notification email and `FULFILLMENT_SHIPPED` webhook; DELIVERED triggers `FULFILLMENT_DELIVERED` webhook +- **delete/cancel:** returns inventory (`quantityOnHand += qty`); recalculates order fulfillment status + +--- + +## Order Templates (`/api/v1/storefront/order-templates`, `@Authenticated`) + +| Method | Path | Description | +|---|---|---| +| GET | `/` | List templates | +| POST | `/` | Save current cart as template | +| GET | `/{id}` | Get template | +| POST | `/{id}/apply` | Load template items into active cart | +| DELETE | `/{id}` | Delete template | diff --git a/docs/specs/05-b2b.md b/docs/specs/05-b2b.md new file mode 100644 index 0000000..991210f --- /dev/null +++ b/docs/specs/05-b2b.md @@ -0,0 +1,126 @@ +# B2B Spec + +**Status:** Current +**Last updated:** 2026-05-26 +**Packages:** `io.k2dv.garden.b2b`, `io.k2dv.garden.quote` +**DB schemas:** `b2b`, `quote` + +> The detailed entity reference, schema table, service logic, API table, and permissions matrix live in +> `docs/b2b-spec.md`. This file is a summary and cross-reference entry point. + +--- + +## Overview + +The B2B system lets verified companies purchase on negotiated terms. The core flow: + +``` +Company created → price lists configured → buyer builds quote cart +→ submits quote request → admin prices items → admin sends quote +→ buyer accepts → order + invoice created → admin records payments +``` + +--- + +## Key Entities + +| Entity | Schema.Table | Purpose | +|---|---|---| +| Company | `b2b.companies` | Organisation | +| CompanyMembership | `b2b.company_memberships` | User↔company (role + spending limit) | +| CompanyInvitation | `b2b.company_invitations` | Email invite with UUID token, expires 7 days | +| PriceList | `b2b.price_lists` | Named contract price set per company | +| PriceListEntry | `b2b.price_list_entries` | Per-variant price with quantity tiers | +| CreditAccount | `b2b.credit_accounts` | Net-terms credit line (one per company) | +| Invoice | `b2b.invoices` | One per order; statuses: ISSUED→PARTIAL→PAID (OVERDUE, VOID) | +| InvoicePayment | `b2b.invoice_payments` | Immutable payment records | +| QuoteCart | `quote.quote_carts` | Active/submitted quote cart (one active per user) | +| QuoteCartItem | `quote.quote_cart_items` | Items in a quote cart | +| QuoteRequest | `quote.quote_requests` | Submitted quote — 10-state lifecycle | +| QuoteItem | `quote.quote_items` | Line items on a quote | + +--- + +## Company Membership Roles + +| Role | Capabilities | +|---|---| +| OWNER | Full control; created on company creation; cannot be assigned via invitation | +| MANAGER | Invite/remove members, approve spending, manage price lists | +| MEMBER | Submit quotes; optionally subject to spending limit | + +--- + +## Quote Lifecycle + +``` +PENDING ──► ASSIGNED ──► DRAFT ──► SENT ──► ACCEPTED ──► PAID + │ │ │ │ + └────────────┴───────────┴──► CANCELLED + │ + SENT ──► EXPIRED (scheduler) + SENT ──► REJECTED (buyer) + ACCEPTED ──► PENDING_APPROVAL (spending limit exceeded) + PENDING_APPROVAL ──► ACCEPTED (manager approves) + PENDING_APPROVAL ──► REJECTED (manager rejects) +``` + +--- + +## Invoice Lifecycle + +``` +ISSUED ──► PARTIAL ──► PAID + │ + ├──► OVERDUE (bulk scheduler or manual admin call) + └──► VOID (admin; cancels order if still INVOICED) +``` + +--- + +## Price Resolution + +`PriceListService.resolvePrice(companyId, variantId, qty)`: + +1. Load active price lists (within `startsAt`/`endsAt`), ordered `priority DESC` +2. Find entries where `variantId = ?` and `minQty <= qty` +3. First match by priority wins; ties broken by highest `minQty` +4. Fallback: variant's default `price` field + +Returns `{ price, isContractPrice }`. + +--- + +## B2B Admin API Paths + +| Resource | Base Path | +|---|---| +| Companies | `/api/v1/admin/companies` | +| Departments | `/api/v1/admin/departments` | +| Price Lists | `/api/v1/admin/price-lists` | +| Credit Accounts | `/api/v1/admin/credit-accounts` | +| Invoices | `/api/v1/admin/invoices` | +| Quotes | `/api/v1/admin/quotes` | +| Approval Rules | `/api/v1/admin/approval-rules` | + +## B2B Storefront API Paths + +| Resource | Base Path | +|---|---| +| Companies | `/api/v1/companies` | +| Invitations | `/api/v1/invitations` | +| Departments | `/api/v1/departments` | +| Quote Cart | `/api/v1/quote-cart` | +| Quotes | `/api/v1/quotes` | + +--- + +## Known Gaps + +See `docs/b2b-spec.md` § "Known Gaps and Open Items" for the full list. Key items: + +- No admin endpoint to list/search all companies +- No admin endpoint to create invoices manually (auto-created only via quote acceptance) +- `deleteEntry` removes ALL tiers for a variant — no way to delete a single tier +- No concurrent-payment guard on `recordPayment` (optimistic lock not applied) +- No re-send endpoint for invitations diff --git a/docs/specs/06-inventory.md b/docs/specs/06-inventory.md new file mode 100644 index 0000000..7207db9 --- /dev/null +++ b/docs/specs/06-inventory.md @@ -0,0 +1,115 @@ +# Inventory Spec + +**Status:** Current +**Last updated:** 2026-05-26 +**Package:** `io.k2dv.garden.inventory` +**DB schema:** `inventory` + +--- + +## Entities + +### InventoryItem +`inventory.inventory_items` + +| Field | Constraint | Notes | +|---|---|---| +| variantId | UUID, unique, not null | one item per variant | +| requiresShipping | boolean, default true | false for digital/virtual variants | + +Created automatically when a variant is created. + +--- + +### Location +`inventory.locations` + +| Field | Constraint | +|---|---| +| name | not null | +| address | nullable | +| isActive | boolean | + +--- + +### InventoryLevel +`inventory.inventory_levels` + +| Field | Constraint | Notes | +|---|---|---| +| inventoryItem | ManyToOne | | +| location | ManyToOne | | +| quantityOnHand | int, not null | physical stock | +| quantityCommitted | int, not null | reserved for unpaid orders | +| lowStockAlertedAt | Instant, nullable | set when alert fired | + +**Unique index:** `(inventory_item_id, location_id)` + +**Available quantity** = `quantityOnHand − quantityCommitted` + +--- + +### InventoryTransaction +`inventory.inventory_transactions` — immutable; extends `ImmutableBaseEntity` + +| Field | Notes | +|---|---| +| inventoryItem | FK | +| location | FK | +| quantity | int (positive = in, negative = out) | +| reason | InventoryTransactionReason | +| note | text, nullable | + +**InventoryTransactionReason enum:** `RECEIVED` · `SOLD` · `ADJUSTED` · `RETURNED` · `DAMAGED` + +--- + +## API — Admin Inventory (`/api/v1/admin/inventory`) + +| Method | Path | Permission | Description | +|---|---|---|---| +| GET | `/items` | `inventory:read` | List inventory items (filterable by variantId, locationId) | +| GET | `/items/{id}` | `inventory:read` | Get item with levels | +| PUT | `/items/{id}/adjust` | `inventory:write` | Manual quantity adjustment (creates ADJUSTED transaction) | +| GET | `/items/{id}/transactions` | `inventory:read` | Transaction history | + +--- + +## API — Admin Locations (`/api/v1/admin/locations`) + +| Method | Path | Permission | Description | +|---|---|---|---| +| GET | `/` | `inventory:read` | List locations | +| POST | `/` | `inventory:write` | Create location | +| PUT | `/{id}` | `inventory:write` | Update location | +| DELETE | `/{id}` | `inventory:delete` | Delete location | +| GET | `/{id}/levels` | `inventory:read` | Stock levels at location | + +--- + +## InventoryService Logic + +**On order creation (reserve):** +- `quantityCommitted += qty` for each order item +- Creates SOLD transaction record + +**On fulfillment creation (fulfill):** +- `quantityOnHand -= qty` for each fulfillment item +- `quantityCommitted -= qty` (de-commit) + +**On fulfillment cancellation:** +- `quantityOnHand += qty` (return to stock) +- `quantityCommitted += qty` (re-commit, since order is still active) + +**On return approval:** +- `quantityOnHand += qty` +- Creates RETURNED transaction + +**InventoryPolicy enforcement (at cart/checkout):** +- `DENY` — blocks add-to-cart if `available < requested qty` +- `CONTINUE` — allows overselling; no block + +**Low stock alerts:** +- Configured threshold per item +- `lowStockAlertedAt` set when level drops below threshold; cleared when restocked +- Alert sent via `AutomationScheduler` (see `08-infra.md`) diff --git a/docs/specs/07-account.md b/docs/specs/07-account.md new file mode 100644 index 0000000..fe8d4bc --- /dev/null +++ b/docs/specs/07-account.md @@ -0,0 +1,72 @@ +# Account, Wishlist & Notification Spec + +**Status:** Current +**Last updated:** 2026-05-26 +**Packages:** `io.k2dv.garden.account`, `io.k2dv.garden.wishlist`, `io.k2dv.garden.notification` + +--- + +## Account + +### AccountController (`/api/v1/account`, `@Authenticated`) + +| Method | Path | Description | +|---|---|---| +| GET | `/` | Get current user profile | +| PUT | `/` | Update profile (firstName, lastName, phone) | +| GET | `/addresses` | List saved addresses | +| POST | `/addresses` | Add address | +| PUT | `/addresses/{id}` | Update address | +| DELETE | `/addresses/{id}` | Delete address | + +**AccountService rules:** +- `updateAddress`: validates ownership (address.userId == caller); if `isDefault = true`, clears default on all other addresses first +- `deleteAddress`: validates ownership + +--- + +## Wishlist + +### Wishlist & WishlistItem +`catalog.wishlists`, `catalog.wishlist_items` + +- One wishlist per user (`userId` unique on `wishlists`) +- `WishlistItem`: `wishlistId`, `variantId` + +### WishlistController (`/api/v1/wishlist`, `@Authenticated`) + +| Method | Path | Description | +|---|---|---| +| GET | `/` | Get wishlist with variant details | +| POST | `/` | Add variant to wishlist | +| DELETE | `/{itemId}` | Remove item | + +**WishlistService:** `getOrCreateWishlist` — lazy creates. `addItem` is idempotent (no duplicate for same variantId). `removeItem` validates ownership. + +--- + +## Notification Preferences + +### NotificationPreference +`auth.notification_preferences` + +| Field | Constraint | Notes | +|---|---|---| +| userId | UUID, not null | | +| notificationType | NotificationType, not null | | +| enabled | boolean, default true | | +| updatedAt | Instant | auto-updated | + +**Unique index:** `(user_id, notification_type)` + +**NotificationType enum:** +`ORDER_CONFIRMATION` · `ORDER_SHIPPED` · `ORDER_DELIVERED` · `ORDER_CANCELLED` · `QUOTE_UPDATE` · `MARKETING` + +### NotificationPreferenceController (`/api/v1/notification-preferences`, `@Authenticated`) + +| Method | Path | Description | +|---|---|---| +| GET | `/` | List all preferences for caller | +| PUT | `/{type}` | Enable or disable a notification type | + +**NotificationPreferenceGate:** `isNotificationEnabled(userId, type)` — called before each outbound email/push to gate delivery. If no preference record exists, defaults to `enabled = true`. diff --git a/docs/specs/08-content.md b/docs/specs/08-content.md new file mode 100644 index 0000000..ce77aa4 --- /dev/null +++ b/docs/specs/08-content.md @@ -0,0 +1,170 @@ +# Content Spec — Blog, Pages, Search, Recommendations, Newsletter + +**Status:** Current +**Last updated:** 2026-05-26 +**Packages:** `io.k2dv.garden.content`, `io.k2dv.garden.search`, `io.k2dv.garden.recommendation`, `io.k2dv.garden.newsletter` +**DB schema:** `content`, `marketing` + +--- + +## Blog & Articles + +### Blog +`content.blogs` + +| Field | Constraint | +|---|---| +| title | not null | +| handle | unique, not null | + +--- + +### Article +`content.articles` + +| Field | Constraint | Notes | +|---|---|---| +| blogId | not null, FK | | +| title | not null | | +| handle | not null | unique within blog | +| body | text | | +| excerpt | text, nullable | | +| authorId | UUID, nullable | | +| authorName | nullable | | +| status | ArticleStatus, not null | | +| featuredImageId | UUID, nullable | | +| metaTitle, metaDescription | nullable | | +| publishedAt | Instant, nullable | | +| deletedAt | Instant, nullable | soft delete | +| tags | ManyToMany → ContentTag | | + +**ArticleStatus enum:** `DRAFT` · `PUBLISHED` · `ARCHIVED` + +--- + +### SitePage +`content.pages` + +| Field | Constraint | Notes | +|---|---|---| +| title | not null | | +| handle | unique, not null | | +| body | text | | +| status | PageStatus, not null | | +| metaTitle, metaDescription | nullable | | +| publishedAt | Instant, nullable | | +| deletedAt | soft delete | | + +**PageStatus enum:** `DRAFT` · `PUBLISHED` · `ARCHIVED` + +--- + +### ContentTag +`content.content_tags` — shared tag entity linked to articles via join table. + +--- + +## API — Admin Blog/Articles (`/api/v1/admin/blogs`) + +| Method | Path | Permission | Description | +|---|---|---|---| +| GET | `/` | `content:read` | List blogs | +| POST | `/` | `content:write` | Create blog | +| PUT | `/{id}` | `content:write` | Update blog | +| DELETE | `/{id}` | `content:delete` | Delete blog | +| GET | `/{blogId}/articles` | `content:read` | List articles | +| POST | `/{blogId}/articles` | `content:write` | Create article | +| GET | `/{blogId}/articles/{id}` | `content:read` | Get article | +| PUT | `/{blogId}/articles/{id}` | `content:write` | Update article | +| DELETE | `/{blogId}/articles/{id}` | `content:delete` | Soft-delete | +| POST | `/{blogId}/articles/{id}/publish` | `content:write` | Set PUBLISHED | +| POST | `/{blogId}/articles/{id}/images` | `content:write` | Upload article image | + +--- + +## API — Admin Pages (`/api/v1/admin/pages`) + +| Method | Path | Permission | Description | +|---|---|---|---| +| GET | `/` | `content:read` | List pages | +| POST | `/` | `content:write` | Create page | +| GET | `/{id}` | `content:read` | Get page | +| PUT | `/{id}` | `content:write` | Update page | +| DELETE | `/{id}` | `content:delete` | Soft-delete | +| POST | `/{id}/publish` | `content:write` | Set PUBLISHED | + +--- + +## API — Storefront Content (no auth) + +| Method | Path | Description | +|---|---|---| +| GET | `/api/v1/blogs` | List blogs | +| GET | `/api/v1/blogs/{handle}/articles` | List published articles | +| GET | `/api/v1/blogs/{handle}/articles/{articleHandle}` | Get published article | +| GET | `/api/v1/pages/{handle}` | Get published page | + +--- + +## Search + +### SearchController (`/api/v1/search`, no auth) + +| Method | Path | Description | +|---|---|---| +| GET | `/?q=&types=&page=&limit=` | Full-text search | + +**Parameters:** +- `q` — required, not blank +- `types` — comma-separated: `products`, `collections`, `articles`, `pages`; defaults to all +- `limit` — clamped to max 50 + +**SearchService logic:** +- Products: title/description/handle match; active only +- Collections: title/description match; published only +- Articles: title/body match; published only +- Pages: title/body match; published only + +Returns `SearchResponse { products: PagedResult, collections: PagedResult, articles: PagedResult, pages: PagedResult }`. + +--- + +## Recommendations + +### StorefrontRecommendationController (`/api/v1/recommendations`, no auth) + +| Method | Path | Description | +|---|---|---| +| GET | `/?handle=&limit=` | Related products for a product handle | + +**RecommendationService logic:** +1. Load source product by handle +2. Find products with tag overlap (excluding self) +3. Order by overlap count (most tags in common first) +4. Limit to min(requested, 12) +5. Resolve variant prices and featured image URLs +6. Returns `List` + +--- + +## Newsletter + +### NewsletterSubscriber +`marketing.newsletter_subscribers` + +| Field | Constraint | Notes | +|---|---|---| +| email | unique, not null | | +| source | nullable | where subscription originated | +| subscribedAt | Instant, auto | | +| unsubscribedAt | Instant, nullable | null = active subscriber | + +### NewsletterController (`/api/v1/newsletter`, no auth) + +| Method | Path | Description | +|---|---|---| +| POST | `/subscribe` | Subscribe; idempotent re-subscribe if previously unsubscribed | + +**Response:** `{ alreadySubscribed: boolean }` — HTTP 201 on new, 200 on existing. + +**NewsletterService:** `subscribe(email, source)` — if unsubscribedAt is set, clears it; if already active, returns `alreadySubscribed = true` without update. diff --git a/docs/specs/09-infra.md b/docs/specs/09-infra.md new file mode 100644 index 0000000..dbca0cb --- /dev/null +++ b/docs/specs/09-infra.md @@ -0,0 +1,174 @@ +# Infrastructure Spec — Blob, Webhooks, Stats, Audit, Scheduler, Automation + +**Status:** Current +**Last updated:** 2026-05-26 +**Packages:** `io.k2dv.garden.blob`, `io.k2dv.garden.webhook`, `io.k2dv.garden.stats`, `io.k2dv.garden.audit`, `io.k2dv.garden.scheduler`, `io.k2dv.garden.automation` + +--- + +## Blob Storage + +### BlobObject +`storage.blob_objects` + +| Field | Constraint | Notes | +|---|---|---| +| key | String, unique, not null | S3 object key | +| filename | not null | original upload name | +| contentType | not null | MIME type | +| size | long, not null | bytes | +| alt, title | nullable | image metadata | +| width, height | Integer, nullable | images only | +| folder | nullable | organizational prefix | + +**Storage backend:** AWS S3 via `S3StorageService` (`StorageService` interface). Presigned URLs for uploads. + +### API — Blob (`/api/v1/blobs`, `@Authenticated` for write) + +| Method | Path | Description | +|---|---|---| +| POST | `/` | Multipart upload → returns BlobObject with key | +| GET | `/{key}` | Serve/redirect to file | +| DELETE | `/{key}` | Delete blob and S3 object | + +--- + +## Outbound Webhooks + +### WebhookEndpoint +`webhook.endpoints` + +| Field | Constraint | Notes | +|---|---|---| +| url | not null | receiver URL | +| secret | not null | used for HMAC-SHA256 signing | +| description | nullable | | +| events | text[] | subscribed WebhookEventType values | +| isActive | boolean, default true | | + +### WebhookDelivery +`webhook.deliveries` + +| Field | Notes | +|---|---| +| endpointId | FK | +| eventType | WebhookEventType | +| payload | jsonb | +| status | WebhookDeliveryStatus | +| attemptCount | int | +| lastAttemptedAt | Instant, nullable | +| nextRetryAt | Instant, nullable | +| httpStatus | Integer, nullable | +| responseBody | text, nullable | + +**WebhookEventType enum:** +`ORDER_PLACED` · `ORDER_PAID` · `ORDER_CANCELLED` · `ORDER_REFUNDED` · `FULFILLMENT_SHIPPED` · `FULFILLMENT_DELIVERED` · `INVOICE_ISSUED` · `INVOICE_PAID` · `INVOICE_OVERDUE` + +**WebhookDeliveryStatus enum:** `PENDING` · `SUCCESS` · `FAILED` + +### API — Admin Webhooks (`/api/v1/admin/webhooks`) + +| Method | Path | Permission | Description | +|---|---|---|---| +| GET | `/` | `webhook:read` | List endpoints | +| POST | `/` | `webhook:write` | Create endpoint | +| GET | `/{id}` | `webhook:read` | Get endpoint | +| PUT | `/{id}` | `webhook:write` | Update endpoint | +| DELETE | `/{id}` | `webhook:delete` | Delete endpoint | +| GET | `/{id}/deliveries` | `webhook:read` | Delivery history | +| POST | `/deliveries/{id}/retry` | `webhook:write` | Retry failed delivery | + +### WebhookDispatchService +- Signs payload with `HMAC-SHA256(secret, body)` → header `X-Webhook-Signature` +- Retry: exponential backoff; max attempts configurable +- `OutboundWebhookService` queues deliveries; `WebhookDispatchService` executes HTTP POST + +--- + +## Stats + +### AdminStatsController (`/api/v1/admin/stats`, `@HasPermission("stats:read")`) + +| Method | Path | Query Params | Response | +|---|---|---|---| +| GET | `/` | `from`, `to` (Instant) | `{ orderCount, totalRevenue, averageOrderValue, newCustomerCount }` | +| GET | `/time-series` | `from`, `to` | `List<{ date, orderCount, revenue }>` (daily buckets) | +| GET | `/top-products` | `from`, `to`, `limit` | `List<{ productId, title, handle, quantitySold, revenue }>` | +| GET | `/top-customers` | `from`, `to`, `limit` | `List<{ userId, email, firstName, lastName, orderCount, totalSpent }>` | + +All aggregate only orders with status PAID / PARTIALLY_FULFILLED / FULFILLED. + +--- + +## Audit Log + +### AuditLog +`shared.audit_log` — extends `ImmutableBaseEntity` + +| Field | Notes | +|---|---| +| actorId | UUID of user who performed the action | +| actorEmail | snapshot of email at time of action | +| action | verb (e.g., `CREATE`, `UPDATE`, `DELETE`) | +| entityType | e.g., `Company`, `Product` | +| entityId | PK of affected entity | +| beforeJson | jsonb — entity state before (nullable) | +| afterJson | jsonb — entity state after (nullable) | + +**`@Audited` AOP annotation** on service methods triggers `AuditAspect` to capture before/after state and write an `AuditLog` record. + +### AdminAuditLogController (`/api/v1/admin/audit-log`, `@HasPermission("audit:read")`) + +| Method | Path | Query Params | Description | +|---|---|---|---| +| GET | `/` | `entityType`, `entityId`, `actorEmail`, `page`, `size` | List audit records, sorted by `createdAt DESC` | + +--- + +## Scheduled Jobs (ShedLock) + +All schedulers use ShedLock for distributed safety — only one node executes at a time. + +### ExpiryScheduler + +| Job | Cron | Logic | +|---|---|---| +| `expireQuotes()` | every 15 min (`0 */15 * * * *`) | Bulk-update SENT quotes where `expiresAt < now` → EXPIRED; sends expiry notification email | +| `expireInvoices()` | configurable | Bulk-update invoices past `dueAt` → OVERDUE via `InvoiceRepository.markOverduePastDue(now)` | +| `expireGiftCards()` | configurable | Deactivates expired gift cards | +| `expireDiscounts()` | configurable | Marks expired discounts inactive | + +### AutomationScheduler + +| Job | Cron | Logic | +|---|---|---| +| `sendAbandonedCartReminders()` | hourly (`0 0 * * * *`) | Finds ACTIVE carts older than configured delay (e.g., 24h) with no `abandonedReminderSentAt`; marks ABANDONED; sends reminder email; sets `abandonedReminderSentAt` | +| `sendLowStockAlerts()` | configurable | Finds inventory levels below threshold with no recent alert; sends alert; sets `lowStockAlertedAt` | + +### PaymentReconciliationScheduler + +| Job | Cron | Logic | +|---|---|---| +| `reconcileStalePendingPayments()` | every 10 min (`0 */10 * * * *`) | Finds orders in PENDING_PAYMENT for >15 min with a `stripeSessionId`; calls Stripe API to verify status; if `complete` → transitions to PAID and publishes `OrderConfirmedEvent`; if spike ≥ 10 unresolved → logs warning (possible webhook outage) | + +--- + +## Automation — AutoTagService + +Called on every `OrderConfirmedEvent` (i.e., after payment). + +**Logic — `applyOrderTags(userId)`:** + +1. Count user's confirmed orders (status: PAID / PARTIALLY_FULFILLED / FULFILLED) +2. Sum total spend across those orders + +**Tag rules (applied to `User.tags` array):** + +| Tag | Condition | +|---|---| +| `first-time-buyer` | orderCount == 1 | +| `repeat-customer` | orderCount >= 2 (also removes `first-time-buyer`) | +| `loyal-customer` | orderCount >= 5 | +| `vip` | totalSpend >= configured threshold | + +Tags are additive; `repeat-customer` replaces `first-time-buyer` when threshold crossed. diff --git a/docs/testing.md b/docs/testing.md index f46f2f9..700cf82 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,83 +1,181 @@ -# testing +# Testing -The integration test harness, end to end +## Test Types -1. Testcontainers starts a real PostgreSQL instance +| Type | Suffix | What it tests | Speed | +|---|---|---|---| +| Unit test | `*Test` or `*Tests` | Single class in isolation (mocked dependencies) | Fast (milliseconds) | +| Integration test | `*IT` | Full Spring context + real Postgres (Testcontainers) | Slower (~seconds each) | +| Seeder test | `DevDataSeederIT` | Dev data seeder — run separately to avoid slowing the main integration suite | -AbstractIntegrationTest starts a PostgreSQLContainer in a static block: +--- + +## Running Tests + +```bash +# Unit tests only +./mvnw test -Dtest="**/*Test,**/*Tests" + +# Integration tests (excluding the seeder) +./mvnw test -Dtest="**/*IT,!DevDataSeederIT" + +# Seeder integration test only +./mvnw test -Dtest="DevDataSeederIT" + +# Everything (slow — avoid locally, use CI) +./mvnw test +``` +--- + +## Integration Test Harness + +All integration tests extend `AbstractIntegrationTest`. The harness: + +### 1. Starts a real Postgres with Testcontainers + +```java static final PostgreSQLContainer postgres; static { -postgres = new PostgreSQLContainer<>("postgres:17-alpine"); -postgres.start(); + postgres = new PostgreSQLContainer<>("postgres:17-alpine"); + postgres.start(); } +``` -static means it starts once for the entire JVM — all test classes that extend AbstractIntegrationTest share the same running container. If it were instance-level, a new container would start and stop for every test class, which is very -slow. +`static` means the container starts once for the entire JVM. All test classes that extend `AbstractIntegrationTest` share the same running container. This is intentional — starting a new container per test class would be very slow. -It's deliberately not annotated with @Container / @Testcontainers because those annotations hand lifecycle control to JUnit, which would stop the container between test classes, invalidating Spring's cached application context. +The container is **not** annotated with `@Container`/`@Testcontainers` because those annotations hand lifecycle control to JUnit, which would stop the container between test classes and break Spring's cached application context. ---- - -1. Spring is told where the database is via @DynamicPropertySource +### 2. Injects the connection URL via @DynamicPropertySource +```java @DynamicPropertySource static void datasourceProperties(DynamicPropertyRegistry registry) { -registry.add("spring.datasource.url", postgres::getJdbcUrl); -registry.add("spring.datasource.username", postgres::getUsername); -registry.add("spring.datasource.password", postgres::getPassword); + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); } +``` -The container assigns a random port at startup. @DynamicPropertySource injects those values into Spring's environment before the application context starts, overriding anything in application.properties. This is why there's no hardcoded -JDBC URL in application-test.properties. - ---- +Testcontainers assigns a random port at startup. `@DynamicPropertySource` injects the actual URL before Spring starts — no hardcoded JDBC URL is needed in `application-test.properties`. -1. Spring Boot starts a full application context (@SpringBootTest) +### 3. Boots a full application context -@SpringBootTest on AbstractIntegrationTest boots the real application context — all beans, all configuration, the whole thing. @ActiveProfiles("test") activates the test profile, which loads application-test.properties on top of -application.properties. +`@SpringBootTest` on `AbstractIntegrationTest` boots the real application context — all beans, all configuration, all security. `@ActiveProfiles("test")` loads `application-test.properties` on top of `application.properties`. ---- +The context is cached by Spring's test framework — it is created once and reused across all test classes that share the same configuration. -1. Flyway runs all migrations automatically on startup +### 4. Runs Flyway migrations automatically -application.properties has: -spring.flyway.enabled=true -spring.flyway.locations=classpath:db/migration +On context startup, Flyway runs all migrations in `classpath:db/migration`. The test profile adds a second location: -application-test.properties overrides the locations to add a second path: +```properties +# application-test.properties spring.flyway.locations=classpath:db/migration,classpath:db/testmigration +``` + +`classpath:db/testmigration` contains `V9999__test_probe_entity.sql` — a test-only table used by `BaseEntityIT` to verify UUID v7 generation and timestamp behavior. It does not interfere with production migrations. -When the Spring context boots, Flyway runs before any test code executes. It scans classpath:db/migration (V1 through V14 — your production migrations) and classpath:db/testmigration (V9999 — a test-only table used by BaseEntityIT). -Flyway's checksum tracking means it only runs migrations that haven't been applied yet. +### 5. Each test method rolls back -The migration files live in: +`@Transactional` + `@Rollback` on `AbstractIntegrationTest` means every `@Test` method runs in a transaction that is rolled back after the method completes. This gives a clean slate per test without truncating tables or restarting the container. -- src/main/resources/db/migration/ — picked up from main resources on the classpath -- src/test/resources/db/testmigration/ — picked up from test resources on the classpath +The post-migration state (schema + seed data from Flyway) persists across all tests. Only the data written by each test method is rolled back. --- -1. Each test method gets a transaction that rolls back +## Test Security Config + +`TestSecurityConfig` replaces the production security configuration in the test context. It disables JWT validation so tests can inject a fake current user without needing real tokens. + +`TestCurrentUserConfig` provides a `@Bean` that returns a configurable `CurrentUser` — tests can set the user ID, email, and roles before exercising service code. + +--- + +## Controller Tests (MockMvc) + +Controller-layer tests (`*Test`) use `@WebMvcTest` with `MockMvc` and mock out the service layer. These tests check: +- HTTP status codes +- Request deserialization and validation (`@Valid` constraints) +- Response body shape +- Permission checks (`@HasPermission` and `@Authenticated`) -@Transactional + @Rollback on AbstractIntegrationTest means every test method runs inside a transaction that is never committed — it's rolled back after the test completes. This gives you a clean slate for each test without truncating -tables or restarting the container. The database schema and seed data from the migrations persist; only the data written by each test is rolled back. +They do not hit the database. --- -The full sequence for a single test run +## The Full Test Sequence +``` JVM starts └─ PostgreSQLContainer starts (random port, blank DB) └─ Spring context starts (@SpringBootTest) -├─ DynamicPropertySource injects the JDBC URL -├─ Flyway runs V1→V14 + V9999 (schema + seed data applied once) -└─ Application context cached for all test classes + ├─ DynamicPropertySource injects the JDBC URL + ├─ Flyway runs V1→V74 + V9999 (schema + seed data applied once) + └─ Application context cached for all test classes └─ For each @Test method: -├─ Transaction begins -├─ Test runs (inserts, updates, service calls) -├─ Transaction rolls back -└─ DB is back to post-migration state + ├─ Transaction begins + ├─ Test runs (inserts, service calls, assertions) + ├─ Transaction rolls back + └─ DB returns to post-migration state +``` + +--- + +## Writing New Tests + +### Integration test + +```java +class MyServiceIT extends AbstractIntegrationTest { + + @Autowired + MyService myService; + + @Test + void doesTheThing() { + // arrange + // act + var result = myService.doThing(); + // assert + assertThat(result).isNotNull(); + } +} +``` + +The transaction and rollback are handled by `AbstractIntegrationTest`. Do not manually call `@BeforeEach` cleanup — rollback handles it. + +### Unit test + +```java +class MyServiceTest { + + @Mock + MyRepository repo; + + @InjectMocks + MyService service; + + @BeforeEach + void setUp() { MockitoAnnotations.openMocks(this); } + + @Test + void doesTheThing() { ... } +} +``` + +Prefer unit tests for pure business logic. Use integration tests when the test depends on Postgres behavior (queries, constraints, Flyway migrations). + +--- + +## CI Test Matrix + +GitHub Actions runs three parallel jobs on every pull request to `main`: + +| Job | Command | What runs | +|---|---|---| +| Unit Tests | `./mvnw test -Dtest="**/*Test,**/*Tests"` | All unit tests | +| Integration Tests | `./mvnw test -Dtest="**/*IT,!DevDataSeederIT"` | All IT tests except seeder | +| Seeder Integration Tests | `./mvnw test -Dtest="DevDataSeederIT"` | Dev data seeder only | -The context is cached by Spring's test framework — it's created once and reused across all test classes that share the same configuration. This is why the integration tests are fast despite using a real database. +Integration tests have a 20-minute timeout; seeder tests have 15 minutes. Unit tests run first; integration tests run in parallel after unit tests pass.