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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions shopsphere/api-gateway/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ app:
- /api/v1/products
- /api/v1/categories
- /api/v1/inventory
- /api/v1/shipments

management:
endpoints:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.shopsphere.common.events;

import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;

import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;

/**
* US38 — published by order-service after an order is cancelled (committed).
* Carries enough for notification now, and for refunds/stock compensation later.
*/
@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
@SuperBuilder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class OrderCancelledEvent extends BaseEvent {

public static final String TYPE = "order.cancelled.v1";

private Long orderId;
private Long userId;

/** Machine-readable reason code, e.g. RESERVATION_EXPIRED. */
private String reason;

/** Customer-facing explanation. */
private String reasonDescription;

private Instant cancelledAt;
private BigDecimal totalAmount;
private List<OrderItemSnapshot> items;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.shopsphere.common.events;

import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;

import java.time.Instant;
import java.util.List;

/**
* US37 — published by inventory-service when an order's stock holds time out before
* payment settled. One event per order (keyed by orderId), listing every released line.
*/
@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
@SuperBuilder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ReservationExpiredEvent extends BaseEvent {

public static final String TYPE = "inventory.reservation-expired.v1";

private Long orderId;
private Instant expiredAt;

/** productId + quantity released (unitPrice not set). */
private List<OrderItemSnapshot> items;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.shopsphere.common.events;

import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;

import java.time.Instant;

/**
* Published by the .NET shipping-service (US36) when a shipment is marked DELIVERED.
* eventId is deterministic per shipment so re-publishes can be de-duplicated.
*/
@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
@SuperBuilder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ShipmentDeliveredEvent extends BaseEvent {

public static final String TYPE = "shipment.delivered.v1";

private Long shipmentId;
private Long orderId;
private Long userId;
private String carrier;
private String trackingNumber;
private Instant deliveredAt;
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@ public final class Topics {

private Topics() {}

public static final String ORDER_CREATED = "order.created";
public static final String PAYMENT_SUCCESSFUL = "payment.successful";
public static final String PAYMENT_FAILED = "payment.failed";
public static final String LOW_STOCK = "inventory.low-stock";
public static final String SHIPMENT_DISPATCHED = "shipment.dispatched";
public static final String ORDER_CREATED = "order.created";
public static final String ORDER_CANCELLED = "order.cancelled";
public static final String PAYMENT_SUCCESSFUL = "payment.successful";
public static final String PAYMENT_FAILED = "payment.failed";
public static final String LOW_STOCK = "inventory.low-stock";
public static final String RESERVATION_EXPIRED = "inventory.reservation-expired";
public static final String SHIPMENT_DISPATCHED = "shipment.dispatched";
public static final String SHIPMENT_DELIVERED = "shipment.delivered";
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ app:
- /api/v1/products
- /api/v1/categories
- /api/v1/inventory
- /api/v1/shipments
3 changes: 3 additions & 0 deletions shopsphere/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ services:
kafka:
image: confluentinc/cp-kafka:7.6.1
container_name: shopsphere-kafka
# On a quick restart ZooKeeper may still hold the old broker registration (NodeExists)
# until its session times out (~18s); retrying lets Kafka come up once it expires.
restart: on-failure
depends_on:
- zookeeper
ports:
Expand Down
7 changes: 7 additions & 0 deletions shopsphere/inventory-service/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-config</artifactId>
</dependency>

<!-- US37: real-database test for optimistic locking during reservation expiry -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.shopsphere.inventoryservice.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.time.Clock;

/** Injectable clock so reservation expiry is testable. */
@Configuration
public class ClockConfig {

@Bean
public Clock clock() {
return Clock.systemUTC();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.shopsphere.inventoryservice.config;

import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;

/** US37 — enables the reservation expiry sweeper. Disable with RESERVATION_SWEEPER_ENABLED=false. */
@Configuration
@EnableScheduling
@ConditionalOnProperty(name = "app.inventory.reservation.sweeper-enabled", havingValue = "true", matchIfMissing = true)
public class SchedulingConfig {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.shopsphere.inventoryservice.entity;

public enum ReservationStatus {
/** Units held against an unpaid order; counted in Inventory.reservedQuantity. */
RESERVED,
/** Payment succeeded; units deducted from available stock. */
COMMITTED,
/** Payment failed; hold returned to sellable stock. */
RELEASED,
/** Hold timed out before payment settled; returned to sellable stock by the sweeper. */
EXPIRED
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package com.shopsphere.inventoryservice.entity;

import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;

import java.time.Instant;

/**
* US37 — one hold per (order, product). Inventory.reservedQuantity is the sum of RESERVED rows;
* this table is what lets a hold expire individually.
*
* <p>{@code @Version} plus Inventory's own {@code @Version} make concurrent expiry safe: two
* sweepers (or a sweeper and a payment) touching the same hold can't both win.</p>
*/
@Entity
@Table(name = "stock_reservations",
uniqueConstraints = @UniqueConstraint(
name = "uk_stock_reservations_order_product",
columnNames = {"order_id", "product_id"}),
indexes = {
@Index(name = "idx_stock_reservations_status_expires", columnList = "status, reservation_expires_at"),
@Index(name = "idx_stock_reservations_order", columnList = "order_id")
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class StockReservation {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "order_id", nullable = false)
private Long orderId;

@Column(name = "product_id", nullable = false)
private Long productId;

@Column(nullable = false)
private Integer quantity;

@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private ReservationStatus status;

/** When this hold is released if payment hasn't settled. */
@Column(name = "reservation_expires_at", nullable = false)
private Instant reservationExpiresAt;

/** When the hold left RESERVED (committed / released / expired). */
@Column(name = "resolved_at")
private Instant resolvedAt;

@Version
@Column(nullable = false)
private Long version;

@CreationTimestamp
@Column(name = "created_at", updatable = false)
private Instant createdAt;

@UpdateTimestamp
@Column(name = "updated_at")
private Instant updatedAt;

public boolean isExpiredAt(Instant now) {
return status == ReservationStatus.RESERVED && !reservationExpiresAt.isAfter(now);
}

public void markExpired(Instant at) {
requireReserved("expire");
status = ReservationStatus.EXPIRED;
resolvedAt = at;
}

public void markReleased(Instant at) {
requireReserved("release");
status = ReservationStatus.RELEASED;
resolvedAt = at;
}

/** Allowed from RESERVED, and from EXPIRED/RELEASED for a late payment. */
public void markCommitted(Instant at) {
if (status == ReservationStatus.COMMITTED) {
throw new IllegalStateException("Reservation " + id + " is already COMMITTED");
}
status = ReservationStatus.COMMITTED;
resolvedAt = at;
}

private void requireReserved(String action) {
if (status != ReservationStatus.RESERVED) {
throw new IllegalStateException("Cannot " + action + " reservation " + id + " in status " + status);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.shopsphere.inventoryservice.messaging;

import com.shopsphere.common.events.LowStockEvent;
import com.shopsphere.common.events.ReservationExpiredEvent;
import com.shopsphere.common.events.Topics;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -27,4 +28,19 @@ public void publishLowStock(LowStockEvent event) {
}
});
}

/** US37 — keyed by orderId so it stays ordered with the order's other events. */
public void publishReservationExpired(ReservationExpiredEvent event) {
String key = String.valueOf(event.getOrderId());
kafkaTemplate.send(Topics.RESERVATION_EXPIRED, key, event)
.whenComplete((res, ex) -> {
if (ex != null) {
log.error("Failed to publish inventory.reservation-expired for orderId={}: {}",
event.getOrderId(), ex.getMessage(), ex);
} else {
log.info("Published inventory.reservation-expired eventId={} orderId={} lines={}",
event.getEventId(), event.getOrderId(), event.getItems().size());
}
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.shopsphere.inventoryservice.repository;

import com.shopsphere.inventoryservice.entity.ReservationStatus;
import com.shopsphere.inventoryservice.entity.StockReservation;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.time.Instant;
import java.util.List;
import java.util.Optional;

@Repository
public interface StockReservationRepository extends JpaRepository<StockReservation, Long> {

Optional<StockReservation> findByOrderIdAndProductId(Long orderId, Long productId);

List<StockReservation> findByOrderIdAndStatus(Long orderId, ReservationStatus status);

/** Orders with at least one overdue hold, oldest order id first. */
@Query("""
SELECT DISTINCT r.orderId FROM StockReservation r
WHERE r.status = :status AND r.reservationExpiresAt <= :now
ORDER BY r.orderId
""")
List<Long> findOrderIdsWithExpiredReservations(@Param("status") ReservationStatus status,
@Param("now") Instant now,
Pageable pageable);
}
Loading
Loading