13 KiB
Gadai Mulia Collection Broadcast Integration
Complete Implementation Guide
📋 Overview
Purpose: Send collection invoices via WhatsApp (Sopiga Collar API) to Gadai Mulia customers.
Architecture:
Gadai Collection Service
→ INSERT broadcast_staging (JSON payload)
→ Go Worker (poll every 30s)
→ Query template_variable_mapping
→ Build message dynamically
→ POST Sopiga Collar API
→ WhatsApp delivery
→ Update status (delivered/failed)
Key Innovation: Flexible template system via template_variable_mapping table — NO code redeploy for new templates.
🗄️ Database Schema (Simplified Denormalized)
Master Configuration (Read-only)
Table: sopiga_template_config
- Stores Sopiga template references (pre-created in Sopiga)
- Fields:
id,sopiga_template_id,template_name,channel(waba),template_type,description,active - Pre-populate: One row per template in Sopiga
Table: template_variable_mapping
- Maps Sopiga template variables → message_payload keys
- CRITICAL:
variable_orderdetermines message interpolation sequence - Fields:
id,sopiga_template_id,variable_order,sopiga_variable_name,variable_type(string/integer/date/decimal),db_field_source,is_required,example_value - Example:
sopiga_template_id=2, order=1, name='Nama', field='nasabah_nama' sopiga_template_id=2, order=2, name='TotalTagihan', field='nominal_tagihan' sopiga_template_id=2, order=3, name='TanggalJatuhTempo', field='tanggal_tempo'
Table: sopiga_collar_config
- Stores Sopiga collar references (pre-created in Sopiga)
- Fields:
id,sopiga_collar_id,broadcast_name,sopiga_template_id(FK),status(open/closed),description - Pre-populate: One row per collar in Sopiga
Processing (Core)
Table: broadcast_staging ← Main table
- DENORMALIZED: All data in
message_payload(JSONB) - NO FK to nasabah or gadai_contract
- Fields:
id(PK)sopiga_collar_id(FK)sopiga_template_id(FK)message_payload(JSONB) ← Single source of truthstatus(pending → dispatched → delivered/failed)error_message,error_countsopiga_recipient_detail_id(response from Sopiga)created_at,updated_at,dispatched_at,delivered_at,failed_at
Example message_payload:
{
"nasabah_nama": "Budi Santoso",
"nasabah_phone": "6281234567890",
"nominal_tagihan": 1500000,
"tanggal_tempo": "2026-06-30",
"invoice_pdf_url": "https://storage.gadai.com/invoices/inv-001.pdf",
"contract_no": "GAD-2026-001"
}
Audit & Tracking
Table: broadcast_audit_log
- Tracks status transitions
- Fields:
id,broadcast_id(FK),old_status,new_status,reason,sopiga_response(JSONB),changed_by,created_at
Table: broadcast_error_log
- Detailed error tracking
- Fields:
id,broadcast_id(FK),error_type,error_code,error_message,error_details(JSONB),attempt_number,next_retry_at
Table: sopiga_sync_job
- Tracks delivery status sync from Sopiga
- Fields:
id,broadcast_id(FK),sopiga_recipient_detail_id,last_synced_at,last_status_from_sopiga,sync_count,next_sync_at
🔧 Setup Process
Step 1: Setup in Sopiga (Manual)
-
Create template in Sopiga:
POST https://omnix.promas.site/api/client/template { "template_name": "Collection Invoice", "channel": "waba", "variables": [ {"name": "Nama", "type": "string"}, {"name": "TotalTagihan", "type": "integer"}, {"name": "TanggalJatuhTempo", "type": "date"} ] } → Response: template_id = 2 -
Create collar in Sopiga:
POST https://omnix.promas.site/api/client/collar { "judul_broadcast": "Invoice May 2026", "template_id": 2 } → Response: collar_id = 70
Step 2: Register in Database (5 minutes)
2.1 Insert template config:
INSERT INTO collection_broadcasts.sopiga_template_config
(template_name, sopiga_template_id, channel, template_type, description)
VALUES
('Collection Invoice May 2026', 2, 'waba', 'utility', 'Invoice bulanan');
2.2 Insert variable mappings (ORDER IS CRITICAL!):
INSERT INTO collection_broadcasts.template_variable_mapping
(sopiga_template_id, variable_order, sopiga_variable_name, variable_type, db_field_source, is_required, example_value)
VALUES
(2, 1, 'Nama', 'string', 'nasabah_nama', TRUE, 'Budi Santoso'),
(2, 2, 'TotalTagihan', 'integer', 'nominal_tagihan', TRUE, '1500000'),
(2, 3, 'TanggalJatuhTempo', 'date', 'tanggal_tempo', TRUE, '2026-06-30');
2.3 Insert collar config:
INSERT INTO collection_broadcasts.sopiga_collar_config
(broadcast_name, sopiga_collar_id, sopiga_template_id, status)
VALUES
('Collection Invoices May 2026', 70, 2, 'open');
Step 3: Deploy Application
Deploy Go worker with:
- Database connection string
- Sopiga base URL:
https://omnix.promas.site - Sopiga API token
Worker runs continuously:
- Broadcast dispatch: Poll every 30s, process pending records
- Status sync: Poll every 5 min, sync delivery status from Sopiga
🔄 How It Works
Runtime Flow
Gadai Collection Service (sends data):
// When collection reminder needed:
payload := map[string]interface{}{
"nasabah_nama": "Budi Santoso",
"nasabah_phone": "6281234567890",
"nominal_tagihan": 1500000.0,
"tanggal_tempo": "2026-06-30",
"invoice_pdf_url": "https://storage.gadai.com/invoices/inv-001.pdf",
"contract_no": "GAD-2026-001",
}
// Insert to broadcast_staging
db.Exec(`
INSERT INTO collection_broadcasts.broadcast_staging
(sopiga_collar_id, sopiga_template_id, message_payload, status)
VALUES ($1, $2, $3, 'pending')
`, 70, 2, payload)
Go Worker (processes data - automatic):
1. Poll broadcast_staging WHERE status='pending' LIMIT 100
2. For each record:
a. Get message_payload from database (already has all fields)
b. Query template_variable_mapping WHERE sopiga_template_id=2 ORDER BY variable_order
c. Build message:
- For each variable in order (1,2,3...):
- Get value from payload[db_field_source]
- Format by type (string/integer/date)
- Append to parts[]
- message = parts.join("#")
- Result: "Budi Santoso#1500000#2026-06-30"
d. Call Sopiga API:
POST /api/client/collar/add-recipient
{
"broadcast_id": 70,
"template_id": 2,
"details": {
"recipient": "6281234567890",
"message": "Budi Santoso#1500000#2026-06-30"
},
"attachment": {
"type": "document",
"file": "https://storage.gadai.com/invoices/inv-001.pdf"
}
}
e. Response (HTTP 202 Accepted):
{
"success": true,
"data": {
"recipient_detail_id": 512,
"status": "pending"
}
}
f. Update broadcast_staging:
UPDATE status='dispatched', sopiga_recipient_detail_id=512
3. Status sync worker (every 5 min):
- For dispatched records:
- GET /api/client/collar/add-recipient/{recipient_detail_id}/detail
- Update status: delivered OR failed
📊 Adding New Template (Zero Code Change)
Scenario: Template now has 4 variables instead of 3
Old:
1. Nama
2. TotalTagihan
3. TanggalJatuhTempo
New:
1. Nama
2. NoKontrak ← NEW!
3. TotalTagihan
4. TanggalJatuhTempo
Setup (5 minutes, Database only)
-
Create template in Sopiga → template_id = 3
-
Create collar in Sopiga → collar_id = 71
-
Register in database:
-- Template
INSERT INTO sopiga_template_config (template_name, sopiga_template_id, ...)
VALUES ('Collection Invoice Extended', 3, ...);
-- Variables (URUTAN PENTING!)
INSERT INTO template_variable_mapping
(sopiga_template_id, variable_order, sopiga_variable_name, variable_type, db_field_source, ...)
VALUES
(3, 1, 'Nama', 'string', 'nasabah_nama', ...),
(3, 2, 'NoKontrak', 'string', 'contract_no', ...), ← NEW!
(3, 3, 'TotalTagihan', 'integer', 'nominal_tagihan', ...),
(3, 4, 'TanggalJatuhTempo', 'date', 'tanggal_tempo', ...);
-- Collar
INSERT INTO sopiga_collar_config (broadcast_name, sopiga_collar_id, sopiga_template_id, ...)
VALUES ('Collection Invoices June 2026', 71, 3, ...);
Go code? ✅ ZERO changes — buildDynamicMessage() queries mapping every time.
🚀 Gadai Service Integration
How Gadai Collection Service calls this:
package gadai
type CollectionBroadcastService struct {
db *sql.DB
}
func (s *CollectionBroadcastService) SendInvoiceReminder(
ctx context.Context,
nasabahID, contractNo string,
nominalTagihan float64,
dueDate time.Time,
) error {
// Get invoice URL (from Gadai storage or API)
invoiceURL := fmt.Sprintf("https://storage.gadai.com/invoices/%s.pdf", contractNo)
// Build payload (flexible - can have extra fields)
payload := map[string]interface{}{
"nasabah_nama": "Budi Santoso",
"nasabah_phone": "6281234567890",
"nominal_tagihan": nominalTagihan,
"tanggal_tempo": dueDate.Format("2006-01-02"),
"invoice_pdf_url": invoiceURL,
"contract_no": contractNo,
"cif": "12345", // Extra field - ignored by worker if not in mapping
}
// Marshal to JSONB
payloadJSON, _ := json.Marshal(payload)
// Insert to broadcast_staging
// Worker will automatically pick it up (every 30s poll)
query := `
INSERT INTO collection_broadcasts.broadcast_staging
(sopiga_collar_id, sopiga_template_id, message_payload, status)
VALUES ($1, $2, $3, 'pending')
`
_, err := s.db.ExecContext(ctx, query,
70, // sopiga_collar_id (hardcoded or from config)
2, // sopiga_template_id (hardcoded or from config)
payloadJSON,
)
return err
}
📈 Monitoring & Reporting
Views Available
v_template_variables_ordered
SELECT * FROM collection_broadcasts.v_template_variables_ordered;
-- Shows all template variables with proper ordering
v_collar_summary
SELECT * FROM collection_broadcasts.v_collar_summary;
-- Shows delivery rate per collar
v_delivery_rate_24h
SELECT * FROM collection_broadcasts.v_delivery_rate_24h;
-- Shows delivery rate for last 24 hours
v_failed_records_24h
SELECT * FROM collection_broadcasts.v_failed_records_24h;
-- Shows failed records with error details
🛠️ Troubleshooting
Issue: Message format wrong
Cause: variable_order in mapping doesn't match Sopiga template order
Fix:
-- Check current mapping
SELECT variable_order, sopiga_variable_name FROM template_variable_mapping
WHERE sopiga_template_id=2 ORDER BY variable_order;
-- If order wrong, delete and re-insert correctly
DELETE FROM template_variable_mapping WHERE sopiga_template_id=2;
-- Re-insert with correct order
Issue: Missing field in message_payload
Cause: Gadai service didn't include field in payload, but it's marked required in mapping
Fix:
-- Either make it optional
UPDATE template_variable_mapping SET is_required=FALSE
WHERE sopiga_variable_name='NoKontrak';
-- Or ensure Gadai service includes it
Issue: Delivery status stuck at 'dispatched'
Cause: Status sync worker not running or Sopiga API unreachable
Fix:
-- Check sync jobs
SELECT * FROM sopiga_sync_job WHERE next_sync_at < NOW();
-- Manually trigger status check
SELECT collection_broadcasts.get_template_variables(2);
🔐 Security Considerations
- Sopiga API Token: Store in environment variable, never hardcode
- Database: Restrict access to
collection_broadcastsschema - Phone Numbers: Already PII, treat as sensitive
- PDFs: Ensure URLs are time-limited or access-controlled
📋 Checklist: New Template
- Create template in Sopiga, note
template_id - Create collar in Sopiga, note
collar_id - Insert
sopiga_template_configrow - Insert
template_variable_mappingrows (check order!) - Insert
sopiga_collar_configrow - Verify mapping:
SELECT * FROM v_template_variables_ordered WHERE sopiga_template_id=X - Test with sample record in
broadcast_staging - Monitor first 10 deliveries in logs
- Confirm in
v_collar_summarydelivery rate
📞 Support
Schema files:
gadai_collection_broadcast_simplified_schema.sql— Complete DDL
Application files:
collection_broadcast_worker_simplified.go— Go worker implementation
Deployment:
- Requires: PostgreSQL 11+, Go 1.16+
- Connects to: Sopiga API at
https://omnix.promas.site
Version: 1.0
Last Updated: 2026-08-03
Status: Ready for deployment ✅