Skip to content

Commit 7c77891

Browse files
committed
feat(C-1): transaction table, service, and currency fields
Add shop transaction infrastructure for purchase flow, stock management, and transaction logging. Includes migration for shop_transactions table, transaction CRUD service with stock validation, and REST endpoints for purchases and transaction history. - Add migration 000010 for shop_transactions table - Add currency fields to character presets (dnd5e, pf2e, drawsteel) - Create transaction model, repository, service, and handler - Wire transaction routes behind armory addon (Scribe+ for writes) - Add relation adapter for stock metadata management during purchases https://claude.ai/code/session_01WJEjfBqjZaGatHiXXXDupo
1 parent 572ddf4 commit 7c77891

11 files changed

Lines changed: 803 additions & 5 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
DROP TABLE IF EXISTS shop_transactions;
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
-- shop_transactions records purchases, sales, transfers, and gifts between
2+
-- entities (typically shop→character or character→character). Linked to the
3+
-- entity_relations system but stored separately for query performance and
4+
-- historical tracking (relations can be deleted, transactions persist).
5+
CREATE TABLE IF NOT EXISTS shop_transactions (
6+
id INT AUTO_INCREMENT PRIMARY KEY,
7+
campaign_id VARCHAR(36) NOT NULL,
8+
shop_entity_id VARCHAR(36) NOT NULL COMMENT 'Shop or seller entity',
9+
item_entity_id VARCHAR(36) NOT NULL COMMENT 'Item entity being transacted',
10+
buyer_entity_id VARCHAR(36) DEFAULT NULL COMMENT 'Buyer character entity (NULL for restocks)',
11+
relation_id INT DEFAULT NULL COMMENT 'Originating shop inventory relation',
12+
quantity INT NOT NULL DEFAULT 1,
13+
price_paid VARCHAR(100) DEFAULT NULL COMMENT 'Display-friendly price string (e.g., "50 gp")',
14+
currency VARCHAR(50) DEFAULT 'gp' COMMENT 'Currency code used for the transaction',
15+
price_numeric DECIMAL(12,2) DEFAULT NULL COMMENT 'Numeric price for aggregation queries',
16+
transaction_type VARCHAR(20) NOT NULL COMMENT 'purchase, sale, transfer, gift, restock',
17+
notes TEXT DEFAULT NULL,
18+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
19+
created_by VARCHAR(36) DEFAULT NULL COMMENT 'User who initiated the transaction',
20+
21+
INDEX idx_shop_tx_campaign (campaign_id),
22+
INDEX idx_shop_tx_shop (shop_entity_id),
23+
INDEX idx_shop_tx_buyer (buyer_entity_id),
24+
INDEX idx_shop_tx_item (item_entity_id),
25+
INDEX idx_shop_tx_created (campaign_id, created_at DESC),
26+
27+
CONSTRAINT fk_shop_tx_campaign FOREIGN KEY (campaign_id)
28+
REFERENCES campaigns(id) ON DELETE CASCADE
29+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

internal/app/routes.go

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -731,6 +731,38 @@ func (a *armoryItemTypeFinderAdapter) FindItemTypes(ctx context.Context, campaig
731731
return infos, nil
732732
}
733733

734+
// armoryRelationMetadataAdapter wraps the relations service to implement
735+
// armory.RelationMetadataUpdater. Used by the transaction service to decrement
736+
// shop stock when a purchase is made.
737+
type armoryRelationMetadataAdapter struct {
738+
svc relations.RelationService
739+
}
740+
741+
// UpdateMetadata updates the metadata JSON for a relation.
742+
func (a *armoryRelationMetadataAdapter) UpdateMetadata(ctx context.Context, id int, metadata json.RawMessage) error {
743+
return a.svc.UpdateMetadata(ctx, id, metadata)
744+
}
745+
746+
// armoryRelationFinderAdapter wraps the relations service to implement
747+
// armory.RelationFinder. Used by the transaction service to validate stock
748+
// before a purchase.
749+
type armoryRelationFinderAdapter struct {
750+
svc relations.RelationService
751+
}
752+
753+
// GetByID retrieves a relation by ID, mapping to the armory.RelationInfo type.
754+
func (a *armoryRelationFinderAdapter) GetByID(ctx context.Context, id int) (*armory.RelationInfo, error) {
755+
rel, err := a.svc.GetByID(ctx, id)
756+
if err != nil {
757+
return nil, err
758+
}
759+
return &armory.RelationInfo{
760+
ID: rel.ID,
761+
Metadata: rel.Metadata,
762+
CampaignID: rel.CampaignID,
763+
}, nil
764+
}
765+
734766
// RegisterRoutes sets up all application routes. It registers public routes
735767
// directly and delegates to each plugin's route registration function.
736768
//
@@ -1081,7 +1113,14 @@ func (a *App) RegisterRoutes() {
10811113
armoryRepo := armory.NewArmoryRepository(a.DB)
10821114
armorySvc := armory.NewArmoryService(armoryRepo, &armoryItemTypeFinderAdapter{svc: entityService})
10831115
armoryHandler := armory.NewHandler(armorySvc)
1084-
armory.RegisterRoutes(e, armoryHandler, campaignService, authService, addonService)
1116+
1117+
// Transaction service: purchase flow, stock management, transaction logging.
1118+
txRepo := armory.NewTransactionRepository(a.DB)
1119+
txSvc := armory.NewTransactionService(txRepo)
1120+
txSvc.SetRelationMetadataUpdater(&armoryRelationMetadataAdapter{svc: relService})
1121+
txSvc.SetRelationFinder(&armoryRelationFinderAdapter{svc: relService})
1122+
txHandler := armory.NewTransactionHandler(txSvc)
1123+
armory.RegisterRoutes(e, armoryHandler, txHandler, campaignService, authService, addonService)
10851124

10861125
// Notes widget: personal floating note-taking panel (Google Keep-style).
10871126
noteRepo := notes.NewNoteRepository(a.DB)

internal/plugins/armory/routes.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import (
1515
// Public-capable routes use AllowPublicCampaignAccess so public campaigns
1616
// show items to unauthenticated visitors. All routes are gated behind the
1717
// "armory" addon — campaign owners can enable/disable via the Plugin Hub.
18-
func RegisterRoutes(e *echo.Echo, h *Handler, campaignSvc campaigns.CampaignService, authSvc auth.AuthService, addonSvc addons.AddonService) {
18+
func RegisterRoutes(e *echo.Echo, h *Handler, th *TransactionHandler, campaignSvc campaigns.CampaignService, authSvc auth.AuthService, addonSvc addons.AddonService) {
1919
// Public-capable routes: gallery view (Player+).
2020
pub := e.Group("/campaigns/:id",
2121
auth.OptionalAuth(authSvc),
@@ -24,4 +24,15 @@ func RegisterRoutes(e *echo.Echo, h *Handler, campaignSvc campaigns.CampaignServ
2424
)
2525
pub.GET("/armory", h.Index, campaigns.RequireRole(campaigns.RolePlayer))
2626
pub.GET("/armory/count", h.CountAPI, campaigns.RequireRole(campaigns.RolePlayer))
27+
28+
// Transaction routes require authenticated campaign member.
29+
txGroup := e.Group("/campaigns/:id",
30+
auth.RequireAuth(authSvc),
31+
campaigns.RequireCampaignAccess(campaignSvc),
32+
addons.RequireAddon(addonSvc, "armory"),
33+
)
34+
txGroup.POST("/armory/purchase", th.Purchase, campaigns.RequireRole(campaigns.RoleScribe))
35+
txGroup.POST("/armory/transactions", th.CreateTransaction, campaigns.RequireRole(campaigns.RoleScribe))
36+
txGroup.GET("/armory/transactions", th.ListTransactions, campaigns.RequireRole(campaigns.RolePlayer))
37+
txGroup.GET("/armory/shops/:eid/transactions", th.ListShopTransactions, campaigns.RequireRole(campaigns.RolePlayer))
2738
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
// transaction_handler.go provides HTTP endpoints for shop transactions.
2+
// Thin handlers: bind request, call service, return JSON. No business logic.
3+
package armory
4+
5+
import (
6+
"encoding/json"
7+
"net/http"
8+
"strconv"
9+
10+
"github.com/labstack/echo/v4"
11+
12+
"github.com/keyxmakerx/chronicle/internal/apperror"
13+
"github.com/keyxmakerx/chronicle/internal/plugins/auth"
14+
"github.com/keyxmakerx/chronicle/internal/plugins/campaigns"
15+
)
16+
17+
// TransactionHandler serves transaction REST endpoints.
18+
type TransactionHandler struct {
19+
svc TransactionService
20+
}
21+
22+
// NewTransactionHandler creates a new transaction handler.
23+
func NewTransactionHandler(svc TransactionService) *TransactionHandler {
24+
return &TransactionHandler{svc: svc}
25+
}
26+
27+
// Purchase handles POST /campaigns/:id/armory/purchase.
28+
// Validates stock, creates transaction, decrements shop inventory.
29+
func (h *TransactionHandler) Purchase(c echo.Context) error {
30+
cc := campaigns.GetCampaignContext(c)
31+
if cc == nil {
32+
return apperror.NewMissingContext()
33+
}
34+
35+
var input CreateTransactionInput
36+
if err := json.NewDecoder(c.Request().Body).Decode(&input); err != nil {
37+
return apperror.NewBadRequest("invalid JSON body")
38+
}
39+
40+
userID := auth.GetUserID(c)
41+
result, err := h.svc.Purchase(c.Request().Context(), cc.Campaign.ID, userID, input)
42+
if err != nil {
43+
return err
44+
}
45+
46+
return c.JSON(http.StatusCreated, result)
47+
}
48+
49+
// CreateTransaction handles POST /campaigns/:id/armory/transactions.
50+
// Records a manual transaction (gift, transfer, restock).
51+
func (h *TransactionHandler) CreateTransaction(c echo.Context) error {
52+
cc := campaigns.GetCampaignContext(c)
53+
if cc == nil {
54+
return apperror.NewMissingContext()
55+
}
56+
57+
var input CreateTransactionInput
58+
if err := json.NewDecoder(c.Request().Body).Decode(&input); err != nil {
59+
return apperror.NewBadRequest("invalid JSON body")
60+
}
61+
62+
userID := auth.GetUserID(c)
63+
tx, err := h.svc.CreateTransaction(c.Request().Context(), cc.Campaign.ID, userID, input)
64+
if err != nil {
65+
return err
66+
}
67+
68+
return c.JSON(http.StatusCreated, tx)
69+
}
70+
71+
// ListTransactions handles GET /campaigns/:id/armory/transactions.
72+
// Returns paginated transactions with optional filters.
73+
func (h *TransactionHandler) ListTransactions(c echo.Context) error {
74+
cc := campaigns.GetCampaignContext(c)
75+
if cc == nil {
76+
return apperror.NewMissingContext()
77+
}
78+
79+
opts := DefaultTransactionListOptions()
80+
if p := c.QueryParam("page"); p != "" {
81+
if n, err := strconv.Atoi(p); err == nil && n > 0 {
82+
opts.Page = n
83+
}
84+
}
85+
if pp := c.QueryParam("per_page"); pp != "" {
86+
if n, err := strconv.Atoi(pp); err == nil && n > 0 && n <= 100 {
87+
opts.PerPage = n
88+
}
89+
}
90+
if sid := c.QueryParam("shop"); sid != "" {
91+
opts.ShopEntityID = sid
92+
}
93+
if bid := c.QueryParam("buyer"); bid != "" {
94+
opts.BuyerEntityID = bid
95+
}
96+
if iid := c.QueryParam("item"); iid != "" {
97+
opts.ItemEntityID = iid
98+
}
99+
if tt := c.QueryParam("type"); tt != "" {
100+
opts.TransactionType = tt
101+
}
102+
103+
txs, total, err := h.svc.ListTransactions(c.Request().Context(), cc.Campaign.ID, opts)
104+
if err != nil {
105+
return apperror.NewInternal(err)
106+
}
107+
108+
// Return empty array, not null.
109+
if txs == nil {
110+
txs = []Transaction{}
111+
}
112+
113+
return c.JSON(http.StatusOK, map[string]any{
114+
"data": txs,
115+
"total": total,
116+
"page": opts.Page,
117+
})
118+
}
119+
120+
// ListShopTransactions handles GET /campaigns/:id/armory/shops/:eid/transactions.
121+
// Returns transactions for a specific shop entity.
122+
func (h *TransactionHandler) ListShopTransactions(c echo.Context) error {
123+
cc := campaigns.GetCampaignContext(c)
124+
if cc == nil {
125+
return apperror.NewMissingContext()
126+
}
127+
128+
entityID := c.Param("eid")
129+
if entityID == "" {
130+
return apperror.NewBadRequest("entity ID is required")
131+
}
132+
133+
opts := DefaultTransactionListOptions()
134+
if p := c.QueryParam("page"); p != "" {
135+
if n, err := strconv.Atoi(p); err == nil && n > 0 {
136+
opts.Page = n
137+
}
138+
}
139+
140+
txs, total, err := h.svc.ListShopTransactions(c.Request().Context(), entityID, opts)
141+
if err != nil {
142+
return apperror.NewInternal(err)
143+
}
144+
145+
if txs == nil {
146+
txs = []Transaction{}
147+
}
148+
149+
return c.JSON(http.StatusOK, map[string]any{
150+
"data": txs,
151+
"total": total,
152+
"page": opts.Page,
153+
})
154+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// transaction_model.go defines data structures for shop transactions.
2+
// Transactions record purchases, sales, transfers, and gifts between entities.
3+
package armory
4+
5+
import "time"
6+
7+
// TransactionType enumerates the kinds of shop transactions.
8+
const (
9+
TxPurchase = "purchase" // Character buys from shop.
10+
TxSale = "sale" // Character sells to shop.
11+
TxTransfer = "transfer" // Item moves between characters.
12+
TxGift = "gift" // Free item transfer.
13+
TxRestock = "restock" // Shop restocking (no buyer).
14+
)
15+
16+
// Transaction represents a single shop transaction record.
17+
type Transaction struct {
18+
ID int `json:"id"`
19+
CampaignID string `json:"campaign_id"`
20+
ShopEntityID string `json:"shop_entity_id"`
21+
ItemEntityID string `json:"item_entity_id"`
22+
BuyerEntityID *string `json:"buyer_entity_id,omitempty"`
23+
RelationID *int `json:"relation_id,omitempty"`
24+
Quantity int `json:"quantity"`
25+
PricePaid *string `json:"price_paid,omitempty"`
26+
Currency string `json:"currency"`
27+
PriceNumeric *float64 `json:"price_numeric,omitempty"`
28+
TransactionType string `json:"transaction_type"`
29+
Notes *string `json:"notes,omitempty"`
30+
CreatedAt time.Time `json:"created_at"`
31+
CreatedBy *string `json:"created_by,omitempty"`
32+
33+
// Joined display fields (populated by list queries).
34+
ShopName string `json:"shop_name,omitempty"`
35+
ItemName string `json:"item_name,omitempty"`
36+
BuyerName string `json:"buyer_name,omitempty"`
37+
}
38+
39+
// CreateTransactionInput is the request payload for creating a transaction.
40+
type CreateTransactionInput struct {
41+
ShopEntityID string `json:"shop_entity_id"`
42+
ItemEntityID string `json:"item_entity_id"`
43+
BuyerEntityID string `json:"buyer_entity_id"`
44+
RelationID int `json:"relation_id"`
45+
Quantity int `json:"quantity"`
46+
PricePaid string `json:"price_paid"`
47+
Currency string `json:"currency"`
48+
PriceNumeric float64 `json:"price_numeric"`
49+
TransactionType string `json:"transaction_type"`
50+
Notes string `json:"notes"`
51+
}
52+
53+
// TransactionListOptions controls filtering and pagination for transaction queries.
54+
type TransactionListOptions struct {
55+
Page int // 1-indexed page number.
56+
PerPage int // Items per page (default 20).
57+
ShopEntityID string // Filter by shop entity.
58+
BuyerEntityID string // Filter by buyer entity.
59+
ItemEntityID string // Filter by item entity.
60+
TransactionType string // Filter by type (purchase, sale, etc.).
61+
}
62+
63+
// DefaultTransactionListOptions returns sensible defaults.
64+
func DefaultTransactionListOptions() TransactionListOptions {
65+
return TransactionListOptions{Page: 1, PerPage: 20}
66+
}
67+
68+
// Offset returns the SQL offset for the current page.
69+
func (o TransactionListOptions) Offset() int {
70+
if o.Page < 1 {
71+
return 0
72+
}
73+
return (o.Page - 1) * o.PerPage
74+
}
75+
76+
// PurchaseResult is returned after a successful purchase to inform
77+
// the caller of the outcome.
78+
type PurchaseResult struct {
79+
Transaction *Transaction `json:"transaction"`
80+
StockRemaining int `json:"stock_remaining"` // -1 means unlimited.
81+
}

0 commit comments

Comments
 (0)