feat(t218): reconcile single order submissions
This commit is contained in:
@@ -0,0 +1,526 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
orderSubmissionStartOperation = "START"
|
||||
orderSubmissionReconcileOperation = "RECONCILE"
|
||||
orderSubmissionManualReviewOperation = "MANUAL_REVIEW"
|
||||
)
|
||||
|
||||
type StartOrderSubmissionCommand struct {
|
||||
UserID string
|
||||
DeviceID string
|
||||
TaskID string
|
||||
ExecutionID string
|
||||
AuthorizationID string
|
||||
ClaimGeneration int64
|
||||
ClaimToken string
|
||||
CommandSHA256 string
|
||||
DryRunID string
|
||||
DryRunEvidenceSHA256 string
|
||||
ObservedTitle string
|
||||
SelectedSKU string
|
||||
Quantity int
|
||||
UnitPriceCents int64
|
||||
TotalPriceCents int64
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
type StartOrderSubmissionWrite struct {
|
||||
StartOrderSubmissionCommand
|
||||
SubmissionID string
|
||||
ClaimTokenHash string
|
||||
RequestSHA256 string
|
||||
Now time.Time
|
||||
Event domain.TaskEvent
|
||||
}
|
||||
|
||||
type ReconcileOrderSubmissionCommand struct {
|
||||
UserID string
|
||||
DeviceID string
|
||||
TaskID string
|
||||
ExecutionID string
|
||||
AuthorizationID string
|
||||
ClaimGeneration int64
|
||||
ClaimToken string
|
||||
CommandSHA256 string
|
||||
SubmissionID string
|
||||
PlatformOrderNo string
|
||||
PlatformOrderedAt string
|
||||
PlatformOrderStatus string
|
||||
ObservedTitle string
|
||||
SelectedSKU string
|
||||
Quantity int
|
||||
TotalPriceCents int64
|
||||
EvidenceAssetID string
|
||||
EvidenceSHA256 string
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
type ReconcileOrderSubmissionWrite struct {
|
||||
ReconcileOrderSubmissionCommand
|
||||
ParsedPlatformOrderedAt time.Time
|
||||
ClaimTokenHash string
|
||||
RequestSHA256 string
|
||||
Now time.Time
|
||||
Event domain.TaskEvent
|
||||
}
|
||||
|
||||
type ManualReviewOrderSubmissionCommand struct {
|
||||
UserID string
|
||||
DeviceID string
|
||||
TaskID string
|
||||
ExecutionID string
|
||||
AuthorizationID string
|
||||
ClaimGeneration int64
|
||||
ClaimToken string
|
||||
CommandSHA256 string
|
||||
SubmissionID string
|
||||
ReasonCode string
|
||||
EvidenceAssetID string
|
||||
EvidenceSHA256 string
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
type ManualReviewOrderSubmissionWrite struct {
|
||||
ManualReviewOrderSubmissionCommand
|
||||
ClaimTokenHash string
|
||||
RequestSHA256 string
|
||||
Now time.Time
|
||||
Event domain.TaskEvent
|
||||
}
|
||||
|
||||
type OrderSubmissionResult struct {
|
||||
Submission domain.OrderSubmission
|
||||
Replayed bool
|
||||
}
|
||||
|
||||
type OrderSubmissionRepository interface {
|
||||
StartOrderSubmission(
|
||||
context.Context,
|
||||
StartOrderSubmissionWrite,
|
||||
) (domain.OrderSubmission, bool, error)
|
||||
ReconcileOrderSubmission(
|
||||
context.Context,
|
||||
ReconcileOrderSubmissionWrite,
|
||||
) (domain.OrderSubmission, bool, error)
|
||||
ManualReviewOrderSubmission(
|
||||
context.Context,
|
||||
ManualReviewOrderSubmissionWrite,
|
||||
) (domain.OrderSubmission, bool, error)
|
||||
}
|
||||
|
||||
type OrderSubmissionService struct {
|
||||
repository OrderSubmissionRepository
|
||||
clock Clock
|
||||
ids IDGenerator
|
||||
}
|
||||
|
||||
func NewOrderSubmissionService(
|
||||
repository OrderSubmissionRepository,
|
||||
clock Clock,
|
||||
ids IDGenerator,
|
||||
) (*OrderSubmissionService, error) {
|
||||
if repository == nil || clock == nil || ids == nil {
|
||||
return nil, errors.New("order submission service dependencies are required")
|
||||
}
|
||||
return &OrderSubmissionService{
|
||||
repository: repository,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (service *OrderSubmissionService) Start(
|
||||
ctx context.Context,
|
||||
command StartOrderSubmissionCommand,
|
||||
) (OrderSubmissionResult, error) {
|
||||
command = normalizeStartOrderSubmission(command)
|
||||
fields := orderSubmissionIdentityFields(
|
||||
command.UserID,
|
||||
command.DeviceID,
|
||||
command.TaskID,
|
||||
command.ExecutionID,
|
||||
command.AuthorizationID,
|
||||
command.ClaimGeneration,
|
||||
command.ClaimToken,
|
||||
command.CommandSHA256,
|
||||
command.IdempotencyKey,
|
||||
)
|
||||
if !isUUID(command.DryRunID) {
|
||||
fields["dry_run_id"] = "must be a UUID"
|
||||
}
|
||||
if !sha256Pattern.MatchString(command.DryRunEvidenceSHA256) {
|
||||
fields["dry_run_evidence_sha256"] = "must be lowercase SHA-256"
|
||||
}
|
||||
validateOrderSnapshot(
|
||||
fields,
|
||||
command.ObservedTitle,
|
||||
command.SelectedSKU,
|
||||
command.Quantity,
|
||||
command.UnitPriceCents,
|
||||
command.TotalPriceCents,
|
||||
)
|
||||
if command.Quantity > 0 &&
|
||||
command.UnitPriceCents <= math.MaxInt64/int64(command.Quantity) &&
|
||||
command.TotalPriceCents !=
|
||||
command.UnitPriceCents*int64(command.Quantity) {
|
||||
fields["total_price_cents"] = "must equal unit price times quantity"
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
return OrderSubmissionResult{}, invalidError(
|
||||
"ORDER_SUBMISSION_START_INVALID",
|
||||
"order submission start request is invalid",
|
||||
fields,
|
||||
)
|
||||
}
|
||||
requestHash, err := lifecycleRequestHash(command)
|
||||
if err != nil {
|
||||
return OrderSubmissionResult{}, internalLifecycleFailure(err)
|
||||
}
|
||||
submissionID, err := service.ids.NewID()
|
||||
if err != nil {
|
||||
return OrderSubmissionResult{}, internalLifecycleFailure(err)
|
||||
}
|
||||
eventID, err := service.ids.NewID()
|
||||
if err != nil {
|
||||
return OrderSubmissionResult{}, internalLifecycleFailure(err)
|
||||
}
|
||||
now := service.clock.Now().UTC()
|
||||
userID, deviceID := command.UserID, command.DeviceID
|
||||
submission, replayed, err := service.repository.StartOrderSubmission(
|
||||
ctx,
|
||||
StartOrderSubmissionWrite{
|
||||
StartOrderSubmissionCommand: command,
|
||||
SubmissionID: submissionID,
|
||||
ClaimTokenHash: hashSecret(command.ClaimToken),
|
||||
RequestSHA256: requestHash,
|
||||
Now: now,
|
||||
Event: domain.TaskEvent{
|
||||
ID: eventID,
|
||||
TaskID: command.TaskID,
|
||||
ActorUserID: &userID,
|
||||
ActorDeviceID: &deviceID,
|
||||
Type: "ORDER_SUBMISSION_FENCED",
|
||||
Message: "single order submission fenced",
|
||||
OccurredAt: now,
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return OrderSubmissionResult{}, wrapLifecycleRepositoryError(err)
|
||||
}
|
||||
return OrderSubmissionResult{
|
||||
Submission: submission,
|
||||
Replayed: replayed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (service *OrderSubmissionService) Reconcile(
|
||||
ctx context.Context,
|
||||
command ReconcileOrderSubmissionCommand,
|
||||
) (OrderSubmissionResult, error) {
|
||||
command = normalizeReconcileOrderSubmission(command)
|
||||
fields := orderSubmissionIdentityFields(
|
||||
command.UserID,
|
||||
command.DeviceID,
|
||||
command.TaskID,
|
||||
command.ExecutionID,
|
||||
command.AuthorizationID,
|
||||
command.ClaimGeneration,
|
||||
command.ClaimToken,
|
||||
command.CommandSHA256,
|
||||
command.IdempotencyKey,
|
||||
)
|
||||
if !isUUID(command.SubmissionID) {
|
||||
fields["submission_id"] = "must be a UUID"
|
||||
}
|
||||
if !platformOrderNumberPattern.MatchString(command.PlatformOrderNo) {
|
||||
fields["platform_order_no"] = "must be 8 to 40 digits"
|
||||
}
|
||||
orderedAt, err := time.Parse(time.RFC3339, command.PlatformOrderedAt)
|
||||
if err != nil {
|
||||
fields["platform_ordered_at"] = "must be RFC3339"
|
||||
}
|
||||
if command.PlatformOrderStatus != "PENDING_PAYMENT" {
|
||||
fields["platform_order_status"] = "must be PENDING_PAYMENT"
|
||||
}
|
||||
validateReconciledOrderSnapshot(
|
||||
fields,
|
||||
command.ObservedTitle,
|
||||
command.SelectedSKU,
|
||||
command.Quantity,
|
||||
command.TotalPriceCents,
|
||||
)
|
||||
if !isUUID(command.EvidenceAssetID) {
|
||||
fields["evidence_asset_id"] = "must be a UUID"
|
||||
}
|
||||
if !sha256Pattern.MatchString(command.EvidenceSHA256) {
|
||||
fields["evidence_sha256"] = "must be lowercase SHA-256"
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
return OrderSubmissionResult{}, invalidError(
|
||||
"ORDER_SUBMISSION_RECONCILE_INVALID",
|
||||
"order submission reconciliation request is invalid",
|
||||
fields,
|
||||
)
|
||||
}
|
||||
requestHash, err := lifecycleRequestHash(command)
|
||||
if err != nil {
|
||||
return OrderSubmissionResult{}, internalLifecycleFailure(err)
|
||||
}
|
||||
eventID, err := service.ids.NewID()
|
||||
if err != nil {
|
||||
return OrderSubmissionResult{}, internalLifecycleFailure(err)
|
||||
}
|
||||
now := service.clock.Now().UTC()
|
||||
userID, deviceID := command.UserID, command.DeviceID
|
||||
submission, replayed, err := service.repository.ReconcileOrderSubmission(
|
||||
ctx,
|
||||
ReconcileOrderSubmissionWrite{
|
||||
ReconcileOrderSubmissionCommand: command,
|
||||
ParsedPlatformOrderedAt: orderedAt.UTC(),
|
||||
ClaimTokenHash: hashSecret(command.ClaimToken),
|
||||
RequestSHA256: requestHash,
|
||||
Now: now,
|
||||
Event: domain.TaskEvent{
|
||||
ID: eventID,
|
||||
TaskID: command.TaskID,
|
||||
ActorUserID: &userID,
|
||||
ActorDeviceID: &deviceID,
|
||||
Type: "ORDER_SUBMISSION_RECONCILED",
|
||||
Message: "pending-payment order uniquely reconciled",
|
||||
OccurredAt: now,
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return OrderSubmissionResult{}, wrapLifecycleRepositoryError(err)
|
||||
}
|
||||
return OrderSubmissionResult{
|
||||
Submission: submission,
|
||||
Replayed: replayed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (service *OrderSubmissionService) ManualReview(
|
||||
ctx context.Context,
|
||||
command ManualReviewOrderSubmissionCommand,
|
||||
) (OrderSubmissionResult, error) {
|
||||
command = normalizeManualReviewOrderSubmission(command)
|
||||
fields := orderSubmissionIdentityFields(
|
||||
command.UserID,
|
||||
command.DeviceID,
|
||||
command.TaskID,
|
||||
command.ExecutionID,
|
||||
command.AuthorizationID,
|
||||
command.ClaimGeneration,
|
||||
command.ClaimToken,
|
||||
command.CommandSHA256,
|
||||
command.IdempotencyKey,
|
||||
)
|
||||
if !isUUID(command.SubmissionID) {
|
||||
fields["submission_id"] = "must be a UUID"
|
||||
}
|
||||
if _, ok := orderSubmissionManualReasons[command.ReasonCode]; !ok {
|
||||
fields["reason_code"] = "must be an allowed reason"
|
||||
}
|
||||
if (command.EvidenceAssetID == "") != (command.EvidenceSHA256 == "") {
|
||||
fields["evidence"] = "asset id and SHA-256 must be provided together"
|
||||
} else if command.EvidenceAssetID != "" {
|
||||
if !isUUID(command.EvidenceAssetID) {
|
||||
fields["evidence_asset_id"] = "must be a UUID"
|
||||
}
|
||||
if !sha256Pattern.MatchString(command.EvidenceSHA256) {
|
||||
fields["evidence_sha256"] = "must be lowercase SHA-256"
|
||||
}
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
return OrderSubmissionResult{}, invalidError(
|
||||
"ORDER_SUBMISSION_MANUAL_REVIEW_INVALID",
|
||||
"order submission manual-review request is invalid",
|
||||
fields,
|
||||
)
|
||||
}
|
||||
requestHash, err := lifecycleRequestHash(command)
|
||||
if err != nil {
|
||||
return OrderSubmissionResult{}, internalLifecycleFailure(err)
|
||||
}
|
||||
eventID, err := service.ids.NewID()
|
||||
if err != nil {
|
||||
return OrderSubmissionResult{}, internalLifecycleFailure(err)
|
||||
}
|
||||
now := service.clock.Now().UTC()
|
||||
userID, deviceID := command.UserID, command.DeviceID
|
||||
submission, replayed, err := service.repository.ManualReviewOrderSubmission(
|
||||
ctx,
|
||||
ManualReviewOrderSubmissionWrite{
|
||||
ManualReviewOrderSubmissionCommand: command,
|
||||
ClaimTokenHash: hashSecret(command.ClaimToken),
|
||||
RequestSHA256: requestHash,
|
||||
Now: now,
|
||||
Event: domain.TaskEvent{
|
||||
ID: eventID,
|
||||
TaskID: command.TaskID,
|
||||
ActorUserID: &userID,
|
||||
ActorDeviceID: &deviceID,
|
||||
Type: "ORDER_SUBMISSION_MANUAL_REVIEW",
|
||||
Message: "order submission requires manual reconciliation",
|
||||
OccurredAt: now,
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return OrderSubmissionResult{}, wrapLifecycleRepositoryError(err)
|
||||
}
|
||||
return OrderSubmissionResult{
|
||||
Submission: submission,
|
||||
Replayed: replayed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func orderSubmissionIdentityFields(
|
||||
userID, deviceID, taskID, executionID, authorizationID string,
|
||||
claimGeneration int64,
|
||||
claimToken, commandSHA256, idempotencyKey string,
|
||||
) map[string]string {
|
||||
return dryRunIdentityFields(
|
||||
userID,
|
||||
deviceID,
|
||||
taskID,
|
||||
executionID,
|
||||
authorizationID,
|
||||
claimGeneration,
|
||||
claimToken,
|
||||
commandSHA256,
|
||||
idempotencyKey,
|
||||
)
|
||||
}
|
||||
|
||||
func validateOrderSnapshot(
|
||||
fields map[string]string,
|
||||
title, sku string,
|
||||
quantity int,
|
||||
unitPriceCents, totalPriceCents int64,
|
||||
) {
|
||||
if title == "" || len([]byte(title)) > 1024 {
|
||||
fields["observed_title"] = "must be 1 to 1024 UTF-8 bytes"
|
||||
}
|
||||
if sku == "" || len([]byte(sku)) > 512 {
|
||||
fields["selected_sku"] = "must be 1 to 512 UTF-8 bytes"
|
||||
}
|
||||
if quantity < 1 || quantity > 99 {
|
||||
fields["quantity"] = "must be 1 to 99"
|
||||
}
|
||||
if unitPriceCents < 1 {
|
||||
fields["unit_price_cents"] = "must be positive"
|
||||
}
|
||||
if totalPriceCents < 1 {
|
||||
fields["total_price_cents"] = "must be positive"
|
||||
}
|
||||
if quantity > 0 &&
|
||||
unitPriceCents > math.MaxInt64/int64(quantity) {
|
||||
fields["total_price_cents"] = "price multiplication overflows"
|
||||
}
|
||||
}
|
||||
|
||||
func validateReconciledOrderSnapshot(
|
||||
fields map[string]string,
|
||||
title, sku string,
|
||||
quantity int,
|
||||
totalPriceCents int64,
|
||||
) {
|
||||
if title == "" || len([]byte(title)) > 1024 {
|
||||
fields["observed_title"] = "must be 1 to 1024 UTF-8 bytes"
|
||||
}
|
||||
if sku == "" || len([]byte(sku)) > 512 {
|
||||
fields["selected_sku"] = "must be 1 to 512 UTF-8 bytes"
|
||||
}
|
||||
if quantity < 1 || quantity > 99 {
|
||||
fields["quantity"] = "must be 1 to 99"
|
||||
}
|
||||
if totalPriceCents < 1 {
|
||||
fields["total_price_cents"] = "must be positive"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeStartOrderSubmission(
|
||||
command StartOrderSubmissionCommand,
|
||||
) StartOrderSubmissionCommand {
|
||||
command.UserID = strings.TrimSpace(command.UserID)
|
||||
command.DeviceID = strings.TrimSpace(command.DeviceID)
|
||||
command.TaskID = strings.TrimSpace(command.TaskID)
|
||||
command.ExecutionID = strings.TrimSpace(command.ExecutionID)
|
||||
command.AuthorizationID = strings.TrimSpace(command.AuthorizationID)
|
||||
command.ClaimToken = strings.TrimSpace(command.ClaimToken)
|
||||
command.CommandSHA256 = strings.TrimSpace(command.CommandSHA256)
|
||||
command.DryRunID = strings.TrimSpace(command.DryRunID)
|
||||
command.DryRunEvidenceSHA256 = strings.TrimSpace(
|
||||
command.DryRunEvidenceSHA256,
|
||||
)
|
||||
command.ObservedTitle = strings.TrimSpace(command.ObservedTitle)
|
||||
command.SelectedSKU = strings.TrimSpace(command.SelectedSKU)
|
||||
command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey)
|
||||
return command
|
||||
}
|
||||
|
||||
func normalizeReconcileOrderSubmission(
|
||||
command ReconcileOrderSubmissionCommand,
|
||||
) ReconcileOrderSubmissionCommand {
|
||||
command.UserID = strings.TrimSpace(command.UserID)
|
||||
command.DeviceID = strings.TrimSpace(command.DeviceID)
|
||||
command.TaskID = strings.TrimSpace(command.TaskID)
|
||||
command.ExecutionID = strings.TrimSpace(command.ExecutionID)
|
||||
command.AuthorizationID = strings.TrimSpace(command.AuthorizationID)
|
||||
command.ClaimToken = strings.TrimSpace(command.ClaimToken)
|
||||
command.CommandSHA256 = strings.TrimSpace(command.CommandSHA256)
|
||||
command.SubmissionID = strings.TrimSpace(command.SubmissionID)
|
||||
command.PlatformOrderNo = strings.TrimSpace(command.PlatformOrderNo)
|
||||
command.PlatformOrderedAt = strings.TrimSpace(command.PlatformOrderedAt)
|
||||
command.PlatformOrderStatus = strings.TrimSpace(command.PlatformOrderStatus)
|
||||
command.ObservedTitle = strings.TrimSpace(command.ObservedTitle)
|
||||
command.SelectedSKU = strings.TrimSpace(command.SelectedSKU)
|
||||
command.EvidenceAssetID = strings.TrimSpace(command.EvidenceAssetID)
|
||||
command.EvidenceSHA256 = strings.TrimSpace(command.EvidenceSHA256)
|
||||
command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey)
|
||||
return command
|
||||
}
|
||||
|
||||
func normalizeManualReviewOrderSubmission(
|
||||
command ManualReviewOrderSubmissionCommand,
|
||||
) ManualReviewOrderSubmissionCommand {
|
||||
command.UserID = strings.TrimSpace(command.UserID)
|
||||
command.DeviceID = strings.TrimSpace(command.DeviceID)
|
||||
command.TaskID = strings.TrimSpace(command.TaskID)
|
||||
command.ExecutionID = strings.TrimSpace(command.ExecutionID)
|
||||
command.AuthorizationID = strings.TrimSpace(command.AuthorizationID)
|
||||
command.ClaimToken = strings.TrimSpace(command.ClaimToken)
|
||||
command.CommandSHA256 = strings.TrimSpace(command.CommandSHA256)
|
||||
command.SubmissionID = strings.TrimSpace(command.SubmissionID)
|
||||
command.ReasonCode = strings.TrimSpace(command.ReasonCode)
|
||||
command.EvidenceAssetID = strings.TrimSpace(command.EvidenceAssetID)
|
||||
command.EvidenceSHA256 = strings.TrimSpace(command.EvidenceSHA256)
|
||||
command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey)
|
||||
return command
|
||||
}
|
||||
|
||||
var platformOrderNumberPattern = regexp.MustCompile(`^[0-9]{8,40}$`)
|
||||
|
||||
var orderSubmissionManualReasons = map[string]struct{}{
|
||||
"ORDER_NOT_FOUND": {},
|
||||
"ORDER_AMBIGUOUS": {},
|
||||
"ORDER_FIELDS_INCOMPLETE": {},
|
||||
"ORDER_PAGE_UNKNOWN": {},
|
||||
"RISK_OR_PAYMENT_BOUNDARY": {},
|
||||
"EVIDENCE_UNAVAILABLE": {},
|
||||
}
|
||||
Reference in New Issue
Block a user