Technical Architecture
Module Structure
modules/billing/
├── domain/
│ ├── aggregates/
│ │ ├── billing/
│ │ │ ├── billing.go
│ │ │ ├── billing_impl.go
│ │ │ ├── billing_repository.go
│ │ │ ├── billing_events.go
│ │ │ ├── gateway.go # Payment gateway enum
│ │ │ ├── status.go # Transaction status enum
│ │ │ └── provider.go # Provider interface
│ │ └── details/
│ │ ├── details.go # Interface definitions
│ │ ├── click_details_impl.go
│ │ ├── payme_details_impl.go
│ │ ├── octo_details_impl.go
│ │ ├── stripe_details_impl.go
│ │ ├── cash_details_impl.go
│ │ └── integrator_details_impl.go
├── infrastructure/
│ ├── persistence/
│ │ ├── billing_repository.go
│ │ ├── billing_mappers.go
│ │ ├── models/
│ │ │ └── models.go
│ │ └── queries/
│ ├── providers/
│ │ ├── provider.go # Base provider interface
│ │ ├── stripe_provider.go # Stripe integration
│ │ ├── click_provider.go # Click integration
│ │ ├── payme_provider.go # Payme integration
│ │ ├── octo_provider.go # Octo integration
│ │ └── integrator_provider.go
│ └── callbacks/
│ ├── stripe_callback.go
│ ├── click_callback.go
│ ├── payme_callback.go
│ └── octo_callback.go
├── services/
│ ├── billing_service.go
│ ├── billing_service_test.go
│ └── setup_test.go
├── presentation/
│ ├── controllers/
│ │ ├── billing_controller.go # Transaction queries
│ │ ├── webhook_controller.go # Webhook handlers
│ │ └── callback_controller.go # Provider callbacks
│ └── locales/
│ ├── en.toml
│ ├── ru.toml
│ └── uz.toml
├── permissions/
│ └── constants.go
├── module.go
└── links.go
Domain Layer
Transaction Aggregate
Interface (billing.go):
type Transaction interface {
ID() uuid.UUID
TenantID() uuid.UUID
Amount() *money.Money // Quantity + Currency
Quantity() float64 // Amount in cents
Currency() Currency // ISO 4217 code
Status() Status // Payment status
SetStatus(Status) Transaction
Gateway() Gateway // Payment processor
Details() details.Details // Gateway-specific data
SetDetails(details.Details) Transaction
Events() []interface{} // Domain events
ClearEvents()
CreatedAt() time.Time
UpdatedAt() time.Time
}
Key Principles:
- Immutable Creation: ID, TenantID, Gateway, Currency cannot change
- Status Management: Controlled state transitions
- Event Publishing: Tracks all domain events
- Details Handling: Supports multiple gateway types
Payment Gateway Types
type Gateway string
const (
GatewayStripe Gateway = "stripe"
GatewayClick Gateway = "click"
GatewayPayme Gateway = "payme"
GatewayOcto Gateway = "octo"
GatewayCash Gateway = "cash"
GatewayIntegrator Gateway = "integrator"
)
Transaction Status
type Status string
const (
Pending Status = "pending"
Completed Status = "completed"
Canceled Status = "canceled"
Refunded Status = "refunded"
PartiallyRefunded Status = "partially_refunded"
)
Provider Interface
type Provider interface {
Gateway() Gateway
Create(ctx context.Context, transaction Transaction) (Transaction, error)
Cancel(ctx context.Context, transaction Transaction) (Transaction, error)
Refund(ctx context.Context, transaction Transaction, amount float64) (Transaction, error)
}
Implementations:
StripeProvider: Stripe API integrationClickProvider: Click UZ API integrationPaymeProvider: Payme API integrationOctoProvider: Octo API integration- No provider for Cash/Integrator (local handling)
Domain Events
type CreatedEvent struct {
Result Transaction
// Metadata
}
type UpdatedEvent struct {
Result Transaction
// Metadata
}
type RefundedEvent struct {
Original Transaction
RefundAmount float64
RefundedResult Transaction
}
type DeletedEvent struct {
Result Transaction
}
Service Layer
BillingService
Responsibilities:
- Transaction CRUD
- Provider coordination
- Event publishing
- Callback handling
Transaction Handling:
func (s *BillingService) Create(ctx context.Context, cmd *CreateTransactionCommand) (Transaction, error) {
entity := billing.New(...)
provider := s.providers[entity.Gateway()]
var createdTransaction Transaction
err := composables.InTx(ctx, func(txCtx context.Context) error {
// If provider exists, use it (Stripe, Click, Payme, Octo)
if provider != nil {
providedTransaction, err := provider.Create(txCtx, entity)
if err != nil {
return err
}
createdTransaction, err = s.repo.Save(txCtx, providedTransaction)
return err
}
// For Cash/Integrator, save directly
createdTransaction, err = s.repo.Save(txCtx, entity)
return err
})
if err != nil {
return nil, err
}
event, _ := billing.NewCreatedEvent(ctx, createdTransaction)
s.publisher.Publish(event)
return createdTransaction, nil
}
Refund Handling:
func (s *BillingService) Refund(ctx context.Context, cmd *RefundTransactionCommand) (Transaction, error) {
entity, err := s.repo.GetByID(ctx, cmd.TransactionID)
if err != nil {
return nil, err
}
provider := s.providers[entity.Gateway()]
var updatedTransaction Transaction
err = composables.InTx(ctx, func(txCtx context.Context) error {
if provider != nil {
providedTransaction, err := provider.Refund(txCtx, entity, cmd.Quantity)
if err != nil {
return err
}
updatedTransaction, err = s.repo.Save(txCtx, providedTransaction)
return err
}
// For Cash/Integrator, just update status
if cmd.Quantity >= entity.Amount().Quantity() {
entity = entity.SetStatus(billing.Refunded)
} else {
entity = entity.SetStatus(billing.PartiallyRefunded)
}
updatedTransaction, err = s.repo.Save(txCtx, entity)
return err
})
if err != nil {
return nil, err
}
event, _ := billing.NewUpdatedEvent(ctx, updatedTransaction)
s.publisher.Publish(event)
return updatedTransaction, nil
}
Repository Layer
Transaction Repository Interface
type Repository interface {
Count(ctx context.Context, params *FindParams) (int64, error)
GetByID(ctx context.Context, id uuid.UUID) (Transaction, error)
GetByDetailsFields(ctx context.Context, gateway Gateway, filters []DetailsFieldFilter) ([]Transaction, error)
GetPaginated(ctx context.Context, params *FindParams) ([]Transaction, error)
Save(ctx context.Context, t Transaction) (Transaction, error)
Delete(ctx context.Context, id uuid.UUID) error
}
type DetailsFieldFilter struct {
Field string // e.g., "click.merchant_id"
Value interface{} // Value to match
}
Repository Implementation
Key Implementation Details:
- Tenant Isolation:
tenantID := composables.UseTenantID(ctx) const getByIDSQL = ` SELECT id, tenant_id, status, quantity, currency, gateway, details, created_at, updated_at FROM billing_transactions WHERE id = $1 AND tenant_id = $2 ` - JSON Details Storage:
var detailsJSON json.RawMessage err := row.Scan(&model.ID, &model.TenantID, ..., &detailsJSON) model.Details = detailsJSON - Dynamic Queries for Details Fields: Uses
pkg/repofor dynamic filtering on JSON fields:// Query transactions by Click merchant ID WHERE gateway = 'click' AND details->>'merchant_id' = $1 - Error Wrapping:
const op serrors.Op = "BillingRepository.GetByID" if err != nil { return nil, serrors.E(op, err) }
Gateway Integration Patterns
Provider Base Implementation
type BaseProvider struct {
gateway Gateway
client *http.Client
}
func (p *BaseProvider) Gateway() Gateway {
return p.gateway
}
Stripe Provider Implementation
type StripeProvider struct {
BaseProvider
apiKey string
}
func (p *StripeProvider) Create(ctx context.Context, transaction Transaction) (Transaction, error) {
// 1. Create Stripe session
sessionReq := &stripe.SessionParams{
BillingReason: transaction.Details().(details.StripeDetails).BillingReason(),
// ... other fields
}
session, err := p.createSession(ctx, sessionReq)
if err != nil {
return nil, err
}
// 2. Update transaction with session details
stripeDetails := transaction.Details().(details.StripeDetails)
stripeDetails = stripeDetails.
SetSessionID(session.ID).
SetURL(session.URL)
return transaction.SetDetails(stripeDetails), nil
}
Click Provider Implementation
type ClickProvider struct {
BaseProvider
merchantID string
serviceID int64
}
func (p *ClickProvider) Create(ctx context.Context, transaction Transaction) (Transaction, error) {
// 1. Call Click Prepare API
prepareResp, err := p.prepare(ctx, &ClickPrepareRequest{
MerchantID: p.merchantID,
ServiceID: p.serviceID,
Amount: int64(transaction.Quantity()),
TransID: uuid.New().String(),
})
if err != nil {
return nil, err
}
// 2. Update transaction with prepare result
clickDetails := transaction.Details().(details.ClickDetails)
clickDetails = clickDetails.
SetMerchantPrepareID(prepareResp.PrepareID).
SetLink(prepareResp.PaymentLink)
return transaction.SetDetails(clickDetails), nil
}
func (p *ClickProvider) Confirm(ctx context.Context, transaction Transaction) (Transaction, error) {
// Called after customer pays on Click
// Updates transaction status to completed
}
Persistence Models
Transaction Table
CREATE TABLE billing_transactions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
status varchar(50) NOT NULL,
quantity float8 NOT NULL,
currency varchar(3) NOT NULL,
gateway varchar(50) NOT NULL,
details jsonb NOT NULL,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
CHECK (gateway IN ('click', 'payme', 'octo', 'stripe', 'cash', 'integrator')),
CHECK (status IN ('pending', 'completed', 'canceled', 'refunded', 'partially_refunded'))
);
CREATE INDEX billing_transactions_tenant_id_idx ON billing_transactions(tenant_id);
CREATE INDEX billing_transactions_status_idx ON billing_transactions(status);
CREATE INDEX billing_transactions_gateway_idx ON billing_transactions(gateway);
CREATE INDEX billing_transactions_created_at_idx ON billing_transactions(created_at);
-- For detailed field searches
CREATE INDEX billing_transactions_details_gin ON billing_transactions USING gin(details);
Database Models
type Transaction struct {
ID string
TenantID string
Status string
Quantity float64
Currency string
Gateway string
Details json.RawMessage
CreatedAt time.Time
UpdatedAt time.Time
}
type ClickDetails struct {
ServiceID int64 `json:"service_id"`
MerchantID int64 `json:"merchant_id"`
MerchantUserID int64 `json:"merchant_user_id"`
MerchantTransID string `json:"merchant_trans_id"`
MerchantPrepareID int64 `json:"merchant_prepare_id"`
MerchantConfirmID int64 `json:"merchant_confirm_id"`
PayDocId int64 `json:"pay_doc_id"`
PaymentID int64 `json:"payment_id"`
PaymentStatus int32 `json:"payment_status"`
SignTime string `json:"sign_time"`
SignString string `json:"sign_string"`
ErrorCode int32 `json:"error_code"`
ErrorNote string `json:"error_note"`
Link string `json:"link"`
Params map[string]any `json:"params"`
}
type StripeDetails struct {
Mode string `json:"mode"`
BillingReason string `json:"billing_reason"`
SessionID string `json:"session_id"`
ClientReferenceID string `json:"client_reference_id"`
InvoiceID string `json:"invoice_id"`
SubscriptionID string `json:"subscription_id"`
CustomerID string `json:"customer_id"`
SubscriptionData *StripeSubscriptionData `json:"subscription_data"`
Items []StripeItem `json:"items"`
SuccessURL string `json:"success_url"`
CancelURL string `json:"cancel_url"`
URL string `json:"url"`
}
Webhook/Callback Handling
Stripe Webhook Handler
func (c *WebhookController) StripeWebhook(w http.ResponseWriter, r *http.Request) {
// 1. Validate Stripe signature
body, _ := ioutil.ReadAll(r.Body)
valid := stripe.ValidateWebhookSignature(body, r.Header.Get("Stripe-Signature"))
if !valid {
w.WriteHeader(http.StatusUnauthorized)
return
}
// 2. Parse event
event := stripe.Event{}
json.Unmarshal(body, &event)
// 3. Handle event type
switch event.Type {
case "payment_intent.succeeded":
// Update transaction status
case "charge.refunded":
// Handle refund
}
}
Click Callback Handler
func (c *WebhookController) ClickCallback(w http.ResponseWriter, r *http.Request) {
// 1. Parse Click callback
callback := &ClickCallback{}
json.NewDecoder(r.Body).Decode(callback)
// 2. Validate signature
if !validateClickSignature(callback) {
return clickError(403)
}
// 3. Update transaction
if callback.Action == "confirm" {
transaction, _ := c.service.GetByDetailsFields(
r.Context(),
billing.GatewayClick,
[]billing.DetailsFieldFilter{
{Field: "click.merchant_prepare_id", Value: callback.PrepareID},
},
)
c.service.Save(r.Context(), transaction.SetStatus(billing.Completed))
}
}
Error Handling
All errors use serrors package:
const op serrors.Op = "BillingService.Create"
if err != nil {
return nil, serrors.E(op, err)
}
Error types:
KindValidation: Invalid transaction dataKindNotFound: Transaction not foundKindPermission: Unauthorized accessKindConflict: Invalid state transitionKindExternal: Provider API errors
Testing Strategy
Service Tests
- Happy path: Create, refund, cancel
- Provider errors: Simulate provider failures
- Transaction states: Verify state transitions
- Event publishing: Verify events published
Repository Tests
- CRUD operations
- Tenant isolation
- Details field filtering
- Transaction status queries
Provider Tests
- Mock provider responses
- Error scenarios
- Idempotency checks
- Detail data transformation