initial commit

This commit is contained in:
Ali Arya 2025-07-07 18:48:38 +03:30
commit 68477ea8b1
29 changed files with 2850 additions and 0 deletions

36
.env.example Normal file
View File

@ -0,0 +1,36 @@
# Required
# Required for logging into LHDN as an intermediary.
TIN=C12345678901
# Required
# Required for logging into LHDN as an intermediary.
CLIENT_ID=j19sd19s-9120-1892-nmv9-128vnak129z0
# Required
# Required for logging into LHDN as an intermediary.
CLIENT_SECRET_1=j19sd19s-9120-1892-nmv9-128vnak129z0
# Optional
# Used as a fallback in case CLIENT_SECRET_1 does not work.
CLIENT_SECRET_2=j19sd19s-9120-1892-nmv9-128vnak129z0
# Required
# Required for signing the documents.
CERTIFICATE_FILENAME=path/to/certificate.p12
# Required
# Required for signing the documents.
CERTIFICATE_PASSWORD=certificate_password
# Optional
# Used only on debug builds.
IRALEX_BACKEND_BASE_URL=https://api.example.com/
IRALEX_AUTH_EMAIL=example@email.com
IRALEX_AUTH_PASSWORD=account_password
# Optional
# Specifies the port the application listens on. Note that the command-line
# argument -port has a higher priority than the PORT environment variable, and
# overwrites it if specified. If a port is not explicitly specified, then a
# default port is used.
PORT=8000

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
.git/
.vscode/
.env
cache.txt
logs.txt
Untitled-1.txt
*.p12
*.json
*.exe

57
README.md Normal file
View File

@ -0,0 +1,57 @@
## Build
Requires go 1.24.3 and later (as specified in `go.mod`).
##### Windows
```powershell
C:\> go build -o iralex-einvoice.exe
```
##### Linux & MacOS
Todo...
## Usage
1. Create the **required** environment variables according to the `.env.example` file. If not provided, the application aborts.
2. Run the program:
```bash
root@ubuntu:~$ iralex-einvoice.exe
Listening on http://0.0.0.0:1404
```
3. The _`GET /ping`_ route can be used to check if the server is running properly.
4. The _`GET /submit`_ route can be used to submit an invoice. The body must have the necessary invoice data to submit.
## Port
You can specify the port the application listens on. There are two ways to do this. You can either specify the `PORT` environment variable.
**Linux and MacOS:**
```bash
export PORT=8080
```
**Windows:**
```powershell
set PORT=8080
```
**.env File:**
```ini
PORT=8080
```
Or you can specify the port inline using the `-port` command-line argument:
```bash
root@ubuntu:~$ iralex-einvoice.exe -port=8080
Listening on http://0.0.0.0:8080
```
Note that `-port` (command-line argument) has a higher priority than `PORT` (environment variable). So specifying `-port` overwrites everything else. If nothing is specified, a default port will be opened.
## Package Structure
- `config`: Some configurations for the application, such as default timeout, default port, debug mode, etc.
- `core`: Core functionalities, including logging, environment variables, command-line arguments, and a very simple file-based caching system.
- `document`: Document generation, signing, etc.
- `route`: The third-party API routes the application calls into, such as those of Iralex and LHDN.
- `server`: A simple HTTP server.

16
config/config.go Normal file
View File

@ -0,0 +1,16 @@
// This package contains configurations of the program.
package config
const (
DEBUG = true
PRODUCTION_URL = "https://api.myinvois.hasil.gov.my"
SANDBOX_URL = "https://preprod-api.myinvois.hasil.gov.my"
BASE_URL = PRODUCTION_URL
DEFAULT_PORT = "1404"
// How long to wait for a response from the server before timing out. In
// seconds. This is not forced, and any request can pick its own timeout.
DEFAULT_TIMEOUT = 30
)

130
core/cache.go Normal file
View File

@ -0,0 +1,130 @@
package core
import (
"errors"
"io"
"os"
"strings"
)
const (
CACHE_FILENAME = "cache.txt"
CACHE_ERROR = "-"
)
// =============================================================================
// CacheItem
// =============================================================================
// An enumeration type that represents the values which can be stored in the
// cache.
type CacheItem int
const (
// LHDN access token
CACHE_ITEM_LHDN_TOKEN CacheItem = iota
// LHDN access token expiration date and time in UTC
CACHE_ITEM_LHDN_EXPIRATION_DATE
// Iralex access token (used for debug purposes only)
CACHE_ITEM_IRALEX_TOKEN
// Iralex access token expiration date and time in UTC (used for debug purposes only)
CACHE_ITEM_IRALEX_EXPIRATION_DATE
CACHE_ITEM_LENGTH
)
var cacheItemNames = map[CacheItem]string{
CACHE_ITEM_LHDN_TOKEN: "LHDNToken",
CACHE_ITEM_LHDN_EXPIRATION_DATE: "LHDNExpirationDate",
CACHE_ITEM_IRALEX_TOKEN: "IralexToken",
CACHE_ITEM_IRALEX_EXPIRATION_DATE: "IralexExpirationDate",
}
func (cv CacheItem) String() string {
return cacheItemNames[cv]
}
// =============================================================================
// Initialization
// =============================================================================
// Initializes the cache file. If a cache file already exists, then we're good,
// but if it doesn't, creates a new cache file with all error values. In case
// the cache file exists, checks to see whether its corrupted or not. In case of
// corruption, fixes it by clearing the cache file with error values.
func SetUpCache() {
file, err := os.OpenFile(CACHE_FILENAME, os.O_RDONLY, 0644)
if errors.Is(err, os.ErrNotExist) {
LogInfo.Printf("INFO: Cache does not exist; creating one...\n")
cacheClear()
} else {
defer file.Close()
cache, err := io.ReadAll(file)
if err != nil {
LogInfo.Printf("ERROR: Cannot read the cache file: %v\n", err)
return
}
var lines []string = strings.Split(string(cache), "\n")
if len(lines) < int(CACHE_ITEM_LENGTH) {
LogInfo.Println("WARNING: Cache file was corrupted; fixing...")
cacheClear()
}
}
}
func cacheClear() {
var out string
for i := 0; i < int(CACHE_ITEM_LENGTH); i++ {
out += CACHE_ERROR + "\n"
}
err := os.WriteFile(CACHE_FILENAME, []byte(out), 0644)
if err != nil {
LogInfo.Printf("ERROR: Cannot write to the cache file: %v\n", err)
}
LogInfo.Println("INFO: Cache file cleared.")
}
// =============================================================================
// Writer Functions
// =============================================================================
func CacheSet(item CacheItem, value string) {
cache, err := os.ReadFile(CACHE_FILENAME)
if err != nil {
LogInfo.Printf("ERROR: Cannot read the cache file: %v\n", err)
return
}
var lines []string = strings.Split(string(cache), "\n")
if len(lines) < int(CACHE_ITEM_LENGTH) {
LogInfo.Printf("ERROR: Cache file is corrupted; resetting the cache file.\n")
cacheClear()
return
}
lines[item] = value
err = os.WriteFile(CACHE_FILENAME, []byte(strings.Join(lines, "\n")), 0644)
if err != nil {
LogInfo.Printf("ERROR: Cannot write to the cache file: %v\n", err)
}
}
// =============================================================================
// Reader Functions
// =============================================================================
func CacheGet(item CacheItem) string {
text, err := os.ReadFile(CACHE_FILENAME)
if err != nil {
LogInfo.Printf("ERROR: Cannot read the cache file - ignoring the CacheGet call: %v\n", err)
return CACHE_ERROR
}
var lines []string = strings.Split(string(text), "\n")
if len(lines) < int(CACHE_ITEM_LENGTH) {
LogInfo.Printf("ERROR: Cache file is corrupted; resetting the cache file.\n")
cacheClear()
return CACHE_ERROR
}
return lines[item]
}

35
core/cmdargs.go Normal file
View File

@ -0,0 +1,35 @@
package core
import (
"flag"
"iralex-einvoice/config"
)
// List of command-line flags the program understands.
var (
port string = config.DEFAULT_PORT
)
func SetUpCommandLineArguments() {
flag.StringVar(&port, "port", "1404", "sets the localhost port the server will listen to e.g. -port=8000")
flag.Parse()
if PORT == "" || isFlagPassed("port") {
// The second condition means if the flag was passed, always overwrite
// the env variable. So the priority is:
// command-line arg > env variable > default value
PORT = port
}
}
// Checks whether a command-line flag was passed to the program or not.
func isFlagPassed(name string) bool {
found := false
flag.Visit(func(f *flag.Flag) {
if f.Name == name {
found = true
}
})
return found
}

118
core/env.go Normal file
View File

@ -0,0 +1,118 @@
package core
import (
"fmt"
"iralex-einvoice/config"
"os"
"strings"
"github.com/joho/godotenv"
)
// List of environment variables that the program understands.
var (
TIN string
CLIENT_ID string
CLIENT_SECRET_1 string
CLIENT_SECRET_2 string
CERTIFICATE_FILENAME string // path to a PKCS12 file e.g. cert.p12, static/1f92.p12, etc
CERTIFICATE_PASSWORD string // the PIN of the aforementioned PKCS12 file
PORT string // the port the program listens to
IRALEX_BACKEND_BASE_URL string
IRALEX_AUTH_EMAIL string // email address used to get an access token from the Iralex backend
IRALEX_AUTH_PASSWORD string // password used to get an access token from the Iralex backend
// We do not directly connect to the database anymore. Rather another
// app calls into us and provides us with the necessary information.
// Uncomment if needed.
// DB_HOST string
// DB_PORT, _ string
// DB_USER string
// DB_PASSWORD string
// DB_NAME string
)
func SetUpEnvironmentVariables() {
err := godotenv.Load(".env")
if err != nil {
LogInfo.Println("INFO: Cannot open .env file.")
}
TIN = os.Getenv("TIN") // required
CLIENT_ID = os.Getenv("CLIENT_ID") // required
CLIENT_SECRET_1 = os.Getenv("CLIENT_SECRET_1") // required
CLIENT_SECRET_2 = os.Getenv("CLIENT_SECRET_2") // optional
CERTIFICATE_FILENAME = os.Getenv("CERTIFICATE_FILENAME") // required
CERTIFICATE_PASSWORD = os.Getenv("CERTIFICATE_PASSWORD") // required
PORT = os.Getenv("PORT")
IRALEX_BACKEND_BASE_URL = os.Getenv("IRALEX_BACKEND_BASE_URL") // required
IRALEX_AUTH_EMAIL = os.Getenv("IRALEX_AUTH_EMAIL") // optional
IRALEX_AUTH_PASSWORD = os.Getenv("IRALEX_AUTH_PASSWORD") // optional
// Uncomment if needed.
// DB_HOST = os.Getenv("DB_HOST")
// DB_PORT, _ = strconv.Atoi(os.Getenv("DB_PORT"))
// DB_USER = os.Getenv("DB_USER")
// DB_PASSWORD = os.Getenv("DB_PASSWORD")
// DB_NAME = os.Getenv("DB_NAME")
_ = CLIENT_SECRET_2
mustTerminate := false
// Required Environment Variables
if TIN == "" {
fmt.Printf("\033[1;31mError: Missing required environment variable: TIN\033[0m\n")
mustTerminate = true
}
if CLIENT_ID == "" {
fmt.Printf("\033[1;31mError: Missing required environment variable: CLIENT_ID\033[0m\n")
mustTerminate = true
}
if CLIENT_SECRET_1 == "" {
fmt.Printf("\033[1;31mError: Missing required environment variable: CLIENT_SECRET_1\033[0m\n")
mustTerminate = true
}
if CERTIFICATE_FILENAME == "" {
fmt.Printf("\033[1;31mError: Missing required environment variable: CERTIFICATE_FILENAME\033[0m\n")
mustTerminate = true
}
if CERTIFICATE_PASSWORD == "" {
fmt.Printf("\033[1;31mError: Missing required environment variable: CERTIFICATE_PASSWORD\033[0m\n")
mustTerminate = true
}
// Debug-only Required Environment Variables
if config.DEBUG {
if IRALEX_BACKEND_BASE_URL == "" {
fmt.Printf("\033[1;31mError: Missing required environment variable: IRALEX_BACKEND_BASE_URL\033[0m\n")
mustTerminate = true
} else if len(IRALEX_BACKEND_BASE_URL) > 1 {
IRALEX_BACKEND_BASE_URL = strings.TrimSuffix(IRALEX_BACKEND_BASE_URL, "/")
}
if IRALEX_AUTH_EMAIL == "" {
fmt.Printf("\033[1;31mError: Missing required environment variable: IRALEX_AUTH_EMAIL\033[0m\n")
mustTerminate = true
}
if IRALEX_AUTH_PASSWORD == "" {
fmt.Printf("\033[1;31mError: Missing required environment variable: IRALEX_AUTH_PASSWORD\033[0m\n")
mustTerminate = true
}
}
// Production-only Required Environment Variables
if !config.DEBUG {
// add more here...
}
if mustTerminate {
fmt.Printf("Provide the specified environment variables first in order to proceed further.\n")
os.Exit(1)
}
}

19
core/log.go Normal file
View File

@ -0,0 +1,19 @@
package core
import (
"log"
"os"
)
// Use this logger to log non-fatal information i.e. things that do not indicate
// errors that should terminate the program immediately. It can contain infos,
// warnings, errors, and other types of logs.
var LogInfo *log.Logger
func SetUpLogging() {
logsFile, err := os.OpenFile("logs.txt", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0666)
if err != nil {
log.Fatalf("Error: Cannot open or create \"logs.txt\" file.")
}
LogInfo = log.New(logsFile, "", log.Ldate|log.Ltime|log.Lshortfile)
}

1
database/constants.go Normal file
View File

@ -0,0 +1 @@
package database

92
database/db.go Normal file
View File

@ -0,0 +1,92 @@
package database
import (
"database/sql"
"fmt"
"log"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
)
func New(host string, port int, user string, password string, name string) (*Database, error) {
psqlInfo := fmt.Sprintf(
"host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
host,
port,
user,
password,
name)
db, err := sql.Open("postgres", psqlInfo)
if err != nil {
return nil, err
}
result := &Database{
db: db,
}
return result, nil
}
func New2(host string, port int, user string, password string, name string) {
// connectionString := "user=iralex dbname=iralex password=thestrongpassword!!!!!2 host=ira-lex.postgres.database.azure.com"
connectionString := fmt.Sprintf("user=%s dbname=%s password=%s host=%s sslmode=disable", user, name, password, host)
db, err := sqlx.Connect("postgres", connectionString)
if err != nil {
log.Fatalln(err)
}
defer db.Close()
// Test the connection to the database
if err := db.Ping(); err != nil {
log.Fatal(err)
} else {
log.Println("New2: Successfully Connected")
}
}
func (d *Database) Close() {
d.db.Close()
}
func (d *Database) CheckConnection() error {
err := d.db.Ping()
if err != nil {
panic(err)
}
return nil
}
func (d *Database) GetBill(billId int) {
rows, err := d.db.Query(
"SELECT id, factor_no, issue_date, type, is_old_version FROM billing_bill WHERE id = $1",
billId)
if err != nil {
panic(err.Error())
}
defer rows.Close()
// tmp := Bill{}
// for rows.Next() {
// log.Println("Iterating over rows")
// err = rows.Scan(
// &tmp.Id,
// &tmp.FactorNumber,
// &tmp.IssueDate,
// &tmp.Type,
// &tmp.IsOldVersion)
// if err != nil {
// log.Fatalln(err)
// }
// log.Printf("Row: %#v\n", tmp)
// }
err = rows.Err()
if err != nil {
panic(err.Error())
}
}

View File

@ -0,0 +1,7 @@
package database
import "database/sql"
type Database struct {
db *sql.DB
}

195
document/generate.go Normal file
View File

@ -0,0 +1,195 @@
package document
import (
"fmt"
"iralex-einvoice/route"
"strconv"
"time"
)
// Converts a bill object to a document (UBL invoice) object.
//
// Pre-conditions:
// The following pre-conditions on the bill parameter must be satisified by the
// caller. If not, the function might succeed, but submitting documents to LHDN
// will silently fail.
//
// - only billings can be submitted, fund requests, or official receipts are not accepted
// - both the firm and client of a bill must have valid addresses (only Addresses[0] will be used)
// - both the firm and the contact must have a country of Malaysia, and a currency of RM
// - states of both the client and the firm must be valid according to getStateCode()
// - the firm's MSIC code must not be null; not doing so results in a panic
// - the firm's TIN must not be null (Accounts.Tin[i].Code); not doing so results in a panic
// - the firm BRN must not be null; not doing so results in a panic
// - bill must have at least one matter associated with it (only Matters[0] will be used)
// - bill's firm phone number, and must be in valid format i.e. +60XXXXXXXXXX
func Generate(bill route.IralexBill) UBLInvoiceRoot {
doc := UBLInvoiceRoot{}
clientName := ""
referenceCode := ""
switch bill.Bill.ToContact.ContactType {
case route.IralexContactType_Person:
// no need to validate FirstName and LastName as they are non-null
clientName = bill.Bill.ToContact.Person.FirstName + " " + bill.Bill.ToContact.Person.LastName
case route.IralexContactType_Company:
// no need to validate Compnay.Name as it is non-null
clientName = bill.Bill.ToContact.Company.Name
}
if bill.Bill.Matters[0].ReferenceCode != nil {
referenceCode = *bill.Bill.Matters[0].ReferenceCode
}
invoiceName := fmt.Sprint("INV-", strconv.Itoa(bill.Bill.FactorNo), "-", referenceCode, "(", clientName, ")")
doc.D = "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
doc.A = "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
doc.B = "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"
// ~~~~~ Invoice
doc.Invoice = []UBLInvoice{{}}
doc.Invoice[0].ID = []UBLText{{Value: invoiceName}}
doc.Invoice[0].IssueDate = []UBLText{{Value: time.Now().UTC().Format(time.DateOnly)}}
doc.Invoice[0].IssueTime = []UBLText{{Value: time.Now().UTC().Format("15:04:05Z")}}
doc.Invoice[0].InvoiceTypeCode = []UBLInvoiceTypeCode{{
Value: "01", // Invoice
ListVersionID: "1.1", // Invoice v1.1
}}
doc.Invoice[0].DocumentCurrencyCode = []UBLText{{Value: "MYR"}}
doc.Invoice[0].TaxCurrencyCode = []UBLText{{Value: "MYR"}}
// ~~~~~ Seller
suppliersSSTNumber := bill.FirmSetting.Setting.Accounts.Tin[len(bill.FirmSetting.Setting.Accounts.Tin)-1].SSTRegisterationNumber
if suppliersSSTNumber == nil {
na := "NA"
suppliersSSTNumber = &na
}
doc.Invoice[0].AccountingSupplierParty = []UBLPartyWrapper{{}}
doc.Invoice[0].AccountingSupplierParty[0].Party = []UBLParty{{}}
doc.Invoice[0].AccountingSupplierParty[0].Party[0].IndustryClassificationCode = []UBLIndustryClassificationCode{{
Value: *bill.FirmSetting.Setting.Accounts.Tin[len(bill.FirmSetting.Setting.Accounts.Tin)-1].MSIC,
Name: "Law firm",
}}
doc.Invoice[0].AccountingSupplierParty[0].Party[0].PartyIdentification = []UBLPartyIdentification{
{
ID: []UBLPartyID{{
Value: *bill.FirmSetting.Setting.Accounts.Tin[len(bill.FirmSetting.Setting.Accounts.Tin)-1].Code,
SchemeID: "TIN",
}},
},
{
ID: []UBLPartyID{{
Value: *bill.FirmSetting.Setting.Accounts.Tin[len(bill.FirmSetting.Setting.Accounts.Tin)-1].BRN,
SchemeID: "BRN",
}},
},
{
ID: []UBLPartyID{{
Value: *suppliersSSTNumber,
SchemeID: "SST",
}},
},
}
doc.Invoice[0].AccountingSupplierParty[0].Party[0].PostalAddress = []UBLPostalAddress{{}}
doc.Invoice[0].AccountingSupplierParty[0].Party[0].PostalAddress[0].CityName = []UBLText{{Value: bill.FirmSetting.Setting.Accounts.City}}
doc.Invoice[0].AccountingSupplierParty[0].Party[0].PostalAddress[0].PostalZone = []UBLText{{Value: bill.FirmSetting.Setting.Accounts.PostCode}}
doc.Invoice[0].AccountingSupplierParty[0].Party[0].PostalAddress[0].CountrySubentityCode = []UBLText{{Value: getStateCode(bill.FirmSetting.Setting.Accounts.State)}}
doc.Invoice[0].AccountingSupplierParty[0].Party[0].PostalAddress[0].AddressLine = []UBLAddressLine{
{
Line: []UBLText{{Value: bill.FirmSetting.Setting.Accounts.Street}},
},
}
doc.Invoice[0].AccountingSupplierParty[0].Party[0].PostalAddress[0].Country = []UBLCountry{{}}
doc.Invoice[0].AccountingSupplierParty[0].Party[0].PostalAddress[0].Country[0].IdentificationCode = []UBLCountryIdentificationCode{{}}
doc.Invoice[0].AccountingSupplierParty[0].Party[0].PostalAddress[0].Country[0].IdentificationCode[0].Value = "MYS"
doc.Invoice[0].AccountingSupplierParty[0].Party[0].PostalAddress[0].Country[0].IdentificationCode[0].ListID = "ISO3166-1"
doc.Invoice[0].AccountingSupplierParty[0].Party[0].PostalAddress[0].Country[0].IdentificationCode[0].ListAgencyID = "6"
doc.Invoice[0].AccountingSupplierParty[0].Party[0].PartyLegalEntity = []UBLPartyLegalEntity{{
RegistrationName: []UBLText{{Value: bill.FirmSetting.Setting.Accounts.FirmName}},
}}
doc.Invoice[0].AccountingSupplierParty[0].Party[0].Contact = []UBLContact{{}}
doc.Invoice[0].AccountingSupplierParty[0].Party[0].Contact[0].Telephone = []UBLText{{Value: *bill.FirmSetting.Setting.Accounts.Phone}}
if bill.FirmSetting.Setting.Accounts.Email != nil {
doc.Invoice[0].AccountingSupplierParty[0].Party[0].Contact[0].ElectronicMail = []UBLText{{Value: *bill.FirmSetting.Setting.Accounts.Email}}
}
// ~~~~~ Buyer
var customersSSTNumber *string
switch bill.Bill.ToContact.ContactType {
case route.IralexContactType_Person:
customersSSTNumber = bill.Bill.ToContact.Person.SSTRegisterationNumber
case route.IralexContactType_Company:
customersSSTNumber = bill.Bill.ToContact.Company.SSTRegisterationNumber
}
if customersSSTNumber == nil {
na := "NA"
customersSSTNumber = &na
}
var customersBRN *string
switch bill.Bill.ToContact.ContactType {
case route.IralexContactType_Person:
customersBRN = bill.Bill.ToContact.Person.Brn
case route.IralexContactType_Company:
customersBRN = bill.Bill.ToContact.Company.Brn
}
var customersTIN *string
switch bill.Bill.ToContact.ContactType {
case route.IralexContactType_Person:
customersTIN = bill.Bill.ToContact.Person.Tin
case route.IralexContactType_Company:
customersTIN = bill.Bill.ToContact.Company.Tin
}
doc.Invoice[0].AccountingCustomerParty = []UBLPartyWrapper{{}}
doc.Invoice[0].AccountingCustomerParty[0].Party = []UBLParty{{}}
doc.Invoice[0].AccountingCustomerParty[0].Party[0].PostalAddress = []UBLPostalAddress{{}}
doc.Invoice[0].AccountingCustomerParty[0].Party[0].PostalAddress[0].CityName = []UBLText{{Value: bill.Bill.ToContact.Addresses[0].City}}
doc.Invoice[0].AccountingCustomerParty[0].Party[0].PostalAddress[0].PostalZone = []UBLText{{Value: bill.Bill.ToContact.Addresses[0].PostCode}}
doc.Invoice[0].AccountingCustomerParty[0].Party[0].PostalAddress[0].CountrySubentityCode = []UBLText{{Value: getStateCode(bill.Bill.ToContact.Addresses[0].State)}}
doc.Invoice[0].AccountingCustomerParty[0].Party[0].PostalAddress[0].AddressLine = []UBLAddressLine{
{
Line: []UBLText{{Value: bill.Bill.ToContact.Addresses[0].Street}},
},
}
doc.Invoice[0].AccountingCustomerParty[0].Party[0].PostalAddress[0].Country = []UBLCountry{{}}
doc.Invoice[0].AccountingCustomerParty[0].Party[0].PostalAddress[0].Country[0].IdentificationCode = []UBLCountryIdentificationCode{{}}
doc.Invoice[0].AccountingCustomerParty[0].Party[0].PostalAddress[0].Country[0].IdentificationCode[0].Value = "MYS"
doc.Invoice[0].AccountingCustomerParty[0].Party[0].PostalAddress[0].Country[0].IdentificationCode[0].ListID = "ISO3166-1"
doc.Invoice[0].AccountingCustomerParty[0].Party[0].PostalAddress[0].Country[0].IdentificationCode[0].ListAgencyID = "6"
doc.Invoice[0].AccountingCustomerParty[0].Party[0].PartyLegalEntity = []UBLPartyLegalEntity{{}}
doc.Invoice[0].AccountingCustomerParty[0].Party[0].PartyLegalEntity[0].RegistrationName = []UBLText{{Value: clientName}}
doc.Invoice[0].AccountingCustomerParty[0].Party[0].PartyIdentification = []UBLPartyIdentification{
{
ID: []UBLPartyID{{
Value: *customersTIN,
SchemeID: "TIN",
}},
},
{
ID: []UBLPartyID{{
Value: *customersBRN,
SchemeID: "BRN",
}},
},
{
ID: []UBLPartyID{{
Value: *customersSSTNumber,
SchemeID: "SST",
}},
},
}
doc.Invoice[0].AccountingCustomerParty[0].Party[0].Contact = []UBLContact{{}}
doc.Invoice[0].AccountingCustomerParty[0].Party[0].Contact[0].Telephone = []UBLText{{Value: *bill.Bill.ToContact.Phones[0].PhoneNumber}}
if bill.Bill.ToContact.Emails != nil && len(bill.Bill.ToContact.Emails) > 0 {
doc.Invoice[0].AccountingCustomerParty[0].Party[0].Contact[0].ElectronicMail = []UBLText{{Value: bill.Bill.ToContact.Emails[0].EmailAddress}}
}
// ~~~~~ Tax
// ~~~~~ Invoice Items
return doc
}

324
document/sign.go Normal file
View File

@ -0,0 +1,324 @@
package document
import (
"bytes"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
b64 "encoding/base64"
"encoding/json"
"fmt"
"iralex-einvoice/core"
"log"
"os"
"strconv"
"strings"
orderedmap "github.com/wk8/go-ordered-map/v2"
"golang.org/x/crypto/pkcs12"
)
// Signs a document, populting its Signature and UBLExtensions sections. Returns
// the signed document.
func Sign(doc UBLInvoiceRoot) UBLInvoiceRoot {
return doc
}
// Signs the specified document.
func GenerateDocument(doc UBLInvoiceRoot) []byte {
// -------------------------------------------------------------------------
// Open the document file, which is a JSON, as a string.
doc_string, err := os.ReadFile("example.json")
if err != nil {
log.Fatalf("Error reading the JSON file: %v\n", err)
}
var _md_ = &bytes.Buffer{} // this is only for debugging purposes
if err := json.Compact(_md_, doc_string); err != nil {
panic(err)
}
core.LogInfo.Printf("Final document minified: %s\n", _md_)
// -------------------------------------------------------------------------
// Transform the document.
// The `UBLExtensions` and `Signature` keys must be removed, if they already
// exist.
// var transformed_doc map[string]interface{}
// _ = json.Unmarshal(doc_string, &transformed_doc)
// err = delKey(transformed_doc, "Invoice.UBLExtensions")
// if err != nil {
// fmt.Printf("ERROR: %v", err)
// }
// err = delKey(transformed_doc, "Invoice.Signature")
// if err != nil {
// fmt.Printf("ERROR: %v", err)
// }
transformed_doc_string, err := os.ReadFile("transformed-example.json")
if err != nil {
log.Fatalf("Error reading the transformed JSON file: %v\n", err)
}
// -------------------------------------------------------------------------
// Minify the transformed document.
// minified_doc, err := json.Marshal(transformed_doc)
// if err != nil {
// log.Fatalf("Error minifying document: %v", err)
// }
var minified_doc = &bytes.Buffer{}
if err := json.Compact(minified_doc, transformed_doc_string); err != nil {
panic(err)
}
core.LogInfo.Printf("Transformed document minified: %s\n", minified_doc)
// -------------------------------------------------------------------------
// Calculate the document digest.
hashed_minified_doc := sha256.Sum256(minified_doc.Bytes())
var doc_digest string = b64.StdEncoding.EncodeToString(hashed_minified_doc[:])
core.LogInfo.Printf("INFO: Document digest: %s\n", doc_digest)
// -------------------------------------------------------------------------
// Open the certificate file and sign the document digest with it.
privateKey, certificate, err := loadPKCS12(core.CERTIFICATE_FILENAME, core.CERTIFICATE_PASSWORD)
if err != nil {
log.Fatalf("Error loading certificate file %v\n", err)
}
sign, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, hashed_minified_doc[:])
if err != nil {
log.Fatalf("Error signing the document %v\n", err)
}
var cert_b64 string = b64.StdEncoding.EncodeToString(certificate.Raw)
fmt.Println("Certificate Raw")
fmt.Println(cert_b64)
fmt.Println()
var cert_tbs_b64 string = b64.StdEncoding.EncodeToString(certificate.RawTBSCertificate)
fmt.Println("Certificate Raw TBS")
fmt.Println(cert_tbs_b64)
fmt.Println()
var signature string = b64.StdEncoding.EncodeToString(sign)
core.LogInfo.Printf("INFO: Siganture: %s\n", signature)
certificate_hash := sha256.Sum256(certificate.RawTBSCertificate)
cert_digest := b64.StdEncoding.EncodeToString(certificate_hash[:])
_, _, _ = doc_digest, signature, cert_digest
// -------------------------------------------------------------------------
// Populating the signed properties section, minifying it, and calculating
// its digest,
var signed_props = SignedProperties{{
Id: "id-xades-signed-props",
SignedSignatureProperties: [1]SignedSignatureProperties{{
SigningTime: [1]SigningTime{{
Value: "2025-06-01T14:58:57Z", // time.Now().UTC().Format(time.RFC3339),
}},
SigningCertificate: [1]SigningCertificate{{
Cert: [1]Cert{{
CertDigest: [1]CertDigest{{
DigestMethod: [1]DigestMethod{{
Value: "",
Algorithm: "http://www.w3.org/2001/04/xmlenc#sha256",
}},
DigestValue: [1]DigestValue{{
Value: cert_digest,
}},
}},
IssuerSerial: [1]IssuerSerial{{
X509IssuerName: [1]X509IssuerName{{
Value: "CN=LHDNM Sub CA G3,OU=Terms of use at http://www.posdigicert.com.my,O=LHDNM,C=MY",
}},
X509SerialNumber: [1]X509SerialNumber{{
Value: "19385988",
}},
}},
}},
}},
}},
}}
// var doc map[string]interface{}
// _ = json.Unmarshal(doc_string, &doc)
// if err = setKey(doc, "Invoice.0.UBLExtensions.0.UBLExtension.0.ExtensionContent.0.UBLDocumentSignatures.0.SignatureInformation.0.Signature.0.Object.0.QualifyingProperties.0.SignedProperties", signed_props); err != nil {
// log.Fatalf("Error setting signed properties key: %v\n", err)
// }
// var signed_props_outer = make(map[string]interface{})
// signed_props_outer["Target"] = "signature"
// signed_props_outer["SignedProperties"] = signed_props
// minified_signed_props, err := json.Marshal(signed_props_outer)
// if err != nil {
// log.Fatalf("Error minifying the signed properties %v\n", err)
// }
var signed_props_outer = orderedmap.New[string, interface{}]()
signed_props_outer.Set("Target", "signature")
signed_props_outer.Set("SignedProperties", signed_props)
minified_signed_props, err := json.Marshal(signed_props_outer)
if err != nil {
log.Fatalf("Error minifying the signed properties %v\n", err)
}
core.LogInfo.Printf("Signed props (ordered map version): %s\n", string(minified_signed_props))
hashed_minified_signed_props := sha256.Sum256(minified_signed_props)
var signed_props_digest string = b64.StdEncoding.EncodeToString(hashed_minified_signed_props[:])
core.LogInfo.Printf("Signed props digest: %s\n", signed_props_digest)
// -------------------------------------------------------------------------
// Return the final document as a byte array.
ans, _ := json.Marshal([]byte{})
return ans
}
// Pretty print the specified document.
func PrintDocument(doc []byte) {
var out bytes.Buffer
err := json.Indent(&out, doc, "", " ")
if err != nil {
panic(err)
}
fmt.Println(out.String())
}
// NOTE:
// The /Invoice/UBLExtensions/UBLExtension/ExtensionContent/UBLDocumentSignatures/SignatureInformation/Signature[@Id] element in the document is said to have a value of "DocSig", but in the example it uses a value of "signature". Check them both
// NOTE:
// Check for the "Canonicalization Method" as it's present in the documents as mandatory but absent in the example.
// removed the following key from /Invoice/AccountingSupplierParty
// "AdditionalAccountID": [{
// "_": "CPT-CCN-W-211111-KL-000002",
// "schemeAgencyName": "CertEX"
// }],
// Sets the value of the specified key in the specified map. If the specified
// path to the key is invalid, it returns an error. So you cannot create new
// keys with this function.
func setKey(data map[string]interface{}, path string, value interface{}) error {
var keys []string = strings.Split(path, ".")
var current_key interface{} = data
for i, k := range keys {
if i == len(keys)-1 {
if val, ok := current_key.(map[string]interface{}); ok {
if _, ok := val[k]; ok {
val[k] = value
} else {
return fmt.Errorf("the last specified key \"%s\" does not exist", k)
}
} else {
return fmt.Errorf("the last specified key \"%s\" does not exist", k)
}
}
if ind, err := strconv.Atoi(k); err == nil {
if val, ok := current_key.([]interface{}); ok && len(val) > ind {
current_key = val[ind]
} else {
return fmt.Errorf("the specified index (%d, %s) does not exist", i, k)
}
} else if val, ok := current_key.(map[string]interface{}); ok {
if elem, ok := val[k]; ok {
current_key = elem
} else {
return fmt.Errorf("the specified key (%d, %s) does not exist", i, k)
}
} else {
return fmt.Errorf("the specified key (%d, %s) is neither an object nor an array", i, k)
}
}
return nil
}
// func setOrCreateKey()
func getKey(data map[string]interface{}, path string) interface{} {
keys := strings.Split(path, ".")
var current interface{} = data
for _, k := range keys {
switch val := current.(type) {
case map[string]interface{}:
current = val[k]
case []interface{}:
current = val[0].(map[string]interface{})[k]
default:
current = interface{}(val)
}
}
return current
}
// Deletes the specified key from the specified UBL json. Returns an error if
// the key doesn't exist.
func delKey(data map[string]interface{}, path string) error {
keys := strings.Split(path, ".")
var current interface{} = data
for i, k := range keys {
switch val := current.(type) {
case map[string]interface{}:
if i == len(keys)-1 {
delete(val, k)
return nil
}
if next, ok := val[k]; ok {
current = next
} else {
return fmt.Errorf("the specified key %q does not exist", path)
}
case []interface{}:
tmp := val[0].(map[string]interface{})
if next, ok := tmp[k]; ok {
if i == len(keys)-1 {
delete(tmp, k)
return nil
} else {
current = next
}
} else {
return fmt.Errorf("the specified key %q does not exist", path)
}
default:
return fmt.Errorf("the specified key %q does not exist", path)
}
}
return nil
}
// Opens a .p12 file and returns its certificate and private key.
func loadPKCS12(filename string, password string) (*rsa.PrivateKey, *x509.Certificate, error) {
p12Data, err := os.ReadFile(filename)
if err != nil {
core.LogInfo.Printf("ERROR: cannot read the PKCS12 file %s: %v\n", filename, err)
return nil, nil, err
}
priv, cert, err := pkcs12.Decode(p12Data, password)
if err != nil {
core.LogInfo.Printf("ERROR: Cannot decode the PKCS12 file %s: %v\n", filename, err)
return nil, nil, err
}
rsaKey, ok := priv.(*rsa.PrivateKey)
if !ok {
core.LogInfo.Printf("ERROR: The private key in the PKCS12 file %s is not an RSA key.\n", filename)
return nil, nil, err
}
return rsaKey, cert, nil
}

View File

@ -0,0 +1,51 @@
package document
type SignedProperties [1]struct {
Id string
SignedSignatureProperties [1]SignedSignatureProperties
}
type SignedSignatureProperties struct {
SigningTime [1]SigningTime
SigningCertificate [1]SigningCertificate
}
type SigningTime struct {
Value string `json:"_"`
}
type SigningCertificate struct {
Cert [1]Cert
}
type Cert struct {
CertDigest [1]CertDigest
IssuerSerial [1]IssuerSerial
}
type DigestMethod struct {
Value string `json:"_"`
Algorithm string
}
type DigestValue struct {
Value string `json:"_"`
}
type CertDigest struct {
DigestMethod [1]DigestMethod
DigestValue [1]DigestValue
}
type X509IssuerName struct {
Value string `json:"_"`
}
type X509SerialNumber struct {
Value string `json:"_"`
}
type IssuerSerial struct {
X509IssuerName [1]X509IssuerName
X509SerialNumber [1]X509SerialNumber
}

35
document/state.go Normal file
View File

@ -0,0 +1,35 @@
package document
import (
"strings"
)
var stateMapping = map[string]string{
"johor": "01",
"kedah": "02",
"kelantan": "03",
"melaka": "04",
"negeri": "05",
"pahang": "06",
"pulau pinang": "07",
"perak": "08",
"perlis": "09",
"selangor": "10",
"terengganu": "11",
"sabah": "12",
"sarawak": "13",
"wilayah persekutuan kuala lumpur": "14",
"kuala lumpur": "14",
"wilayah persekutuan labuan": "15",
"labuan": "15",
"wilayah persekutuan putrajaya": "16",
"putrajaya": "16",
}
// Converts the specified state to its code.
func getStateCode(state string) string {
if value, ok := stateMapping[strings.ToLower(state)]; ok {
return value
}
return "17" // Not Applicable
}

287
document/types.go Normal file
View File

@ -0,0 +1,287 @@
package document
type UBLInvoiceRoot struct {
D string `json:"_D"`
A string `json:"_A"`
B string `json:"_B"`
Invoice []UBLInvoice `json:"Invoice"`
}
type UBLInvoice struct {
ID []UBLText `json:"ID"`
IssueDate []UBLText `json:"IssueDate"`
IssueTime []UBLText `json:"IssueTime"`
InvoiceTypeCode []UBLInvoiceTypeCode `json:"InvoiceTypeCode"`
DocumentCurrencyCode []UBLText `json:"DocumentCurrencyCode"`
TaxCurrencyCode []UBLText `json:"TaxCurrencyCode"`
AccountingSupplierParty []UBLPartyWrapper `json:"AccountingSupplierParty"`
AccountingCustomerParty []UBLPartyWrapper `json:"AccountingCustomerParty"`
AllowanceCharge []UBLAllowanceCharge `json:"AllowanceCharge"`
TaxTotal []UBLTaxTotal `json:"TaxTotal"`
LegalMonetaryTotal []UBLLegalMonetaryTotal `json:"LegalMonetaryTotal"`
InvoiceLine []UBLInvoiceLine `json:"InvoiceLine"`
UBLExtensions []UBLExtensionsWrapper `json:"UBLExtensions"`
Signature []UBLSignature `json:"Signature"`
}
type UBLText struct {
Value string `json:"_"`
}
type UBLInvoiceTypeCode struct {
Value string `json:"_"`
ListVersionID string `json:"listVersionID"`
}
type UBLPartyWrapper struct {
Party []UBLParty `json:"Party"`
}
type UBLParty struct {
IndustryClassificationCode []UBLIndustryClassificationCode `json:"IndustryClassificationCode,omitempty"`
PartyIdentification []UBLPartyIdentification `json:"PartyIdentification,omitempty"`
PostalAddress []UBLPostalAddress `json:"PostalAddress,omitempty"`
PartyLegalEntity []UBLPartyLegalEntity `json:"PartyLegalEntity,omitempty"`
Contact []UBLContact `json:"Contact,omitempty"`
}
type UBLIndustryClassificationCode struct {
Value string `json:"_"`
Name string `json:"name"`
}
type UBLPartyIdentification struct {
ID []UBLPartyID `json:"ID"`
}
type UBLPartyID struct {
Value string `json:"_"`
SchemeID string `json:"schemeID,omitempty"`
}
type UBLPostalAddress struct {
CityName []UBLText `json:"CityName,omitempty"`
PostalZone []UBLText `json:"PostalZone,omitempty"`
CountrySubentityCode []UBLText `json:"CountrySubentityCode,omitempty"`
AddressLine []UBLAddressLine `json:"AddressLine,omitempty"`
Country []UBLCountry `json:"Country,omitempty"`
}
type UBLAddressLine struct {
Line []UBLText `json:"Line"`
}
type UBLCountry struct {
IdentificationCode []UBLCountryIdentificationCode `json:"IdentificationCode"`
}
type UBLCountryIdentificationCode struct {
Value string `json:"_"`
ListID string `json:"listID,omitempty"`
ListAgencyID string `json:"listAgencyID,omitempty"`
}
type UBLPartyLegalEntity struct {
RegistrationName []UBLText `json:"RegistrationName"`
}
type UBLContact struct {
Telephone []UBLText `json:"Telephone,omitempty"`
ElectronicMail []UBLText `json:"ElectronicMail,omitempty"`
}
type UBLAllowanceCharge struct {
ChargeIndicator []UBLBool `json:"ChargeIndicator"`
AllowanceChargeReason []UBLText `json:"AllowanceChargeReason"`
Amount []UBLAmount `json:"Amount"`
MultiplierFactorNumeric []UBLAmount `json:"MultiplierFactorNumeric,omitempty"`
}
type UBLBool struct {
Value bool `json:"_"`
}
type UBLAmount struct {
Value float64 `json:"_"`
CurrencyID string `json:"currencyID,omitempty"`
}
type UBLTaxTotal struct {
TaxAmount []UBLAmount `json:"TaxAmount"`
TaxSubtotal []UBLTaxSubtotal `json:"TaxSubtotal"`
}
type UBLTaxSubtotal struct {
TaxableAmount []UBLAmount `json:"TaxableAmount,omitempty"`
TaxAmount []UBLAmount `json:"TaxAmount"`
TaxCategory []UBLTaxCategory `json:"TaxCategory"`
}
type UBLTaxCategory struct {
ID []UBLText `json:"ID"`
Percent []UBLAmount `json:"Percent,omitempty"`
TaxScheme []UBLTaxScheme `json:"TaxScheme"`
}
type UBLTaxScheme struct {
ID []UBLTaxSchemeID `json:"ID"`
}
type UBLTaxSchemeID struct {
Value string `json:"_"`
SchemeID string `json:"schemeID,omitempty"`
SchemeAgencyID string `json:"schemeAgencyID,omitempty"`
}
type UBLLegalMonetaryTotal struct {
LineExtensionAmount []UBLAmount `json:"LineExtensionAmount"`
TaxExclusiveAmount []UBLAmount `json:"TaxExclusiveAmount"`
TaxInclusiveAmount []UBLAmount `json:"TaxInclusiveAmount"`
AllowanceTotalAmount []UBLAmount `json:"AllowanceTotalAmount"`
PayableAmount []UBLAmount `json:"PayableAmount"`
}
type UBLInvoiceLine struct {
ID []UBLText `json:"ID"`
InvoicedQuantity []UBLInvoicedQuantity `json:"InvoicedQuantity"`
LineExtensionAmount []UBLAmount `json:"LineExtensionAmount"`
AllowanceCharge []UBLAllowanceCharge `json:"AllowanceCharge"`
TaxTotal []UBLTaxTotal `json:"TaxTotal"`
Item []UBLItem `json:"Item"`
Price []UBLPrice `json:"Price"`
ItemPriceExtension []UBLItemPriceExtension `json:"ItemPriceExtension"`
}
type UBLInvoicedQuantity struct {
Value float64 `json:"_"`
UnitCode string `json:"unitCode"`
}
type UBLItem struct {
CommodityClassification []UBLCommodityClassification `json:"CommodityClassification,omitempty"`
Description []UBLText `json:"Description,omitempty"`
OriginCountry []UBLCountry `json:"OriginCountry,omitempty"`
}
type UBLCommodityClassification struct {
ItemClassificationCode []UBLItemClassificationCode `json:"ItemClassificationCode"`
}
type UBLItemClassificationCode struct {
Value string `json:"_"`
ListID string `json:"listID,omitempty"`
}
type UBLPrice struct {
PriceAmount []UBLAmount `json:"PriceAmount"`
}
type UBLItemPriceExtension struct {
Amount []UBLAmount `json:"Amount"`
}
type UBLExtensionsWrapper struct {
UBLExtension []UBLExtension `json:"UBLExtension"`
}
type UBLExtension struct {
ExtensionURI []UBLText `json:"ExtensionURI"`
ExtensionContent []UBLExtensionContent `json:"ExtensionContent"`
}
type UBLExtensionContent struct {
UBLDocumentSignatures []UBLDocumentSignatures `json:"UBLDocumentSignatures"`
}
type UBLDocumentSignatures struct {
SignatureInformation []UBLSignatureInformation `json:"SignatureInformation"`
}
type UBLSignatureInformation struct {
ID []UBLText `json:"ID"`
ReferencedSignatureID []UBLText `json:"ReferencedSignatureID"`
Signature []UBLSignatureObj `json:"Signature"`
}
type UBLSignatureObj struct {
Id string `json:"Id"`
Object []UBLSignatureObject `json:"Object"`
KeyInfo []UBLKeyInfo `json:"KeyInfo"`
SignatureValue []UBLText `json:"SignatureValue"`
SignedInfo []UBLSignedInfo `json:"SignedInfo"`
}
type UBLSignatureObject struct {
QualifyingProperties []UBLQualifyingProperties `json:"QualifyingProperties"`
}
type UBLQualifyingProperties struct {
Target string `json:"Target"`
SignedProperties []UBLSignedProperties `json:"SignedProperties"`
}
type UBLSignedProperties struct {
Id string `json:"Id"`
SignedSignatureProperties []UBLSignedSignatureProperties `json:"SignedSignatureProperties"`
}
type UBLSignedSignatureProperties struct {
SigningTime []UBLText `json:"SigningTime"`
SigningCertificate []UBLSigningCertificate `json:"SigningCertificate"`
}
type UBLSigningCertificate struct {
Cert []UBLCert `json:"Cert"`
}
type UBLCert struct {
CertDigest []UBLCertDigest `json:"CertDigest"`
IssuerSerial []UBLIssuerSerial `json:"IssuerSerial"`
}
type UBLCertDigest struct {
DigestMethod []UBLDigestMethod `json:"DigestMethod"`
DigestValue []UBLText `json:"DigestValue"`
}
type UBLDigestMethod struct {
Value string `json:"_"`
Algorithm string `json:"Algorithm"`
}
type UBLIssuerSerial struct {
X509IssuerName []UBLText `json:"X509IssuerName"`
X509SerialNumber []UBLText `json:"X509SerialNumber"`
}
type UBLKeyInfo struct {
X509Data []UBLX509Data `json:"X509Data"`
}
type UBLX509Data struct {
X509Certificate []UBLText `json:"X509Certificate"`
X509SubjectName []UBLText `json:"X509SubjectName"`
X509IssuerSerial []UBLIssuerSerial `json:"X509IssuerSerial"`
}
type UBLSignedInfo struct {
SignatureMethod []UBLSignatureMethod `json:"SignatureMethod"`
Reference []UBLReference `json:"Reference"`
}
type UBLSignatureMethod struct {
Value string `json:"_"`
Algorithm string `json:"Algorithm"`
}
type UBLReference struct {
Type string `json:"Type"`
URI string `json:"URI"`
DigestMethod []UBLDigestMethod `json:"DigestMethod"`
DigestValue []UBLText `json:"DigestValue"`
}
type UBLSignature struct {
ID []UBLText `json:"ID"`
SignatureMethod []UBLText `json:"SignatureMethod"`
}

17
go.mod Normal file
View File

@ -0,0 +1,17 @@
module iralex-einvoice
go 1.23.0
toolchain go1.24.3
require (
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/jmoiron/sqlx v1.4.0 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
golang.org/x/crypto v0.38.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

30
go.sum Normal file
View File

@ -0,0 +1,30 @@
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/wk8/go-ordered-map v1.0.0 h1:BV7z+2PaK8LTSd/mWgY12HyMAo5CEgkHqbkVq2thqr8=
github.com/wk8/go-ordered-map v1.0.0/go.mod h1:9ZIbRunKbuvfPKyBP1SIKLcXNlv74YCOZ3t3VTS6gRk=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

24
main.go Normal file
View File

@ -0,0 +1,24 @@
package main
import (
"iralex-einvoice/core"
"iralex-einvoice/server"
)
func main() {
// ------------------------------------------------------------------------
// App-level initializations.
//
// Note that initialization order is important, as each service will use
// services from previously initialized services.
core.SetUpLogging() // logging must be set up before all other services
core.SetUpEnvironmentVariables() // must be set up before cmd flags to provide them default values
core.SetUpCommandLineArguments()
core.SetUpCache()
// ------------------------------------------------------------------------
// Server main.
server.Run()
}

125
route/iralex_auth.go Normal file
View File

@ -0,0 +1,125 @@
package route
import (
"bytes"
"encoding/json"
"errors"
"io"
"iralex-einvoice/config"
"iralex-einvoice/core"
"net/http"
"net/http/httputil"
"time"
)
// NOTE: This whole logging into Iralex is for debug purposes only. On
// production, this app works as a service that Iralex calls into.
type iralexLoginRequestBody struct {
EmailAddress string `json:"email_address"`
Password string `json:"password"`
}
type iralexLoginResponseBody struct {
ExpireDate string `json:"expire_date"`
Id int `json:"id"`
LawyerUser struct {
AvatarUrl string `json:"avatar_url"`
CreatedAt string `json:"created_at"`
EmailAddress string `json:"email_address"`
FirmAvatarUrl string `json:"firm_avatar_url"`
FirstName string `json:"first_name"`
Id int `json:"id"`
IsMain bool `json:"is_main"`
LastName string `json:"last_name"`
Main *bool `json:"main"`
PhoneNumber string `json:"phone_number"`
Roles *interface{} `json:"roles"`
Subscription interface{} `json:"subscription"`
TotalFirmUploadedSize int `json:"total_firm_uploaded_size"`
TotalUploadedSize int `json:"total_uploaded_size"`
UpdatedAt string `json:"updated_at"`
} `json:"lawyer_user"`
Payload interface{} `json:"payload"`
Token string `json:"token"`
}
// Authenticates in Iralex with the specified email and password provided via
// environment variables, and returns the bearer token.
func IralexAuthenticate() (string, error) {
// -------------------------------------------------------------------------
// Check cache.
accessToken := core.CacheGet(core.CACHE_ITEM_IRALEX_TOKEN)
expirationDate := core.CacheGet(core.CACHE_ITEM_IRALEX_EXPIRATION_DATE)
if accessToken != core.CACHE_ERROR && expirationDate != core.CACHE_ERROR {
var now time.Time = time.Now().UTC()
expirationDate, _ := time.Parse("2006-01-02T15:04:05", expirationDate)
if now.Before(expirationDate) {
return accessToken, nil
}
}
// -------------------------------------------------------------------------
// Send request.
core.LogInfo.Println("INFO: Sending request iralex: POST /account/token/")
body, err := json.Marshal(iralexLoginRequestBody{
EmailAddress: core.IRALEX_AUTH_EMAIL,
Password: core.IRALEX_AUTH_PASSWORD,
})
if err != nil {
core.LogInfo.Printf("ERROR: Failed to marshal the request body: %v\n", err)
core.CacheSet(core.CACHE_ITEM_IRALEX_TOKEN, core.CACHE_ERROR)
core.CacheSet(core.CACHE_ITEM_IRALEX_EXPIRATION_DATE, core.CACHE_ERROR)
return "", errors.New("failed to marshal the request body")
}
req, err := http.NewRequest(http.MethodPost, core.IRALEX_BACKEND_BASE_URL+"/account/token/", bytes.NewBuffer(body))
if err != nil {
core.LogInfo.Printf("ERROR: Couldn't construct the request\n")
core.CacheSet(core.CACHE_ITEM_IRALEX_TOKEN, core.CACHE_ERROR)
core.CacheSet(core.CACHE_ITEM_IRALEX_EXPIRATION_DATE, core.CACHE_ERROR)
return "", errors.New("couldn't construct the request")
}
req.Header.Add("Accept", "application/json")
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Access-Control-Allow-Header", "Content-Type")
reqDmp, _ := httputil.DumpRequest(req, true)
core.LogInfo.Printf("INFO: Request dump:\n%s\n", reqDmp)
client := &http.Client{
Timeout: time.Second * config.DEFAULT_TIMEOUT,
}
resp, err := client.Do(req)
if err != nil {
core.LogInfo.Printf("ERROR: Failed to send the request: %v\n", err)
core.CacheSet(core.CACHE_ITEM_IRALEX_TOKEN, core.CACHE_ERROR)
core.CacheSet(core.CACHE_ITEM_IRALEX_EXPIRATION_DATE, core.CACHE_ERROR)
return "", errors.New("failed to send the request")
}
// -------------------------------------------------------------------------
// Use the response.
defer resp.Body.Close()
var respBody iralexLoginResponseBody
bodyContent, err := io.ReadAll(resp.Body)
if err != nil {
core.LogInfo.Printf("ERROR: Failed to read the response body: %v\n", err)
core.CacheSet(core.CACHE_ITEM_IRALEX_TOKEN, core.CACHE_ERROR)
core.CacheSet(core.CACHE_ITEM_IRALEX_EXPIRATION_DATE, core.CACHE_ERROR)
return "", errors.New("failed to read the response body")
}
err = json.Unmarshal(bodyContent, &respBody)
if err != nil {
core.LogInfo.Printf("ERROR: Failed to unmarshal the response body: %v\n", err)
core.CacheSet(core.CACHE_ITEM_IRALEX_TOKEN, core.CACHE_ERROR)
core.CacheSet(core.CACHE_ITEM_IRALEX_EXPIRATION_DATE, core.CACHE_ERROR)
return "", errors.New("failed to read the response body")
}
s, _ := json.MarshalIndent(respBody, "", "\t")
core.LogInfo.Printf("INFO: Response:\n---------\n%d %v\n", resp.StatusCode, string(s))
core.CacheSet(core.CACHE_ITEM_IRALEX_TOKEN, respBody.Token)
core.CacheSet(core.CACHE_ITEM_IRALEX_EXPIRATION_DATE, respBody.ExpireDate)
return respBody.Token, nil
}

59
route/iralex_bill.go Normal file
View File

@ -0,0 +1,59 @@
package route
import (
"encoding/json"
"errors"
"fmt"
"io"
"iralex-einvoice/core"
"net/http"
"net/http/httputil"
"strconv"
"time"
)
func IralexGetBill(billId int, accessToken string) (IralexBill, error) {
// -------------------------------------------------------------------------
// Send request.
core.LogInfo.Println("INFO: Sending request iralex: POST /billing/lhdn-test/:id")
var result = IralexBill{}
var url = fmt.Sprint(core.IRALEX_BACKEND_BASE_URL, "/billing/lhdn-test/", strconv.Itoa(billId), "/")
req, err := http.NewRequest(http.MethodGet, url, http.NoBody)
if err != nil {
core.LogInfo.Printf("ERROR: Couldn't construct the request\n")
return result, errors.New("couldn't construct the request")
}
req.Header.Add("Accept", "application/json")
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Access-Control-Allow-Header", "Content-Type")
req.Header.Add("Auth", accessToken)
reqDmp, _ := httputil.DumpRequest(req, true)
core.LogInfo.Printf("INFO: Request dump:\n%s\n", reqDmp)
client := &http.Client{
Timeout: time.Second * 40,
}
resp, err := client.Do(req)
if err != nil {
core.LogInfo.Printf("ERROR: Failed to send the request: %v\n", err)
return result, errors.New("failed to send the request")
}
// -------------------------------------------------------------------------
// Use the response.
defer resp.Body.Close()
bodyContent, err := io.ReadAll(resp.Body)
if err != nil {
core.LogInfo.Printf("ERROR: Failed to read the response body: %v\n", err)
return result, errors.New("failed to read the response body")
}
err = json.Unmarshal(bodyContent, &result)
if err != nil {
core.LogInfo.Printf("ERROR: Failed to unmarshal the response body: %v\n", err)
return result, errors.New("failed to read the response body")
}
return result, nil
}

336
route/iralex_types.go Normal file
View File

@ -0,0 +1,336 @@
package route
type IralexBill struct {
Bill IralexBillMain `json:"bill"`
BillData IralexBillData `json:"bill_data"`
TaxRows []IralexTaxRow `json:"tax_rows"`
DiscountRows []IralexDiscountRow `json:"discount_rows"`
FirmSetting IralexFirmSetting `json:"firm_setting"`
}
type IralexBillMain struct {
ID int `json:"id"`
Type int `json:"type"`
Creator IralexUser `json:"creator"`
ToContact IralexContact `json:"to_contact"`
IssueDate string `json:"issue_date"`
Note *string `json:"note"`
IsOldVersion bool `json:"is_old_version"`
Status IralexBillType `json:"status"`
DetailLevel int `json:"detail_level"`
DueDate string `json:"due_date"`
IsApplyTax bool `json:"is_apply_tax"`
IsApplySecondTax bool `json:"is_apply_second_tax"`
Tax float64 `json:"tax"`
SecondTax float64 `json:"second_tax"`
TaxValue float64 `json:"tax_value"`
SecondTaxValue float64 `json:"second_tax_value"`
IsSkipApprovalProcess bool `json:"is_skip_approval_process"`
TotalAmount float64 `json:"total_amount"`
PayedAmount float64 `json:"payed_amount"`
IsRemoved bool `json:"is_removed"`
Discount float64 `json:"discount"`
FactorNo int `json:"factor_no"`
Matters []IralexMatter `json:"matters"`
TimeEntries []IralexTimeEntry `json:"time_entries"`
ExpenseEntries []interface{} `json:"expense_entries"`
FixedTotalAmount float64 `json:"fixed_total_amount"`
}
type IralexBillType int16
const (
IralexBillType_Billing IralexBillType = 1
IralexBillType_FundRequest IralexBillType = 2
)
var iralexBillTypeString = map[IralexBillType]string{
IralexBillType_Billing: "Billing",
IralexBillType_FundRequest: "Fund Request",
}
func (billType IralexBillType) String() string {
return iralexBillTypeString[billType]
}
type IralexBillData struct {
Rows []IralexBillDataRow `json:"rows"`
InvoiceId int `json:"invoiceId"`
Currency string `json:"currency"`
CurDate string `json:"curDate"`
DueDate string `json:"dueDate"`
LegalName string `json:"legalName"`
Total float64 `json:"total"`
IsOldVersion bool `json:"is_old_version"`
TotalAmount float64 `json:"total_amount"`
}
type IralexBillDataRow struct {
MatterRowNumber string `json:"matterRowNumber"`
MatterTitle string `json:"matterTitle"`
MatterHours float64 `json:"matterHours"`
MatterQuantity *float64 `json:"matterQuantity"`
MatterTotal float64 `json:"matterTotal"`
TimeEntries []IralexTimeEntry `json:"time_entries"`
ExpenseEntries []interface{} `json:"expense_entries"`
}
type IralexTaxRow struct {
ItemNo int `json:"item_no"`
Type string `json:"type"`
ID int `json:"id"`
ItemMatter string `json:"item_matter"`
ItemDescription string `json:"item_description"`
ItemQt int `json:"item_qt"`
Tax bool `json:"tax"`
ItemCategory string `json:"item_category"`
UnitPrice string `json:"unit_price"`
UnitAmount string `json:"unit_amount"`
}
type IralexDiscountRow struct {
ItemNo int `json:"item_no"`
Type string `json:"type"`
ID int `json:"id"`
Discount *int `json:"discount"` // Percentage
DiscountCash *float64 `json:"discount_cash"`
ItemMatter string `json:"item_matter"`
ItemDescription string `json:"item_description"`
ItemQt int `json:"item_qt"`
Tax bool `json:"tax"`
ItemCategory string `json:"item_category"`
UnitPrice string `json:"unit_price"`
UnitAmount string `json:"unit_amount"`
}
type IralexFirmSetting struct {
ID IralexFirmSettingID `json:"_id"`
MainLawyerUser int `json:"main_lawyer_user"`
Setting IralexFirmSettingDetail `json:"setting"`
}
type IralexFirmSettingID struct {
Oid string `json:"$oid"`
}
type IralexFirmSettingDetail struct {
Accounts IralexFirmAccounts `json:"accounts"`
Templates IralexFirmTemplates `json:"templates"`
Tax IralexFirmTax `json:"tax"`
}
type IralexFirmAccounts struct {
FirmName string `json:"firm_name"`
Street string `json:"street"`
City string `json:"city"`
State string `json:"state"`
Country string `json:"country"`
PostCode string `json:"post_code"`
Phone *string `json:"phone"`
Fax string `json:"fax"`
Email *string `json:"contact_email"`
Website *string `json:"contact_website"`
Currency string `json:"currency"`
Tin []IralexFirmTin `json:"tin"`
}
type IralexFirmTin struct {
Code *string `json:"code"`
Type *string `json:"type"`
IDValue *string `json:"id_value"`
MSIC *string `json:"msic_code"`
SSTRegisterationNumber *string `json:"sst_registeration_number"`
BRN *string `json:"brn"`
}
type IralexFirmTemplates struct {
Template int `json:"template"`
Avatar string `json:"avatar"`
Description string `json:"description"`
}
type IralexFirmTax struct {
Rate int `json:"rate"`
Type string `json:"type"`
}
type IralexUser struct {
ID int `json:"id"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
EmailAddress string `json:"email_address"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
IsMain bool `json:"is_main"`
PhoneNumber string `json:"phone_number"`
Roles interface{} `json:"roles"` // could be a struct if roles structure is known
AvatarURL string `json:"avatar_url"`
FirmAvatarURL string `json:"firm_avatar_url"`
Main *IralexUser `json:"main"`
Subscription interface{} `json:"subscription"`
TotalUploadedSize int64 `json:"total_uploaded_size"`
TotalFirmUploadedSize int64 `json:"total_firm_uploaded_size"`
}
type IralexContact struct {
ID int `json:"id"`
MainUser int `json:"main_user"`
CreatorUser int `json:"creator_user"`
AvatarURL *string `json:"avatar_url"`
Keywords string `json:"keywords"`
Fund float64 `json:"fund"`
ContactType IralexContactType `json:"contact_type"`
IsRemoved bool `json:"is_removed"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Person *IralexPerson `json:"person"`
Company *IralexCompany `json:"company"`
Phones []IralexPhone `json:"phones"`
Emails []IralexEmail `json:"emails"`
Websites []string `json:"websites"`
BillingRates []interface{} `json:"billing_rates"`
Addresses []IralexAddress `json:"addresses"`
Meta IralexContactMeta `json:"meta"`
}
type IralexContactType int
const (
IralexContactType_Person IralexContactType = 1
IralexContactType_Company IralexContactType = 2
)
var iralexContactTypeString = map[IralexContactType]string{
IralexContactType_Company: "Company",
IralexContactType_Person: "Person",
}
func (contactType IralexContactType) String() string {
return iralexContactTypeString[contactType]
}
type IralexPerson struct {
BirthDate *string `json:"birth_date,omitempty"`
Contact int `json:"contact"`
FirstName string `json:"first_name"`
Gender *int `json:"gender"`
ID int `json:"id"`
LastName string `json:"last_name"`
MiddleName *string `json:"middle_name"`
Prefix *string `json:"prefix"`
Tin *string `json:"tin"`
Brn *string `json:"brn"`
SSTRegisterationNumber *string `json:"sst_registeration_number"`
}
type IralexCompany struct {
ID int `json:"id"`
ContactID int `json:"contact_id"`
Name string `json:"name"`
Tin *string `json:"tin"`
Brn *string `json:"brn"`
SSTRegisterationNumber *string `json:"sst_registeration_number"`
}
type IralexPhone struct {
ID int `json:"id"`
ContactID int `json:"contact_id"`
PhoneNumber string `json:"phone_number"`
PhoneType int `json:"phone_type"`
}
type IralexEmail struct {
ID int `json:"id"`
ContactID int `json:"contact_id"`
EmailAddress string `json:"email_address"`
EmailType int `json:"email_type"`
}
type IralexAddress struct {
ID int `json:"id"`
ContactID int `json:"contact_id"`
Street string `json:"street"`
City string `json:"city"`
State string `json:"state"`
PostCode string `json:"post_code"`
Country int `json:"country"`
AddressType int `json:"address_type"`
CountryName string `json:"country_name"`
}
type IralexContactMeta struct {
OutstandingBalance float64 `json:"outstanding_balance"`
}
type IralexMatter struct {
ID int `json:"id"`
Title string `json:"title"`
Owner int `json:"owner"`
Client IralexContact `json:"client"`
Group interface{} `json:"group"`
IsForEveryOne bool `json:"is_for_every_one"`
Description string `json:"description"`
PracticeArea IralexPracticeArea `json:"practice_area"`
Status int `json:"status"`
ReferenceCode *string `json:"reference_code"`
OpenDate string `json:"open_date"`
ClosedDate *string `json:"closed_date"`
PendingDate *string `json:"pending_date"`
LimitationDate string `json:"limitation_date"`
IsLimitationDateSatisfied bool `json:"is_limitation_date_satisfied"`
Location string `json:"location"`
IsBillable bool `json:"is_billable"`
IsRemoved bool `json:"is_removed"`
FixedFee *float64 `json:"fixed_fee"`
Fund float64 `json:"fund"`
BillingType int `json:"billing_type"`
HasBudget bool `json:"has_budget"`
Budget *float64 `json:"budget"`
HasNotify *bool `json:"has_notify"`
NotifyLimit *float64 `json:"notify_limit"`
UsageBudget float64 `json:"usage_budget"`
Responsible []IralexUser `json:"responsible"`
Origination []IralexUser `json:"origination"`
Notifications []interface{} `json:"notifications"`
CreatedAt string `json:"created_at"`
Relations []interface{} `json:"relations"`
HourlyRates []interface{} `json:"hourly_rates"`
ContingencyRates []interface{} `json:"contingency_rates"`
}
type IralexPracticeArea struct {
ID int `json:"id"`
Title string `json:"title"`
OwnerMain *string `json:"owner_main"`
}
type IralexTimeEntry struct {
ID int `json:"id"`
Creator IralexUser `json:"creator"`
Matter IralexMatter `json:"matter"`
Duration float64 `json:"duration"`
Date string `json:"date"`
User IralexUser `json:"user"`
Rate float64 `json:"rate"`
Category IralexCategory `json:"category"`
Description string `json:"description"`
Task interface{} `json:"task"`
IsNoBillable bool `json:"is_no_billable"`
IsRemoved bool `json:"is_removed"`
IsUsedInInvoice bool `json:"is_used_in_invoice"`
IsTax bool `json:"is_tax"`
DiscountCash *float64 `json:"discount_cash"`
Discount *float64 `json:"discount"`
DiscountReason *string `json:"discount_reason"`
UnitPrice []string `json:"unit_price"`
UnitAmount string `json:"unit_amount"`
Tax bool `json:"tax"`
}
type IralexCategory struct {
ID int `json:"id"`
Creator IralexUser `json:"creator"`
Name string `json:"name"`
Rate float64 `json:"rate"`
IsRemoved bool `json:"is_removed"`
}

113
route/lhdn_auth.go Normal file
View File

@ -0,0 +1,113 @@
package route
import (
"bytes"
"encoding/json"
"fmt"
"io"
"iralex-einvoice/config"
"iralex-einvoice/core"
"log"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"time"
)
func AuthenticateFormData(ClientID string, ClientSecret string, TIN string) string {
// Check if the cached access token is still valid, and then use it. The
// LHDN auth API has a rate limit of 12 requests per minute, so we need to
// cache the access token to avoid redundant requests.
accessToken := core.CacheGet(core.CACHE_ITEM_LHDN_TOKEN)
expirationDate := core.CacheGet(core.CACHE_ITEM_LHDN_EXPIRATION_DATE)
if accessToken != core.CACHE_ERROR && expirationDate != core.CACHE_ERROR {
old, _ := time.Parse("2006-01-02T15:04:05", expirationDate)
var now time.Time = time.Now().UTC()
var diff time.Duration = now.Sub(old)
if diff.Seconds() <= 1 {
return accessToken
}
}
fmt.Println("Sending Request (Authenticate - Form Data):\n-----------------")
form := url.Values{}
form.Add("client_id", ClientID)
form.Add("client_secret", ClientSecret)
form.Add("grant_type", "client_credentials")
form.Add("scope", "InvoicingAPI")
req, err := http.NewRequest(http.MethodPost, config.BASE_URL+"/connect/token", strings.NewReader(form.Encode()))
if err != nil {
log.Fatalf("Error in constructing the request: %v", err)
}
req.PostForm = form
req.Header.Add("Accept", "application/json")
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("onbehalfof", TIN)
bytes, _ := httputil.DumpRequestOut(req, true)
fmt.Printf("%s\n", bytes)
client := &http.Client{
Timeout: time.Second * config.DEFAULT_TIMEOUT,
}
resp, err := client.Do(req)
// resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatalf("Error in sending the request: %v", err)
}
defer resp.Body.Close()
var res map[string]interface{}
json.NewDecoder(resp.Body).Decode(&res)
fmt.Printf("\n\n\nResponse:\n---------\n%v\n", res)
accessToken = fmt.Sprintf("%v", res["access_token"])
expiresIn := res["expires_in"].(int)
core.CacheSet(core.CACHE_ITEM_LHDN_TOKEN, accessToken)
core.CacheSet(core.CACHE_ITEM_LHDN_EXPIRATION_DATE, time.Now().UTC().Add(time.Second*time.Duration(expiresIn)).Format("2006-01-02T15:04:05"))
return accessToken
}
func AuthenticateJSON(ClientID string, ClientSecret string, TIN string) {
fmt.Println("Sending Request (Authenticate - JSON Body):\n-----------------")
body, _ := json.Marshal(map[string]string{
"client_id": ClientID,
"client_secret": ClientSecret,
"grant_type": "client_credentials",
"scope": "InvoicingAPI",
})
req, err := http.NewRequest(http.MethodPost, config.BASE_URL+"/connect/token", bytes.NewBuffer(body))
if err != nil {
log.Fatalf("Error in constructing the request: %v", err)
}
req.Header.Add("Accept", "application/json")
req.Header.Add("onbehalfof", TIN)
bytes, _ := httputil.DumpRequestOut(req, true)
fmt.Printf("%s\n", bytes)
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatalf("Error in sending request: %v", err)
}
defer resp.Body.Close()
out, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatalf("Error in reading response body: %v", err)
}
sb := string(out)
log.Print(sb)
}

View File

@ -0,0 +1,107 @@
package route
import (
"bytes"
"crypto/sha256"
b64 "encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"iralex-einvoice/config"
"iralex-einvoice/document"
"log"
"net/http"
"net/http/httputil"
"time"
)
type requestBody struct {
Documents []requestDocument `json:"documents"`
}
type requestDocument struct {
Format string `json:"format"`
Document string `json:"document"`
DocumentHash string `json:"documentHash"`
CodeNumber string `json:"codeNumber"`
}
// Submits a document to LHDN.
func SubmitDocument(accessToken string, doc document.UBLInvoiceRoot) {
fmt.Println("Sending Request (/submit-document):\n-----------------------------------")
// Minify and convert the document to base64.
docJson, _ := json.MarshalIndent(doc, "", " ")
var minified_doc = &bytes.Buffer{}
if err := json.Compact(minified_doc, docJson); err != nil {
panic(err)
}
var sEnc string = b64.StdEncoding.EncodeToString(minified_doc.Bytes())
// Create a SHA256 hash of the document.
hash := sha256.New()
hash.Write(minified_doc.Bytes())
body, err := json.Marshal(requestBody{
Documents: []requestDocument{
{
Format: "JSON",
CodeNumber: doc.Invoice[0].ID[0].Value,
Document: sEnc,
DocumentHash: hex.EncodeToString(hash.Sum(nil)),
},
},
})
if err != nil {
log.Fatalf("Err %v\n", err.Error())
}
// Pretty print the document/body.
// var out bytes.Buffer
// err = json.Indent(&out, body, "", "\t")
// if err != nil {
// panic(err)
// }
// fmt.Println(string(out.Bytes()))
req, err := http.NewRequest(http.MethodPost, config.BASE_URL+"/api/v1.0/documentsubmissions/", bytes.NewBuffer(body))
if err != nil {
log.Fatalf("Error in constructing the request: %v", err)
}
req.Header.Add("Accept", "application/json")
req.Header.Add("Accept-Language", "en")
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Authorization", "Bearer "+accessToken)
bytes, _ := httputil.DumpRequestOut(req, true)
fmt.Printf("%s\n", bytes)
client := &http.Client{
Timeout: time.Second * 10,
}
resp, err := client.Do(req)
// resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatalf("Error in sending the request: %v", err)
}
defer resp.Body.Close()
var res map[string]interface{}
json.NewDecoder(resp.Body).Decode(&res)
b, _ := json.MarshalIndent(res, "", " ")
fmt.Printf("\n\n\nResponse:\n---------\n%d %v\n", resp.StatusCode, string(b))
// var bill = database.Bill{}
// document.SubmitDocument("aasdasd")
}
// Temporary Dump (Ignore the following)
// removed from / ubl:Invoice / cac:InvoiceLine / cac:AllowanceCharge / cbc:AllowanceChargeReason
// "AllowanceChargeReason": [{
// "_": "Sample Description"
// }],
// https://golang.cafe/blog/golang-json-marshal-example.html

122
server/decode_json_body.go Normal file
View File

@ -0,0 +1,122 @@
package server
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
)
const (
// When parsing the request JSON body, whether to send an error or not when
// extra fields not specified in route.IralexBill are present.
_ALLOW_UNKNOWN_FIELDS = true
// How long should the JSON request body be in bytes. This prevents
// malicious clients from sending very large request bodies that waste
// server resources. Set to 0 to disable checks for the request body size.
_MAXIMUM_REQUEST_BODY_SIZE = 1048576 // 1 MB
// Since using this function means that you expect a JSON in the request
// body, it also checks for Content-Type to be application/json. You can
// disable it to allow any Content-Type, or let the callers to validate for
// Content-Type.
_CHECK_CONTENT_TYPE = true
)
type malformedRequestError struct {
status int
msg string
}
func (mr *malformedRequestError) Error() string {
return fmt.Sprint(mr.status, ": ", mr.msg)
}
func decodeJSONBody[T any](w http.ResponseWriter, r *http.Request) (T, error) {
var body T
if _CHECK_CONTENT_TYPE {
contentType := r.Header.Get("Content-Type")
if contentType != "" {
// normalize Content-Type to remove additional parameters such as charset or boundary
mediaType := strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0]))
if mediaType != "application/json" {
return body, &malformedRequestError{
http.StatusUnsupportedMediaType,
"Error - the only supported Content-Type is application/json",
}
}
}
}
if _MAXIMUM_REQUEST_BODY_SIZE != 0 {
r.Body = http.MaxBytesReader(w, r.Body, _MAXIMUM_REQUEST_BODY_SIZE)
}
// -------------------------------------------------------------------------
// Get Bill
var decoder *json.Decoder = json.NewDecoder(r.Body)
if !_ALLOW_UNKNOWN_FIELDS {
decoder.DisallowUnknownFields()
}
err := decoder.Decode(&body)
if err != nil {
var syntaxError *json.SyntaxError
var unmarshalTypeError *json.UnmarshalTypeError
var maxBytesError *http.MaxBytesError
switch {
case errors.As(err, &syntaxError):
return body, &malformedRequestError{
http.StatusBadRequest,
fmt.Sprintf("Request body contains ill-formed JSON (at position %d)", syntaxError.Offset),
}
case errors.Is(err, io.ErrUnexpectedEOF):
return body, &malformedRequestError{
http.StatusBadRequest,
"Request body contains ill-formed JSON",
}
case errors.As(err, &unmarshalTypeError):
return body, &malformedRequestError{
http.StatusBadRequest,
fmt.Sprintf("Request body contains an invalid value for the %q field (at position %d)", unmarshalTypeError.Field, unmarshalTypeError.Offset),
}
case strings.HasPrefix(err.Error(), "json: unknown field "):
fieldName := strings.TrimPrefix(err.Error(), "json: unknown field ")
return body, &malformedRequestError{
http.StatusBadRequest,
fmt.Sprintf("Request body contains unknown field %s", fieldName),
}
case errors.Is(err, io.EOF):
// an io.EOF error is returned by Decode() if the request body is empty
return body, &malformedRequestError{
http.StatusBadRequest,
"Request body must not be empty",
}
case errors.As(err, &maxBytesError):
return body, &malformedRequestError{
http.StatusRequestEntityTooLarge,
fmt.Sprintf("Request body must not be larger than %d bytes", maxBytesError.Limit),
}
default:
return body, err
}
}
// parse the request body again to make sure it does not have any more JSON
// objects in it
err = decoder.Decode(&struct{}{})
if !errors.Is(err, io.EOF) {
return body, &malformedRequestError{
http.StatusBadRequest,
"Request body must only contain a single JSON object",
}
}
return body, nil
}

78
server/errors.go Normal file
View File

@ -0,0 +1,78 @@
package server
// This file contains all the sub-error codes that the server sends. In case of
// logical errors, a 422 response is returned with a json body that contains the
// following fields:
// "code" : a sub-error code, that specifies the exact logical error
// "name" : the name of the error code, in the same vein as HTTP errors such as Unprocessable Entity, Unauthenticated, etc
// "message" : A human-readable (preferably English) description of the error, and preferably how to get rid of it.
const (
ERR_INVALID = 0
ERR_INVALID_NAME = "Invalid"
ERR_INVALID_MSG = "The bill you want to submit is either an Official Receipt or Fund Request. Only bills can be submitted to LHDN."
ERR_BAD_COUNTRY_FIRM = 2
ERR_BAD_COUNTRY_FIRM_NAME = "Bad Country (Firm)"
ERR_BAD_COUNTRY_FIRM_MSG = "Only Malaysia is allowed as a country for the firm. Bills from other countries cannot be submitted to LHDN. Expects Malaysia (case-insensitive)."
ERR_BAD_COUNTRY_CLIENT = 3
ERR_BAD_COUNTRY_CLIENT_NAME = "Bad Country (Client)"
ERR_BAD_COUNTRY_CLIENT_MSG = "Only Malaysia is allowed as a country for the client. Bills from other countries cannot be submitted to LHDN. Expects Malaysia (case-insensitive)."
ERR_BAD_CURRENCY = 4
ERR_BAD_CURRENCY_NAME = "Bad Currency"
ERR_BAD_CURRENCY_MSG = "The only allowed currency by LHDN is RM (Malaysian ringgit)."
ERR_BAD_STATE_FIRM = 5
ERR_BAD_STATE_FIRM_NAME = "Bad State (Firm)"
ERR_BAD_STATE_FIRM_MSG = "The firm has an invalid state. Must be one of Johor, Kedah, Kelantan, Melaka, Negeri Sembilan, Pahang, Pulau Pinang, Perak, Perlis, Selangor, Terengganu, Sabah, Sarawak, Kuala Lumpur, Wilayah Persekutuan Kuala Lumpur, Labuan, Wilayah Persekutuan Labuan, Putrajaya, or Wilayah Persekutuan Putrajaya (case-insensitive)."
ERR_BAD_STATE_CLIENT = 5
ERR_BAD_STATE_CLIENT_NAME = "Bad State (Client)"
ERR_BAD_STATE_CLIENT_MSG = "The client has an invalid state. Must be one of Johor, Kedah, Kelantan, Melaka, Negeri Sembilan, Pahang, Pulau Pinang, Perak, Perlis, Selangor, Terengganu, Sabah, Sarawak, Kuala Lumpur, Wilayah Persekutuan Kuala Lumpur, Labuan, Wilayah Persekutuan Labuan, Putrajaya, or Wilayah Persekutuan Putrajaya (case-insensitive)."
ERR_BAD_PHONE_NUMBER_FIRM = 5
ERR_BAD_PHONE_NUMBER_FIRM_NAME = "Bad Phone Number (Firm)"
ERR_BAD_PHONE_NUMBER_FIRM_MSG = "The firm's phone number is not a valid Malaysian phone number. It must start with +60, and have 9 or 10 digits, without any spaces or dashes."
ERR_BAD_PHONE_NUMBER_CLIENT = 5
ERR_BAD_PHONE_NUMBER_CLIENT_NAME = "Bad Phone Number (Client)"
ERR_BAD_PHONE_NUMBER_CLIENT_MSG = "The client's phone number is not a valid Malaysian phone number. It must start with +60, and have 9 or 10 digits, without any spaces or dashes."
ERR_BAD_EMAIL = 6
ERR_BAD_EMAIL_NAME = "Bad Email"
ERR_BAD_EMAIL_MSG = "The specified email is not in a valid format. Double check to make sure your email address is correct."
ERR_MISSING_MSIC = 7
ERR_MISSING_MSIC_NAME = "MSIC Missing"
ERR_MISSING_MSIC_MSG = "The firm's MSIC (Malaysia Standard Industrial Classification) code is not specified."
ERR_MISSING_MATTER = 8
ERR_MISSING_MATTER_NAME = "Matter Missing"
ERR_MISSING_MATTER_MSG = "The specified bill does not have an associated matter."
ERR_MISSING_ADDRESS_CLIENT = 9
ERR_MISSING_ADDRESS_CLIENT_NAME = "Missing Address (Client)"
ERR_MISSING_ADDRESS_CLIENT_MSG = "The bill's client does not have an associated address. Make sure to fill in all the address fields correctly."
ERR_MISSING_PHONE_NUMBER_FIRM = 14
ERR_MISSING_PHONE_NUMBER_FIRM_NAME = "Missing Phone Number (Firm)"
ERR_MISSING_PHONE_NUMBER_FIRM_MSG = "The firm's phone number is not specified. Please enter the field in your settings."
ERR_MISSING_PHONE_NUMBER_CLIENT = 14
ERR_MISSING_PHONE_NUMBER_CLIENT_NAME = "Missing Phone Number (Client)"
ERR_MISSING_PHONE_NUMBER_CLIENT_MSG = "The client's phone number is not specified. Please edit the contact and add a phone number."
ERR_MISSING_EINVOICE_INFO = 10
ERR_MISSING_EINVOICE_INFO_NAME = "Missing E-Invoicing Info"
ERR_MISSING_EINVOICE_INFO_MSG = "The firm's e-invoicing info (such as TIN, BRN, MSIC, etc) are missing. Please fill them in."
ERR_MISSING_TIN_FIRM = 11
ERR_MISSING_TIN_FIRM_NAME = "Missing Tin (Firm)"
ERR_MISSING_TIN_FIRM_MSG = "The firm's TIN (Tax Identification Number) code is missing. Please enter the field in your settings."
ERR_MISSING_BRN_FIRM = 12
ERR_MISSING_BRN_FIRM_NAME = "Missing BRN (Firm)"
ERR_MISSING_BRN_FIRM_MSG = "The firm's BRN (Business Registration Number) is missing. Please enter the field in your settings."
)

11
server/handler_ping.go Normal file
View File

@ -0,0 +1,11 @@
package server
import (
"fmt"
"net/http"
)
func HandlePing(w http.ResponseWriter, r *http.Request) {
fmt.Printf("Accepted request at /ping\n")
fmt.Fprintf(w, "pong")
}

366
server/handler_submit.go Normal file
View File

@ -0,0 +1,366 @@
package server
import (
"encoding/json"
"errors"
"fmt"
"iralex-einvoice/core"
"iralex-einvoice/document"
"iralex-einvoice/route"
"net/http"
"net/mail"
"regexp"
"strconv"
"strings"
)
// Table of Contents:
// - type billValidationError
// - func HandleSubmitDebug
// - func HandleSubmit
// - func validateBill
// - func validatePhoneNumber
// - func validateEmail
type billValidationError struct {
Code int `json:"code"`
Name string `json:"name"`
Msg string `json:"msg"`
}
// the following operations need to happen in order to submit a document:
// 1. log into Iralex and retrieve access token
// 2. get data for a specific document
// 3. log into LHDN and retrieve access token
// 4. generate the document (including signing it with the private key)
// 5. submit the document
//
// Semantics:
// • In case of a logical error in the bill, sends a 422.
func HandleSubmitDebug(w http.ResponseWriter, r *http.Request) {
fmt.Println("Accepted request at /submit")
core.LogInfo.Println("Accepted request at /submit")
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
fmt.Fprintf(w, "The %s method is not allowed.\n", r.Method)
return
}
var billIdParam string = r.URL.Query().Get("id")
if billIdParam == "" {
w.WriteHeader(http.StatusUnprocessableEntity)
fmt.Fprintf(w, "Missing required query parameter id.\n")
return
}
billId, err := strconv.Atoi(billIdParam)
if err != nil {
w.WriteHeader(http.StatusUnprocessableEntity)
fmt.Fprintf(w, "Query parameter id is not an integer number.\n")
return
}
// -------------------------------------------------------------------------
// Get Bill
accessToken, err := route.IralexAuthenticate()
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, "Error - cannot authenticate to Iralex: %s\n", err.Error())
return
}
bill, err := route.IralexGetBill(billId, accessToken)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, "Error - cannot get the bill with id %d: %s\n", billId, err.Error())
return
}
// DEBUG: Log the output (or return it if needed)
// s, _ := json.MarshalIndent(bill, "", "\t")
// core.LogInfo.Printf("Bill:\n%v\n", string(s))
// -------------------------------------------------------------------------
// Submit Document
billErrors := validateBill(bill)
if len(billErrors) > 0 {
w.WriteHeader(http.StatusUnprocessableEntity) // 422
json.NewEncoder(w).Encode(billErrors)
return
}
doc := document.Generate(bill)
doc = document.Sign(doc)
docJson, _ := json.MarshalIndent(doc, "", " ")
fmt.Printf("Generated Document:\n%s\n", string(docJson))
return
if false {
var accessToken string = route.AuthenticateFormData(core.CLIENT_ID, core.CLIENT_SECRET_1, core.TIN)
route.SubmitDocument(accessToken, doc)
} else {
var _ []byte = document.GenerateDocument(doc)
// document.PrintDocument(doc)
}
fmt.Fprintf(w, "200")
}
// the following operations need to happen in order to submit a document:
// 1. log into LHDN and retrieve access token
// 2. generate the document (including signing it with the private key)
// 3. submit the document
func HandleSubmit(w http.ResponseWriter, r *http.Request) {
fmt.Println("Accepted request at /submit")
core.LogInfo.Println("Accepted request at /submit")
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
fmt.Fprintf(w, "The %s method is not allowed.\n", r.Method)
return
}
bill, err := decodeJSONBody[route.IralexBill](w, r)
if err != nil {
core.LogInfo.Printf("ERROR: Cannot decode request JSON body: %s\n", err.Error())
var reqError *malformedRequestError
if errors.As(err, &reqError) {
w.WriteHeader(reqError.status)
fmt.Fprintln(w, reqError.msg)
} else {
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintln(w, http.StatusText(http.StatusInternalServerError))
}
return
}
// -------------------------------------------------------------------------
// Use the Bill
billErrors := validateBill(bill)
if len(billErrors) > 0 {
w.WriteHeader(http.StatusUnprocessableEntity) // 422
json.NewEncoder(w).Encode(billErrors)
return
}
doc := document.Generate(bill)
doc = document.Sign(doc)
accessToken := route.AuthenticateFormData(core.CLIENT_ID, core.CLIENT_SECRET_1, core.TIN)
route.SubmitDocument(accessToken, doc)
}
// =============================================================================
// VALIDATORS
// =============================================================================
// NOTE: Currently, this route is the only one that validates things. If more
// routes need validation in the future, consider moving these validators into
// their own separate file.
// Validates the specified bill to make sure its data is OK for submission to
// LHDN. In presence of no validation errors, an empty slice is returned with
// a of length 0.
func validateBill(bill route.IralexBill) []billValidationError {
var errs = make([]billValidationError, 0)
// -------------------------------------------------------------------------
// Essential Checks
// -------------------------------------------------------------------------
// Validations that reject the bill immediately without further checks.
if bill.Bill.Status != route.IralexBillType_Billing {
errs = append(errs, billValidationError{
Code: ERR_INVALID,
Name: ERR_INVALID_NAME,
Msg: ERR_INVALID_MSG,
})
return errs
}
if bill.Bill.ToContact.Addresses == nil || len(bill.Bill.ToContact.Addresses) == 0 {
errs = append(errs, billValidationError{
Code: ERR_MISSING_ADDRESS_CLIENT,
Name: ERR_MISSING_ADDRESS_CLIENT_NAME,
Msg: ERR_MISSING_ADDRESS_CLIENT_MSG,
})
// If the client's address is not specified, further validations cannot
// be checked.
return errs
}
if bill.FirmSetting.Setting.Accounts.Country != "Malaysia" {
errs = append(errs, billValidationError{
Code: ERR_BAD_COUNTRY_FIRM,
Name: ERR_BAD_COUNTRY_FIRM_NAME,
Msg: ERR_BAD_COUNTRY_FIRM_MSG,
})
}
if bill.Bill.ToContact.Addresses[0].CountryName != "Malaysia" {
errs = append(errs, billValidationError{
Code: ERR_BAD_COUNTRY_CLIENT,
Name: ERR_BAD_COUNTRY_CLIENT_NAME,
Msg: ERR_BAD_COUNTRY_CLIENT_MSG,
})
}
if bill.FirmSetting.Setting.Accounts.Currency != "RM" {
errs = append(errs, billValidationError{
Code: ERR_BAD_CURRENCY,
Name: ERR_BAD_CURRENCY_NAME,
Msg: ERR_BAD_CURRENCY_MSG,
})
}
if len(bill.FirmSetting.Setting.Accounts.Tin) == 0 {
errs = append(errs, billValidationError{
Code: ERR_MISSING_EINVOICE_INFO,
Name: ERR_MISSING_EINVOICE_INFO_NAME,
Msg: ERR_MISSING_EINVOICE_INFO_MSG,
})
}
if len(errs) != 0 { // early return
return errs
}
// -------------------------------------------------------------------------
// Contact Information Checks
// -------------------------------------------------------------------------
// Validating contact information of both the firm and the client, such as
// addresses, countries, cities, emails, phone numbers, etc.
var stateMapping = map[string]string{
"johor": "01",
"kedah": "02",
"kelantan": "03",
"melaka": "04",
"negeri": "05",
"pahang": "06",
"pulau pinang": "07",
"perak": "08",
"perlis": "09",
"selangor": "10",
"terengganu": "11",
"sabah": "12",
"sarawak": "13",
"wilayah persekutuan kuala lumpur": "14",
"kuala lumpur": "14",
"wilayah persekutuan labuan": "15",
"labuan": "15",
"wilayah persekutuan putrajaya": "16",
"putrajaya": "16",
}
if _, ok := stateMapping[strings.ToLower(bill.FirmSetting.Setting.Accounts.State)]; ok {
errs = append(errs, billValidationError{
Code: ERR_BAD_STATE_FIRM,
Name: ERR_BAD_STATE_FIRM_NAME,
Msg: ERR_BAD_STATE_FIRM_MSG,
})
}
if _, ok := stateMapping[strings.ToLower(bill.Bill.ToContact.Addresses[0].State)]; ok {
errs = append(errs, billValidationError{
Code: ERR_BAD_STATE_CLIENT,
Name: ERR_BAD_STATE_CLIENT_NAME,
Msg: ERR_BAD_STATE_CLIENT_MSG,
})
}
if bill.FirmSetting.Setting.Accounts.Phone == nil || strings.TrimSpace(*bill.FirmSetting.Setting.Accounts.Phone) == "" {
errs = append(errs, billValidationError{
Code: ERR_MISSING_PHONE_NUMBER_FIRM,
Name: ERR_MISSING_PHONE_NUMBER_FIRM_NAME,
Msg: ERR_MISSING_PHONE_NUMBER_FIRM_MSG,
})
} else if !validatePhoneNumber(*bill.FirmSetting.Setting.Accounts.Phone) {
errs = append(errs, billValidationError{
Code: ERR_BAD_PHONE_NUMBER_FIRM,
Name: ERR_BAD_PHONE_NUMBER_FIRM_NAME,
Msg: ERR_BAD_PHONE_NUMBER_FIRM_MSG,
})
}
if bill.Bill.ToContact.Phones != nil && len(bill.Bill.ToContact.Phones) > 0 {
errs = append(errs, billValidationError{
Code: ERR_BAD_PHONE_NUMBER_CLIENT,
Name: ERR_BAD_PHONE_NUMBER_CLIENT_NAME,
Msg: ERR_BAD_PHONE_NUMBER_CLIENT_MSG,
})
} else if !validatePhoneNumber(bill.Bill.ToContact.Phones[0].PhoneNumber) {
errs = append(errs, billValidationError{
Code: ERR_MISSING_PHONE_NUMBER_CLIENT,
Name: ERR_MISSING_PHONE_NUMBER_CLIENT_NAME,
Msg: ERR_MISSING_PHONE_NUMBER_CLIENT_MSG,
})
}
// -------------------------------------------------------------------------
// Tax Settings Checks
// -------------------------------------------------------------------------
// Validating tax related fields such as e-invoicing information.
if bill.FirmSetting.Setting.Accounts.Tin[len(bill.FirmSetting.Setting.Accounts.Tin)-1].MSIC == nil {
errs = append(errs, billValidationError{
Code: ERR_MISSING_MSIC,
Name: ERR_MISSING_MSIC_NAME,
Msg: ERR_MISSING_MSIC_MSG,
})
}
if bill.FirmSetting.Setting.Accounts.Tin[len(bill.FirmSetting.Setting.Accounts.Tin)-1].Code == nil {
errs = append(errs, billValidationError{
Code: ERR_MISSING_TIN_FIRM,
Name: ERR_MISSING_TIN_FIRM_NAME,
Msg: ERR_MISSING_TIN_FIRM_MSG,
})
}
if bill.FirmSetting.Setting.Accounts.Tin[len(bill.FirmSetting.Setting.Accounts.Tin)-1].BRN == nil {
errs = append(errs, billValidationError{
Code: ERR_MISSING_BRN_FIRM,
Name: ERR_MISSING_BRN_FIRM_NAME,
Msg: ERR_MISSING_BRN_FIRM_MSG,
})
}
// -------------------------------------------------------------------------
// Matter Checks
// -------------------------------------------------------------------------
// Validating the matter-related things of the bill.
if len(bill.Bill.Matters) == 0 {
errs = append(errs, billValidationError{
Code: ERR_MISSING_MATTER,
Name: ERR_MISSING_MATTER_NAME,
Msg: ERR_MISSING_MATTER_MSG,
})
}
return errs
}
// Validates a Malaysian phone number, and returns true if the format is
// correct, otherwise false.
// A Malaysian phone number must start with either +60 or 60, and followed by 9
// or 10 digits. if the phone number starts wit 11, then it must have 10 digits,
// otherwise it must have 9 digits.
//
// Here's the full article on Malaysian phone number validation:
// https://www.sent.dm/resources/my
func validatePhoneNumber(phoneNumber string) bool {
match, _ := regexp.MatchString("\\+60\\d{9,10}", phoneNumber)
return match
}
func validateEmail(email string) bool {
_, err := mail.ParseAddress(email)
return err == nil
}

50
server/run.go Normal file
View File

@ -0,0 +1,50 @@
package server
import (
"fmt"
"iralex-einvoice/config"
"iralex-einvoice/core"
"log"
"net/http"
)
// Initializes the server and starts listening on the specified tcp port.
func Run() {
// ------------------------------------------------------------------------
// Set up the database connection.
// db, err := database.New(DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME)
// if err != nil {
// fmt.Printf("Error database.New(): %v", err)
// panic(err)
// }
// defer db.Close()
// err = db.CheckConnection()
// if err != nil {
// panic(err)
// }
// fmt.Println("Successfully connected to database!")
// ------------------------------------------------------------------------
// Register routes.
http.HandleFunc("/ping", HandlePing)
if config.DEBUG {
http.HandleFunc("/submit", HandleSubmitDebug)
} else {
http.HandleFunc("/submit", HandleSubmit)
}
// ------------------------------------------------------------------------
// Listen on 0.0.0.0:PORT.
fmt.Printf("Listening on http://0.0.0.0:%s\n", core.PORT)
log.Fatal(http.ListenAndServe(":"+core.PORT, nil))
// db.New2(DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME)
// db.GetBill(1158)
// route.SubmitDocument("sadasd")
}