Compare commits
No commits in common. "5937c5505a95ce28d55d4346cc6944a5c8a55993" and "de8618191c26161b9f78ce9927533718dfc5e2fc" have entirely different histories.
5937c5505a
...
de8618191c
21
cmd/main.go
21
cmd/main.go
|
|
@ -14,12 +14,10 @@ import (
|
||||||
"Yimaru-Backend/internal/repository"
|
"Yimaru-Backend/internal/repository"
|
||||||
activitylogservice "Yimaru-Backend/internal/services/activity_log"
|
activitylogservice "Yimaru-Backend/internal/services/activity_log"
|
||||||
"Yimaru-Backend/internal/services/arifpay"
|
"Yimaru-Backend/internal/services/arifpay"
|
||||||
"Yimaru-Backend/internal/services/chapa"
|
|
||||||
"Yimaru-Backend/internal/services/assessment"
|
"Yimaru-Backend/internal/services/assessment"
|
||||||
"Yimaru-Backend/internal/services/authentication"
|
"Yimaru-Backend/internal/services/authentication"
|
||||||
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
|
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
|
||||||
coursesservice "Yimaru-Backend/internal/services/courses"
|
coursesservice "Yimaru-Backend/internal/services/courses"
|
||||||
"Yimaru-Backend/internal/services/emailtemplates"
|
|
||||||
"Yimaru-Backend/internal/services/examprep"
|
"Yimaru-Backend/internal/services/examprep"
|
||||||
"Yimaru-Backend/internal/services/faqs"
|
"Yimaru-Backend/internal/services/faqs"
|
||||||
issuereporting "Yimaru-Backend/internal/services/issue_reporting"
|
issuereporting "Yimaru-Backend/internal/services/issue_reporting"
|
||||||
|
|
@ -108,14 +106,16 @@ func main() {
|
||||||
settingSvc := settings.NewService(settingRepo)
|
settingSvc := settings.NewService(settingRepo)
|
||||||
|
|
||||||
messengerSvc := messenger.NewService(settingSvc, cfg)
|
messengerSvc := messenger.NewService(settingSvc, cfg)
|
||||||
emailTemplateSvc := emailtemplates.NewService(repository.NewEmailTemplateStore(store))
|
// statSvc := stats.NewService(
|
||||||
|
// repository.NewCompanyStatStore(store),
|
||||||
|
// repository.NewBranchStatStore(store),
|
||||||
|
// )
|
||||||
|
|
||||||
userSvc := user.NewService(
|
userSvc := user.NewService(
|
||||||
repository.NewTokenStore(store),
|
repository.NewTokenStore(store),
|
||||||
repository.NewUserStore(store),
|
repository.NewUserStore(store),
|
||||||
repository.NewOTPStore(store),
|
repository.NewOTPStore(store),
|
||||||
messengerSvc,
|
messengerSvc,
|
||||||
emailTemplateSvc,
|
|
||||||
cfg,
|
cfg,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -419,7 +419,7 @@ func main() {
|
||||||
// Subscriptions service
|
// Subscriptions service
|
||||||
subscriptionsSvc := subscriptions.NewService(store)
|
subscriptionsSvc := subscriptions.NewService(store)
|
||||||
|
|
||||||
// ArifPay service (direct/legacy payment flows)
|
// ArifPay service with payment and subscription stores
|
||||||
arifpaySvc := arifpay.NewArifpayService(
|
arifpaySvc := arifpay.NewArifpayService(
|
||||||
cfg,
|
cfg,
|
||||||
&http.Client{Timeout: 30 * time.Second},
|
&http.Client{Timeout: 30 * time.Second},
|
||||||
|
|
@ -427,15 +427,6 @@ func main() {
|
||||||
store, // implements SubscriptionStore
|
store, // implements SubscriptionStore
|
||||||
)
|
)
|
||||||
|
|
||||||
// Chapa service for subscription checkout payments
|
|
||||||
chapaSvc := chapa.NewService(
|
|
||||||
cfg,
|
|
||||||
&http.Client{Timeout: 30 * time.Second},
|
|
||||||
store,
|
|
||||||
store,
|
|
||||||
store,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Team management service
|
// Team management service
|
||||||
teamSvc := team.NewService(repository.NewTeamStore(store), cfg.RefreshExpiry)
|
teamSvc := team.NewService(repository.NewTeamStore(store), cfg.RefreshExpiry)
|
||||||
|
|
||||||
|
|
@ -467,7 +458,6 @@ func main() {
|
||||||
assessmentSvc,
|
assessmentSvc,
|
||||||
questionsSvc,
|
questionsSvc,
|
||||||
faqSvc,
|
faqSvc,
|
||||||
emailTemplateSvc,
|
|
||||||
personasSvc,
|
personasSvc,
|
||||||
examPrepSvc,
|
examPrepSvc,
|
||||||
programSvc,
|
programSvc,
|
||||||
|
|
@ -478,7 +468,6 @@ func main() {
|
||||||
practiceSvc,
|
practiceSvc,
|
||||||
subscriptionsSvc,
|
subscriptionsSvc,
|
||||||
arifpaySvc,
|
arifpaySvc,
|
||||||
chapaSvc,
|
|
||||||
issueReportingSvc,
|
issueReportingSvc,
|
||||||
vimeoSvc,
|
vimeoSvc,
|
||||||
teamSvc,
|
teamSvc,
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
DROP TABLE IF EXISTS email_templates;
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
CREATE TABLE IF NOT EXISTS email_templates (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
slug VARCHAR(100) NOT NULL UNIQUE,
|
|
||||||
name VARCHAR(200) NOT NULL,
|
|
||||||
subject TEXT NOT NULL,
|
|
||||||
body_text TEXT NOT NULL,
|
|
||||||
body_html TEXT NOT NULL,
|
|
||||||
variables JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
||||||
is_system BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' CHECK (status IN ('ACTIVE', 'INACTIVE')),
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_email_templates_status ON email_templates(status);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_email_templates_slug ON email_templates(slug);
|
|
||||||
|
|
||||||
INSERT INTO email_templates (slug, name, subject, body_text, body_html, variables, is_system, status)
|
|
||||||
VALUES
|
|
||||||
(
|
|
||||||
'otp',
|
|
||||||
'One-Time Password',
|
|
||||||
'Yimaru - One Time Password',
|
|
||||||
'Welcome to Yimaru Online Learning Platform{{if .FirstName}}, {{.FirstName}}{{end}}. Your OTP is {{.OTP}}. It expires in {{.ExpiresMinutes}} minutes. Please do not share it with anyone.',
|
|
||||||
'<p>Welcome to Yimaru Online Learning Platform{{if .FirstName}}, <strong>{{.FirstName}}</strong>{{end}}.</p><p>Your one-time password is <strong>{{.OTP}}</strong>.</p><p>It expires in {{.ExpiresMinutes}} minutes. Please do not share it with anyone.</p>',
|
|
||||||
'["OTP", "FirstName", "ExpiresMinutes"]'::jsonb,
|
|
||||||
TRUE,
|
|
||||||
'ACTIVE'
|
|
||||||
),
|
|
||||||
(
|
|
||||||
'invitation',
|
|
||||||
'User Invitation',
|
|
||||||
'You are invited to join Yimaru',
|
|
||||||
'Hi{{if .FirstName}} {{.FirstName}}{{end}}, you have been invited{{if .InviterName}} by {{.InviterName}}{{end}} to join Yimaru Online Learning Platform. Accept your invitation: {{.InviteLink}}',
|
|
||||||
'<p>Hi{{if .FirstName}} <strong>{{.FirstName}}</strong>{{end}},</p><p>You have been invited{{if .InviterName}} by <strong>{{.InviterName}}</strong>{{end}} to join Yimaru Online Learning Platform.</p><p><a href="{{.InviteLink}}">Accept your invitation</a></p>',
|
|
||||||
'["FirstName", "InviterName", "InviteLink"]'::jsonb,
|
|
||||||
TRUE,
|
|
||||||
'ACTIVE'
|
|
||||||
),
|
|
||||||
(
|
|
||||||
'password_reset',
|
|
||||||
'Password Reset',
|
|
||||||
'Reset your Yimaru password',
|
|
||||||
'Hi{{if .FirstName}} {{.FirstName}}{{end}}, use this link to reset your password: {{.ResetLink}}. The link expires in {{.ExpiresMinutes}} minutes.',
|
|
||||||
'<p>Hi{{if .FirstName}} <strong>{{.FirstName}}</strong>{{end}},</p><p>Use the link below to reset your password. It expires in {{.ExpiresMinutes}} minutes.</p><p><a href="{{.ResetLink}}">Reset your password</a></p>',
|
|
||||||
'["FirstName", "ResetLink", "ExpiresMinutes"]'::jsonb,
|
|
||||||
TRUE,
|
|
||||||
'ACTIVE'
|
|
||||||
),
|
|
||||||
(
|
|
||||||
'welcome',
|
|
||||||
'Welcome Email',
|
|
||||||
'Welcome to Yimaru',
|
|
||||||
'Hi{{if .FirstName}} {{.FirstName}}{{end}}, welcome to Yimaru Online Learning Platform! Sign in at {{.LoginURL}} to get started.',
|
|
||||||
'<p>Hi{{if .FirstName}} <strong>{{.FirstName}}</strong>{{end}},</p><p>Welcome to Yimaru Online Learning Platform!</p><p><a href="{{.LoginURL}}">Sign in to get started</a></p>',
|
|
||||||
'["FirstName", "LoginURL"]'::jsonb,
|
|
||||||
TRUE,
|
|
||||||
'ACTIVE'
|
|
||||||
),
|
|
||||||
(
|
|
||||||
'custom_message',
|
|
||||||
'Custom Message',
|
|
||||||
'{{.Subject}}',
|
|
||||||
'{{.Message}}',
|
|
||||||
'<p>{{.Message}}</p>',
|
|
||||||
'["Subject", "Message"]'::jsonb,
|
|
||||||
TRUE,
|
|
||||||
'ACTIVE'
|
|
||||||
)
|
|
||||||
ON CONFLICT (slug) DO NOTHING;
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
# Chapa Payment Gateway Integration
|
|
||||||
|
|
||||||
Subscription payments for learners use [Chapa](https://developer.chapa.co/docs) hosted checkout, following the same payment-first flow as the previous ArifPay integration.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
- Subscriptions are created only after Chapa confirms payment (webhook and/or verify).
|
|
||||||
- `tx_ref` is stored as the payment `nonce` and returned as `session_id` in API responses.
|
|
||||||
- ArifPay direct-payment routes remain available for legacy flows; subscription checkout uses Chapa.
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
```env
|
|
||||||
CHAPA_SECRET_KEY=CHASECK_TEST-xxxxxxxx
|
|
||||||
CHAPA_PUBLIC_KEY=CHAPUBK_TEST-xxxxxxxx
|
|
||||||
CHAPA_WEBHOOK_SECRET=your_webhook_secret_from_dashboard
|
|
||||||
CHAPA_BASE_URL=https://api.chapa.co/v1
|
|
||||||
CHAPA_CALLBACK_URL=https://your-api.example.com/api/v1/payments/chapa/callback
|
|
||||||
CHAPA_RETURN_URL=https://your-app.example.com/payment/success
|
|
||||||
CHAPA_RECEIPT_URL=
|
|
||||||
```
|
|
||||||
|
|
||||||
Configure the same webhook URL in the Chapa dashboard:
|
|
||||||
|
|
||||||
`https://your-api.example.com/api/v1/payments/webhook`
|
|
||||||
|
|
||||||
## Payment Flow
|
|
||||||
|
|
||||||
1. Learner calls `POST /api/v1/subscriptions/checkout` or `POST /api/v1/payments/subscribe`.
|
|
||||||
2. Backend creates a pending payment and calls Chapa `POST /transaction/initialize`.
|
|
||||||
3. Client redirects the user to `payment_url` (`checkout_url` from Chapa).
|
|
||||||
4. After payment, Chapa calls `callback_url` and sends a webhook.
|
|
||||||
5. Backend verifies via `GET /transaction/verify/{tx_ref}` and activates the subscription.
|
|
||||||
6. Client may poll `GET /api/v1/payments/verify/{tx_ref}` (`session_id` path param is the `tx_ref`).
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
| Method | Path | Auth | Description |
|
|
||||||
|--------|------|------|-------------|
|
|
||||||
| POST | `/api/v1/subscriptions/checkout` | Yes | Initiate subscription payment |
|
|
||||||
| POST | `/api/v1/payments/subscribe` | Yes | Same as checkout |
|
|
||||||
| GET | `/api/v1/payments/verify/:session_id` | Yes | Verify by `tx_ref` |
|
|
||||||
| POST | `/api/v1/payments/webhook` | No | Chapa webhook (HMAC signature required) |
|
|
||||||
| GET | `/api/v1/payments/chapa/callback` | No | Chapa redirect callback |
|
|
||||||
| GET | `/api/v1/payments/methods` | No | Supported Chapa methods |
|
|
||||||
|
|
||||||
### Initiate payment request
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"plan_id": 1,
|
|
||||||
"phone": "0912345678",
|
|
||||||
"email": "learner@example.com"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Initiate payment response
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"message": "Payment initiated. Complete payment to activate subscription.",
|
|
||||||
"data": {
|
|
||||||
"payment_id": 42,
|
|
||||||
"session_id": "550e8400-e29b-41d4-a716-446655440000",
|
|
||||||
"payment_url": "https://checkout.chapa.co/checkout/payment/...",
|
|
||||||
"amount": 500,
|
|
||||||
"currency": "ETB",
|
|
||||||
"expires_at": "2026-05-21T18:00:00Z"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Webhook Security
|
|
||||||
|
|
||||||
Chapa signs the raw JSON body with HMAC-SHA256 using your webhook secret. The handler checks `x-chapa-signature` or `chapa-signature` before processing.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Use Chapa test keys and [test credentials](https://developer.chapa.co/test/testing-mobile). After checkout, confirm the subscription via verify endpoint or webhook logs.
|
|
||||||
|
|
||||||
### Postman
|
|
||||||
|
|
||||||
Import `postman/Chapa-Subscription-Payments.postman_collection.json`. Set collection variables (`base_url`, learner credentials, `chapa_webhook_secret`), then run folders **00 → 02** in order.
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
package domain
|
|
||||||
|
|
||||||
// ChapaInitializeRequest is sent to POST /transaction/initialize.
|
|
||||||
type ChapaInitializeRequest struct {
|
|
||||||
Amount string `json:"amount"`
|
|
||||||
Currency string `json:"currency"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
FirstName string `json:"first_name"`
|
|
||||||
LastName string `json:"last_name"`
|
|
||||||
PhoneNumber string `json:"phone_number,omitempty"`
|
|
||||||
TxRef string `json:"tx_ref"`
|
|
||||||
CallbackURL string `json:"callback_url,omitempty"`
|
|
||||||
ReturnURL string `json:"return_url,omitempty"`
|
|
||||||
Customization struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
} `json:"customization,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ChapaInitializeResponse struct {
|
|
||||||
Status string `json:"status"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Data struct {
|
|
||||||
CheckoutURL string `json:"checkout_url"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ChapaVerifyResponse struct {
|
|
||||||
Status string `json:"status"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Data ChapaTransactionData `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ChapaTransactionData struct {
|
|
||||||
TxRef string `json:"tx_ref"`
|
|
||||||
Reference string `json:"reference"`
|
|
||||||
Amount string `json:"amount"`
|
|
||||||
Currency string `json:"currency"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
PaymentMethod string `json:"payment_method"`
|
|
||||||
Mode string `json:"mode"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChapaWebhookPayload is the body POSTed to the webhook URL.
|
|
||||||
type ChapaWebhookPayload struct {
|
|
||||||
Event string `json:"event"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
TxRef string `json:"tx_ref"`
|
|
||||||
Reference string `json:"reference"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Amount string `json:"amount"`
|
|
||||||
Currency string `json:"currency"`
|
|
||||||
PaymentMethod string `json:"payment_method"`
|
|
||||||
Mode string `json:"mode"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChapaCallbackQuery is sent to callback_url after payment (GET).
|
|
||||||
type ChapaCallbackQuery struct {
|
|
||||||
TrxRef string `json:"trx_ref"`
|
|
||||||
RefID string `json:"ref_id"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ChapaPaymentMethod struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
DisplayName string `json:"display_name"`
|
|
||||||
}
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
package domain
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
const (
|
|
||||||
EmailTemplateStatusActive = "ACTIVE"
|
|
||||||
EmailTemplateStatusInactive = "INACTIVE"
|
|
||||||
|
|
||||||
EmailTemplateSlugOTP = "otp"
|
|
||||||
EmailTemplateSlugInvitation = "invitation"
|
|
||||||
EmailTemplateSlugPasswordReset = "password_reset"
|
|
||||||
EmailTemplateSlugWelcome = "welcome"
|
|
||||||
EmailTemplateSlugCustomMessage = "custom_message"
|
|
||||||
)
|
|
||||||
|
|
||||||
type EmailTemplate struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Slug string `json:"slug"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Subject string `json:"subject"`
|
|
||||||
BodyText string `json:"body_text"`
|
|
||||||
BodyHTML string `json:"body_html"`
|
|
||||||
Variables []string `json:"variables"`
|
|
||||||
IsSystem bool `json:"is_system"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type RenderedEmail struct {
|
|
||||||
Subject string `json:"subject"`
|
|
||||||
Text string `json:"text"`
|
|
||||||
HTML string `json:"html"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CreateEmailTemplateInput struct {
|
|
||||||
Slug string
|
|
||||||
Name string
|
|
||||||
Subject string
|
|
||||||
BodyText string
|
|
||||||
BodyHTML string
|
|
||||||
Variables []string
|
|
||||||
Status *string
|
|
||||||
}
|
|
||||||
|
|
||||||
type UpdateEmailTemplateInput struct {
|
|
||||||
Name *string
|
|
||||||
Subject *string
|
|
||||||
BodyText *string
|
|
||||||
BodyHTML *string
|
|
||||||
Variables []string
|
|
||||||
Status *string
|
|
||||||
}
|
|
||||||
|
|
||||||
type PreviewEmailTemplateInput struct {
|
|
||||||
Slug string
|
|
||||||
Variables map[string]any
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
package ports
|
|
||||||
|
|
||||||
import (
|
|
||||||
"Yimaru-Backend/internal/domain"
|
|
||||||
"context"
|
|
||||||
)
|
|
||||||
|
|
||||||
type EmailTemplateStore interface {
|
|
||||||
CreateEmailTemplate(ctx context.Context, input domain.CreateEmailTemplateInput) (domain.EmailTemplate, error)
|
|
||||||
UpdateEmailTemplate(ctx context.Context, id int64, input domain.UpdateEmailTemplateInput) (domain.EmailTemplate, error)
|
|
||||||
GetEmailTemplateByID(ctx context.Context, id int64, includeInactive bool) (domain.EmailTemplate, error)
|
|
||||||
GetEmailTemplateBySlug(ctx context.Context, slug string, includeInactive bool) (domain.EmailTemplate, error)
|
|
||||||
ListEmailTemplates(ctx context.Context, status *string, query *string, limit int32, offset int32) ([]domain.EmailTemplate, int64, error)
|
|
||||||
DeleteEmailTemplate(ctx context.Context, id int64) error
|
|
||||||
}
|
|
||||||
|
|
@ -1,230 +0,0 @@
|
||||||
package repository
|
|
||||||
|
|
||||||
import (
|
|
||||||
"Yimaru-Backend/internal/domain"
|
|
||||||
"Yimaru-Backend/internal/ports"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
|
||||||
)
|
|
||||||
|
|
||||||
func NewEmailTemplateStore(s *Store) ports.EmailTemplateStore { return s }
|
|
||||||
|
|
||||||
func emailTemplateToDomain(
|
|
||||||
id int64,
|
|
||||||
slug string,
|
|
||||||
name string,
|
|
||||||
subject string,
|
|
||||||
bodyText string,
|
|
||||||
bodyHTML string,
|
|
||||||
variables []byte,
|
|
||||||
isSystem bool,
|
|
||||||
status string,
|
|
||||||
createdAt pgtype.Timestamptz,
|
|
||||||
updatedAt pgtype.Timestamptz,
|
|
||||||
) (domain.EmailTemplate, error) {
|
|
||||||
var vars []string
|
|
||||||
if len(variables) > 0 {
|
|
||||||
if err := json.Unmarshal(variables, &vars); err != nil {
|
|
||||||
return domain.EmailTemplate{}, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if vars == nil {
|
|
||||||
vars = []string{}
|
|
||||||
}
|
|
||||||
|
|
||||||
return domain.EmailTemplate{
|
|
||||||
ID: id,
|
|
||||||
Slug: slug,
|
|
||||||
Name: name,
|
|
||||||
Subject: subject,
|
|
||||||
BodyText: bodyText,
|
|
||||||
BodyHTML: bodyHTML,
|
|
||||||
Variables: vars,
|
|
||||||
IsSystem: isSystem,
|
|
||||||
Status: status,
|
|
||||||
CreatedAt: createdAt.Time,
|
|
||||||
UpdatedAt: timePtr(updatedAt),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func marshalEmailTemplateVariables(vars []string) ([]byte, error) {
|
|
||||||
if vars == nil {
|
|
||||||
vars = []string{}
|
|
||||||
}
|
|
||||||
return json.Marshal(vars)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) CreateEmailTemplate(ctx context.Context, input domain.CreateEmailTemplateInput) (domain.EmailTemplate, error) {
|
|
||||||
status := domain.EmailTemplateStatusActive
|
|
||||||
if input.Status != nil {
|
|
||||||
status = *input.Status
|
|
||||||
}
|
|
||||||
|
|
||||||
variablesJSON, err := marshalEmailTemplateVariables(input.Variables)
|
|
||||||
if err != nil {
|
|
||||||
return domain.EmailTemplate{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
row := s.conn.QueryRow(ctx, `
|
|
||||||
INSERT INTO email_templates (slug, name, subject, body_text, body_html, variables, is_system, status)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb, FALSE, $7)
|
|
||||||
RETURNING id, slug, name, subject, body_text, body_html, variables, is_system, status, created_at, updated_at
|
|
||||||
`, input.Slug, input.Name, input.Subject, input.BodyText, input.BodyHTML, variablesJSON, status)
|
|
||||||
|
|
||||||
return scanEmailTemplateRow(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) UpdateEmailTemplate(ctx context.Context, id int64, input domain.UpdateEmailTemplateInput) (domain.EmailTemplate, error) {
|
|
||||||
variablesSet := input.Variables != nil
|
|
||||||
variablesJSON, err := marshalEmailTemplateVariables(input.Variables)
|
|
||||||
if err != nil {
|
|
||||||
return domain.EmailTemplate{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
row := s.conn.QueryRow(ctx, `
|
|
||||||
UPDATE email_templates
|
|
||||||
SET name = COALESCE($2, name),
|
|
||||||
subject = COALESCE($3, subject),
|
|
||||||
body_text = COALESCE($4, body_text),
|
|
||||||
body_html = COALESCE($5, body_html),
|
|
||||||
variables = CASE WHEN $6::boolean THEN $7::jsonb ELSE variables END,
|
|
||||||
status = COALESCE($8, status),
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE id = $1
|
|
||||||
RETURNING id, slug, name, subject, body_text, body_html, variables, is_system, status, created_at, updated_at
|
|
||||||
`, id, input.Name, input.Subject, input.BodyText, input.BodyHTML, variablesSet, variablesJSON, input.Status)
|
|
||||||
|
|
||||||
return scanEmailTemplateRow(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) GetEmailTemplateByID(ctx context.Context, id int64, includeInactive bool) (domain.EmailTemplate, error) {
|
|
||||||
row := s.conn.QueryRow(ctx, `
|
|
||||||
SELECT id, slug, name, subject, body_text, body_html, variables, is_system, status, created_at, updated_at
|
|
||||||
FROM email_templates
|
|
||||||
WHERE id = $1
|
|
||||||
AND ($2::boolean = TRUE OR status = 'ACTIVE')
|
|
||||||
`, id, includeInactive)
|
|
||||||
|
|
||||||
return scanEmailTemplateRow(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) GetEmailTemplateBySlug(ctx context.Context, slug string, includeInactive bool) (domain.EmailTemplate, error) {
|
|
||||||
row := s.conn.QueryRow(ctx, `
|
|
||||||
SELECT id, slug, name, subject, body_text, body_html, variables, is_system, status, created_at, updated_at
|
|
||||||
FROM email_templates
|
|
||||||
WHERE slug = $1
|
|
||||||
AND ($2::boolean = TRUE OR status = 'ACTIVE')
|
|
||||||
`, slug, includeInactive)
|
|
||||||
|
|
||||||
return scanEmailTemplateRow(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) ListEmailTemplates(ctx context.Context, status *string, query *string, limit int32, offset int32) ([]domain.EmailTemplate, int64, error) {
|
|
||||||
rows, err := s.conn.Query(ctx, `
|
|
||||||
SELECT id, slug, name, subject, body_text, body_html, variables, is_system, status, created_at, updated_at
|
|
||||||
FROM email_templates
|
|
||||||
WHERE ($1::text IS NULL OR status = $1)
|
|
||||||
AND (
|
|
||||||
$2::text IS NULL
|
|
||||||
OR slug ILIKE '%' || $2 || '%'
|
|
||||||
OR name ILIKE '%' || $2 || '%'
|
|
||||||
OR subject ILIKE '%' || $2 || '%'
|
|
||||||
)
|
|
||||||
ORDER BY is_system DESC, name ASC, id ASC
|
|
||||||
LIMIT $3 OFFSET $4
|
|
||||||
`, toPgText(status), toPgText(query), limit, offset)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
templates := make([]domain.EmailTemplate, 0)
|
|
||||||
for rows.Next() {
|
|
||||||
tmpl, err := scanEmailTemplateRows(rows)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
templates = append(templates, tmpl)
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var totalCount int64
|
|
||||||
if err := s.conn.QueryRow(ctx, `
|
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM email_templates
|
|
||||||
WHERE ($1::text IS NULL OR status = $1)
|
|
||||||
AND (
|
|
||||||
$2::text IS NULL
|
|
||||||
OR slug ILIKE '%' || $2 || '%'
|
|
||||||
OR name ILIKE '%' || $2 || '%'
|
|
||||||
OR subject ILIKE '%' || $2 || '%'
|
|
||||||
)
|
|
||||||
`, toPgText(status), toPgText(query)).Scan(&totalCount); err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return templates, totalCount, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) DeleteEmailTemplate(ctx context.Context, id int64) error {
|
|
||||||
cmd, err := s.conn.Exec(ctx, `
|
|
||||||
DELETE FROM email_templates
|
|
||||||
WHERE id = $1 AND is_system = FALSE
|
|
||||||
`, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if cmd.RowsAffected() == 0 {
|
|
||||||
return pgx.ErrNoRows
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type emailTemplateScanner interface {
|
|
||||||
Scan(dest ...any) error
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanEmailTemplateRow(row emailTemplateScanner) (domain.EmailTemplate, error) {
|
|
||||||
var (
|
|
||||||
id int64
|
|
||||||
slug string
|
|
||||||
name string
|
|
||||||
subject string
|
|
||||||
bodyText string
|
|
||||||
bodyHTML string
|
|
||||||
variables []byte
|
|
||||||
isSystem bool
|
|
||||||
status string
|
|
||||||
createdAt pgtype.Timestamptz
|
|
||||||
updatedAt pgtype.Timestamptz
|
|
||||||
)
|
|
||||||
if err := row.Scan(&id, &slug, &name, &subject, &bodyText, &bodyHTML, &variables, &isSystem, &status, &createdAt, &updatedAt); err != nil {
|
|
||||||
return domain.EmailTemplate{}, err
|
|
||||||
}
|
|
||||||
return emailTemplateToDomain(id, slug, name, subject, bodyText, bodyHTML, variables, isSystem, status, createdAt, updatedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanEmailTemplateRows(rows pgx.Rows) (domain.EmailTemplate, error) {
|
|
||||||
var (
|
|
||||||
id int64
|
|
||||||
slug string
|
|
||||||
name string
|
|
||||||
subject string
|
|
||||||
bodyText string
|
|
||||||
bodyHTML string
|
|
||||||
variables []byte
|
|
||||||
isSystem bool
|
|
||||||
status string
|
|
||||||
createdAt pgtype.Timestamptz
|
|
||||||
updatedAt pgtype.Timestamptz
|
|
||||||
)
|
|
||||||
if err := rows.Scan(&id, &slug, &name, &subject, &bodyText, &bodyHTML, &variables, &isSystem, &status, &createdAt, &updatedAt); err != nil {
|
|
||||||
return domain.EmailTemplate{}, err
|
|
||||||
}
|
|
||||||
return emailTemplateToDomain(id, slug, name, subject, bodyText, bodyHTML, variables, isSystem, status, createdAt, updatedAt)
|
|
||||||
}
|
|
||||||
|
|
@ -1,472 +0,0 @@
|
||||||
package chapa
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"crypto/hmac"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"math"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"Yimaru-Backend/internal/config"
|
|
||||||
"Yimaru-Backend/internal/domain"
|
|
||||||
"Yimaru-Backend/internal/ports"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrPaymentNotFound = errors.New("payment not found")
|
|
||||||
ErrPaymentAlreadyPaid = errors.New("payment already processed")
|
|
||||||
ErrInvalidPaymentState = errors.New("invalid payment state")
|
|
||||||
ErrInvalidWebhook = errors.New("invalid webhook signature")
|
|
||||||
ErrChapaNotConfigured = errors.New("chapa is not configured")
|
|
||||||
)
|
|
||||||
|
|
||||||
type Service struct {
|
|
||||||
cfg *config.Config
|
|
||||||
httpClient *http.Client
|
|
||||||
paymentStore ports.PaymentStore
|
|
||||||
subscriptionStore ports.SubscriptionStore
|
|
||||||
userStore ports.UserStore
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewService(
|
|
||||||
cfg *config.Config,
|
|
||||||
httpClient *http.Client,
|
|
||||||
paymentStore ports.PaymentStore,
|
|
||||||
subscriptionStore ports.SubscriptionStore,
|
|
||||||
userStore ports.UserStore,
|
|
||||||
) *Service {
|
|
||||||
return &Service{
|
|
||||||
cfg: cfg,
|
|
||||||
httpClient: httpClient,
|
|
||||||
paymentStore: paymentStore,
|
|
||||||
subscriptionStore: subscriptionStore,
|
|
||||||
userStore: userStore,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) configured() error {
|
|
||||||
if s.cfg.CHAPA_SECRET_KEY == "" {
|
|
||||||
return ErrChapaNotConfigured
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// InitiateSubscriptionPayment creates a Chapa checkout session for a subscription plan.
|
|
||||||
func (s *Service) InitiateSubscriptionPayment(ctx context.Context, userID int64, req domain.InitiateSubscriptionPaymentRequest) (*domain.InitiateSubscriptionPaymentResponse, error) {
|
|
||||||
if err := s.configured(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
plan, err := s.subscriptionStore.GetSubscriptionPlanByID(ctx, req.PlanID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get subscription plan: %w", err)
|
|
||||||
}
|
|
||||||
if !plan.IsActive {
|
|
||||||
return nil, errors.New("subscription plan is not active")
|
|
||||||
}
|
|
||||||
|
|
||||||
hasActive, err := s.subscriptionStore.HasActiveSubscription(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to check active subscription: %w", err)
|
|
||||||
}
|
|
||||||
if hasActive {
|
|
||||||
return nil, errors.New("user already has an active subscription")
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := s.userStore.GetUserByID(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get user: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
firstName := strings.TrimSpace(user.FirstName)
|
|
||||||
lastName := strings.TrimSpace(user.LastName)
|
|
||||||
if firstName == "" {
|
|
||||||
firstName = "Customer"
|
|
||||||
}
|
|
||||||
if lastName == "" {
|
|
||||||
lastName = "User"
|
|
||||||
}
|
|
||||||
|
|
||||||
email := strings.TrimSpace(req.Email)
|
|
||||||
if email == "" {
|
|
||||||
email = user.Email
|
|
||||||
}
|
|
||||||
if email == "" {
|
|
||||||
return nil, errors.New("email is required for payment")
|
|
||||||
}
|
|
||||||
|
|
||||||
phone := formatChapaPhone(req.Phone)
|
|
||||||
if phone == "" && user.PhoneNumber != "" {
|
|
||||||
phone = formatChapaPhone(user.PhoneNumber)
|
|
||||||
}
|
|
||||||
|
|
||||||
txRef := uuid.NewString()
|
|
||||||
expiresAt := time.Now().Add(3 * time.Hour)
|
|
||||||
|
|
||||||
payment, err := s.paymentStore.CreatePayment(ctx, domain.CreatePaymentInput{
|
|
||||||
UserID: userID,
|
|
||||||
PlanID: &req.PlanID,
|
|
||||||
Amount: plan.Price,
|
|
||||||
Currency: plan.Currency,
|
|
||||||
Nonce: txRef,
|
|
||||||
ExpiresAt: &expiresAt,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create payment record: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
initReq := domain.ChapaInitializeRequest{
|
|
||||||
Amount: formatAmount(plan.Price),
|
|
||||||
Currency: normalizeCurrency(plan.Currency),
|
|
||||||
Email: email,
|
|
||||||
FirstName: firstName,
|
|
||||||
LastName: lastName,
|
|
||||||
PhoneNumber: phone,
|
|
||||||
TxRef: txRef,
|
|
||||||
CallbackURL: s.cfg.CHAPA_CALLBACK_URL,
|
|
||||||
ReturnURL: s.cfg.CHAPA_RETURN_URL,
|
|
||||||
}
|
|
||||||
initReq.Customization.Title = "Yimaru LMS"
|
|
||||||
initReq.Customization.Description = fmt.Sprintf("Subscription: %s", plan.Name)
|
|
||||||
|
|
||||||
checkoutURL, err := s.initializeTransaction(ctx, initReq)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.paymentStore.UpdatePaymentSessionID(ctx, payment.ID, txRef, checkoutURL); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to update payment session: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &domain.InitiateSubscriptionPaymentResponse{
|
|
||||||
PaymentID: payment.ID,
|
|
||||||
SessionID: txRef,
|
|
||||||
PaymentURL: checkoutURL,
|
|
||||||
Amount: plan.Price,
|
|
||||||
Currency: plan.Currency,
|
|
||||||
ExpiresAt: expiresAt.Format(time.RFC3339),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) initializeTransaction(ctx context.Context, req domain.ChapaInitializeRequest) (string, error) {
|
|
||||||
payload, err := json.Marshal(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to marshal initialize request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
url := strings.TrimSuffix(s.cfg.CHAPA_BASE_URL, "/") + "/transaction/initialize"
|
|
||||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(payload))
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
httpReq.Header.Set("Content-Type", "application/json")
|
|
||||||
httpReq.Header.Set("Authorization", "Bearer "+s.cfg.CHAPA_SECRET_KEY)
|
|
||||||
|
|
||||||
resp, err := s.httpClient.Do(httpReq)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to call Chapa API: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return "", fmt.Errorf("Chapa API error (status %d): %s", resp.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
var result domain.ChapaInitializeResponse
|
|
||||||
if err := json.Unmarshal(body, &result); err != nil {
|
|
||||||
return "", fmt.Errorf("invalid response from Chapa: %w", err)
|
|
||||||
}
|
|
||||||
if strings.ToLower(result.Status) != "success" || result.Data.CheckoutURL == "" {
|
|
||||||
return "", fmt.Errorf("Chapa initialize failed: %s", result.Message)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.Data.CheckoutURL, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// VerifyWebhookSignature validates x-chapa-signature or chapa-signature headers.
|
|
||||||
func (s *Service) VerifyWebhookSignature(body []byte, signatures ...string) error {
|
|
||||||
secret := s.cfg.CHAPA_WEBHOOK_SECRET
|
|
||||||
if secret == "" {
|
|
||||||
secret = s.cfg.CHAPA_SECRET_KEY
|
|
||||||
}
|
|
||||||
if secret == "" {
|
|
||||||
return ErrInvalidWebhook
|
|
||||||
}
|
|
||||||
|
|
||||||
mac := hmac.New(sha256.New, []byte(secret))
|
|
||||||
mac.Write(body)
|
|
||||||
expected := hex.EncodeToString(mac.Sum(nil))
|
|
||||||
|
|
||||||
for _, sig := range signatures {
|
|
||||||
sig = strings.TrimSpace(sig)
|
|
||||||
if sig != "" && hmac.Equal([]byte(expected), []byte(sig)) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ErrInvalidWebhook
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProcessPaymentWebhook handles Chapa webhook events (charge.success, etc.).
|
|
||||||
func (s *Service) ProcessPaymentWebhook(ctx context.Context, payload domain.ChapaWebhookPayload) error {
|
|
||||||
if payload.TxRef == "" {
|
|
||||||
return errors.New("tx_ref is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always verify with Chapa before granting subscription access.
|
|
||||||
verifyData, err := s.fetchVerifiedTransaction(ctx, payload.TxRef)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.applyVerifiedTransaction(ctx, verifyData)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProcessCallback handles the redirect callback query and verifies the transaction.
|
|
||||||
func (s *Service) ProcessCallback(ctx context.Context, query domain.ChapaCallbackQuery) error {
|
|
||||||
txRef := query.TrxRef
|
|
||||||
if txRef == "" {
|
|
||||||
return errors.New("trx_ref is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
verifyData, err := s.fetchVerifiedTransaction(ctx, txRef)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.applyVerifiedTransaction(ctx, verifyData)
|
|
||||||
}
|
|
||||||
|
|
||||||
// VerifyPayment checks payment status with Chapa using tx_ref (stored as nonce / session_id).
|
|
||||||
func (s *Service) VerifyPayment(ctx context.Context, txRef string) (*domain.Payment, error) {
|
|
||||||
if err := s.configured(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
payment, err := s.lookupPayment(ctx, txRef)
|
|
||||||
if err != nil {
|
|
||||||
return nil, ErrPaymentNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
if payment.Status == string(domain.PaymentStatusSuccess) ||
|
|
||||||
payment.Status == string(domain.PaymentStatusFailed) {
|
|
||||||
return payment, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
verifyData, err := s.fetchVerifiedTransaction(ctx, payment.Nonce)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.applyVerifiedTransaction(ctx, verifyData); err != nil && !errors.Is(err, ErrPaymentAlreadyPaid) {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.lookupPayment(ctx, txRef)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) fetchVerifiedTransaction(ctx context.Context, txRef string) (domain.ChapaTransactionData, error) {
|
|
||||||
url := strings.TrimSuffix(s.cfg.CHAPA_BASE_URL, "/") + "/transaction/verify/" + txRef
|
|
||||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return domain.ChapaTransactionData{}, err
|
|
||||||
}
|
|
||||||
httpReq.Header.Set("Authorization", "Bearer "+s.cfg.CHAPA_SECRET_KEY)
|
|
||||||
|
|
||||||
resp, err := s.httpClient.Do(httpReq)
|
|
||||||
if err != nil {
|
|
||||||
return domain.ChapaTransactionData{}, fmt.Errorf("failed to verify with Chapa: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return domain.ChapaTransactionData{}, err
|
|
||||||
}
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return domain.ChapaTransactionData{}, fmt.Errorf("Chapa verify API error (status %d): %s", resp.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
var result domain.ChapaVerifyResponse
|
|
||||||
if err := json.Unmarshal(body, &result); err != nil {
|
|
||||||
return domain.ChapaTransactionData{}, fmt.Errorf("failed to parse Chapa verify response: %w", err)
|
|
||||||
}
|
|
||||||
if strings.ToLower(result.Status) != "success" {
|
|
||||||
return domain.ChapaTransactionData{}, fmt.Errorf("Chapa verify failed: %s", result.Message)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.Data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) applyVerifiedTransaction(ctx context.Context, data domain.ChapaTransactionData) error {
|
|
||||||
if data.TxRef == "" {
|
|
||||||
return errors.New("tx_ref missing in verified transaction")
|
|
||||||
}
|
|
||||||
|
|
||||||
payment, err := s.paymentStore.GetPaymentByNonce(ctx, data.TxRef)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("payment not found for tx_ref %s: %w", data.TxRef, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if payment.Status == string(domain.PaymentStatusSuccess) {
|
|
||||||
return ErrPaymentAlreadyPaid
|
|
||||||
}
|
|
||||||
|
|
||||||
newStatus := mapChapaStatus(data.Status)
|
|
||||||
transactionID := data.Reference
|
|
||||||
paymentMethod := data.PaymentMethod
|
|
||||||
if paymentMethod == "" {
|
|
||||||
paymentMethod = "chapa"
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.paymentStore.UpdatePaymentStatusByNonce(
|
|
||||||
ctx,
|
|
||||||
data.TxRef,
|
|
||||||
newStatus,
|
|
||||||
transactionID,
|
|
||||||
paymentMethod,
|
|
||||||
); err != nil {
|
|
||||||
return fmt.Errorf("failed to update payment status: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if newStatus != string(domain.PaymentStatusSuccess) || payment.PlanID == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.activateSubscription(ctx, payment, paymentMethod)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) activateSubscription(ctx context.Context, payment *domain.Payment, paymentMethod string) error {
|
|
||||||
plan, err := s.subscriptionStore.GetSubscriptionPlanByID(ctx, *payment.PlanID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get subscription plan: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
startsAt := time.Now()
|
|
||||||
expiresAt := domain.CalculateExpiryDate(startsAt, plan.DurationValue, plan.DurationUnit)
|
|
||||||
activeStatus := string(domain.SubscriptionStatusActive)
|
|
||||||
autoRenew := false
|
|
||||||
paymentRef := payment.Nonce
|
|
||||||
|
|
||||||
subscription, err := s.subscriptionStore.CreateUserSubscription(ctx, domain.CreateUserSubscriptionInput{
|
|
||||||
UserID: payment.UserID,
|
|
||||||
PlanID: *payment.PlanID,
|
|
||||||
StartsAt: &startsAt,
|
|
||||||
ExpiresAt: expiresAt,
|
|
||||||
Status: &activeStatus,
|
|
||||||
PaymentReference: &paymentRef,
|
|
||||||
PaymentMethod: &paymentMethod,
|
|
||||||
AutoRenew: &autoRenew,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create subscription: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.paymentStore.LinkPaymentToSubscription(ctx, payment.ID, subscription.ID); err != nil {
|
|
||||||
return fmt.Errorf("failed to link payment to subscription: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) lookupPayment(ctx context.Context, ref string) (*domain.Payment, error) {
|
|
||||||
payment, err := s.paymentStore.GetPaymentByNonce(ctx, ref)
|
|
||||||
if err == nil {
|
|
||||||
return payment, nil
|
|
||||||
}
|
|
||||||
return s.paymentStore.GetPaymentBySessionID(ctx, ref)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) GetPaymentsByUser(ctx context.Context, userID int64, limit, offset int32) ([]domain.Payment, error) {
|
|
||||||
return s.paymentStore.GetPaymentsByUserID(ctx, userID, limit, offset)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) GetPaymentByID(ctx context.Context, id int64) (*domain.Payment, error) {
|
|
||||||
return s.paymentStore.GetPaymentByID(ctx, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) CancelPayment(ctx context.Context, paymentID int64, userID int64) error {
|
|
||||||
payment, err := s.paymentStore.GetPaymentByID(ctx, paymentID)
|
|
||||||
if err != nil {
|
|
||||||
return ErrPaymentNotFound
|
|
||||||
}
|
|
||||||
if payment.UserID != userID {
|
|
||||||
return errors.New("unauthorized")
|
|
||||||
}
|
|
||||||
if payment.Status != string(domain.PaymentStatusPending) {
|
|
||||||
return ErrInvalidPaymentState
|
|
||||||
}
|
|
||||||
return s.paymentStore.UpdatePaymentStatus(ctx, paymentID, string(domain.PaymentStatusCancelled))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) GetPaymentMethods() []domain.ChapaPaymentMethod {
|
|
||||||
return []domain.ChapaPaymentMethod{
|
|
||||||
{Name: "telebirr", DisplayName: "Telebirr"},
|
|
||||||
{Name: "cbebirr", DisplayName: "CBE Birr"},
|
|
||||||
{Name: "mpesa", DisplayName: "M-Pesa"},
|
|
||||||
{Name: "ebirr", DisplayName: "E-Birr"},
|
|
||||||
{Name: "amole", DisplayName: "Amole"},
|
|
||||||
{Name: "awashbirr", DisplayName: "Awash Birr"},
|
|
||||||
{Name: "enat_bank", DisplayName: "Enat Bank"},
|
|
||||||
{Name: "card", DisplayName: "Card"},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapChapaStatus(status string) string {
|
|
||||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
|
||||||
case "success", "successful", "completed":
|
|
||||||
return string(domain.PaymentStatusSuccess)
|
|
||||||
case "failed", "failure":
|
|
||||||
return string(domain.PaymentStatusFailed)
|
|
||||||
case "cancelled", "canceled":
|
|
||||||
return string(domain.PaymentStatusCancelled)
|
|
||||||
case "pending", "processing":
|
|
||||||
return string(domain.PaymentStatusProcessing)
|
|
||||||
default:
|
|
||||||
return string(domain.PaymentStatusPending)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatAmount(amount float64) string {
|
|
||||||
return strconv.FormatFloat(math.Round(amount*100)/100, 'f', 2, 64)
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeCurrency(currency string) string {
|
|
||||||
c := strings.TrimSpace(strings.ToUpper(currency))
|
|
||||||
if c == "" {
|
|
||||||
return "ETB"
|
|
||||||
}
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatChapaPhone(phone string) string {
|
|
||||||
phone = strings.TrimSpace(phone)
|
|
||||||
phone = strings.TrimPrefix(phone, "+")
|
|
||||||
if strings.HasPrefix(phone, "251") && len(phone) >= 12 {
|
|
||||||
local := phone[3:]
|
|
||||||
if strings.HasPrefix(local, "9") || strings.HasPrefix(local, "7") {
|
|
||||||
return "0" + local
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(phone, "09") || strings.HasPrefix(phone, "07") {
|
|
||||||
return phone
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(phone, "9") || strings.HasPrefix(phone, "7") {
|
|
||||||
return "0" + phone
|
|
||||||
}
|
|
||||||
return phone
|
|
||||||
}
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
package emailtemplates
|
|
||||||
|
|
||||||
import (
|
|
||||||
"Yimaru-Backend/internal/domain"
|
|
||||||
"bytes"
|
|
||||||
)
|
|
||||||
|
|
||||||
var defaultTemplates = map[string]domain.EmailTemplate{
|
|
||||||
domain.EmailTemplateSlugOTP: {
|
|
||||||
Slug: domain.EmailTemplateSlugOTP,
|
|
||||||
Name: "One-Time Password",
|
|
||||||
Subject: "Yimaru - One Time Password",
|
|
||||||
BodyText: "Welcome to Yimaru Online Learning Platform{{if .FirstName}}, {{.FirstName}}{{end}}. Your OTP is {{.OTP}}. It expires in {{.ExpiresMinutes}} minutes. Please do not share it with anyone.",
|
|
||||||
BodyHTML: "<p>Welcome to Yimaru Online Learning Platform{{if .FirstName}}, <strong>{{.FirstName}}</strong>{{end}}.</p><p>Your one-time password is <strong>{{.OTP}}</strong>.</p><p>It expires in {{.ExpiresMinutes}} minutes. Please do not share it with anyone.</p>",
|
|
||||||
Variables: []string{"OTP", "FirstName", "ExpiresMinutes"},
|
|
||||||
Status: domain.EmailTemplateStatusActive,
|
|
||||||
},
|
|
||||||
domain.EmailTemplateSlugInvitation: {
|
|
||||||
Slug: domain.EmailTemplateSlugInvitation,
|
|
||||||
Name: "User Invitation",
|
|
||||||
Subject: "You are invited to join Yimaru",
|
|
||||||
BodyText: "Hi{{if .FirstName}} {{.FirstName}}{{end}}, you have been invited{{if .InviterName}} by {{.InviterName}}{{end}} to join Yimaru Online Learning Platform. Accept your invitation: {{.InviteLink}}",
|
|
||||||
BodyHTML: "<p>Hi{{if .FirstName}} <strong>{{.FirstName}}</strong>{{end}},</p><p>You have been invited{{if .InviterName}} by <strong>{{.InviterName}}</strong>{{end}} to join Yimaru Online Learning Platform.</p><p><a href=\"{{.InviteLink}}\">Accept your invitation</a></p>",
|
|
||||||
Variables: []string{"FirstName", "InviterName", "InviteLink"},
|
|
||||||
Status: domain.EmailTemplateStatusActive,
|
|
||||||
},
|
|
||||||
domain.EmailTemplateSlugPasswordReset: {
|
|
||||||
Slug: domain.EmailTemplateSlugPasswordReset,
|
|
||||||
Name: "Password Reset",
|
|
||||||
Subject: "Reset your Yimaru password",
|
|
||||||
BodyText: "Hi{{if .FirstName}} {{.FirstName}}{{end}}, use this link to reset your password: {{.ResetLink}}. The link expires in {{.ExpiresMinutes}} minutes.",
|
|
||||||
BodyHTML: "<p>Hi{{if .FirstName}} <strong>{{.FirstName}}</strong>{{end}},</p><p>Use the link below to reset your password. It expires in {{.ExpiresMinutes}} minutes.</p><p><a href=\"{{.ResetLink}}\">Reset your password</a></p>",
|
|
||||||
Variables: []string{"FirstName", "ResetLink", "ExpiresMinutes"},
|
|
||||||
Status: domain.EmailTemplateStatusActive,
|
|
||||||
},
|
|
||||||
domain.EmailTemplateSlugWelcome: {
|
|
||||||
Slug: domain.EmailTemplateSlugWelcome,
|
|
||||||
Name: "Welcome Email",
|
|
||||||
Subject: "Welcome to Yimaru",
|
|
||||||
BodyText: "Hi{{if .FirstName}} {{.FirstName}}{{end}}, welcome to Yimaru Online Learning Platform! Sign in at {{.LoginURL}} to get started.",
|
|
||||||
BodyHTML: "<p>Hi{{if .FirstName}} <strong>{{.FirstName}}</strong>{{end}},</p><p>Welcome to Yimaru Online Learning Platform!</p><p><a href=\"{{.LoginURL}}\">Sign in to get started</a></p>",
|
|
||||||
Variables: []string{"FirstName", "LoginURL"},
|
|
||||||
Status: domain.EmailTemplateStatusActive,
|
|
||||||
},
|
|
||||||
domain.EmailTemplateSlugCustomMessage: {
|
|
||||||
Slug: domain.EmailTemplateSlugCustomMessage,
|
|
||||||
Name: "Custom Message",
|
|
||||||
Subject: "{{.Subject}}",
|
|
||||||
BodyText: "{{.Message}}",
|
|
||||||
BodyHTML: "<p>{{.Message}}</p>",
|
|
||||||
Variables: []string{"Subject", "Message"},
|
|
||||||
Status: domain.EmailTemplateStatusActive,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func defaultTemplate(slug string) (domain.EmailTemplate, bool) {
|
|
||||||
tmpl, ok := defaultTemplates[slug]
|
|
||||||
return tmpl, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderTemplateFields(tmpl domain.EmailTemplate, data map[string]any) (domain.RenderedEmail, error) {
|
|
||||||
if data == nil {
|
|
||||||
data = map[string]any{}
|
|
||||||
}
|
|
||||||
|
|
||||||
subject, err := executeTextTemplate("subject:"+tmpl.Slug, tmpl.Subject, data)
|
|
||||||
if err != nil {
|
|
||||||
return domain.RenderedEmail{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
text, err := executeTextTemplate("text:"+tmpl.Slug, tmpl.BodyText, data)
|
|
||||||
if err != nil {
|
|
||||||
return domain.RenderedEmail{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
html, err := executeHTMLTemplate("html:"+tmpl.Slug, tmpl.BodyHTML, data)
|
|
||||||
if err != nil {
|
|
||||||
return domain.RenderedEmail{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return domain.RenderedEmail{
|
|
||||||
Subject: subject,
|
|
||||||
Text: text,
|
|
||||||
HTML: html,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func executeTextTemplate(name, content string, data map[string]any) (string, error) {
|
|
||||||
tmpl, err := newTextTemplate(name, content)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if err := tmpl.Execute(&buf, data); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return buf.String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func executeHTMLTemplate(name, content string, data map[string]any) (string, error) {
|
|
||||||
tmpl, err := newHTMLTemplate(name, content)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if err := tmpl.Execute(&buf, data); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return buf.String(), nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,318 +0,0 @@
|
||||||
package emailtemplates
|
|
||||||
|
|
||||||
import (
|
|
||||||
"Yimaru-Backend/internal/domain"
|
|
||||||
"Yimaru-Backend/internal/ports"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
errSlugRequired = errors.New("slug is required")
|
|
||||||
errInvalidSlug = errors.New("slug must start with a letter and contain only lowercase letters, numbers, and underscores")
|
|
||||||
errInvalidStatus = fmt.Errorf("status must be one of %s, %s", domain.EmailTemplateStatusActive, domain.EmailTemplateStatusInactive)
|
|
||||||
)
|
|
||||||
|
|
||||||
const cacheTTL = 2 * time.Minute
|
|
||||||
|
|
||||||
type cacheEntry struct {
|
|
||||||
template domain.EmailTemplate
|
|
||||||
expiresAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type Service struct {
|
|
||||||
store ports.EmailTemplateStore
|
|
||||||
|
|
||||||
mu sync.RWMutex
|
|
||||||
cache map[string]cacheEntry
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewService(store ports.EmailTemplateStore) *Service {
|
|
||||||
return &Service{
|
|
||||||
store: store,
|
|
||||||
cache: make(map[string]cacheEntry),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) CreateEmailTemplate(ctx context.Context, input domain.CreateEmailTemplateInput) (domain.EmailTemplate, error) {
|
|
||||||
slug, err := normalizeSlug(input.Slug)
|
|
||||||
if err != nil {
|
|
||||||
return domain.EmailTemplate{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
input.Slug = slug
|
|
||||||
input.Name = strings.TrimSpace(input.Name)
|
|
||||||
input.Subject = strings.TrimSpace(input.Subject)
|
|
||||||
input.BodyText = strings.TrimSpace(input.BodyText)
|
|
||||||
input.BodyHTML = strings.TrimSpace(input.BodyHTML)
|
|
||||||
input.Variables = normalizeVariables(input.Variables)
|
|
||||||
|
|
||||||
if input.Name == "" {
|
|
||||||
return domain.EmailTemplate{}, fmt.Errorf("name is required")
|
|
||||||
}
|
|
||||||
if input.Subject == "" {
|
|
||||||
return domain.EmailTemplate{}, fmt.Errorf("subject is required")
|
|
||||||
}
|
|
||||||
if input.BodyText == "" {
|
|
||||||
return domain.EmailTemplate{}, fmt.Errorf("body_text is required")
|
|
||||||
}
|
|
||||||
if input.BodyHTML == "" {
|
|
||||||
return domain.EmailTemplate{}, fmt.Errorf("body_html is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
status, err := normalizeStatus(input.Status)
|
|
||||||
if err != nil {
|
|
||||||
return domain.EmailTemplate{}, err
|
|
||||||
}
|
|
||||||
input.Status = &status
|
|
||||||
|
|
||||||
if err := s.validateTemplateSyntax(input.Subject, input.BodyText, input.BodyHTML); err != nil {
|
|
||||||
return domain.EmailTemplate{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
tmpl, err := s.store.CreateEmailTemplate(ctx, input)
|
|
||||||
if err != nil {
|
|
||||||
return domain.EmailTemplate{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
s.invalidateCache(tmpl.Slug)
|
|
||||||
return tmpl, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) UpdateEmailTemplate(ctx context.Context, id int64, input domain.UpdateEmailTemplateInput) (domain.EmailTemplate, error) {
|
|
||||||
if id <= 0 {
|
|
||||||
return domain.EmailTemplate{}, fmt.Errorf("invalid email template id")
|
|
||||||
}
|
|
||||||
|
|
||||||
if input.Name != nil {
|
|
||||||
trimmed := strings.TrimSpace(*input.Name)
|
|
||||||
if trimmed == "" {
|
|
||||||
return domain.EmailTemplate{}, fmt.Errorf("name cannot be empty")
|
|
||||||
}
|
|
||||||
input.Name = &trimmed
|
|
||||||
}
|
|
||||||
if input.Subject != nil {
|
|
||||||
trimmed := strings.TrimSpace(*input.Subject)
|
|
||||||
if trimmed == "" {
|
|
||||||
return domain.EmailTemplate{}, fmt.Errorf("subject cannot be empty")
|
|
||||||
}
|
|
||||||
input.Subject = &trimmed
|
|
||||||
}
|
|
||||||
if input.BodyText != nil {
|
|
||||||
trimmed := strings.TrimSpace(*input.BodyText)
|
|
||||||
if trimmed == "" {
|
|
||||||
return domain.EmailTemplate{}, fmt.Errorf("body_text cannot be empty")
|
|
||||||
}
|
|
||||||
input.BodyText = &trimmed
|
|
||||||
}
|
|
||||||
if input.BodyHTML != nil {
|
|
||||||
trimmed := strings.TrimSpace(*input.BodyHTML)
|
|
||||||
if trimmed == "" {
|
|
||||||
return domain.EmailTemplate{}, fmt.Errorf("body_html cannot be empty")
|
|
||||||
}
|
|
||||||
input.BodyHTML = &trimmed
|
|
||||||
}
|
|
||||||
if input.Variables != nil {
|
|
||||||
input.Variables = normalizeVariables(input.Variables)
|
|
||||||
}
|
|
||||||
if input.Status != nil {
|
|
||||||
status, err := normalizeStatus(input.Status)
|
|
||||||
if err != nil {
|
|
||||||
return domain.EmailTemplate{}, err
|
|
||||||
}
|
|
||||||
input.Status = &status
|
|
||||||
}
|
|
||||||
|
|
||||||
current, err := s.store.GetEmailTemplateByID(ctx, id, true)
|
|
||||||
if err != nil {
|
|
||||||
return domain.EmailTemplate{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
subject := current.Subject
|
|
||||||
if input.Subject != nil {
|
|
||||||
subject = *input.Subject
|
|
||||||
}
|
|
||||||
bodyText := current.BodyText
|
|
||||||
if input.BodyText != nil {
|
|
||||||
bodyText = *input.BodyText
|
|
||||||
}
|
|
||||||
bodyHTML := current.BodyHTML
|
|
||||||
if input.BodyHTML != nil {
|
|
||||||
bodyHTML = *input.BodyHTML
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.validateTemplateSyntax(subject, bodyText, bodyHTML); err != nil {
|
|
||||||
return domain.EmailTemplate{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
tmpl, err := s.store.UpdateEmailTemplate(ctx, id, input)
|
|
||||||
if err != nil {
|
|
||||||
return domain.EmailTemplate{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
s.invalidateCache(tmpl.Slug)
|
|
||||||
return tmpl, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) GetEmailTemplateByID(ctx context.Context, id int64, includeInactive bool) (domain.EmailTemplate, error) {
|
|
||||||
if id <= 0 {
|
|
||||||
return domain.EmailTemplate{}, fmt.Errorf("invalid email template id")
|
|
||||||
}
|
|
||||||
return s.store.GetEmailTemplateByID(ctx, id, includeInactive)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) GetEmailTemplateBySlug(ctx context.Context, slug string, includeInactive bool) (domain.EmailTemplate, error) {
|
|
||||||
normalized, err := normalizeSlug(slug)
|
|
||||||
if err != nil {
|
|
||||||
return domain.EmailTemplate{}, err
|
|
||||||
}
|
|
||||||
return s.store.GetEmailTemplateBySlug(ctx, normalized, includeInactive)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) ListEmailTemplates(ctx context.Context, status *string, query *string, limit int32, offset int32) ([]domain.EmailTemplate, int64, error) {
|
|
||||||
if status != nil {
|
|
||||||
normalized, err := normalizeStatus(status)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
status = &normalized
|
|
||||||
}
|
|
||||||
if query != nil {
|
|
||||||
trimmed := strings.TrimSpace(*query)
|
|
||||||
if trimmed == "" {
|
|
||||||
query = nil
|
|
||||||
} else {
|
|
||||||
query = &trimmed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 20
|
|
||||||
}
|
|
||||||
if limit > 200 {
|
|
||||||
limit = 200
|
|
||||||
}
|
|
||||||
if offset < 0 {
|
|
||||||
offset = 0
|
|
||||||
}
|
|
||||||
return s.store.ListEmailTemplates(ctx, status, query, limit, offset)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) DeleteEmailTemplate(ctx context.Context, id int64) error {
|
|
||||||
if id <= 0 {
|
|
||||||
return fmt.Errorf("invalid email template id")
|
|
||||||
}
|
|
||||||
|
|
||||||
tmpl, err := s.store.GetEmailTemplateByID(ctx, id, true)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if tmpl.IsSystem {
|
|
||||||
return fmt.Errorf("system email templates cannot be deleted")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.store.DeleteEmailTemplate(ctx, id); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
s.invalidateCache(tmpl.Slug)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) PreviewEmailTemplate(ctx context.Context, input domain.PreviewEmailTemplateInput) (domain.RenderedEmail, error) {
|
|
||||||
slug, err := normalizeSlug(input.Slug)
|
|
||||||
if err != nil {
|
|
||||||
return domain.RenderedEmail{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
tmpl, err := s.resolveTemplate(ctx, slug, true)
|
|
||||||
if err != nil {
|
|
||||||
return domain.RenderedEmail{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return renderTemplateFields(tmpl, input.Variables)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) Render(ctx context.Context, slug string, data map[string]any) (domain.RenderedEmail, error) {
|
|
||||||
normalized, err := normalizeSlug(slug)
|
|
||||||
if err != nil {
|
|
||||||
return domain.RenderedEmail{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
tmpl, err := s.resolveTemplate(ctx, normalized, false)
|
|
||||||
if err != nil {
|
|
||||||
return domain.RenderedEmail{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return renderTemplateFields(tmpl, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) resolveTemplate(ctx context.Context, slug string, includeInactive bool) (domain.EmailTemplate, error) {
|
|
||||||
if !includeInactive {
|
|
||||||
if tmpl, ok := s.getCached(slug); ok {
|
|
||||||
return tmpl, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tmpl, err := s.store.GetEmailTemplateBySlug(ctx, slug, includeInactive)
|
|
||||||
if err == nil {
|
|
||||||
if !includeInactive && tmpl.Status == domain.EmailTemplateStatusActive {
|
|
||||||
s.setCached(slug, tmpl)
|
|
||||||
}
|
|
||||||
return tmpl, nil
|
|
||||||
}
|
|
||||||
if !errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return domain.EmailTemplate{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
fallback, ok := defaultTemplate(slug)
|
|
||||||
if !ok {
|
|
||||||
return domain.EmailTemplate{}, fmt.Errorf("email template not found: %s", slug)
|
|
||||||
}
|
|
||||||
return fallback, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) validateTemplateSyntax(subject, bodyText, bodyHTML string) error {
|
|
||||||
if _, err := newTextTemplate("validate-subject", subject); err != nil {
|
|
||||||
return fmt.Errorf("invalid subject template: %w", err)
|
|
||||||
}
|
|
||||||
if _, err := newTextTemplate("validate-text", bodyText); err != nil {
|
|
||||||
return fmt.Errorf("invalid body_text template: %w", err)
|
|
||||||
}
|
|
||||||
if _, err := newHTMLTemplate("validate-html", bodyHTML); err != nil {
|
|
||||||
return fmt.Errorf("invalid body_html template: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) getCached(slug string) (domain.EmailTemplate, bool) {
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
|
|
||||||
entry, ok := s.cache[slug]
|
|
||||||
if !ok || time.Now().After(entry.expiresAt) {
|
|
||||||
return domain.EmailTemplate{}, false
|
|
||||||
}
|
|
||||||
return entry.template, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) setCached(slug string, tmpl domain.EmailTemplate) {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
s.cache[slug] = cacheEntry{
|
|
||||||
template: tmpl,
|
|
||||||
expiresAt: time.Now().Add(cacheTTL),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) invalidateCache(slug string) {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
delete(s.cache, slug)
|
|
||||||
}
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
package emailtemplates
|
|
||||||
|
|
||||||
import (
|
|
||||||
"Yimaru-Backend/internal/domain"
|
|
||||||
"html/template"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
texttemplate "text/template"
|
|
||||||
)
|
|
||||||
|
|
||||||
var slugPattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
|
|
||||||
|
|
||||||
func normalizeSlug(slug string) (string, error) {
|
|
||||||
value := strings.ToLower(strings.TrimSpace(slug))
|
|
||||||
if value == "" {
|
|
||||||
return "", errSlugRequired
|
|
||||||
}
|
|
||||||
if !slugPattern.MatchString(value) {
|
|
||||||
return "", errInvalidSlug
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeStatus(status *string) (string, error) {
|
|
||||||
if status == nil || strings.TrimSpace(*status) == "" {
|
|
||||||
return domain.EmailTemplateStatusActive, nil
|
|
||||||
}
|
|
||||||
value := strings.ToUpper(strings.TrimSpace(*status))
|
|
||||||
switch value {
|
|
||||||
case domain.EmailTemplateStatusActive, domain.EmailTemplateStatusInactive:
|
|
||||||
return value, nil
|
|
||||||
default:
|
|
||||||
return "", errInvalidStatus
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeVariables(vars []string) []string {
|
|
||||||
if len(vars) == 0 {
|
|
||||||
return []string{}
|
|
||||||
}
|
|
||||||
out := make([]string, 0, len(vars))
|
|
||||||
seen := make(map[string]struct{}, len(vars))
|
|
||||||
for _, variable := range vars {
|
|
||||||
trimmed := strings.TrimSpace(variable)
|
|
||||||
if trimmed == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, ok := seen[trimmed]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[trimmed] = struct{}{}
|
|
||||||
out = append(out, trimmed)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTextTemplate(name, content string) (*texttemplate.Template, error) {
|
|
||||||
return texttemplate.New(name).Parse(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
func newHTMLTemplate(name, content string) (*template.Template, error) {
|
|
||||||
return template.New(name).Parse(content)
|
|
||||||
}
|
|
||||||
|
|
@ -246,14 +246,6 @@ var AllPermissions = []domain.PermissionSeed{
|
||||||
{Key: "faqs.update", Name: "Update FAQ", Description: "Update a FAQ item", GroupName: "FAQs"},
|
{Key: "faqs.update", Name: "Update FAQ", Description: "Update a FAQ item", GroupName: "FAQs"},
|
||||||
{Key: "faqs.delete", Name: "Delete FAQ", Description: "Delete a FAQ item", GroupName: "FAQs"},
|
{Key: "faqs.delete", Name: "Delete FAQ", Description: "Delete a FAQ item", GroupName: "FAQs"},
|
||||||
|
|
||||||
// Email templates
|
|
||||||
{Key: "email_templates.create", Name: "Create Email Template", Description: "Create an email template", GroupName: "Email Templates"},
|
|
||||||
{Key: "email_templates.list", Name: "List Email Templates", Description: "List email templates for admin management", GroupName: "Email Templates"},
|
|
||||||
{Key: "email_templates.get", Name: "Get Email Template", Description: "Get an email template by ID or slug", GroupName: "Email Templates"},
|
|
||||||
{Key: "email_templates.update", Name: "Update Email Template", Description: "Update an email template", GroupName: "Email Templates"},
|
|
||||||
{Key: "email_templates.delete", Name: "Delete Email Template", Description: "Delete a custom email template", GroupName: "Email Templates"},
|
|
||||||
{Key: "email_templates.preview", Name: "Preview Email Template", Description: "Preview a rendered email template", GroupName: "Email Templates"},
|
|
||||||
|
|
||||||
// Analytics
|
// Analytics
|
||||||
{Key: "analytics.dashboard", Name: "View Dashboard", Description: "View analytics dashboard", GroupName: "Analytics"},
|
{Key: "analytics.dashboard", Name: "View Dashboard", Description: "View analytics dashboard", GroupName: "Analytics"},
|
||||||
|
|
||||||
|
|
@ -456,9 +448,6 @@ var DefaultRolePermissions = map[string][]string{
|
||||||
// FAQs
|
// FAQs
|
||||||
"faqs.create", "faqs.list", "faqs.get", "faqs.update", "faqs.delete",
|
"faqs.create", "faqs.list", "faqs.get", "faqs.update", "faqs.delete",
|
||||||
|
|
||||||
// Email templates
|
|
||||||
"email_templates.create", "email_templates.list", "email_templates.get", "email_templates.update", "email_templates.delete", "email_templates.preview",
|
|
||||||
|
|
||||||
// Analytics (previously OnlyAdminAndAbove)
|
// Analytics (previously OnlyAdminAndAbove)
|
||||||
"analytics.dashboard",
|
"analytics.dashboard",
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,6 @@ import (
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Service) renderOtpMessage(ctx context.Context, otpCode, firstName string) (domain.RenderedEmail, error) {
|
|
||||||
return s.emailTemplateSvc.Render(ctx, domain.EmailTemplateSlugOTP, map[string]any{
|
|
||||||
"OTP": otpCode,
|
|
||||||
"FirstName": firstName,
|
|
||||||
"ExpiresMinutes": int(OtpExpiry.Minutes()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) ResendOtp(
|
func (s *Service) ResendOtp(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
email, phone string,
|
email, phone string,
|
||||||
|
|
@ -30,28 +22,29 @@ func (s *Service) ResendOtp(
|
||||||
|
|
||||||
otpCode := helpers.GenerateOTP()
|
otpCode := helpers.GenerateOTP()
|
||||||
|
|
||||||
|
message := fmt.Sprintf(
|
||||||
|
"Welcome to Yimaru Online Learning Platform, your OTP is %s please don't share with anyone.",
|
||||||
|
otpCode,
|
||||||
|
)
|
||||||
|
|
||||||
otp, err := s.otpStore.GetOtp(ctx, user.ID)
|
otp, err := s.otpStore.GetOtp(ctx, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
rendered, err := s.renderOtpMessage(ctx, otpCode, user.FirstName)
|
// Broadcast OTP (same logic as SendOtp)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
switch otp.Medium {
|
switch otp.Medium {
|
||||||
case domain.OtpMediumSms:
|
case domain.OtpMediumSms:
|
||||||
if err := s.messengerSvc.SendAfroMessageSMSLatest(ctx, otp.SentTo, rendered.Text, nil); err != nil {
|
if err := s.messengerSvc.SendAfroMessageSMSLatest(ctx, otp.SentTo, message, nil); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
case domain.OtpMediumEmail:
|
case domain.OtpMediumEmail:
|
||||||
if err := s.messengerSvc.SendEmail(
|
if err := s.messengerSvc.SendEmail(
|
||||||
ctx,
|
ctx,
|
||||||
otp.SentTo,
|
otp.SentTo,
|
||||||
rendered.Text,
|
message,
|
||||||
rendered.HTML,
|
message,
|
||||||
rendered.Subject,
|
"Yimaru - One Time Password",
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -70,26 +63,16 @@ func (s *Service) ResendOtp(
|
||||||
func (s *Service) SendOtp(ctx context.Context, userID int64, sentTo string, otpFor domain.OtpFor, medium domain.OtpMedium, provider domain.SMSProvider) error {
|
func (s *Service) SendOtp(ctx context.Context, userID int64, sentTo string, otpFor domain.OtpFor, medium domain.OtpMedium, provider domain.SMSProvider) error {
|
||||||
otpCode := helpers.GenerateOTP()
|
otpCode := helpers.GenerateOTP()
|
||||||
|
|
||||||
firstName := ""
|
message := fmt.Sprintf("Welcome to Yimaru Online Learning Platform, your OTP is %s please don't share with anyone.", otpCode)
|
||||||
if userID > 0 {
|
|
||||||
user, err := s.userStore.GetUserByID(ctx, userID)
|
|
||||||
if err == nil {
|
|
||||||
firstName = user.FirstName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rendered, err := s.renderOtpMessage(ctx, otpCode, firstName)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
switch medium {
|
switch medium {
|
||||||
case domain.OtpMediumSms:
|
case domain.OtpMediumSms:
|
||||||
if err := s.messengerSvc.SendAfroMessageSMSLatest(ctx, sentTo, rendered.Text, nil); err != nil {
|
|
||||||
|
if err := s.messengerSvc.SendAfroMessageSMSLatest(ctx, sentTo, message, nil); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
case domain.OtpMediumEmail:
|
case domain.OtpMediumEmail:
|
||||||
if err := s.messengerSvc.SendEmail(ctx, sentTo, rendered.Text, rendered.HTML, rendered.Subject); err != nil {
|
if err := s.messengerSvc.SendEmail(ctx, sentTo, message, message, "Yimaru - One Time Password"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package user
|
||||||
import (
|
import (
|
||||||
"Yimaru-Backend/internal/config"
|
"Yimaru-Backend/internal/config"
|
||||||
"Yimaru-Backend/internal/ports"
|
"Yimaru-Backend/internal/ports"
|
||||||
emailtemplates "Yimaru-Backend/internal/services/emailtemplates"
|
|
||||||
"Yimaru-Backend/internal/services/messenger"
|
"Yimaru-Backend/internal/services/messenger"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
@ -13,12 +12,11 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
tokenStore ports.TokenStore
|
tokenStore ports.TokenStore
|
||||||
userStore ports.UserStore
|
userStore ports.UserStore
|
||||||
otpStore ports.OtpStore
|
otpStore ports.OtpStore
|
||||||
messengerSvc *messenger.Service
|
messengerSvc *messenger.Service
|
||||||
emailTemplateSvc *emailtemplates.Service
|
config *config.Config
|
||||||
config *config.Config
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(
|
func NewService(
|
||||||
|
|
@ -26,15 +24,13 @@ func NewService(
|
||||||
userStore ports.UserStore,
|
userStore ports.UserStore,
|
||||||
otpStore ports.OtpStore,
|
otpStore ports.OtpStore,
|
||||||
messengerSvc *messenger.Service,
|
messengerSvc *messenger.Service,
|
||||||
emailTemplateSvc *emailtemplates.Service,
|
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
) *Service {
|
) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
tokenStore: tokenStore,
|
tokenStore: tokenStore,
|
||||||
userStore: userStore,
|
userStore: userStore,
|
||||||
otpStore: otpStore,
|
otpStore: otpStore,
|
||||||
messengerSvc: messengerSvc,
|
messengerSvc: messengerSvc,
|
||||||
emailTemplateSvc: emailTemplateSvc,
|
config: cfg,
|
||||||
config: cfg,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,10 @@ import (
|
||||||
"Yimaru-Backend/internal/config"
|
"Yimaru-Backend/internal/config"
|
||||||
activitylogservice "Yimaru-Backend/internal/services/activity_log"
|
activitylogservice "Yimaru-Backend/internal/services/activity_log"
|
||||||
"Yimaru-Backend/internal/services/arifpay"
|
"Yimaru-Backend/internal/services/arifpay"
|
||||||
"Yimaru-Backend/internal/services/chapa"
|
|
||||||
"Yimaru-Backend/internal/services/assessment"
|
"Yimaru-Backend/internal/services/assessment"
|
||||||
"Yimaru-Backend/internal/services/authentication"
|
"Yimaru-Backend/internal/services/authentication"
|
||||||
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
|
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
|
||||||
"Yimaru-Backend/internal/services/courses"
|
"Yimaru-Backend/internal/services/courses"
|
||||||
"Yimaru-Backend/internal/services/emailtemplates"
|
|
||||||
"Yimaru-Backend/internal/services/examprep"
|
"Yimaru-Backend/internal/services/examprep"
|
||||||
"Yimaru-Backend/internal/services/faqs"
|
"Yimaru-Backend/internal/services/faqs"
|
||||||
issuereporting "Yimaru-Backend/internal/services/issue_reporting"
|
issuereporting "Yimaru-Backend/internal/services/issue_reporting"
|
||||||
|
|
@ -51,7 +49,6 @@ type App struct {
|
||||||
assessmentSvc *assessment.Service
|
assessmentSvc *assessment.Service
|
||||||
questionsSvc *questions.Service
|
questionsSvc *questions.Service
|
||||||
faqSvc *faqs.Service
|
faqSvc *faqs.Service
|
||||||
emailTemplateSvc *emailtemplates.Service
|
|
||||||
personaSvc *personas.Service
|
personaSvc *personas.Service
|
||||||
examPrepSvc *examprep.Service
|
examPrepSvc *examprep.Service
|
||||||
programSvc *programs.Service
|
programSvc *programs.Service
|
||||||
|
|
@ -62,7 +59,6 @@ type App struct {
|
||||||
practiceSvc *practices.Service
|
practiceSvc *practices.Service
|
||||||
subscriptionsSvc *subscriptions.Service
|
subscriptionsSvc *subscriptions.Service
|
||||||
arifpaySvc *arifpay.ArifpayService
|
arifpaySvc *arifpay.ArifpayService
|
||||||
chapaSvc *chapa.Service
|
|
||||||
issueReportingSvc *issuereporting.Service
|
issueReportingSvc *issuereporting.Service
|
||||||
vimeoSvc *vimeoservice.Service
|
vimeoSvc *vimeoservice.Service
|
||||||
teamSvc *team.Service
|
teamSvc *team.Service
|
||||||
|
|
@ -93,7 +89,6 @@ func NewApp(
|
||||||
assessmentSvc *assessment.Service,
|
assessmentSvc *assessment.Service,
|
||||||
questionsSvc *questions.Service,
|
questionsSvc *questions.Service,
|
||||||
faqSvc *faqs.Service,
|
faqSvc *faqs.Service,
|
||||||
emailTemplateSvc *emailtemplates.Service,
|
|
||||||
personaSvc *personas.Service,
|
personaSvc *personas.Service,
|
||||||
examPrepSvc *examprep.Service,
|
examPrepSvc *examprep.Service,
|
||||||
programSvc *programs.Service,
|
programSvc *programs.Service,
|
||||||
|
|
@ -104,7 +99,6 @@ func NewApp(
|
||||||
practiceSvc *practices.Service,
|
practiceSvc *practices.Service,
|
||||||
subscriptionsSvc *subscriptions.Service,
|
subscriptionsSvc *subscriptions.Service,
|
||||||
arifpaySvc *arifpay.ArifpayService,
|
arifpaySvc *arifpay.ArifpayService,
|
||||||
chapaSvc *chapa.Service,
|
|
||||||
issueReportingSvc *issuereporting.Service,
|
issueReportingSvc *issuereporting.Service,
|
||||||
vimeoSvc *vimeoservice.Service,
|
vimeoSvc *vimeoservice.Service,
|
||||||
teamSvc *team.Service,
|
teamSvc *team.Service,
|
||||||
|
|
@ -147,7 +141,6 @@ func NewApp(
|
||||||
assessmentSvc: assessmentSvc,
|
assessmentSvc: assessmentSvc,
|
||||||
questionsSvc: questionsSvc,
|
questionsSvc: questionsSvc,
|
||||||
faqSvc: faqSvc,
|
faqSvc: faqSvc,
|
||||||
emailTemplateSvc: emailTemplateSvc,
|
|
||||||
personaSvc: personaSvc,
|
personaSvc: personaSvc,
|
||||||
examPrepSvc: examPrepSvc,
|
examPrepSvc: examPrepSvc,
|
||||||
programSvc: programSvc,
|
programSvc: programSvc,
|
||||||
|
|
@ -158,7 +151,6 @@ func NewApp(
|
||||||
practiceSvc: practiceSvc,
|
practiceSvc: practiceSvc,
|
||||||
subscriptionsSvc: subscriptionsSvc,
|
subscriptionsSvc: subscriptionsSvc,
|
||||||
arifpaySvc: arifpaySvc,
|
arifpaySvc: arifpaySvc,
|
||||||
chapaSvc: chapaSvc,
|
|
||||||
vimeoSvc: vimeoSvc,
|
vimeoSvc: vimeoSvc,
|
||||||
teamSvc: teamSvc,
|
teamSvc: teamSvc,
|
||||||
activityLogSvc: activityLogSvc,
|
activityLogSvc: activityLogSvc,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
|
|
||||||
"Yimaru-Backend/internal/domain"
|
"Yimaru-Backend/internal/domain"
|
||||||
"Yimaru-Backend/internal/services/chapa"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
|
|
@ -68,16 +65,14 @@ func (h *Handler) InitiateSubscriptionPayment(c *fiber.Ctx) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := h.chapaSvc.InitiateSubscriptionPayment(c.Context(), userID, domain.InitiateSubscriptionPaymentRequest{
|
result, err := h.arifpaySvc.InitiateSubscriptionPayment(c.Context(), userID, domain.InitiateSubscriptionPaymentRequest{
|
||||||
PlanID: req.PlanID,
|
PlanID: req.PlanID,
|
||||||
Phone: req.Phone,
|
Phone: req.Phone,
|
||||||
Email: req.Email,
|
Email: req.Email,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
status := fiber.StatusInternalServerError
|
status := fiber.StatusInternalServerError
|
||||||
if errors.Is(err, chapa.ErrChapaNotConfigured) {
|
if err.Error() == "user already has an active subscription" {
|
||||||
status = fiber.StatusServiceUnavailable
|
|
||||||
} else if err.Error() == "user already has an active subscription" {
|
|
||||||
status = fiber.StatusConflict
|
status = fiber.StatusConflict
|
||||||
}
|
}
|
||||||
return c.Status(status).JSON(domain.ErrorResponse{
|
return c.Status(status).JSON(domain.ErrorResponse{
|
||||||
|
|
@ -110,7 +105,7 @@ func (h *Handler) VerifyPayment(c *fiber.Ctx) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
payment, err := h.chapaSvc.VerifyPayment(c.Context(), sessionID)
|
payment, err := h.arifpaySvc.VerifyPayment(c.Context(), sessionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||||
Message: "Payment not found or verification failed",
|
Message: "Payment not found or verification failed",
|
||||||
|
|
@ -148,7 +143,7 @@ func (h *Handler) GetMyPayments(c *fiber.Ctx) error {
|
||||||
limit, _ := strconv.Atoi(c.Query("limit", "20"))
|
limit, _ := strconv.Atoi(c.Query("limit", "20"))
|
||||||
offset, _ := strconv.Atoi(c.Query("offset", "0"))
|
offset, _ := strconv.Atoi(c.Query("offset", "0"))
|
||||||
|
|
||||||
payments, err := h.chapaSvc.GetPaymentsByUser(c.Context(), userID, int32(limit), int32(offset))
|
payments, err := h.arifpaySvc.GetPaymentsByUser(c.Context(), userID, int32(limit), int32(offset))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
Message: "Failed to get payment history",
|
Message: "Failed to get payment history",
|
||||||
|
|
@ -191,7 +186,7 @@ func (h *Handler) GetPaymentByID(c *fiber.Ctx) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
payment, err := h.chapaSvc.GetPaymentByID(c.Context(), id)
|
payment, err := h.arifpaySvc.GetPaymentByID(c.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||||
Message: "Payment not found",
|
Message: "Payment not found",
|
||||||
|
|
@ -234,7 +229,7 @@ func (h *Handler) CancelPayment(c *fiber.Ctx) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.chapaSvc.CancelPayment(c.Context(), id, userID); err != nil {
|
if err := h.arifpaySvc.CancelPayment(c.Context(), id, userID); err != nil {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
Message: "Failed to cancel payment",
|
Message: "Failed to cancel payment",
|
||||||
Error: err.Error(),
|
Error: err.Error(),
|
||||||
|
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
"Yimaru-Backend/internal/domain"
|
|
||||||
"Yimaru-Backend/internal/services/chapa"
|
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// HandleChapaWebhook godoc
|
|
||||||
// @Summary Handle Chapa webhook
|
|
||||||
// @Description Processes payment notifications from Chapa (charge.success, etc.)
|
|
||||||
// @Tags payments
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Success 200 {object} domain.Response
|
|
||||||
// @Failure 400 {object} domain.ErrorResponse
|
|
||||||
// @Router /api/v1/payments/webhook [post]
|
|
||||||
func (h *Handler) HandleChapaWebhook(c *fiber.Ctx) error {
|
|
||||||
body := c.Body()
|
|
||||||
signature := c.Get("x-chapa-signature")
|
|
||||||
if signature == "" {
|
|
||||||
signature = c.Get("chapa-signature")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.chapaSvc.VerifyWebhookSignature(body, signature); err != nil {
|
|
||||||
h.logger.Error("Invalid Chapa webhook signature", "error", err)
|
|
||||||
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
|
|
||||||
Message: "Invalid webhook signature",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var payload domain.ChapaWebhookPayload
|
|
||||||
if err := json.Unmarshal(body, &payload); err != nil {
|
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
||||||
Message: "Invalid webhook payload",
|
|
||||||
Error: err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.chapaSvc.ProcessPaymentWebhook(c.Context(), payload); err != nil {
|
|
||||||
if errors.Is(err, chapa.ErrPaymentAlreadyPaid) {
|
|
||||||
return c.JSON(domain.Response{Message: "Webhook already processed"})
|
|
||||||
}
|
|
||||||
h.logger.Error("Failed to process Chapa webhook", "error", err, "tx_ref", payload.TxRef)
|
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
|
||||||
Message: "Failed to process webhook",
|
|
||||||
Error: err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(domain.Response{
|
|
||||||
Message: "Webhook processed successfully",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleChapaCallback godoc
|
|
||||||
// @Summary Chapa payment callback
|
|
||||||
// @Description Verifies payment after Chapa redirects to callback_url
|
|
||||||
// @Tags payments
|
|
||||||
// @Produce json
|
|
||||||
// @Param trx_ref query string false "Transaction reference"
|
|
||||||
// @Param ref_id query string false "Chapa reference ID"
|
|
||||||
// @Param status query string false "Payment status"
|
|
||||||
// @Success 200 {object} domain.Response
|
|
||||||
// @Router /api/v1/payments/chapa/callback [get]
|
|
||||||
func (h *Handler) HandleChapaCallback(c *fiber.Ctx) error {
|
|
||||||
query := domain.ChapaCallbackQuery{
|
|
||||||
TrxRef: c.Query("trx_ref"),
|
|
||||||
RefID: c.Query("ref_id"),
|
|
||||||
Status: c.Query("status"),
|
|
||||||
}
|
|
||||||
if query.TrxRef == "" {
|
|
||||||
query.TrxRef = c.Query("tx_ref")
|
|
||||||
}
|
|
||||||
|
|
||||||
if query.TrxRef == "" {
|
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
||||||
Message: "trx_ref is required",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.chapaSvc.ProcessCallback(c.Context(), query); err != nil {
|
|
||||||
if errors.Is(err, chapa.ErrPaymentAlreadyPaid) {
|
|
||||||
return c.JSON(domain.Response{Message: "Payment already processed"})
|
|
||||||
}
|
|
||||||
h.logger.Error("Failed to process Chapa callback", "error", err, "trx_ref", query.TrxRef)
|
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
|
||||||
Message: "Failed to process callback",
|
|
||||||
Error: err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(domain.Response{
|
|
||||||
Message: "Callback processed successfully",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetChapaPaymentMethods godoc
|
|
||||||
// @Summary Get Chapa payment methods
|
|
||||||
// @Description Returns payment methods available on Chapa checkout
|
|
||||||
// @Tags payments
|
|
||||||
// @Produce json
|
|
||||||
// @Success 200 {object} domain.Response
|
|
||||||
// @Router /api/v1/payments/methods [get]
|
|
||||||
func (h *Handler) GetChapaPaymentMethods(c *fiber.Ctx) error {
|
|
||||||
return c.JSON(domain.Response{
|
|
||||||
Message: "Payment methods retrieved successfully",
|
|
||||||
Data: h.chapaSvc.GetPaymentMethods(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,467 +0,0 @@
|
||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"Yimaru-Backend/internal/domain"
|
|
||||||
"errors"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
type createEmailTemplateReq struct {
|
|
||||||
Slug string `json:"slug" validate:"required"`
|
|
||||||
Name string `json:"name" validate:"required"`
|
|
||||||
Subject string `json:"subject" validate:"required"`
|
|
||||||
BodyText string `json:"body_text" validate:"required"`
|
|
||||||
BodyHTML string `json:"body_html" validate:"required"`
|
|
||||||
Variables []string `json:"variables"`
|
|
||||||
Status *string `json:"status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type updateEmailTemplateReq struct {
|
|
||||||
Name *string `json:"name"`
|
|
||||||
Subject *string `json:"subject"`
|
|
||||||
BodyText *string `json:"body_text"`
|
|
||||||
BodyHTML *string `json:"body_html"`
|
|
||||||
Variables []string `json:"variables"`
|
|
||||||
Status *string `json:"status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type previewEmailTemplateReq struct {
|
|
||||||
Variables map[string]any `json:"variables"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type emailTemplateRes struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Slug string `json:"slug"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Subject string `json:"subject"`
|
|
||||||
BodyText string `json:"body_text"`
|
|
||||||
BodyHTML string `json:"body_html"`
|
|
||||||
Variables []string `json:"variables"`
|
|
||||||
IsSystem bool `json:"is_system"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
UpdatedAt *string `json:"updated_at,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type listEmailTemplatesRes struct {
|
|
||||||
Templates []emailTemplateRes `json:"templates"`
|
|
||||||
TotalCount int64 `json:"total_count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapEmailTemplateToRes(t domain.EmailTemplate) emailTemplateRes {
|
|
||||||
var updatedAt *string
|
|
||||||
if t.UpdatedAt != nil {
|
|
||||||
value := t.UpdatedAt.String()
|
|
||||||
updatedAt = &value
|
|
||||||
}
|
|
||||||
variables := t.Variables
|
|
||||||
if variables == nil {
|
|
||||||
variables = []string{}
|
|
||||||
}
|
|
||||||
return emailTemplateRes{
|
|
||||||
ID: t.ID,
|
|
||||||
Slug: t.Slug,
|
|
||||||
Name: t.Name,
|
|
||||||
Subject: t.Subject,
|
|
||||||
BodyText: t.BodyText,
|
|
||||||
BodyHTML: t.BodyHTML,
|
|
||||||
Variables: variables,
|
|
||||||
IsSystem: t.IsSystem,
|
|
||||||
Status: t.Status,
|
|
||||||
CreatedAt: t.CreatedAt.String(),
|
|
||||||
UpdatedAt: updatedAt,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListEmailTemplatesAdmin godoc
|
|
||||||
// @Summary List email templates (admin)
|
|
||||||
// @Description Returns email templates for admin management
|
|
||||||
// @Tags email-templates
|
|
||||||
// @Produce json
|
|
||||||
// @Param status query string false "ACTIVE or INACTIVE"
|
|
||||||
// @Param query query string false "Search by slug, name, or subject"
|
|
||||||
// @Param limit query int false "Limit (default 20)"
|
|
||||||
// @Param offset query int false "Offset (default 0)"
|
|
||||||
// @Success 200 {object} domain.Response
|
|
||||||
// @Failure 400 {object} domain.ErrorResponse
|
|
||||||
// @Failure 500 {object} domain.ErrorResponse
|
|
||||||
// @Router /api/v1/admin/email-templates [get]
|
|
||||||
func (h *Handler) ListEmailTemplatesAdmin(c *fiber.Ctx) error {
|
|
||||||
status := strings.ToUpper(strings.TrimSpace(c.Query("status")))
|
|
||||||
var statusPtr *string
|
|
||||||
if status != "" {
|
|
||||||
statusPtr = &status
|
|
||||||
}
|
|
||||||
search := strings.TrimSpace(c.Query("query"))
|
|
||||||
var searchPtr *string
|
|
||||||
if search != "" {
|
|
||||||
searchPtr = &search
|
|
||||||
}
|
|
||||||
|
|
||||||
limit, err := strconv.Atoi(c.Query("limit", "20"))
|
|
||||||
if err != nil {
|
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
||||||
Message: "Invalid limit",
|
|
||||||
Error: err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
offset, err := strconv.Atoi(c.Query("offset", "0"))
|
|
||||||
if err != nil {
|
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
||||||
Message: "Invalid offset",
|
|
||||||
Error: err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
templates, total, err := h.emailTemplateSvc.ListEmailTemplates(c.Context(), statusPtr, searchPtr, int32(limit), int32(offset))
|
|
||||||
if err != nil {
|
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
||||||
Message: "Failed to list email templates",
|
|
||||||
Error: err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make([]emailTemplateRes, 0, len(templates))
|
|
||||||
for _, tmpl := range templates {
|
|
||||||
out = append(out, mapEmailTemplateToRes(tmpl))
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(domain.Response{
|
|
||||||
Message: "Email templates retrieved successfully",
|
|
||||||
Data: listEmailTemplatesRes{
|
|
||||||
Templates: out,
|
|
||||||
TotalCount: total,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetEmailTemplateByIDAdmin godoc
|
|
||||||
// @Summary Get email template by ID (admin)
|
|
||||||
// @Description Returns one email template regardless of status
|
|
||||||
// @Tags email-templates
|
|
||||||
// @Produce json
|
|
||||||
// @Param id path int true "Email template ID"
|
|
||||||
// @Success 200 {object} domain.Response
|
|
||||||
// @Failure 400 {object} domain.ErrorResponse
|
|
||||||
// @Failure 404 {object} domain.ErrorResponse
|
|
||||||
// @Failure 500 {object} domain.ErrorResponse
|
|
||||||
// @Router /api/v1/admin/email-templates/{id} [get]
|
|
||||||
func (h *Handler) GetEmailTemplateByIDAdmin(c *fiber.Ctx) error {
|
|
||||||
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
|
||||||
if err != nil || id <= 0 {
|
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
||||||
Message: "Invalid email template ID",
|
|
||||||
Error: "id must be a positive integer",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
tmpl, err := h.emailTemplateSvc.GetEmailTemplateByID(c.Context(), id, true)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
|
||||||
Message: "Email template not found",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
|
||||||
Message: "Failed to get email template",
|
|
||||||
Error: err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(domain.Response{
|
|
||||||
Message: "Email template retrieved successfully",
|
|
||||||
Data: mapEmailTemplateToRes(tmpl),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetEmailTemplateBySlugAdmin godoc
|
|
||||||
// @Summary Get email template by slug (admin)
|
|
||||||
// @Description Returns one email template by slug regardless of status
|
|
||||||
// @Tags email-templates
|
|
||||||
// @Produce json
|
|
||||||
// @Param slug path string true "Email template slug"
|
|
||||||
// @Success 200 {object} domain.Response
|
|
||||||
// @Failure 400 {object} domain.ErrorResponse
|
|
||||||
// @Failure 404 {object} domain.ErrorResponse
|
|
||||||
// @Failure 500 {object} domain.ErrorResponse
|
|
||||||
// @Router /api/v1/admin/email-templates/slug/{slug} [get]
|
|
||||||
func (h *Handler) GetEmailTemplateBySlugAdmin(c *fiber.Ctx) error {
|
|
||||||
slug := strings.TrimSpace(c.Params("slug"))
|
|
||||||
if slug == "" {
|
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
||||||
Message: "Invalid email template slug",
|
|
||||||
Error: "slug is required",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
tmpl, err := h.emailTemplateSvc.GetEmailTemplateBySlug(c.Context(), slug, true)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
|
||||||
Message: "Email template not found",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
||||||
Message: "Failed to get email template",
|
|
||||||
Error: err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(domain.Response{
|
|
||||||
Message: "Email template retrieved successfully",
|
|
||||||
Data: mapEmailTemplateToRes(tmpl),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateEmailTemplate godoc
|
|
||||||
// @Summary Create email template
|
|
||||||
// @Description Creates a new custom email template
|
|
||||||
// @Tags email-templates
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param body body createEmailTemplateReq true "Create email template payload"
|
|
||||||
// @Success 201 {object} domain.Response
|
|
||||||
// @Failure 400 {object} domain.ErrorResponse
|
|
||||||
// @Failure 500 {object} domain.ErrorResponse
|
|
||||||
// @Router /api/v1/admin/email-templates [post]
|
|
||||||
func (h *Handler) CreateEmailTemplate(c *fiber.Ctx) error {
|
|
||||||
var req createEmailTemplateReq
|
|
||||||
if err := c.BodyParser(&req); err != nil {
|
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
||||||
Message: "Invalid request body",
|
|
||||||
Error: err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if valErrs, ok := h.validator.Validate(c, req); !ok {
|
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
||||||
Message: "Validation failed",
|
|
||||||
Error: firstValidationError(valErrs),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
tmpl, err := h.emailTemplateSvc.CreateEmailTemplate(c.Context(), domain.CreateEmailTemplateInput{
|
|
||||||
Slug: req.Slug,
|
|
||||||
Name: req.Name,
|
|
||||||
Subject: req.Subject,
|
|
||||||
BodyText: req.BodyText,
|
|
||||||
BodyHTML: req.BodyHTML,
|
|
||||||
Variables: req.Variables,
|
|
||||||
Status: req.Status,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
||||||
Message: "Failed to create email template",
|
|
||||||
Error: err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.Status(fiber.StatusCreated).JSON(domain.Response{
|
|
||||||
Message: "Email template created successfully",
|
|
||||||
Data: mapEmailTemplateToRes(tmpl),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateEmailTemplate godoc
|
|
||||||
// @Summary Update email template
|
|
||||||
// @Description Updates an existing email template
|
|
||||||
// @Tags email-templates
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param id path int true "Email template ID"
|
|
||||||
// @Param body body updateEmailTemplateReq true "Update email template payload"
|
|
||||||
// @Success 200 {object} domain.Response
|
|
||||||
// @Failure 400 {object} domain.ErrorResponse
|
|
||||||
// @Failure 404 {object} domain.ErrorResponse
|
|
||||||
// @Failure 500 {object} domain.ErrorResponse
|
|
||||||
// @Router /api/v1/admin/email-templates/{id} [put]
|
|
||||||
func (h *Handler) UpdateEmailTemplate(c *fiber.Ctx) error {
|
|
||||||
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
|
||||||
if err != nil || id <= 0 {
|
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
||||||
Message: "Invalid email template ID",
|
|
||||||
Error: "id must be a positive integer",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var req updateEmailTemplateReq
|
|
||||||
if err := c.BodyParser(&req); err != nil {
|
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
||||||
Message: "Invalid request body",
|
|
||||||
Error: err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
tmpl, err := h.emailTemplateSvc.UpdateEmailTemplate(c.Context(), id, domain.UpdateEmailTemplateInput{
|
|
||||||
Name: req.Name,
|
|
||||||
Subject: req.Subject,
|
|
||||||
BodyText: req.BodyText,
|
|
||||||
BodyHTML: req.BodyHTML,
|
|
||||||
Variables: req.Variables,
|
|
||||||
Status: req.Status,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
|
||||||
Message: "Email template not found",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
||||||
Message: "Failed to update email template",
|
|
||||||
Error: err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(domain.Response{
|
|
||||||
Message: "Email template updated successfully",
|
|
||||||
Data: mapEmailTemplateToRes(tmpl),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteEmailTemplate godoc
|
|
||||||
// @Summary Delete email template
|
|
||||||
// @Description Deletes a custom email template
|
|
||||||
// @Tags email-templates
|
|
||||||
// @Produce json
|
|
||||||
// @Param id path int true "Email template ID"
|
|
||||||
// @Success 200 {object} domain.Response
|
|
||||||
// @Failure 400 {object} domain.ErrorResponse
|
|
||||||
// @Failure 404 {object} domain.ErrorResponse
|
|
||||||
// @Failure 500 {object} domain.ErrorResponse
|
|
||||||
// @Router /api/v1/admin/email-templates/{id} [delete]
|
|
||||||
func (h *Handler) DeleteEmailTemplate(c *fiber.Ctx) error {
|
|
||||||
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
|
||||||
if err != nil || id <= 0 {
|
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
||||||
Message: "Invalid email template ID",
|
|
||||||
Error: "id must be a positive integer",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.emailTemplateSvc.DeleteEmailTemplate(c.Context(), id); err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
|
||||||
Message: "Email template not found",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
||||||
Message: "Failed to delete email template",
|
|
||||||
Error: err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(domain.Response{
|
|
||||||
Message: "Email template deleted successfully",
|
|
||||||
Data: fiber.Map{"id": id},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// PreviewEmailTemplateBySlug godoc
|
|
||||||
// @Summary Preview email template by slug
|
|
||||||
// @Description Renders an email template with sample variables without sending
|
|
||||||
// @Tags email-templates
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param slug path string true "Email template slug"
|
|
||||||
// @Param body body previewEmailTemplateReq true "Preview variables"
|
|
||||||
// @Success 200 {object} domain.Response
|
|
||||||
// @Failure 400 {object} domain.ErrorResponse
|
|
||||||
// @Failure 404 {object} domain.ErrorResponse
|
|
||||||
// @Failure 500 {object} domain.ErrorResponse
|
|
||||||
// @Router /api/v1/admin/email-templates/slug/{slug}/preview [post]
|
|
||||||
func (h *Handler) PreviewEmailTemplateBySlug(c *fiber.Ctx) error {
|
|
||||||
slug := strings.TrimSpace(c.Params("slug"))
|
|
||||||
if slug == "" {
|
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
||||||
Message: "Invalid email template slug",
|
|
||||||
Error: "slug is required",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var req previewEmailTemplateReq
|
|
||||||
if err := c.BodyParser(&req); err != nil {
|
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
||||||
Message: "Invalid request body",
|
|
||||||
Error: err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
rendered, err := h.emailTemplateSvc.PreviewEmailTemplate(c.Context(), domain.PreviewEmailTemplateInput{
|
|
||||||
Slug: slug,
|
|
||||||
Variables: req.Variables,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
||||||
Message: "Failed to preview email template",
|
|
||||||
Error: err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(domain.Response{
|
|
||||||
Message: "Email template preview generated successfully",
|
|
||||||
Data: rendered,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// PreviewEmailTemplateByID godoc
|
|
||||||
// @Summary Preview email template by ID
|
|
||||||
// @Description Renders an email template with sample variables without sending
|
|
||||||
// @Tags email-templates
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param id path int true "Email template ID"
|
|
||||||
// @Param body body previewEmailTemplateReq true "Preview variables"
|
|
||||||
// @Success 200 {object} domain.Response
|
|
||||||
// @Failure 400 {object} domain.ErrorResponse
|
|
||||||
// @Failure 404 {object} domain.ErrorResponse
|
|
||||||
// @Failure 500 {object} domain.ErrorResponse
|
|
||||||
// @Router /api/v1/admin/email-templates/{id}/preview [post]
|
|
||||||
func (h *Handler) PreviewEmailTemplateByID(c *fiber.Ctx) error {
|
|
||||||
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
|
||||||
if err != nil || id <= 0 {
|
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
||||||
Message: "Invalid email template ID",
|
|
||||||
Error: "id must be a positive integer",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var req previewEmailTemplateReq
|
|
||||||
if err := c.BodyParser(&req); err != nil {
|
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
||||||
Message: "Invalid request body",
|
|
||||||
Error: err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
tmpl, err := h.emailTemplateSvc.GetEmailTemplateByID(c.Context(), id, true)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
|
||||||
Message: "Email template not found",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
|
||||||
Message: "Failed to get email template",
|
|
||||||
Error: err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
rendered, err := h.emailTemplateSvc.PreviewEmailTemplate(c.Context(), domain.PreviewEmailTemplateInput{
|
|
||||||
Slug: tmpl.Slug,
|
|
||||||
Variables: req.Variables,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
||||||
Message: "Failed to preview email template",
|
|
||||||
Error: err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(domain.Response{
|
|
||||||
Message: "Email template preview generated successfully",
|
|
||||||
Data: rendered,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -8,12 +8,10 @@ import (
|
||||||
"Yimaru-Backend/internal/domain"
|
"Yimaru-Backend/internal/domain"
|
||||||
activitylogservice "Yimaru-Backend/internal/services/activity_log"
|
activitylogservice "Yimaru-Backend/internal/services/activity_log"
|
||||||
"Yimaru-Backend/internal/services/arifpay"
|
"Yimaru-Backend/internal/services/arifpay"
|
||||||
"Yimaru-Backend/internal/services/chapa"
|
|
||||||
"Yimaru-Backend/internal/services/assessment"
|
"Yimaru-Backend/internal/services/assessment"
|
||||||
"Yimaru-Backend/internal/services/authentication"
|
"Yimaru-Backend/internal/services/authentication"
|
||||||
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
|
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
|
||||||
"Yimaru-Backend/internal/services/courses"
|
"Yimaru-Backend/internal/services/courses"
|
||||||
"Yimaru-Backend/internal/services/emailtemplates"
|
|
||||||
"Yimaru-Backend/internal/services/examprep"
|
"Yimaru-Backend/internal/services/examprep"
|
||||||
"Yimaru-Backend/internal/services/faqs"
|
"Yimaru-Backend/internal/services/faqs"
|
||||||
issuereporting "Yimaru-Backend/internal/services/issue_reporting"
|
issuereporting "Yimaru-Backend/internal/services/issue_reporting"
|
||||||
|
|
@ -50,7 +48,6 @@ type Handler struct {
|
||||||
assessmentSvc *assessment.Service
|
assessmentSvc *assessment.Service
|
||||||
questionsSvc *questions.Service
|
questionsSvc *questions.Service
|
||||||
faqSvc *faqs.Service
|
faqSvc *faqs.Service
|
||||||
emailTemplateSvc *emailtemplates.Service
|
|
||||||
personaSvc *personas.Service
|
personaSvc *personas.Service
|
||||||
examPrepSvc *examprep.Service
|
examPrepSvc *examprep.Service
|
||||||
programSvc *programs.Service
|
programSvc *programs.Service
|
||||||
|
|
@ -61,7 +58,6 @@ type Handler struct {
|
||||||
practiceSvc *practices.Service
|
practiceSvc *practices.Service
|
||||||
subscriptionsSvc *subscriptions.Service
|
subscriptionsSvc *subscriptions.Service
|
||||||
arifpaySvc *arifpay.ArifpayService
|
arifpaySvc *arifpay.ArifpayService
|
||||||
chapaSvc *chapa.Service
|
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
settingSvc *settings.Service
|
settingSvc *settings.Service
|
||||||
notificationSvc *notificationservice.Service
|
notificationSvc *notificationservice.Service
|
||||||
|
|
@ -88,7 +84,6 @@ func New(
|
||||||
assessmentSvc *assessment.Service,
|
assessmentSvc *assessment.Service,
|
||||||
questionsSvc *questions.Service,
|
questionsSvc *questions.Service,
|
||||||
faqSvc *faqs.Service,
|
faqSvc *faqs.Service,
|
||||||
emailTemplateSvc *emailtemplates.Service,
|
|
||||||
personaSvc *personas.Service,
|
personaSvc *personas.Service,
|
||||||
examPrepSvc *examprep.Service,
|
examPrepSvc *examprep.Service,
|
||||||
programSvc *programs.Service,
|
programSvc *programs.Service,
|
||||||
|
|
@ -99,7 +94,6 @@ func New(
|
||||||
practiceSvc *practices.Service,
|
practiceSvc *practices.Service,
|
||||||
subscriptionsSvc *subscriptions.Service,
|
subscriptionsSvc *subscriptions.Service,
|
||||||
arifpaySvc *arifpay.ArifpayService,
|
arifpaySvc *arifpay.ArifpayService,
|
||||||
chapaSvc *chapa.Service,
|
|
||||||
logger *slog.Logger,
|
logger *slog.Logger,
|
||||||
settingSvc *settings.Service,
|
settingSvc *settings.Service,
|
||||||
notificationSvc *notificationservice.Service,
|
notificationSvc *notificationservice.Service,
|
||||||
|
|
@ -125,7 +119,6 @@ func New(
|
||||||
assessmentSvc: assessmentSvc,
|
assessmentSvc: assessmentSvc,
|
||||||
questionsSvc: questionsSvc,
|
questionsSvc: questionsSvc,
|
||||||
faqSvc: faqSvc,
|
faqSvc: faqSvc,
|
||||||
emailTemplateSvc: emailTemplateSvc,
|
|
||||||
personaSvc: personaSvc,
|
personaSvc: personaSvc,
|
||||||
examPrepSvc: examPrepSvc,
|
examPrepSvc: examPrepSvc,
|
||||||
programSvc: programSvc,
|
programSvc: programSvc,
|
||||||
|
|
@ -136,7 +129,6 @@ func New(
|
||||||
practiceSvc: practiceSvc,
|
practiceSvc: practiceSvc,
|
||||||
subscriptionsSvc: subscriptionsSvc,
|
subscriptionsSvc: subscriptionsSvc,
|
||||||
arifpaySvc: arifpaySvc,
|
arifpaySvc: arifpaySvc,
|
||||||
chapaSvc: chapaSvc,
|
|
||||||
logger: logger,
|
logger: logger,
|
||||||
settingSvc: settingSvc,
|
settingSvc: settingSvc,
|
||||||
notificationSvc: notificationSvc,
|
notificationSvc: notificationSvc,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"Yimaru-Backend/internal/domain"
|
"Yimaru-Backend/internal/domain"
|
||||||
"Yimaru-Backend/internal/services/chapa"
|
|
||||||
subscriptionsvc "Yimaru-Backend/internal/services/subscriptions"
|
subscriptionsvc "Yimaru-Backend/internal/services/subscriptions"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
@ -382,16 +381,14 @@ func (h *Handler) SubscribeWithPayment(c *fiber.Ctx) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use ArifPay service to initiate payment
|
// Use ArifPay service to initiate payment
|
||||||
result, err := h.chapaSvc.InitiateSubscriptionPayment(c.Context(), userID, domain.InitiateSubscriptionPaymentRequest{
|
result, err := h.arifpaySvc.InitiateSubscriptionPayment(c.Context(), userID, domain.InitiateSubscriptionPaymentRequest{
|
||||||
PlanID: req.PlanID,
|
PlanID: req.PlanID,
|
||||||
Phone: req.Phone,
|
Phone: req.Phone,
|
||||||
Email: req.Email,
|
Email: req.Email,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
status := fiber.StatusInternalServerError
|
status := fiber.StatusInternalServerError
|
||||||
if errors.Is(err, chapa.ErrChapaNotConfigured) {
|
if err.Error() == "user already has an active subscription" {
|
||||||
status = fiber.StatusServiceUnavailable
|
|
||||||
} else if err.Error() == "user already has an active subscription" {
|
|
||||||
status = fiber.StatusConflict
|
status = fiber.StatusConflict
|
||||||
} else if err.Error() == "subscription plan is not active" {
|
} else if err.Error() == "subscription plan is not active" {
|
||||||
status = fiber.StatusBadRequest
|
status = fiber.StatusBadRequest
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ func (a *App) initAppRoutes() {
|
||||||
a.assessmentSvc,
|
a.assessmentSvc,
|
||||||
a.questionsSvc,
|
a.questionsSvc,
|
||||||
a.faqSvc,
|
a.faqSvc,
|
||||||
a.emailTemplateSvc,
|
|
||||||
a.personaSvc,
|
a.personaSvc,
|
||||||
a.examPrepSvc,
|
a.examPrepSvc,
|
||||||
a.programSvc,
|
a.programSvc,
|
||||||
|
|
@ -27,7 +26,6 @@ func (a *App) initAppRoutes() {
|
||||||
a.practiceSvc,
|
a.practiceSvc,
|
||||||
a.subscriptionsSvc,
|
a.subscriptionsSvc,
|
||||||
a.arifpaySvc,
|
a.arifpaySvc,
|
||||||
a.chapaSvc,
|
|
||||||
a.logger,
|
a.logger,
|
||||||
a.settingSvc,
|
a.settingSvc,
|
||||||
a.NotidicationStore,
|
a.NotidicationStore,
|
||||||
|
|
@ -197,16 +195,6 @@ func (a *App) initAppRoutes() {
|
||||||
groupV1.Put("/admin/faqs/:id", a.authMiddleware, a.RequirePermission("faqs.update"), h.UpdateFAQ)
|
groupV1.Put("/admin/faqs/:id", a.authMiddleware, a.RequirePermission("faqs.update"), h.UpdateFAQ)
|
||||||
groupV1.Delete("/admin/faqs/:id", a.authMiddleware, a.RequirePermission("faqs.delete"), h.DeleteFAQ)
|
groupV1.Delete("/admin/faqs/:id", a.authMiddleware, a.RequirePermission("faqs.delete"), h.DeleteFAQ)
|
||||||
|
|
||||||
// Email templates
|
|
||||||
groupV1.Get("/admin/email-templates", a.authMiddleware, a.RequirePermission("email_templates.list"), h.ListEmailTemplatesAdmin)
|
|
||||||
groupV1.Get("/admin/email-templates/slug/:slug", a.authMiddleware, a.RequirePermission("email_templates.get"), h.GetEmailTemplateBySlugAdmin)
|
|
||||||
groupV1.Post("/admin/email-templates/slug/:slug/preview", a.authMiddleware, a.RequirePermission("email_templates.preview"), h.PreviewEmailTemplateBySlug)
|
|
||||||
groupV1.Get("/admin/email-templates/:id", a.authMiddleware, a.RequirePermission("email_templates.get"), h.GetEmailTemplateByIDAdmin)
|
|
||||||
groupV1.Post("/admin/email-templates/:id/preview", a.authMiddleware, a.RequirePermission("email_templates.preview"), h.PreviewEmailTemplateByID)
|
|
||||||
groupV1.Post("/admin/email-templates", a.authMiddleware, a.RequirePermission("email_templates.create"), h.CreateEmailTemplate)
|
|
||||||
groupV1.Put("/admin/email-templates/:id", a.authMiddleware, a.RequirePermission("email_templates.update"), h.UpdateEmailTemplate)
|
|
||||||
groupV1.Delete("/admin/email-templates/:id", a.authMiddleware, a.RequirePermission("email_templates.delete"), h.DeleteEmailTemplate)
|
|
||||||
|
|
||||||
// Question Sets
|
// Question Sets
|
||||||
groupV1.Post("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.create"), h.CreateQuestionSet)
|
groupV1.Post("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.create"), h.CreateQuestionSet)
|
||||||
groupV1.Get("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.list"), h.GetQuestionSetsByType)
|
groupV1.Get("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.list"), h.GetQuestionSetsByType)
|
||||||
|
|
@ -243,15 +231,14 @@ func (a *App) initAppRoutes() {
|
||||||
groupV1.Post("/subscriptions/:id/cancel", a.authMiddleware, a.RequirePermission("subscriptions.cancel"), h.CancelSubscription)
|
groupV1.Post("/subscriptions/:id/cancel", a.authMiddleware, a.RequirePermission("subscriptions.cancel"), h.CancelSubscription)
|
||||||
groupV1.Put("/subscriptions/:id/auto-renew", a.authMiddleware, a.RequirePermission("subscriptions.set_auto_renew"), h.SetAutoRenew)
|
groupV1.Put("/subscriptions/:id/auto-renew", a.authMiddleware, a.RequirePermission("subscriptions.set_auto_renew"), h.SetAutoRenew)
|
||||||
|
|
||||||
// Payments (Chapa)
|
// Payments (ArifPay)
|
||||||
groupV1.Post("/payments/subscribe", a.authMiddleware, a.RequirePermission("payments.initiate"), h.InitiateSubscriptionPayment)
|
groupV1.Post("/payments/subscribe", a.authMiddleware, a.RequirePermission("payments.initiate"), h.InitiateSubscriptionPayment)
|
||||||
groupV1.Get("/payments/verify/:session_id", a.authMiddleware, a.RequirePermission("payments.verify"), h.VerifyPayment)
|
groupV1.Get("/payments/verify/:session_id", a.authMiddleware, a.RequirePermission("payments.verify"), h.VerifyPayment)
|
||||||
groupV1.Get("/payments", a.authMiddleware, a.RequirePermission("payments.list_mine"), h.GetMyPayments)
|
groupV1.Get("/payments", a.authMiddleware, a.RequirePermission("payments.list_mine"), h.GetMyPayments)
|
||||||
groupV1.Get("/payments/methods", h.GetChapaPaymentMethods)
|
groupV1.Get("/payments/methods", h.GetArifpayPaymentMethods)
|
||||||
groupV1.Get("/payments/:id", a.authMiddleware, a.RequirePermission("payments.get"), h.GetPaymentByID)
|
groupV1.Get("/payments/:id", a.authMiddleware, a.RequirePermission("payments.get"), h.GetPaymentByID)
|
||||||
groupV1.Post("/payments/:id/cancel", a.authMiddleware, a.RequirePermission("payments.cancel"), h.CancelPayment)
|
groupV1.Post("/payments/:id/cancel", a.authMiddleware, a.RequirePermission("payments.cancel"), h.CancelPayment)
|
||||||
groupV1.Post("/payments/webhook", h.HandleChapaWebhook)
|
groupV1.Post("/payments/webhook", h.HandleArifpayWebhook)
|
||||||
groupV1.Get("/payments/chapa/callback", h.HandleChapaCallback)
|
|
||||||
|
|
||||||
// Direct Payments
|
// Direct Payments
|
||||||
groupV1.Post("/payments/direct", a.authMiddleware, a.RequirePermission("payments.direct_initiate"), h.InitiateDirectPayment)
|
groupV1.Post("/payments/direct", a.authMiddleware, a.RequirePermission("payments.direct_initiate"), h.InitiateDirectPayment)
|
||||||
|
|
|
||||||
|
|
@ -1,543 +0,0 @@
|
||||||
{
|
|
||||||
"info": {
|
|
||||||
"_postman_id": "c4a8f2e1-9b3d-4c7a-a1e6-chapa-payments-01",
|
|
||||||
"name": "Chapa Subscription Payments",
|
|
||||||
"description": "Postman collection for Yimaru LMS Chapa subscription payment flow.\n\n## Setup\n1. Set `base_url` (default `http://localhost:8080`).\n2. Set `learner_email`, `learner_password`, and `learner_phone`.\n3. Set `chapa_webhook_secret` (same as `CHAPA_WEBHOOK_SECRET` in `.env`).\n4. Run **Customer Login** to populate `access_token`.\n5. Run **List Subscription Plans** to populate `plan_id`.\n6. Run **Subscribe with Payment** — open `payment_url` in a browser and complete Chapa test checkout.\n7. Run **Verify Payment** (uses `tx_ref` saved as `session_id`).\n\n## Notes\n- `session_id` in verify/cancel paths is Chapa `tx_ref` (UUID returned at checkout).\n- Webhook request includes a pre-request script that signs the body with HMAC-SHA256.\n- See `docs/CHAPA_INTEGRATION.md` for dashboard webhook URL configuration.",
|
|
||||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
|
||||||
},
|
|
||||||
"auth": {
|
|
||||||
"type": "bearer",
|
|
||||||
"bearer": [
|
|
||||||
{
|
|
||||||
"key": "token",
|
|
||||||
"value": "{{access_token}}",
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"variable": [
|
|
||||||
{
|
|
||||||
"key": "base_url",
|
|
||||||
"value": "http://localhost:8080"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "access_token",
|
|
||||||
"value": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "learner_email",
|
|
||||||
"value": "learner@example.com"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "learner_password",
|
|
||||||
"value": "your-password"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "learner_phone",
|
|
||||||
"value": "0912345678"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "plan_id",
|
|
||||||
"value": "1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "payment_id",
|
|
||||||
"value": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "tx_ref",
|
|
||||||
"value": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "payment_url",
|
|
||||||
"value": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "chapa_webhook_secret",
|
|
||||||
"value": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "chapa_ref_id",
|
|
||||||
"value": "APqDvYw1okk2"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"item": [
|
|
||||||
{
|
|
||||||
"name": "00 - Auth",
|
|
||||||
"item": [
|
|
||||||
{
|
|
||||||
"name": "Customer Login",
|
|
||||||
"event": [
|
|
||||||
{
|
|
||||||
"listen": "test",
|
|
||||||
"script": {
|
|
||||||
"exec": [
|
|
||||||
"pm.test('Status code is 200', function () {",
|
|
||||||
" pm.response.to.have.status(200);",
|
|
||||||
"});",
|
|
||||||
"const body = pm.response.json();",
|
|
||||||
"if (body.data && body.data.access_token) {",
|
|
||||||
" pm.collectionVariables.set('access_token', body.data.access_token);",
|
|
||||||
" pm.test('Access token saved', function () {",
|
|
||||||
" pm.expect(body.data.access_token).to.be.a('string').and.not.empty;",
|
|
||||||
" });",
|
|
||||||
"}"
|
|
||||||
],
|
|
||||||
"type": "text/javascript"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"request": {
|
|
||||||
"auth": {
|
|
||||||
"type": "noauth"
|
|
||||||
},
|
|
||||||
"method": "POST",
|
|
||||||
"header": [
|
|
||||||
{
|
|
||||||
"key": "Content-Type",
|
|
||||||
"value": "application/json"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"body": {
|
|
||||||
"mode": "raw",
|
|
||||||
"raw": "{\n \"email\": \"{{learner_email}}\",\n \"password\": \"{{learner_password}}\"\n}"
|
|
||||||
},
|
|
||||||
"url": {
|
|
||||||
"raw": "{{base_url}}/api/v1/auth/customer-login",
|
|
||||||
"host": ["{{base_url}}"],
|
|
||||||
"path": ["api", "v1", "auth", "customer-login"]
|
|
||||||
},
|
|
||||||
"description": "Authenticates a learner and saves `access_token` for subsequent requests."
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "01 - Subscription Plans",
|
|
||||||
"item": [
|
|
||||||
{
|
|
||||||
"name": "List Subscription Plans",
|
|
||||||
"event": [
|
|
||||||
{
|
|
||||||
"listen": "test",
|
|
||||||
"script": {
|
|
||||||
"exec": [
|
|
||||||
"pm.test('Status code is 200', function () {",
|
|
||||||
" pm.response.to.have.status(200);",
|
|
||||||
"});",
|
|
||||||
"const body = pm.response.json();",
|
|
||||||
"if (Array.isArray(body.data) && body.data.length > 0) {",
|
|
||||||
" pm.collectionVariables.set('plan_id', String(body.data[0].id));",
|
|
||||||
"}"
|
|
||||||
],
|
|
||||||
"type": "text/javascript"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"request": {
|
|
||||||
"auth": {
|
|
||||||
"type": "noauth"
|
|
||||||
},
|
|
||||||
"method": "GET",
|
|
||||||
"header": [],
|
|
||||||
"url": {
|
|
||||||
"raw": "{{base_url}}/api/v1/subscription-plans?active_only=true",
|
|
||||||
"host": ["{{base_url}}"],
|
|
||||||
"path": ["api", "v1", "subscription-plans"],
|
|
||||||
"query": [
|
|
||||||
{
|
|
||||||
"key": "active_only",
|
|
||||||
"value": "true"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"description": "Public list of active plans. Saves first plan `id` to `plan_id`."
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Get Subscription Plan by ID",
|
|
||||||
"request": {
|
|
||||||
"auth": {
|
|
||||||
"type": "noauth"
|
|
||||||
},
|
|
||||||
"method": "GET",
|
|
||||||
"header": [],
|
|
||||||
"url": {
|
|
||||||
"raw": "{{base_url}}/api/v1/subscription-plans/{{plan_id}}",
|
|
||||||
"host": ["{{base_url}}"],
|
|
||||||
"path": ["api", "v1", "subscription-plans", "{{plan_id}}"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "02 - Chapa Payment Flow",
|
|
||||||
"item": [
|
|
||||||
{
|
|
||||||
"name": "Subscribe with Payment (Checkout)",
|
|
||||||
"event": [
|
|
||||||
{
|
|
||||||
"listen": "test",
|
|
||||||
"script": {
|
|
||||||
"exec": [
|
|
||||||
"pm.test('Status code is 200', function () {",
|
|
||||||
" pm.response.to.have.status(200);",
|
|
||||||
"});",
|
|
||||||
"const body = pm.response.json();",
|
|
||||||
"if (body.data) {",
|
|
||||||
" if (body.data.payment_id) {",
|
|
||||||
" pm.collectionVariables.set('payment_id', String(body.data.payment_id));",
|
|
||||||
" }",
|
|
||||||
" if (body.data.session_id) {",
|
|
||||||
" pm.collectionVariables.set('tx_ref', body.data.session_id);",
|
|
||||||
" }",
|
|
||||||
" if (body.data.payment_url) {",
|
|
||||||
" pm.collectionVariables.set('payment_url', body.data.payment_url);",
|
|
||||||
" }",
|
|
||||||
" pm.test('Payment URL returned', function () {",
|
|
||||||
" pm.expect(body.data.payment_url).to.be.a('string').and.not.empty;",
|
|
||||||
" });",
|
|
||||||
"}"
|
|
||||||
],
|
|
||||||
"type": "text/javascript"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"header": [
|
|
||||||
{
|
|
||||||
"key": "Content-Type",
|
|
||||||
"value": "application/json"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"body": {
|
|
||||||
"mode": "raw",
|
|
||||||
"raw": "{\n \"plan_id\": {{plan_id}},\n \"phone\": \"{{learner_phone}}\",\n \"email\": \"{{learner_email}}\"\n}"
|
|
||||||
},
|
|
||||||
"url": {
|
|
||||||
"raw": "{{base_url}}/api/v1/subscriptions/checkout",
|
|
||||||
"host": ["{{base_url}}"],
|
|
||||||
"path": ["api", "v1", "subscriptions", "checkout"]
|
|
||||||
},
|
|
||||||
"description": "Primary learner endpoint. Returns Chapa `payment_url`. Open it in a browser to complete payment. `session_id` in the response is the Chapa `tx_ref`."
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Initiate Subscription Payment",
|
|
||||||
"event": [
|
|
||||||
{
|
|
||||||
"listen": "test",
|
|
||||||
"script": {
|
|
||||||
"exec": [
|
|
||||||
"const body = pm.response.json();",
|
|
||||||
"if (body.data) {",
|
|
||||||
" if (body.data.payment_id) pm.collectionVariables.set('payment_id', String(body.data.payment_id));",
|
|
||||||
" if (body.data.session_id) pm.collectionVariables.set('tx_ref', body.data.session_id);",
|
|
||||||
" if (body.data.payment_url) pm.collectionVariables.set('payment_url', body.data.payment_url);",
|
|
||||||
"}"
|
|
||||||
],
|
|
||||||
"type": "text/javascript"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"header": [
|
|
||||||
{
|
|
||||||
"key": "Content-Type",
|
|
||||||
"value": "application/json"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"body": {
|
|
||||||
"mode": "raw",
|
|
||||||
"raw": "{\n \"plan_id\": {{plan_id}},\n \"phone\": \"{{learner_phone}}\",\n \"email\": \"{{learner_email}}\"\n}"
|
|
||||||
},
|
|
||||||
"url": {
|
|
||||||
"raw": "{{base_url}}/api/v1/payments/subscribe",
|
|
||||||
"host": ["{{base_url}}"],
|
|
||||||
"path": ["api", "v1", "payments", "subscribe"]
|
|
||||||
},
|
|
||||||
"description": "Alias of checkout — same Chapa initialize flow."
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Verify Payment",
|
|
||||||
"request": {
|
|
||||||
"method": "GET",
|
|
||||||
"header": [],
|
|
||||||
"url": {
|
|
||||||
"raw": "{{base_url}}/api/v1/payments/verify/{{tx_ref}}",
|
|
||||||
"host": ["{{base_url}}"],
|
|
||||||
"path": ["api", "v1", "payments", "verify", "{{tx_ref}}"]
|
|
||||||
},
|
|
||||||
"description": "Verifies payment with Chapa using `tx_ref` (path param named `session_id` in the API). Run after completing checkout."
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Get My Payments",
|
|
||||||
"request": {
|
|
||||||
"method": "GET",
|
|
||||||
"header": [],
|
|
||||||
"url": {
|
|
||||||
"raw": "{{base_url}}/api/v1/payments?limit=20&offset=0",
|
|
||||||
"host": ["{{base_url}}"],
|
|
||||||
"path": ["api", "v1", "payments"],
|
|
||||||
"query": [
|
|
||||||
{
|
|
||||||
"key": "limit",
|
|
||||||
"value": "20"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "offset",
|
|
||||||
"value": "0"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Get Payment by ID",
|
|
||||||
"request": {
|
|
||||||
"method": "GET",
|
|
||||||
"header": [],
|
|
||||||
"url": {
|
|
||||||
"raw": "{{base_url}}/api/v1/payments/{{payment_id}}",
|
|
||||||
"host": ["{{base_url}}"],
|
|
||||||
"path": ["api", "v1", "payments", "{{payment_id}}"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Cancel Pending Payment",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"header": [],
|
|
||||||
"url": {
|
|
||||||
"raw": "{{base_url}}/api/v1/payments/{{payment_id}}/cancel",
|
|
||||||
"host": ["{{base_url}}"],
|
|
||||||
"path": ["api", "v1", "payments", "{{payment_id}}", "cancel"]
|
|
||||||
},
|
|
||||||
"description": "Only works while payment status is PENDING."
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Get Chapa Payment Methods",
|
|
||||||
"request": {
|
|
||||||
"auth": {
|
|
||||||
"type": "noauth"
|
|
||||||
},
|
|
||||||
"method": "GET",
|
|
||||||
"header": [],
|
|
||||||
"url": {
|
|
||||||
"raw": "{{base_url}}/api/v1/payments/methods",
|
|
||||||
"host": ["{{base_url}}"],
|
|
||||||
"path": ["api", "v1", "payments", "methods"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "03 - Subscription Status",
|
|
||||||
"item": [
|
|
||||||
{
|
|
||||||
"name": "Get My Subscription",
|
|
||||||
"request": {
|
|
||||||
"method": "GET",
|
|
||||||
"header": [],
|
|
||||||
"url": {
|
|
||||||
"raw": "{{base_url}}/api/v1/subscriptions/me",
|
|
||||||
"host": ["{{base_url}}"],
|
|
||||||
"path": ["api", "v1", "subscriptions", "me"]
|
|
||||||
},
|
|
||||||
"description": "Returns active subscription after successful payment."
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Check Subscription Status",
|
|
||||||
"request": {
|
|
||||||
"method": "GET",
|
|
||||||
"header": [],
|
|
||||||
"url": {
|
|
||||||
"raw": "{{base_url}}/api/v1/subscriptions/status",
|
|
||||||
"host": ["{{base_url}}"],
|
|
||||||
"path": ["api", "v1", "subscriptions", "status"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Get Subscription History",
|
|
||||||
"request": {
|
|
||||||
"method": "GET",
|
|
||||||
"header": [],
|
|
||||||
"url": {
|
|
||||||
"raw": "{{base_url}}/api/v1/subscriptions/history",
|
|
||||||
"host": ["{{base_url}}"],
|
|
||||||
"path": ["api", "v1", "subscriptions", "history"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "04 - Chapa Webhooks (no auth)",
|
|
||||||
"item": [
|
|
||||||
{
|
|
||||||
"name": "Chapa Webhook (charge.success)",
|
|
||||||
"event": [
|
|
||||||
{
|
|
||||||
"listen": "prerequest",
|
|
||||||
"script": {
|
|
||||||
"exec": [
|
|
||||||
"const secret = pm.collectionVariables.get('chapa_webhook_secret') || '';",
|
|
||||||
"const body = pm.request.body.raw || '';",
|
|
||||||
"if (!secret) {",
|
|
||||||
" console.warn('Set chapa_webhook_secret collection variable to sign the webhook');",
|
|
||||||
"}",
|
|
||||||
"const signature = CryptoJS.HmacSHA256(body, secret).toString(CryptoJS.enc.Hex);",
|
|
||||||
"pm.request.headers.upsert({ key: 'x-chapa-signature', value: signature });"
|
|
||||||
],
|
|
||||||
"type": "text/javascript"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"request": {
|
|
||||||
"auth": {
|
|
||||||
"type": "noauth"
|
|
||||||
},
|
|
||||||
"method": "POST",
|
|
||||||
"header": [
|
|
||||||
{
|
|
||||||
"key": "Content-Type",
|
|
||||||
"value": "application/json"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"body": {
|
|
||||||
"mode": "raw",
|
|
||||||
"raw": "{\n \"event\": \"charge.success\",\n \"type\": \"API\",\n \"tx_ref\": \"{{tx_ref}}\",\n \"reference\": \"{{chapa_ref_id}}\",\n \"status\": \"success\",\n \"amount\": \"500.00\",\n \"currency\": \"ETB\",\n \"payment_method\": \"telebirr\",\n \"mode\": \"test\"\n}"
|
|
||||||
},
|
|
||||||
"url": {
|
|
||||||
"raw": "{{base_url}}/api/v1/payments/webhook",
|
|
||||||
"host": ["{{base_url}}"],
|
|
||||||
"path": ["api", "v1", "payments", "webhook"]
|
|
||||||
},
|
|
||||||
"description": "Simulates Chapa webhook. Requires valid `tx_ref` from a real initialize call. Backend re-verifies with Chapa API before activating subscription. Set `chapa_webhook_secret` to match dashboard / `.env`."
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Chapa Callback (GET)",
|
|
||||||
"request": {
|
|
||||||
"auth": {
|
|
||||||
"type": "noauth"
|
|
||||||
},
|
|
||||||
"method": "GET",
|
|
||||||
"header": [],
|
|
||||||
"url": {
|
|
||||||
"raw": "{{base_url}}/api/v1/payments/chapa/callback?trx_ref={{tx_ref}}&ref_id={{chapa_ref_id}}&status=success",
|
|
||||||
"host": ["{{base_url}}"],
|
|
||||||
"path": ["api", "v1", "payments", "chapa", "callback"],
|
|
||||||
"query": [
|
|
||||||
{
|
|
||||||
"key": "trx_ref",
|
|
||||||
"value": "{{tx_ref}}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "ref_id",
|
|
||||||
"value": "{{chapa_ref_id}}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "status",
|
|
||||||
"value": "success"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"description": "Simulates Chapa redirect to `CHAPA_CALLBACK_URL`. Uses same verify flow as webhook."
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "05 - ArifPay Direct (legacy)",
|
|
||||||
"description": "OTP/direct payment flows still use ArifPay. Subscription checkout uses Chapa (folder 02).",
|
|
||||||
"item": [
|
|
||||||
{
|
|
||||||
"name": "Get Direct Payment Methods",
|
|
||||||
"request": {
|
|
||||||
"auth": {
|
|
||||||
"type": "noauth"
|
|
||||||
},
|
|
||||||
"method": "GET",
|
|
||||||
"header": [],
|
|
||||||
"url": {
|
|
||||||
"raw": "{{base_url}}/api/v1/payments/direct/methods",
|
|
||||||
"host": ["{{base_url}}"],
|
|
||||||
"path": ["api", "v1", "payments", "direct", "methods"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Initiate Direct Payment",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"header": [
|
|
||||||
{
|
|
||||||
"key": "Content-Type",
|
|
||||||
"value": "application/json"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"body": {
|
|
||||||
"mode": "raw",
|
|
||||||
"raw": "{\n \"plan_id\": {{plan_id}},\n \"phone\": \"{{learner_phone}}\",\n \"email\": \"{{learner_email}}\",\n \"payment_method\": \"TELEBIRR\"\n}"
|
|
||||||
},
|
|
||||||
"url": {
|
|
||||||
"raw": "{{base_url}}/api/v1/payments/direct",
|
|
||||||
"host": ["{{base_url}}"],
|
|
||||||
"path": ["api", "v1", "payments", "direct"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Verify Direct Payment OTP",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"header": [
|
|
||||||
{
|
|
||||||
"key": "Content-Type",
|
|
||||||
"value": "application/json"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"body": {
|
|
||||||
"mode": "raw",
|
|
||||||
"raw": "{\n \"session_id\": \"{{tx_ref}}\",\n \"otp\": \"123456\"\n}"
|
|
||||||
},
|
|
||||||
"url": {
|
|
||||||
"raw": "{{base_url}}/api/v1/payments/direct/verify-otp",
|
|
||||||
"host": ["{{base_url}}"],
|
|
||||||
"path": ["api", "v1", "payments", "direct", "verify-otp"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user