Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
c0d4b43
feat: let a mapping refuse a provider's answer and classify failed ch…
nisargabd Oct 5, 2026
c9004ab
feat: add tokenHeader auth for upstreams that log in and read a bare …
nisargabd Oct 5, 2026
5a53a4a
feat: add the Grievance plugin with PMFBY init, confirm and status ma…
nisargabd Oct 5, 2026
84d06cc
fix: classify null PMFBY messages and refuse a numeric phone before t…
nisargabd Oct 5, 2026
b0aa6e9
fix: stop logging the provider's body when a mapping rejects its answ…
nisargabd Oct 5, 2026
5904a31
refactor: rename exchangeQueryToken to exchangeLoginToken [#57]
nisargabd Oct 5, 2026
12f11cf
fix: drop null or empty PMFBY status fields instead of failing or map…
nisargabd Oct 5, 2026
9f494d3
feat: read a binding from the channel when a payload has no contract …
nisargabd Oct 5, 2026
55fed88
feat: lodge PMFBY grievances through support and answer errors as des…
nisargabd Oct 5, 2026
65888b1
refactor: fold the mapping refusal into serve.go [#57]
nisargabd Oct 5, 2026
5d35af6
fix: name the repeated array and correct comments in the PMFBY grieva…
nisargabd Oct 6, 2026
e711765
feat: align the PMFBY grievance replies with the updated design and p…
nisargabd Oct 6, 2026
8e0e19f
feat: move the PMFBY grievance into its grievance and case bands [#57]
nisargabd Oct 7, 2026
b55ce2d
feat: read a login token from a nested path in the reply [#57]
nisargabd Oct 8, 2026
6cddac5
feat: drop the PMFBY OTP leg and read FGMS's reply envelope as docume…
nisargabd Oct 8, 2026
4a949a8
fix: let other capabilities' support requests pass the fallback and d…
nisargabd Oct 8, 2026
1897321
fix: check the PMFBY phone and description and date a lodge once [#57]
nisargabd Oct 8, 2026
e314280
refactor: read a binding key per action instead of through a fallback…
nisargabd Oct 9, 2026
472d77b
refactor: leave a half-set action block to New's path check [#57]
nisargabd Oct 9, 2026
9ef57fb
refactor: list binding paths in order instead of per action [#57]
nisargabd Oct 9, 2026
152e506
refactor: keep the login exchange's name and comments as they were [#57]
nisargabd Oct 9, 2026
1637127
fix: answer the review on PR #70 [#57]
nisargabd Oct 10, 2026
a412ec7
feat: follow the grievance pack's code-only case status and orderId b…
nisargabd Oct 10, 2026
22a2d1c
refactor: shorten the grievance plugin's comments and merge splitPath…
nisargabd Oct 12, 2026
46a2094
fix: read PMFBY's real no-record reply and refuse another ticket's re…
nisargabd Oct 12, 2026
d481e21
fix: default PMFBY's category pair and read enrolmentId from the chan…
nisargabd Oct 13, 2026
50e3e9c
fix: answer no grievance found as a 202 NACK [#57]
nisargabd Oct 13, 2026
38a764f
fix: answer PMFBY refusals with its own message and case times in UTC…
nisargabd Oct 14, 2026
c12e34f
feat: hold a login token until the expiry its reply states [#57]
nisargabd Oct 14, 2026
f962c0c
fix: hold PMFBY's token until validTo, its real expiry [#57]
nisargabd Oct 14, 2026
d21fdc7
fix: read PMFBY's InsertDateTime as the RFC 3339 time it sends [#57]
nisargabd Oct 14, 2026
4efe9c6
fix: answer PMFBY failures in our own words, its own faults as 502 [#57]
nisargabd Oct 16, 2026
e1dd4bc
feat: move the PMFBY grievance onto the Grievance pack and return its…
nisargabd Oct 16, 2026
af726ae
fix: map Open to Registered and date a lodge by when it was lodged [#57]
nisargabd Oct 16, 2026
7cb5bb5
refactor: leave required fields to the Grievance pack [#57]
nisargabd Oct 19, 2026
2d623b7
refactor: name the PMFBY grievance mappings' constants and variables …
nisargabd Oct 22, 2026
21f3884
fix: reject a blank PMFBY ticket and echo the trimmed description [#57]
nisargabd Oct 22, 2026
b5d7048
refactor: name the status mapping's empty-object helper omitIfEmpty […
nisargabd Oct 22, 2026
7d0c2e1
Name the field a composite schema error was really about
manjudr Oct 23, 2026
f4ba64f
Stop reporting branches the schema already ruled out
manjudr Oct 23, 2026
d4c0409
Answer with a calendar date, and with a complaint that has a description
manjudr Oct 23, 2026
cf13438
Route a referenced object by the action, and keep what a "not" said
manjudr Oct 23, 2026
58e589b
Share one config reader, and let several steps sit in one pipeline
manjudr Oct 23, 2026
da4f87c
Say where an object sits once, not twice
manjudr Oct 23, 2026
9756a99
Answer with the record PMFBY named, and call the mode what the pack c…
manjudr Oct 23, 2026
2c51a6e
Keep the request out of the log when a provider quotes it back
manjudr Oct 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions config/mappings/pmfby/grievance.status.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# PMFBY status -> POST /krphapi/FGMS/GetGrievenceTicketsStatus.
# Reads a grievance back by ticket number and phone. Only listed fields are
# returned; the farmer's personal details are dropped.
# Required fields and formats are checked by the Grievance pack.

request: |
(
$attributes := beckn.message.contract.commitments[0].commitmentAttributes;
{
"requestorMobileNo": $attributes.applicantMobile,
"GrievenceSupportTicketNo": $attributes.case.ticketNo
}
)

# Empty PMFBY fields are dropped. No record, or none naming the ticket asked
# for, is a 400 in our words; any other failure is a 502. PMFBY's message is
# logged -- stripped of the request it quoted -- and never returned.
response: |
(
/* Constants */
$PMFBY_SUCCESS_CODE := "1";
$SCHEME := { "code": "PMFBY", "name": "Pradhan Mantri Fasal Bima Yojana" };
$INFORMATION_MODE := "OnDemand";
$COMMITMENT_STATUS_ACTIVE := "ACTIVE";
$DEFAULT_CASE_STATUS := "UnderReview";
$CASE_STATUS_CODES := { "open": "Registered", "resolved": "Resolved" };
$SEASON_NAMES := { "1": "Kharif", "2": "Rabi", "3": "Zaid" };
/* filedOn is a calendar date, and the day is the one the farmer filed on
-- PMFBY is an Indian portal, so a late-evening filing is that day in
IST and the day before in UTC. */
$DATE_FORMAT := "[Y0001]-[M01]-[D01]";
$IST_OFFSET := "+0530";

/* e.g. "2026-10-12T11:02:49.000Z" */
$RFC3339_PATTERN := /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})$/;
$DIGITS_PATTERN := /^[0-9]+$/;
$YEAR_PATTERN := /^[0-9]{4}$/;

$MSG_NOT_FOUND := "No grievance was found for this ticket number and phone number.";
$MSG_NO_FILING_DATE := "PMFBY returned the grievance without a usable filing date.";
$MSG_UNAVAILABLE := "PMFBY could not return the grievance status right now. Please try again later.";

$ERROR_REFUSED := { "status": 400, "code": "BIZ_GENERIC_ERROR" };
$ERROR_UNAVAILABLE := { "status": 502, "code": "NET_DOWNSTREAM_UNAVAILABLE" };
/* PMFBY fails everything with responseCode "0", so its wording (lower case)
is the only way to tell a caller mistake from a PMFBY fault. */
$KNOWN_REFUSALS := { "no record found": $MSG_NOT_FOUND };

/* Helpers */
/* PMFBY sends null or "" for missing values. */
$isText := function($value) { $type($value) = "string" and $value != "" };
$asText := function($value) { $isText($value) ? $value };
/* Ids come as numbers or digit strings, and 0 means none. */
$asId := function($value) {
($type($value) = "number" or $type($value) = "string")
and $contains($string($value), $DIGITS_PATTERN) and $number($string($value)) != 0
? $string($value)
};
/* Drops an object with no fields instead of sending {}. */
$omitIfEmpty := function($object) { $exists($keys($object)[0]) ? $object };
$error := function($kind, $message, $cause) {
{ "_error": $merge([$kind, { "message": $message, "cause": $cause }]) }
};

/* Inputs */
$commitment := beckn.message.contract.commitments[0];
$attributes := $commitment.commitmentAttributes;
$requestedTicket := $string($attributes.case.ticketNo);
/* The record that NAMES the ticket asked for, searched across every record
returned rather than taken from the first. PMFBY reads by phone, so the
list can hold this farmer's other grievances, and a record that does not
name its own ticket cannot be shown as the answer to this one. The
predicate reads a bare record and a list alike. */
$record := response.responseDynamic[$string(GrievenceSupportTicketNo) = $requestedTicket][0];
$pmfbyMessage := $type(response.responseMessage) = "string" ? $trim(response.responseMessage);
$refusalMessage := $pmfbyMessage ? $lookup($KNOWN_REFUSALS, $lowercase($pmfbyMessage));
$isAccepted := $string(response.responseCode) = $PMFBY_SUCCESS_CODE;
$isFound := $isAccepted and ($isText($record.ApplicationNo) or $isText($record.TicketStatus));
$mappedStatus := $isText($record.TicketStatus) ? $lookup($CASE_STATUS_CODES, $lowercase($trim($record.TicketStatus)));
$caseStatus := $exists($mappedStatus) ? $mappedStatus : $DEFAULT_CASE_STATUS;
$requestYear := $asId($record.RequestYear);
$cropYear := $requestYear and $contains($requestYear, $YEAR_PATTERN) ? $requestYear;
$filedOn := $isText($record.InsertDateTime) and $contains($record.InsertDateTime, $RFC3339_PATTERN)
? $fromMillis($toMillis($record.InsertDateTime), $DATE_FORMAT, $IST_OFFSET);
/* The officer's answer; "" until there is one. */
$reply := $asText($isText($record.latestRemark) ? $trim($record.latestRemark));
/* The complaint rides only with its description: the pack requires one,
so a category on its own would be an answer the caller must refuse. */
$description := $asText($record.GrievenceDescription);
/* filedOn is required on an answer, and a date is not ours to invent. */
$isComplete := $isFound and $exists($filedOn);

/* Output */
$isComplete
? {
"context": $merge([beckn.context, { "action": "on_status", "timestamp": $now() }]),
"message": {
"contract": $merge([beckn.message.contract, {
"commitments": [$merge([$commitment, {
"status": { "descriptor": { "code": $COMMITMENT_STATUS_ACTIVE } },
"commitmentAttributes": {
"@context": $attributes.`@context`,
"@type": $attributes.`@type`,
"informationMode": $INFORMATION_MODE,
"scheme": $SCHEME,
"enrolmentId": $asText($record.ApplicationNo),
"cropYear": $cropYear,
"season": $lookup($SEASON_NAMES, $asId($record.RequestSeason)),
"grievance": $exists($description) ? {
"category": $omitIfEmpty({ "code": $asId($record.TicketCategoryID), "name": $asText($record.TicketCategoryName) }),
"subCategory": $omitIfEmpty({ "code": $asId($record.TicketSubCategoryID), "name": $asText($record.TicketSubCategoryName) }),
"description": $description
},
"case": {
/* The record's own ticket, not the one asked for: the answer
reports what PMFBY returned, never what the caller said. */
"ticketNo": $string($record.GrievenceSupportTicketNo),
"status": { "code": $caseStatus },
"filedOn": $filedOn,
"reply": $reply,
"cropName": $asText($record.CropName)
}
}
}])]
}])
}
}
: $isFound ? $error($ERROR_UNAVAILABLE, $MSG_NO_FILING_DATE, $pmfbyMessage)
: $isAccepted ? $error($ERROR_REFUSED, $MSG_NOT_FOUND)
: $exists($refusalMessage) ? $error($ERROR_REFUSED, $refusalMessage, $pmfbyMessage)
: $error($ERROR_UNAVAILABLE, $MSG_UNAVAILABLE, $pmfbyMessage)
)
114 changes: 114 additions & 0 deletions config/mappings/pmfby/grievance.support.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# PMFBY support -> POST /krphapi/FGMS/AddKRPHNCIPGrievenceSupportTicket.
# Files a grievance; no OTP. "Grievence" is PMFBY's spelling.
# Required fields and formats are checked by the Grievance pack.
# The two halves cannot share variables, so the default codes are in both.

request: |
(
/* Constants */
$DEFAULT_CATEGORY_CODE := "3";
$DEFAULT_SUB_CATEGORY_CODE := "10";
$DEFAULT_RECEIPT_SOURCE_ID := "134306";
$SEASON_CODES := { "Kharif": "1", "Rabi": "2", "Zaid": "3" };

$DATE_FORMAT := "[Y0001]-[M01]-[D01]";
$IST_OFFSET := "+0530";

/* Helpers */
$orDefault := function($value, $default) { $exists($value) ? $string($value) : $default };

/* Inputs */
$channel := beckn.message.support.channels[0];
$grievance := $channel.grievance;
$filedAt := $exists($channel.complaintDate) ? $toMillis($channel.complaintDate) : $millis();

/* Output */
{
"requestorMobileNo": $channel.applicantMobile,
"applicationNo": $channel.enrolmentId,
"requestYear": $string($channel.cropYear),
"requestSeason": $lookup($SEASON_CODES, $channel.season),
"ticketCategoryID": $orDefault($grievance.category.code, $DEFAULT_CATEGORY_CODE),
"ticketSubCategoryID": $orDefault($grievance.subCategory.code, $DEFAULT_SUB_CATEGORY_CODE),
"grievenceDescription": $trim($grievance.description),
"complaintDate": $fromMillis($filedAt, $DATE_FORMAT, $IST_OFFSET),
"receiptSourceID": $orDefault($channel.receiptSourceId, $DEFAULT_RECEIPT_SOURCE_ID)
}
)

# Echoes the request and adds the ticket. A known PMFBY refusal is a 400 in our
# words; any other failure is a 502. PMFBY's message is logged, never returned.
response: |
(
/* Constants */
$DEFAULT_CATEGORY_CODE := "3";
$DEFAULT_SUB_CATEGORY_CODE := "10";
$PMFBY_SUCCESS_CODE := "1";
$PROVIDER_NAME := "PMFBY Grievance Portal";
$SCHEME := { "code": "PMFBY", "name": "Pradhan Mantri Fasal Bima Yojana" };
$INFORMATION_MODE := "OnDemand";
$CASE_STATUS_REGISTERED := "Registered";
$DATE_FORMAT := "[Y0001]-[M01]-[D01]";
$IST_OFFSET := "+0530";

$MSG_PHONE_MISMATCH := "The phone number does not match the one registered for this application number.";
$MSG_APPLICATION_NOT_FOUND := "No PMFBY application was found for this application number.";
$MSG_UNAVAILABLE := "PMFBY could not register the grievance right now. Please try again later.";

$ERROR_REFUSED := { "status": 400, "code": "BIZ_GENERIC_ERROR" };
$ERROR_UNAVAILABLE := { "status": 502, "code": "NET_DOWNSTREAM_UNAVAILABLE" };
/* PMFBY fails everything with responseCode "0", so its wording (lower case)
is the only way to tell a caller mistake from a PMFBY fault. */
$KNOWN_REFUSALS := {
"mobile number not matched": $MSG_PHONE_MISMATCH,
"application details not exists": $MSG_APPLICATION_NOT_FOUND
};

/* Helpers */
$orDefault := function($value, $default) { $exists($value) ? $string($value) : $default };
$error := function($kind, $message, $cause) {
{ "_error": $merge([$kind, { "message": $message, "cause": $cause }]) }
};

/* Inputs */
$channel := beckn.message.support.channels[0];
$grievance := $channel.grievance;
$ticketNo := response.responseDynamic.GrievenceSupportTicketNo;
$pmfbyMessage := $type(response.responseMessage) = "string" ? $trim(response.responseMessage);
$refusalMessage := $pmfbyMessage ? $lookup($KNOWN_REFUSALS, $lowercase($pmfbyMessage));
$hasTicket := $type($ticketNo) = "number" or ($type($ticketNo) = "string" and $ticketNo != "");
/* "Ticket Already Raised" also comes back as success with a ticket. */
$isLodged := $string(response.responseCode) = $PMFBY_SUCCESS_CODE and $hasTicket;

/* Output */
$isLodged
? {
"context": $merge([beckn.context, { "action": "on_support", "timestamp": $now() }]),
"message": {
"support": {
"channels": [{
"@context": $channel.`@context`,
"@type": $channel.`@type`,
"informationMode": $INFORMATION_MODE,
"provider": { "id": $channel.provider.id, "descriptor": { "name": $PROVIDER_NAME } },
"scheme": $SCHEME,
"enrolmentId": $channel.enrolmentId,
"grievance": {
"category": { "code": $orDefault($grievance.category.code, $DEFAULT_CATEGORY_CODE) },
"subCategory": { "code": $orDefault($grievance.subCategory.code, $DEFAULT_SUB_CATEGORY_CODE) },
"description": $trim($grievance.description)
},
"case": {
"ticketNo": $string($ticketNo),
"status": { "code": $CASE_STATUS_REGISTERED },
/* Today, not the caller's complaintDate: a status read reports
PMFBY's InsertDateTime, which is when the row was written. */
"filedOn": $fromMillis($millis(), $DATE_FORMAT, $IST_OFFSET)
}
}]
}
}
}
: $exists($refusalMessage) ? $error($ERROR_REFUSED, $refusalMessage, $pmfbyMessage)
: $error($ERROR_UNAVAILABLE, $MSG_UNAVAILABLE, $pmfbyMessage)
)
27 changes: 27 additions & 0 deletions config/provider-adapter.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,32 @@ modules:
# concurrency for this capability.
searchConcurrency: 1

# ------------------------------------------------------------------
# Grievance -- support files a grievance, status reads it back.
# ------------------------------------------------------------------
- id: Grievance
config:
bindingKeys: "pmfby|openagrinet:Grievance"

# Paired by position, tried in order: status names the binding on
# its contract, support on its channel.
providerIdAt: "message.contract.commitments[].offer.provider.id, message.support.channels[].provider.id"
capabilityCodeAt: "message.contract.commitments[].commitmentAttributes.@type, message.support.channels[].@type"

# Logs in at PMFBY's NICUsersLogin and sends the token bare in
# Authorization, held until the login reply's validTo.
# tokenUrl is set per deployment.
pmfby:
authScheme: tokenHeader
tokenUrl: <>
tokenUserField: appAccessUID
tokenUserEnv: PMFBY_ACCESS_UID
tokenSecretField: appAccessPWD
tokenSecretEnv: PMFBY_ACCESS_PWD
tokenResponseField: responseDynamic.token.Token
tokenExpiryField: responseDynamic.token.validTo
headerName: Authorization

# Declaring a step above is not enough: THIS list is what runs. A step
# that appears under providerSteps but not here never executes, and the
# request falls through to the 404 above -- which looks like a registry
Expand All @@ -516,6 +542,7 @@ modules:
- MandiPrice # its binding key, or pass through
- KnowledgeAdvisory # its binding key, or pass through
- AgricultureFacility # its binding key, or pass through
- Grievance # its binding key, or pass through
- signAck # signs whatever the step answered with

# ----------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions install/build-plugins.sh
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ plugins=(
"jsonmapper"
"WeatherObservation"
"MandiPrice"
"Grievance"
"KnowledgeAdvisory"
"AgricultureFacility"
"manifestloader"
Expand Down
20 changes: 20 additions & 0 deletions pkg/plugin/implementation/Grievance/Grievance.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Package Grievance serves the network's grievance capabilities: filing a
// grievance against a government scheme and checking on it. Provider details
// live in the registry, the mappings and the adapter config, not here.
package Grievance

import (
"context"

"github.com/beckn-one/beckn-onix/pkg/plugin/definition"
"github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common"
)

// Config is the shared step config.
type Config = common.Config

// New creates the grievance step. It has no prerequisites.
func New(ctx context.Context, registry definition.ProviderRecordLookup, mapper definition.Mapper,
cfg *Config) (definition.Step, func() error, error) {
return common.New(ctx, registry, mapper, nil, cfg)
}
55 changes: 55 additions & 0 deletions pkg/plugin/implementation/Grievance/cmd/plugin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Command plugin builds the grievance provider step as a loadable plugin. The
// .so's filename is the id a deployment names in providerSteps.
package main

import (
"context"
"errors"
"fmt"

"github.com/beckn-one/beckn-onix/pkg/log"
"github.com/beckn-one/beckn-onix/pkg/plugin/definition"
"github.com/beckn-one/beckn-onix/pkg/plugin/implementation/Grievance"
"github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common"
)

// grievanceProvider implements definition.ProviderStepProvider.
type grievanceProvider struct{}

// newStepFunc creates a new step. Indirected for tests.
var newStepFunc = Grievance.New

// parseConfig turns the plugin config map into a typed Config. Every provider
// step reads the same settings the same way, so the reading is shared; what is
// defaulted and what is refused are left to Grievance.New.
func (p grievanceProvider) parseConfig(config map[string]string) (*Grievance.Config, error) {
return common.ParseStepConfig(config)
}

// New creates a new grievance provider step instance.
func (p grievanceProvider) New(ctx context.Context, registry definition.ProviderRecordLookup, mapper definition.Mapper, config map[string]string) (definition.Step, func() error, error) {
if ctx == nil {
return nil, nil, errors.New("context cannot be nil")
}

cfg, err := p.parseConfig(config)
if err != nil {
log.Errorf(ctx, err, "Failed to parse grievance configuration")
return nil, nil, fmt.Errorf("failed to parse grievance configuration: %w", err)
}

step, closer, err := newStepFunc(ctx, registry, mapper, cfg)
if err != nil {
log.Errorf(ctx, err, "Failed to create grievance step")
return nil, nil, err
}

log.Infof(ctx, "Grievance step created successfully")
return step, closer, nil
}

// Provider is the exported plugin instance.
var Provider = grievanceProvider{}

// Compile-time check that Provider satisfies the interface.
var _ definition.ProviderStepProvider = Provider
Loading
Loading