Returns qr_code and doc_id upon sumbission, added a new api handler as fallback for qr_code link generation.

This commit is contained in:
pooyarhz99 2025-09-03 16:50:35 +03:30
parent 1710ce35d2
commit 274f96df3e
12 changed files with 309 additions and 89 deletions

1
.gitignore vendored
View File

@ -1,5 +1,6 @@
.git/
.vscode/
storage/
.env
cache.txt
logs.txt

View File

@ -48,11 +48,11 @@ 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
## Module Structure
- `common`: Contains common types for use between packages in order to avoid cyclic dependencies. This package should not depend on any other package except those from the standard library or third-party libraries.
- `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.
- `core`: Core functionalities, including logging, environment variables, command-line arguments, disk operations, 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.

View File

@ -10,7 +10,7 @@ import (
"github.com/shopspring/decimal"
)
// Converts a bill object to a document (UBL invoice) object.
// Converts an Iralex bill object to a document (UBL invoice) object.
//
// Pre-conditions:
// The following pre-conditions on the bill parameter must be satisified by the

View File

@ -4,6 +4,33 @@ import (
"github.com/shopspring/decimal"
)
// DocumentExtended represents the ExtendedDocument table from the MyInvois API response.
type ExtendedDocument struct {
UUID string `json:"uuid"`
SubmissionUID string `json:"submissionUid"`
LongID string `json:"longId"`
InternalID string `json:"internalId"`
TypeName string `json:"typeName"`
TypeVersionName string `json:"typeVersionName"`
IssuerTIN string `json:"issuerTin"`
IssuerName string `json:"issuerName"`
ReceiverID string `json:"receiverId"`
ReceiverName string `json:"receiverName"`
DateTimeIssued string `json:"dateTimeIssued"` // ISO8601 UTC
DateTimeReceived string `json:"dateTimeReceived"` // ISO8601 UTC
DateTimeValidated string `json:"dateTimeValidated"` // ISO8601 UTC
TotalExcludingTax decimal.Decimal `json:"totalExcludingTax"`
TotalDiscount decimal.Decimal `json:"totalDiscount"`
TotalNetAmount decimal.Decimal `json:"totalNetAmount"`
TotalPayableAmount decimal.Decimal `json:"totalPayableAmount"`
Status string `json:"status"`
CancelDateTime string `json:"cancelDateTime"` // ISO8601 UTC
RejectRequestDateTime string `json:"rejectRequestDateTime"` // ISO8601 UTC
DocumentStatusReason string `json:"documentStatusReason"`
CreatedByUserID string `json:"createdByUserId"`
Document string `json:"document"`
}
type UBLInvoiceRoot struct {
D string `json:"_D"`
A string `json:"_A"`

View File

@ -3,6 +3,13 @@ package route
// This file contains error types that different routes use to communicate
// failure states with the rest of the application.
/*
| -----------------------------------------------------------------------------
| Document Submission Rejection Error
| -----------------------------------------------------------------------------
| Error returned in case the document was rejected by LHDN, along with the list
| of rejection reasons.
*/
type SubmitDocRejectionError struct {
RejectedDocuments []string
}
@ -15,6 +22,14 @@ func (err SubmitDocRejectionError) Error() string {
return result[:len(result)-2]
}
/*
| -----------------------------------------------------------------------------
| Document Submission Bad Structure Error
| -----------------------------------------------------------------------------
| Error returned in case the generated UBL invoice does not have a correct
| format. Incorrect JSON format, incorrect field names, missing required fields,
| etc.
*/
type SubmitDocBadStructureError struct {
}
@ -22,6 +37,13 @@ func (err SubmitDocBadStructureError) Error() string {
return "the submitted document was ill-formed"
}
/*
| -----------------------------------------------------------------------------
| Document Submission Maximum Size Error
| -----------------------------------------------------------------------------
| Error returned in case the UBL invoice was too large. The LHDN API has a
| limitation where the sent document cannot exceed 5 MB in size.
*/
type SubmitDocMaxSizeError struct {
}
@ -29,9 +51,32 @@ func (err SubmitDocMaxSizeError) Error() string {
return "maximum size exceeded: 5 MB maximum submission size, 100 maximum e-Invoices per submission, and 300 KB maximum per e-Invoice"
}
/*
| -----------------------------------------------------------------------------
| Document Submission Duplication Error
| -----------------------------------------------------------------------------
| Error returned in case the exact same UBL invoice was sent consecutively. The
| LHDN API has a limitation where the exact same document cannot be sent again
| until after 10 minutes has passed.
*/
type SubmitDocDuplicateError struct {
}
func (err SubmitDocDuplicateError) Error() string {
return "duplicate submission was sent not so long ago"
}
/*
| -----------------------------------------------------------------------------
| Document Submission Not Found Error
| -----------------------------------------------------------------------------
| Error returned in case the document submission was successful, but no
| acceptedDocuments fields was returned, or it was present but had no elements.
| This error should rarely happen, if any at all.
*/
type SubmitDocNotFound struct {
}
func (err SubmitDocNotFound) Error() string {
return "document could not but found"
}

View File

@ -0,0 +1,42 @@
package route
import (
"encoding/json"
"errors"
"iralex-einvoice/config"
"iralex-einvoice/core"
"iralex-einvoice/document"
"net/http"
"time"
)
func GetDocument(accessToken, docUUID string) (document.ExtendedDocument, error) {
core.LogInfo.Printf("INFO: sending request GET %s\n", config.LHDN_BASE_URL+"/api/v1.0/documents/"+docUUID+"/raw")
req, err := http.NewRequest(http.MethodGet, config.LHDN_BASE_URL+"/api/v1.0/documents/"+docUUID+"/raw", nil)
if err != nil {
core.LogInfo.Printf("ERROR: in constructing the LHDN GetDocument request: %v", err)
return document.ExtendedDocument{}, err
}
req.Header.Add("Accept", "application/json")
req.Header.Add("Authorization", "Bearer "+accessToken)
client := &http.Client{
Timeout: time.Second * config.DEFAULT_TIMEOUT,
}
resp, err := client.Do(req)
if err != nil {
core.LogInfo.Printf("ERROR: couldn't send the LHDN GetDocument request: %v", err)
return document.ExtendedDocument{}, err
}
defer resp.Body.Close()
var respBody document.ExtendedDocument
json.NewDecoder(resp.Body).Decode(&respBody)
if resp.StatusCode == 200 {
return respBody, nil
} else {
core.LogInfo.Printf("ERROR: LHDN GetDocument error response (%d):\n%v\n", resp.StatusCode, resp.Body)
return document.ExtendedDocument{}, errors.New("LHDN GetDocument failed")
}
}

View File

@ -51,8 +51,8 @@ type responseSuccess struct {
} `json:"rejectedDocuments"`
}
// Submits a document to LHDN.
func SubmitDocument(accessToken string, doc document.UBLInvoiceRoot) error {
// Submits a document to LHDN. Returns the document UUID on success.
func SubmitDocument(accessToken string, doc document.UBLInvoiceRoot) (string, error) {
core.LogInfo.Println("INFO: Sending Request (/submit-document):")
// Minify and convert the document to base64.
@ -83,7 +83,7 @@ func SubmitDocument(accessToken string, doc document.UBLInvoiceRoot) error {
},
})
if err != nil {
return fmt.Errorf("couldn't marshal the request body: %w", err)
return "", fmt.Errorf("couldn't marshal the request body: %w", err)
}
// Pretty print the document/body.
@ -99,7 +99,7 @@ func SubmitDocument(accessToken string, doc document.UBLInvoiceRoot) error {
req, err := http.NewRequest(http.MethodPost, config.LHDN_BASE_URL+"/api/v1.0/documentsubmissions/", bytes.NewBuffer(body))
if err != nil {
return fmt.Errorf("couldn't construct the /submit-document request: %w", err)
return "", fmt.Errorf("couldn't construct the /submit-document request: %w", err)
}
req.Header.Add("Accept", "application/json")
@ -117,7 +117,7 @@ func SubmitDocument(accessToken string, doc document.UBLInvoiceRoot) error {
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("couldn't send the request: %w", err)
return "", fmt.Errorf("couldn't send the request: %w", err)
}
defer resp.Body.Close()
@ -135,13 +135,22 @@ func SubmitDocument(accessToken string, doc document.UBLInvoiceRoot) error {
for _, value := range rejectedDocs {
result = append(result, value.(map[string]interface{})["invoiceCodeNumber"].(string))
}
return SubmitDocRejectionError{
return "", SubmitDocRejectionError{
RejectedDocuments: result,
}
}
}
} else if acceptedDocs, ok := res["acceptedDocuments"]; ok {
if acceptedDocs, ok := acceptedDocs.([]interface{}); ok {
if len(acceptedDocs) > 0 {
var docUUID string
for _, value := range acceptedDocs {
docUUID = value.(map[string]interface{})["uuid"].(string)
return docUUID, nil
}
}
}
}
return nil
} else {
var res lhdnStdError
json.NewDecoder(resp.Body).Decode(&res)
@ -151,15 +160,15 @@ func SubmitDocument(accessToken string, doc document.UBLInvoiceRoot) error {
}
if resp.StatusCode == 400 && *res.ErrorCode == "BadStructure" {
return SubmitDocBadStructureError{}
return "", SubmitDocBadStructureError{}
} else if resp.StatusCode == 400 && *res.ErrorCode == "MaximumSizeExceeded" {
return SubmitDocMaxSizeError{}
return "", SubmitDocMaxSizeError{}
} else if resp.StatusCode == 422 {
return SubmitDocDuplicateError{}
return "", SubmitDocDuplicateError{}
}
}
return nil
return "", SubmitDocNotFound{}
// var bill = database.Bill{}
// document.SubmitDocument("aasdasd")

View File

@ -1,68 +0,0 @@
package server
import (
"encoding/json"
"fmt"
"iralex-einvoice/config"
"iralex-einvoice/core"
"iralex-einvoice/route"
"net/http"
"time"
)
func HandleDebugQRCodeGenerate(w http.ResponseWriter, r *http.Request) {
// filepath := "N:\\Pooya\\Projects\\iralex einvoice\\storage\\2025\\8-August\\qrcode\\408BMZ0D2AVGTWHDWAH3GQ0K10.png"
// content, err := os.ReadFile(filepath)
// if err != nil {
// fmt.Println("failed to read file %v", err)
// }
// b := b64.StdEncoding.EncodeToString(content)
// fmt.Println(b)
// return
// hardcoded for testing
// QR code generation must be parameterized on document UUID
const docUUID = "408BMZ0D2AVGTWHDWAH3GQ0K10"
var longID string = ""
accessToken := route.AuthenticateFormData(core.CLIENT_ID, core.CLIENT_SECRET_1, core.TIN)
req, err := http.NewRequest(http.MethodGet, config.LHDN_BASE_URL+"/api/v1.0/documents/"+docUUID+"/raw", nil) // GetDocument
if err != nil {
fmt.Printf("Error in constructing the request: %v", err)
return
}
req.Header.Add("Accept", "application/json")
req.Header.Add("Authorization", "Bearer "+accessToken)
client := &http.Client{
Timeout: time.Second * config.DEFAULT_TIMEOUT,
}
resp, err := client.Do(req)
if err != nil {
fmt.Printf("couldn't send the request: %v", err)
return
}
defer resp.Body.Close()
var respBody map[string]interface{}
json.NewDecoder(resp.Body).Decode(&respBody)
if resp.StatusCode == 200 {
if _longID, ok := respBody["longID"]; ok {
longID = _longID.(string)
}
} else {
fmt.Printf("Error\n")
return
}
var link = "https://myinvois.hasil.gov.my/" + docUUID + "/share/" + longID
fmt.Println("Link")
fmt.Println(link)
fmt.Fprintf(w, "Link: %s\n", link)
err = core.CreateQRCodeImage(link, docUUID)
if err != nil {
fmt.Fprintf(w, "ERROR: Couldn't create the QRCode image: %s", err.Error())
}
}

139
server/handle_qrcode.go Normal file
View File

@ -0,0 +1,139 @@
package server
import (
"encoding/json"
"errors"
"fmt"
"iralex-einvoice/config"
"iralex-einvoice/core"
"iralex-einvoice/route"
"net/http"
"runtime/debug"
"time"
)
type sendQRCodeRequestBody struct {
DocID string `json:"doc_id"`
}
type sendQRCodeResponseBody struct {
DocID string `json:"doc_id"`
QRCode string `json:"qr_code"`
}
// This route generates the QR code link of the specified bill. It expects a
// JSON body, with a single "doc_id" field that contains the LHDN document UUID.
func HandleQRCodeGenerate(w http.ResponseWriter, r *http.Request) {
fmt.Println("Accepted request at /send-qrcode")
core.LogInfo.Println("Accepted request at /send-qrcode")
// In case of a panic, do not stop the server, but send a 500.
defer func() {
if r := recover(); r != nil {
core.LogInfo.Printf("ERROR - recovered a panic: %v\n", r)
core.LogInfo.Println("STACK TRACE:")
core.LogInfo.Println(string(debug.Stack()))
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, http.StatusText(http.StatusInternalServerError))
}
}()
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
fmt.Fprintf(w, "The %s method is not allowed.", r.Method)
return
}
reqBody, err := decodeJSONBody[sendQRCodeRequestBody](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.Fprint(w, reqError.msg)
} else {
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, http.StatusText(http.StatusInternalServerError))
}
return
}
w.Header().Set("Content-Type", "application/json")
// -------------------------------------------------------------------------
// Retreive long ID and generate and return the link.
accessToken := route.AuthenticateFormData(core.CLIENT_ID, core.CLIENT_SECRET_1, core.TIN)
docEx, err := route.GetDocument(accessToken, reqBody.DocID)
if err != nil {
core.LogInfo.Println("ERROR - Failed to get the document")
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, http.StatusText(http.StatusInternalServerError))
return
}
var link = "https://myinvois.hasil.gov.my/" + reqBody.DocID + "/share/" + docEx.LongID
json.NewEncoder(w).Encode(sendQRCodeResponseBody{
DocID: reqBody.DocID,
QRCode: link,
})
}
func HandleDebugQRCodeGenerate(w http.ResponseWriter, r *http.Request) {
// filepath := "N:\\Pooya\\Projects\\iralex einvoice\\storage\\2025\\8-August\\qrcode\\408BMZ0D2AVGTWHDWAH3GQ0K10.png"
// content, err := os.ReadFile(filepath)
// if err != nil {
// fmt.Println("failed to read file %v", err)
// }
// b := b64.StdEncoding.EncodeToString(content)
// fmt.Println(b)
// return
// hardcoded for testing
// QR code generation must be parameterized on document UUID
const docUUID = "408BMZ0D2AVGTWHDWAH3GQ0K10"
var longID string = ""
accessToken := route.AuthenticateFormData(core.CLIENT_ID, core.CLIENT_SECRET_1, core.TIN)
req, err := http.NewRequest(http.MethodGet, config.LHDN_BASE_URL+"/api/v1.0/documents/"+docUUID+"/raw", nil) // GetDocument
if err != nil {
fmt.Printf("Error in constructing the request: %v", err)
return
}
req.Header.Add("Accept", "application/json")
req.Header.Add("Authorization", "Bearer "+accessToken)
client := &http.Client{
Timeout: time.Second * config.DEFAULT_TIMEOUT,
}
resp, err := client.Do(req)
if err != nil {
fmt.Printf("couldn't send the request: %v", err)
return
}
defer resp.Body.Close()
var respBody map[string]interface{}
json.NewDecoder(resp.Body).Decode(&respBody)
if resp.StatusCode == 200 {
if _longID, ok := respBody["longID"]; ok {
longID = _longID.(string)
}
} else {
fmt.Printf("Error\n")
return
}
var link = "https://myinvois.hasil.gov.my/" + docUUID + "/share/" + longID
fmt.Println("Link")
fmt.Println(link)
fmt.Fprintf(w, "Link: %s\n", link)
err = core.CreateQRCodeImage(link, docUUID)
if err != nil {
fmt.Fprintf(w, "ERROR: Couldn't create the QRCode image: %s", err.Error())
}
}

View File

@ -31,6 +31,15 @@ type billValidationError struct {
Msg string `json:"msg"`
}
type submitResponseBody struct {
DocID string `json:"doc_id"`
QRCode string `json:"qr_code"`
}
type submitDamagedResponseBody struct {
DocID string `json:"doc_id"`
}
// 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
@ -42,7 +51,7 @@ type billValidationError struct {
// • 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")
fmt.Println("Accepted request at /submit (debug)")
core.LogInfo.Println("Accepted request at /submit (debug)")
// In case of a panic, do not stop the server, but send a 500.
@ -146,7 +155,7 @@ func HandleSubmit(w http.ResponseWriter, r *http.Request) {
// In case of a panic, do not stop the server, but send a 500.
defer func() {
if r := recover(); r != nil {
core.LogInfo.Printf("ERROR: recovered a panic: %v\n", r)
core.LogInfo.Printf("ERROR - recovered a panic: %v\n", r)
core.LogInfo.Println("STACK TRACE:")
core.LogInfo.Println(string(debug.Stack()))
w.WriteHeader(http.StatusInternalServerError)
@ -196,7 +205,7 @@ func HandleSubmit(w http.ResponseWriter, r *http.Request) {
}
accessToken := route.AuthenticateFormData(core.CLIENT_ID, core.CLIENT_SECRET_1, core.TIN)
err = route.SubmitDocument(accessToken, doc)
docUUID, err := route.SubmitDocument(accessToken, doc)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, err)
@ -204,9 +213,22 @@ func HandleSubmit(w http.ResponseWriter, r *http.Request) {
}
// -------------------------------------------------------------------------
// If we reached here, everything worked fine.
// Generate QRCode Link
fmt.Fprint(w, "success")
docEx, err := route.GetDocument(accessToken, docUUID)
if err != nil {
json.NewEncoder(w).Encode(submitResponseBody{
DocID: docUUID,
QRCode: "",
})
core.LogInfo.Printf("ERROR - Could not get the document details upon submission: %v", err)
}
// If we reached here, everything worked fine.
json.NewEncoder(w).Encode(submitResponseBody{
DocID: docUUID,
QRCode: "https://myinvois.hasil.gov.my/" + docUUID + "/share/" + docEx.LongID,
})
}
// =============================================================================

View File

@ -29,6 +29,7 @@ func Run() {
// Register routes.
http.HandleFunc("/ping", HandlePing)
http.HandleFunc("/send-qrcode", HandleQRCodeGenerate)
if core.DEV {
http.HandleFunc("/submit", HandleSubmitDebug)
@ -47,7 +48,5 @@ func Run() {
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")
}

View File

@ -9,6 +9,8 @@ import (
"strings"
)
// Decoding Configurations
// Configure how the decoding logic should behave.
const (
// When parsing the request JSON body, whether to send an error or not when
// extra fields not specified in route.IralexBill are present.
@ -26,6 +28,8 @@ const (
_CHECK_CONTENT_TYPE = true
)
// Custom Error types returned from the decoder. Each different type represents
// a different error.
type malformedRequestError struct {
status int
msg string