Compare commits

..

2 Commits

Author SHA1 Message Date
27e2572ded updated to python v13.3 and added mockable tests 2026-04-24 16:51:48 +02:00
67ab05055c First vibe coded example 2026-04-24 15:37:46 +02:00
39 changed files with 1463 additions and 1918 deletions

View File

@ -45,36 +45,12 @@ paths:
"contractStatus": "contractStatus":
{ "contractStatusId": 2, "name": "active" }, { "contractStatusId": 2, "name": "active" },
"productId": 1, "productId": 1,
"productName": "HTM Maand 20% korting doorlopend", "productName": "HTM Maand 20% korting",
"termDuration": "P1M", "termDuration": "P0Y1M0D",
"billingDay": 15, "billingDay": 15,
"highestInvoiceTerm": 1, "highestInvoiceTerm": 1,
"created": "2024-08-01T15:01:00.000Z", "created": "2024-08-01 15:01:00.000",
"ovPayTokenId": 1337, "ovPayTokenId": 1337,
"contractVersions": [
{
"contractVersionId": 1,
"termsAndConditions": "https://www.htm.nl/reisproducten/productvoorwaarden/htm-maandkorting/",
"productId": 1,
"productName": "HTM Maand 20% korting doorlopend",
"taxCode": "V9",
"taxPercentage": 9,
"termAmountInclTax": 400,
"start": "2024-08-01T15:01:00.000Z",
"end": "2025-01-01T03:00:00.000Z"
},
{
"contractVersionId": 2,
"termsAndConditions": "https://www.htm.nl/reisproducten/productvoorwaarden/htm-maandkorting/",
"productId": 1,
"productName": "HTM Maand 20% korting doorlopend",
"taxCode": "V9",
"taxPercentage": 9,
"termAmountInclTax": 500,
"start": "2025-01-01T03:00:00.000Z",
"end": null
}
],
"_links": "_links":
{ {
"get_token": "get_token":
@ -92,38 +68,14 @@ paths:
"orderLineId": "42f68042-908f-41f4-9d9b-4cab843ff0e8", "orderLineId": "42f68042-908f-41f4-9d9b-4cab843ff0e8",
"touchpointId": 2, "touchpointId": 2,
"contractStatus": "contractStatus":
{ "contractStatusId": 6, "name": "pending cancellation" }, { "contractStatusId": 1, "name": "new" },
"productId": 1, "productId": 1,
"productName": "HTM 20% Korting doorlopend", "productName": "HTM Maand 20% korting",
"termDuration": "P1M", "termDuration": "P0Y1M0D",
"billingDay": 15, "billingDay": 15,
"highestInvoiceTerm": 1, "highestInvoiceTerm": 1,
"created": "2024-08-01T15:01:00.000Z", "created": "2024-08-01 15:01:00.000",
"ovPayTokenId": 1338, "ovPayTokenId": 1338,
"contractVersions": [
{
"contractVersionId": 1,
"termsAndConditions": "https://www.htm.nl/reisproducten/productvoorwaarden/htm-maandkorting/",
"productId": 1,
"productName": "HTM 20% Korting doorlopend",
"taxCode": "V9",
"taxPercentage": 9,
"termAmountInclTax": 400,
"start": "2024-08-01T15:01:00.000Z",
"end": "2025-01-01T03:00:00.000Z"
},
{
"contractVersionId": 2,
"termsAndConditions": "https://www.htm.nl/reisproducten/productvoorwaarden/htm-maandkorting/",
"productId": 1,
"productName": "HTM 20% Korting doorlopend",
"taxCode": "V9",
"taxPercentage": 9,
"termAmountInclTax": 500,
"start": "2025-01-01T03:00:00.000Z",
"end": "2025-02-01T03:00:00.000Z"
}
],
"_links": "_links":
{ {
"get_token": "get_token":
@ -202,8 +154,8 @@ paths:
"contractStatus": "contractStatus":
{ "contractStatusId": 2, "name": "active" }, { "contractStatusId": 2, "name": "active" },
"productId": 1, "productId": 1,
"productName": "HTM 20% Korting doorlopend", "productName": "HTM Maand 20% korting",
"termDuration": "P1M", "termDuration": "P0Y1M0D",
"billingDay": 15, "billingDay": 15,
"highestInvoiceTerm": 1, "highestInvoiceTerm": 1,
"ovPayTokenId": 1337, "ovPayTokenId": 1337,
@ -213,22 +165,22 @@ paths:
"contractVersionId": 1, "contractVersionId": 1,
"termsAndConditions": "https://www.htm.nl/reisproducten/productvoorwaarden/htm-maandkorting/", "termsAndConditions": "https://www.htm.nl/reisproducten/productvoorwaarden/htm-maandkorting/",
"productId": 1, "productId": 1,
"productName": "HTM 20% Korting doorlopend", "productName": "HTM Maand 20% korting",
"taxCode": "V9", "taxCode": "V9",
"taxPercentage": 9.0, "taxPercentage": 9.0,
"termAmountInclTax": 400, "termAmountInclTax": 400,
"start": "2024-08-01T15:01:00.000Z", "start": "2024-07-04 15:01:00.000",
"end": "2025-01-01T03:00:00.000Z", "end": "2024-12-31 15:01:00.000",
}, },
{ {
"contractVersionId": 2, "contractVersionId": 2,
"termsAndConditions": "https://www.htm.nl/reisproducten/productvoorwaarden/htm-maandkorting/", "termsAndConditions": "https://www.htm.nl/reisproducten/productvoorwaarden/htm-maandkorting/",
"productId": 1, "productId": 1,
"productName": "HTM 20% Korting doorlopend", "productName": "HTM Maand 20% korting",
"taxCode": "V9", "taxCode": "V9",
"taxPercentage": 9.0, "taxPercentage": 9.0,
"termAmountInclTax": 400, "termAmountInclTax": 400,
"start": "2025-01-01T03:00:00.000Z", "start": "2025-01-01 15:01:00.000",
}, },
], ],
"contractActions": "contractActions":
@ -238,7 +190,7 @@ paths:
"actionType": "actionType":
{ "actionTypeId": 1, "name": "create" }, { "actionTypeId": 1, "name": "create" },
"user": "subid123456", "user": "subid123456",
"timestamp": "2024-07-02T15:01:00.000Z", "timestamp": "2024-07-02 15:01:00.000",
"details": "Contract created", "details": "Contract created",
"correlationId": "976e7a4c-bf24-43d2-b444-55817556e7ee", "correlationId": "976e7a4c-bf24-43d2-b444-55817556e7ee",
}, },
@ -247,7 +199,7 @@ paths:
"actionType": "actionType":
{ "actionTypeId": 2, "name": "change" }, { "actionTypeId": 2, "name": "change" },
"user": "subid123456", "user": "subid123456",
"timestamp": "2024-07-03T15:01:00.000Z", "timestamp": "2024-07-03 15:01:00.000",
"details": "Contract changed", "details": "Contract changed",
"correlationId": "e2462347-6749-4841-b42a-cf8de19ec727", "correlationId": "e2462347-6749-4841-b42a-cf8de19ec727",
}, },
@ -259,8 +211,8 @@ paths:
"externalReference": "F2024-0001", "externalReference": "F2024-0001",
"term": 1, "term": 1,
"invoiceDate": "2024-07-02", "invoiceDate": "2024-07-02",
"created": "2024-07-02T15:01:00.000Z", "created": "2024-07-02 15:01:00.000",
"updated": "2024-07-02T15:01:00.000Z", "updated": "2024-07-02 15:01:00.000",
"state": "invoice_created", "state": "invoice_created",
"data": "{json}", "data": "{json}",
"isCredit": false, "isCredit": false,
@ -318,8 +270,8 @@ paths:
"externalReference": "F2024-0001", "externalReference": "F2024-0001",
"term": 1, "term": 1,
"invoiceDate": "2024-07-02", "invoiceDate": "2024-07-02",
"created": "2024-07-02T15:01:34.000Z", "created": "2024-07-02 15:01:34.000",
"updated": "2024-07-04T00:04:56.000Z", "updated": "2024-07-04 00:04:56.000",
"state": "invoice_created", "state": "invoice_created",
"public_link": "http://mijnfactuurinzien.nl/F2024-0001", "public_link": "http://mijnfactuurinzien.nl/F2024-0001",
"isCredit": false, "isCredit": false,
@ -330,8 +282,8 @@ paths:
"externalReference": "F2024-0002", "externalReference": "F2024-0002",
"term": 2, "term": 2,
"invoiceDate": "2024-08-02", "invoiceDate": "2024-08-02",
"created": "2024-08-02T15:01:34.000Z", "created": "2024-08-02 15:01:34.000",
"updated": "2024-08-04T00:04:56.000Z", "updated": "2024-08-04 00:04:56.000",
"state": "invoice_created", "state": "invoice_created",
"public_link": "http://mijnfactuurinzien.nl/F2024-0002", "public_link": "http://mijnfactuurinzien.nl/F2024-0002",
"isCredit": false, "isCredit": false,
@ -376,8 +328,8 @@ paths:
"cancellationMoment": "termBound", "cancellationMoment": "termBound",
"termDuration": "P1M", "termDuration": "P1M",
"billingDay": 18, "billingDay": 18,
"cancellationFrom": "2024-08-10T00:00:00Z", "cancellationFrom": "2024-08-10T00:00:00",
"cancellationUntil": "2026-08-10T00:00:00Z", "cancellationUntil": "2026-08-10T00:00:00",
} }
/contracts/{contractId}/cancellationvalidation: /contracts/{contractId}/cancellationvalidation:
parameters: parameters:
@ -430,7 +382,7 @@ paths:
{ {
"validationResult": true, "validationResult": true,
"validationMessage": "", "validationMessage": "",
"end": "2024-08-10T03:59:59Z", "end": "2024-08-10T03:59:59",
"refundAmount": 2489, "refundAmount": 2489,
"refundMethods": ["creditInvoice", "iDeal"], "refundMethods": ["creditInvoice", "iDeal"],
} }
@ -499,7 +451,7 @@ paths:
the refund amount and refund method. the refund amount and refund method.
value: value:
{ {
"end": "2024-08-10T03:59:59Z", "end": "2024-08-10T03:59:59",
"refundAmount": 2489, "refundAmount": 2489,
"refundMethod": "creditInvoice", "refundMethod": "creditInvoice",
} }
@ -564,8 +516,8 @@ paths:
"taxCode": "V9", "taxCode": "V9",
"taxPercentage": 9.0, "taxPercentage": 9.0,
"termAmountInclTax": 400, "termAmountInclTax": 400,
"start": "2024-07-04T15:01:00.000Z", "start": "2024-07-04 15:01:00.000",
"end": "2024-12-31T15:01:00.000Z", "end": "2024-12-31 15:01:00.000",
}, },
{ {
"contractVersionId": 2, "contractVersionId": 2,
@ -575,8 +527,7 @@ paths:
"taxCode": "V9", "taxCode": "V9",
"taxPercentage": 9.0, "taxPercentage": 9.0,
"termAmountInclTax": 400, "termAmountInclTax": 400,
"start": "2025-01-01T15:01:00.000Z", "start": "2025-01-01 15:01:00.000",
"end": null
}, },
], ],
"contractActions": "contractActions":
@ -586,7 +537,7 @@ paths:
"actionType": "actionType":
{ "actionTypeId": 1, "name": "create" }, { "actionTypeId": 1, "name": "create" },
"user": "subid123456", "user": "subid123456",
"timestamp": "2024-07-02T15:01:00.000Z", "timestamp": "2024-07-02 15:01:00.000",
"details": "Contract created", "details": "Contract created",
"correlationId": "976e7a4c-bf24-43d2-b444-55817556e7ee", "correlationId": "976e7a4c-bf24-43d2-b444-55817556e7ee",
}, },
@ -595,7 +546,7 @@ paths:
"actionType": "actionType":
{ "actionTypeId": 2, "name": "change" }, { "actionTypeId": 2, "name": "change" },
"user": "subid123456", "user": "subid123456",
"timestamp": "2024-07-03T15:01:00.000Z", "timestamp": "2024-07-03 15:01:00.000",
"details": "Contract changed", "details": "Contract changed",
"correlationId": "e2462347-6749-4841-b42a-cf8de19ec727", "correlationId": "e2462347-6749-4841-b42a-cf8de19ec727",
}, },
@ -607,8 +558,8 @@ paths:
"externalReference": "F2024-0001", "externalReference": "F2024-0001",
"term": 1, "term": 1,
"invoiceDate": "2024-07-02", "invoiceDate": "2024-07-02",
"created": "2024-07-02T15:01:00.000Z", "created": "2024-07-02 15:01:00.000",
"updated": "2024-07-02T15:01:00.000Z", "updated": "2024-07-02 15:01:00.000",
"state": "invoice_created", "state": "invoice_created",
"data": "{json}", "data": "{json}",
"isCredit": false, "isCredit": false,
@ -731,7 +682,7 @@ paths:
"termDuration": "P0Y1M0D", "termDuration": "P0Y1M0D",
"billingDay": 15, "billingDay": 15,
"highestInvoiceTerm": 1, "highestInvoiceTerm": 1,
"created": "2024-08-01T15:01:00.000Z", "created": "2024-08-01 15:01:00.000",
"ovPayTokenId": 1337, "ovPayTokenId": 1337,
"contractVersions": "contractVersions":
[ [
@ -830,7 +781,7 @@ paths:
"termDuration": "P0Y1M0D", "termDuration": "P0Y1M0D",
"billingDay": 15, "billingDay": 15,
"highestInvoiceTerm": 1, "highestInvoiceTerm": 1,
"created": "2024-08-01T15:01:00.000Z", "created": "2024-08-01 15:01:00.000",
"ovPayTokenId": 1337, "ovPayTokenId": 1337,
"contractVersions": "contractVersions":
[ [

View File

@ -1081,10 +1081,6 @@ paths:
"photo": null, "photo": null,
}, },
"gboAgeProfile": null, "gboAgeProfile": null,
"tatSecurity": {
"isSecured": true,
"isAuthorized": true,
},
"_links": "_links":
{ {
"self": "self":
@ -1134,9 +1130,9 @@ paths:
}, },
}, },
} }
OV pas without PAD, without AGO: OV pas without PAD:
summary: OV pas without PAD, without AGO summary: OV pas without PAD
description: OV pas without PAD, without AGO description: OV pas without PAD
value: value:
{ {
"ovPayTokens": "ovPayTokens":
@ -1168,10 +1164,6 @@ paths:
"photo": null, "photo": null,
}, },
"gboAgeProfile": null, "gboAgeProfile": null,
"tatSecurity": {
"isSecured": false,
"isAuthorized": null,
},
"_links": "_links":
{ {
"self": "self":
@ -1221,9 +1213,9 @@ paths:
}, },
}, },
} }
OV pas with PAD, with autoReload, with AGO: OV pas with PAD and autoReload:
summary: OV pas with PAD, with autoReload, with AGO summary: OV pas with PAD and autoReload
description: OV pas with PAD, with autoReload, with AGO description: OV pas with PAD and autoReload
value: value:
{ {
"ovPayTokens": "ovPayTokens":
@ -1295,10 +1287,6 @@ paths:
"ageFromInclusive": 4, "ageFromInclusive": 4,
"ageToInclusive": 11, "ageToInclusive": 11,
}, },
"tatSecurity": {
"isSecured": true,
"isAuthorized": true,
},
"_links": "_links":
{ {
"self": "self":
@ -1397,10 +1385,6 @@ paths:
"ageFromInclusive": 4, "ageFromInclusive": 4,
"ageToInclusive": 11, "ageToInclusive": 11,
}, },
"tatSecurity": {
"isSecured": true,
"isAuthorized": true,
},
"_links": "_links":
{ {
"self": "self":
@ -1517,10 +1501,6 @@ paths:
"ageFromInclusive": 4, "ageFromInclusive": 4,
"ageToInclusive": 11, "ageToInclusive": 11,
}, },
"tatSecurity": {
"isSecured": true,
"isAuthorized": true,
},
"_links": "_links":
{ {
"self": "self":
@ -1619,10 +1599,6 @@ paths:
"photo": null, "photo": null,
}, },
"gboAgeProfile": null, "gboAgeProfile": null,
"tatSecurity": {
"isSecured": true,
"isAuthorized": true,
},
}, },
{ {
"customerProfileId": 18, "customerProfileId": 18,
@ -1645,10 +1621,6 @@ paths:
"photo": null, "photo": null,
}, },
"gboAgeProfile": null, "gboAgeProfile": null,
"tatSecurity": {
"isSecured": true,
"isAuthorized": true,
},
}, },
{ {
"customerProfileId": 132, "customerProfileId": 132,
@ -1671,10 +1643,6 @@ paths:
"photo": null, "photo": null,
}, },
"gboAgeProfile": null, "gboAgeProfile": null,
"tatSecurity": {
"isSecured": true,
"isAuthorized": true,
},
}, },
{ {
"customerProfileId": 166, "customerProfileId": 166,
@ -1700,10 +1668,6 @@ paths:
"photo": null, "photo": null,
}, },
"gboAgeProfile": null, "gboAgeProfile": null,
"tatSecurity": {
"isSecured": true,
"isAuthorized": true,
},
}, },
{ {
"customerProfileId": 166, "customerProfileId": 166,
@ -1726,10 +1690,6 @@ paths:
"photo": null, "photo": null,
}, },
"gboAgeProfile": null, "gboAgeProfile": null,
"tatSecurity": {
"isSecured": true,
"isAuthorized": true,
},
}, },
{ {
"customerProfileId": 1, "customerProfileId": 1,
@ -1752,10 +1712,6 @@ paths:
"photo": null, "photo": null,
}, },
"gboAgeProfile": null, "gboAgeProfile": null,
"tatSecurity": {
"isSecured": true,
"isAuthorized": true,
},
}, },
], ],
_links: _links:
@ -1856,10 +1812,6 @@ paths:
"personalAccountData": "personalAccountData":
{ "name": null, "birthdate": null, "photo": null }, { "name": null, "birthdate": null, "photo": null },
"gboAgeProfile": null, "gboAgeProfile": null,
"tatSecurity": {
"isSecured": true,
"isAuthorized": true,
},
"_links": "_links":
{ {
"self": "self":
@ -1938,10 +1890,6 @@ paths:
"personalAccountData": "personalAccountData":
{ "name": null, "birthdate": null, "photo": null }, { "name": null, "birthdate": null, "photo": null },
"gboAgeProfile": null, "gboAgeProfile": null,
"tatSecurity": {
"isSecured": true,
"isAuthorized": true,
},
"_links": "_links":
{ {
"self": "self":
@ -2138,10 +2086,6 @@ paths:
"personalAccountData": "personalAccountData":
{ "name": null, "birthdate": null, "photo": null }, { "name": null, "birthdate": null, "photo": null },
"gboAgeProfile": null, "gboAgeProfile": null,
"tatSecurity": {
"isSecured": true,
"isAuthorized": true,
},
"_links": "_links":
{ {
"self": "self":
@ -2205,10 +2149,6 @@ paths:
"personalAccountData": "personalAccountData":
{ "name": null, "birthdate": null, "photo": null }, { "name": null, "birthdate": null, "photo": null },
"gboAgeProfile": null, "gboAgeProfile": null,
"tatSecurity": {
"isSecured": true,
"isAuthorized": true,
},
"_links": "_links":
{ {
"self": "self":
@ -3696,6 +3636,7 @@ paths:
"vouchers": [] "vouchers": []
} }
} }
/customers/devices: /customers/devices:
post: post:
summary: Add a new device to a customer profile. summary: Add a new device to a customer profile.
@ -4662,3 +4603,4 @@ components:
customerDataItem: customerDataItem:
type: string type: string
example: emailAddress example: emailAddress

View File

@ -1,341 +0,0 @@
openapi: 3.0.1
info:
title: Service Engine APIs for TAT security
description: >-
Service Engine APIs for TAT security. These are NOT the raw GBO APIs to access TAT security at GBO directly.
To be used by touch points to get secure a TAT.
version: "2.0"
servers:
- url: https://services.acc.api.htm.nl/abt/touchpoint/2.0
tags:
- name: TAT Security
paths:
/tokens/securetoken:
parameters:
- name: X-HTM-JWT-AUTH-HEADER
in: header
schema:
type: string
example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
required: false
description: The JWT of a customer in case of touchpoint were customer logs in themselves
- name: X-HTM-CUSTOMER-PROFILE-ID-HEADER
in: header
schema:
type: integer
example: 323
required: false
description: The id of the customer Profile
- name: X-HTM-ROLE-HEADER
in: header
schema:
type: string
example: Customer
required: false
description: The role of the HTM employee in the case of the SMP
post:
tags:
- TAT Security
summary: Request additional OV-pas security for a token either in profile or anonymous.
description: Request additional OV-pas security for a token either in profile or anonymous
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/SecureTokenRequest"
examples:
With customer account:
value:
ovPayTokenId: 42
emailAddress: stasjo@htm.nl
Without customer account:
value:
xtat: 62914b49-2c7f-437f-b4b0-2ad61a9f902d
emailAddress: stasjo@htm.nl
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: "#/components/schemas/EmailNotPreApprovedResponse"
example:
uid: 7594f3ee-cd3d-40a3-8e82-73b90d16c481
recipient: xxxxxx.user@gmail.com
key: 123456789123456789123456789abcde
description: OTP Sent
"400":
description: Bad Request
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
examples:
Missing Parameters:
value:
status: 400
title: Missing Mandatory Parameter
detail: Required parameter {0} is missing.
Invalid Parameters:
value:
status: 400
title: Invalid Parameter
detail: Required parameter {0} is invalid.
"401":
description: Unauthorized
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
examples:
Unauthorized:
value:
status: 401
title: Unauthorized
detail: Invalid Access Token
"404":
description: Not found
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
examples:
TAT not found:
value:
status: 404
title: Not Found
detail: TAT Account Not Found
"409":
description: Conflict
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
TAT Already Secured:
value:
status: 409
title: Conflict
detail: TAT Already Secured
"500":
description: Internal server error
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
example:
error: An unknown error has occurred
/tokens/verifyotp:
parameters:
- name: X-HTM-JWT-AUTH-HEADER
in: header
schema:
type: string
example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
required: false
description: The JWT of a customer in case of touchpoint were customer logs in themselves
- name: X-HTM-CUSTOMER-PROFILE-ID-HEADER
in: header
schema:
type: integer
example: 323
required: false
description: The id of the customer Profile
- name: X-HTM-ROLE-HEADER
in: header
schema:
type: string
example: Customer
required: false
description: The role of the HTM employee in the case of the SMP
post:
tags:
- TAT Security
summary: Submit an OTP for a triggered OTP flow.
description: |
Submit an OTP for a triggered OTP flow. This can either be result of an AGO activation, or
result of an AGO authorization flow. Since the backoffice behaves slightly different depending
on which use case is executed, the calling TP needs to provide an `action` in the request body.
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/VerifyOtpRequest"
examples:
OTP verification for TAT security activation for token in customer account:
value:
ovPayTokenId: 42
otp: 123456
action: secure
OTP verification for TAT security authorization for anonymous token:
value:
xtat: 0f0defe8-828c-48e5-97e5-26d1d0179ef0
otp: 123456
action: authorize
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/VerifyOtpResponse'
examples:
Tat Secured:
value:
status: Success
description: TAT Secured
Tat Unsecured:
value:
status: Success
description: TAT Unsecured
Tat Authorized:
value:
status: Success
description: TAT Authorized
"400":
description: Bad request
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
examples:
Missing Parameters:
value:
status: 400
title: Missing Mandatory Parameter
detail: Required parameter {0} is missing.
Invalid Parameters:
value:
status: 400
title: Invalid Parameter
detail: Required parameter {0} is invalid.
"401":
description: Unauthorized
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
examples:
Unauthorized:
value:
status: 401
title: Unauthorized
detail: Invalid Access Token
"500":
description: Internal server error
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
example:
error: An unknown error has occurred
components:
schemas:
unavailable:
type: object
GenerateTatOutput:
type: object
properties:
uid:
type: string
description: >-
An uid IS A unique identifier THAT is associated with the
user.
recipient:
type: string
description: >-
A recipient IS A unique identifier THAT is associated with the
TAT owner.
key:
type: string
description: >-
A key IS a 32 character string THAT uniquely identifies the
OTP session.
EmailNotPreApprovedResponse:
type: object
description: >-
EmailNotPreApprovedResponse IS AN object THAT represents the response of
email pre-approval check.
properties:
uid:
type: string
description: A uid IS A unique identifier THAT is associated with the user.
example: 7594f3ee-cd3d-40a3-8e82-73b90d16c481
recipient:
type: string
description: >-
A recipient IS A unique identifier THAT is associated with the TAT
owner.
example: xxxxxx.user@gmail.com
key:
type: string
description: >-
A key IS a 32 character string THAT uniquely identifies the OTP
session.
example: 123456789123456789123456789abcde
description:
type: string
description: >-
A description IS A string THAT describes the reason why the email is
not pre-approved.
example: OTP Sent
SecureTokenRequest:
type: object
properties:
ovPayTokenId:
type: integer
example: 42
xtat:
type: string
format: uuid
example: 6134db53-9ae5-41d1-a343-36656b60b510
emailAddress:
type: string
format: email
example: stasjo@htm.nl
required:
- ovPayTokenId
VerifyOtpRequest:
type: object
properties:
ovPayTokenId:
type: integer
example: 42
xtat:
type: string
format: uuid
example: f3474452-e1d4-428c-b366-e5ad5965eb8c
otp:
type: string
example: 123456
action:
type: string
example: secure
required:
- otp
- action
VerifyOtpResponse:
type: object
properties:
status:
type: string
example: Success
description:
type: string
example: TAT Secured
ErrorResponse:
description: Default response when an invalid request has been sent
type: object
properties:
status:
type: integer
description: >-
A status IS An integer that represents the HTTP status code of the
response.
example: 400
title:
type: string
description: A title IS A string that provides a brief summary of the error.
detail:
type: string
description: A detail IS A string that provides more details about the error.

View File

@ -243,7 +243,7 @@ paths:
suffix: PhD suffix: PhD
dateOfBirth: "2002-10-29" dateOfBirth: "2002-10-29"
emailAddresses: TEST@HTM.NL emailAddresses: TEST@HTM.NL
isEmailVerified: false isEmailVerified: True
addresses: addresses:
- addressId: 1 - addressId: 1
street: mystreet street: mystreet
@ -332,6 +332,14 @@ paths:
user: user user: user
timestamp: "2023-10-20T17:05:52.000+02:00" timestamp: "2023-10-20T17:05:52.000+02:00"
correlationId: 01c6d8b4-1cd3-4f9b-85ea-c9adca53ce95 correlationId: 01c6d8b4-1cd3-4f9b-85ea-c9adca53ce95
_links:
{
"self":
{
"href": "https://api.integratielaag.nl/abt/abtcustomers/2.0/customers?customerProfileId=1",
"method": "GET",
},
}
- customerProfileId: 12 - customerProfileId: 12
customerNumber: 1000002 customerNumber: 1000002
debtorNumber: DB100121 debtorNumber: DB100121
@ -369,7 +377,7 @@ paths:
suffix: PhD suffix: PhD
dateOfBirth: "2002-10-29" dateOfBirth: "2002-10-29"
emailAddresses: TEST@HTM.NL emailAddresses: TEST@HTM.NL
isEmailVerified: false isEmailVerified: False
address: address:
- addressId: 1 - addressId: 1
street: mystreet street: mystreet
@ -459,7 +467,32 @@ paths:
user: user user: user
timestamp: "2023-10-20T17:05:52.000+02:00" timestamp: "2023-10-20T17:05:52.000+02:00"
correlationId: 01c6d8b4-1cd3-4f9b-85ea-c9adca53ce95 correlationId: 01c6d8b4-1cd3-4f9b-85ea-c9adca53ce95
href: "https://api.integratielaag.nl/abt/abtcustomers/2.0/customers?offset=20&limit=20" _links:
{
"self":
{
"href": "https://api.integratielaag.nl/abt/abtcustomers/2.0/customers?customerProfileId=12",
"method": "GET",
},
}
_links:
{
"self":
{
"href": "https://api.integratielaag.nl/abt/abtcustomers/2.0/customers?offset=20",
"method": "GET",
},
"prev":
{
"href": "https://api.integratielaag.nl/abt/abtcustomers/2.0/customers",
"method": "GET",
},
"next":
{
"href": "https://api.integratielaag.nl/abt/abtcustomers/2.0/customers?offset=40",
"method": "GET",
},
}
getCustomerActive: getCustomerActive:
summary: getCustomerActive summary: getCustomerActive
description: >- description: >-
@ -493,7 +526,7 @@ paths:
suffix: PhD suffix: PhD
dateOfBirth: "2002-10-29" dateOfBirth: "2002-10-29"
emailAddresses: TEST@HTM.NL emailAddresses: TEST@HTM.NL
isEmailVerified: false isEmailVerified: False
addresses: addresses:
- addressId: 1 - addressId: 1
street: mystreet street: mystreet
@ -582,7 +615,22 @@ paths:
user: user user: user
timestamp: "2023-10-20T17:05:52.000+02:00" timestamp: "2023-10-20T17:05:52.000+02:00"
correlationId: 01c6d8b4-1cd3-4f9b-85ea-c9adca53ce95 correlationId: 01c6d8b4-1cd3-4f9b-85ea-c9adca53ce95
href: https://api.integratielaag.nl/abt/abtcustomers/2.0/customers?offset=20&limit=20 _links:
{
"self":
{
"href": "https://api.integratielaag.nl/abt/abtcustomers/2.0/customers?customerProfileId=1",
"method": "GET",
},
}
_links:
{
"self":
{
"href": "https://api.integratielaag.nl/abt/abtcustomers/2.0/customers",
"method": "GET",
},
}
getCustomerBlocked: getCustomerBlocked:
summary: getCustomerBlocked summary: getCustomerBlocked
description: >- description: >-
@ -621,7 +669,7 @@ paths:
suffix: PhD suffix: PhD
dateOfBirth: "2002-10-29" dateOfBirth: "2002-10-29"
emailAddresses: TEST@HTM.NL emailAddresses: TEST@HTM.NL
isEmailVerified: false isEmailVerified: True
addresses: addresses:
- addressId: 1 - addressId: 1
street: mystreet street: mystreet
@ -710,7 +758,22 @@ paths:
user: user user: user
timestamp: "2023-10-20T17:05:52.000+02:00" timestamp: "2023-10-20T17:05:52.000+02:00"
correlationId: 01c6d8b4-1cd3-4f9b-85ea-c9adca53ce95 correlationId: 01c6d8b4-1cd3-4f9b-85ea-c9adca53ce95
href: https://api.integratielaag.nl/abt/abtcustomers/2.0/customers?offset=20&limit=20 _links:
{
"self":
{
"href": "https://api.integratielaag.nl/abt/abtcustomers/2.0/customers?customerProfileId=1",
"method": "GET",
},
}
_links:
{
"self":
{
"href": "https://api.integratielaag.nl/abt/abtcustomers/2.0/customers",
"method": "GET",
},
}
400: 400:
description: Bad Request description: Bad Request
content: content:
@ -772,7 +835,7 @@ paths:
suffix: jr suffix: jr
dateOfBirth: "1970-01-01" dateOfBirth: "1970-01-01"
emailAddress: j.jansen@hatseflats.nl emailAddress: j.jansen@hatseflats.nl
isEmailVerified: false isEmailVerified: False
addresses: addresses:
- street: Laan van Meerdervoort - street: Laan van Meerdervoort
houseNumber: 5 houseNumber: 5
@ -871,7 +934,7 @@ paths:
suffix: "jr", suffix: "jr",
dateOfBirth: "1970-01-01", dateOfBirth: "1970-01-01",
emailAddress: "TEST@TEST1.NL", emailAddress: "TEST@TEST1.NL",
isEmailVerified: false isEmailVerified: False
} }
updateCompleteEntity: updateCompleteEntity:
value: value:
@ -883,7 +946,7 @@ paths:
suffix: "jr", suffix: "jr",
dateOfBirth: "1970-01-01", dateOfBirth: "1970-01-01",
emailAddress: "TEST@TEST1.NL", emailAddress: "TEST@TEST1.NL",
isEmailVerified: false isEmailVerified: True
} }
required: true required: true
responses: responses:
@ -1375,7 +1438,13 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/patchDeviceResponse" $ref: "#/components/schemas/getDevices"
examples:
updateDeviceResponse:
value:
deviceId: "5bedce29-af0c-4f3c-b182-2caa8a1f9377"
externalDeviceId: "7122a988-a00a-417d-a5b4-da2d91354976"
alias: "iPhone zakelijk"
security: security:
- default: [] - default: []
@ -1538,7 +1607,24 @@ paths:
"replacedByTokenId": 12, "replacedByTokenId": 12,
}, },
], ],
"href": "https://api.integratielaag.nl/abt/abtcustomers/2.0/ovPayTokens?offset=20&limit=20" "_links":
{
"self":
{
"href": "https://api.integratielaag.nl/abt/abtcustomers/2.0/ovPayTokens?offset=20",
"method": "GET",
},
"prev":
{
"href": "https://api.integratielaag.nl/abt/abtcustomers/2.0/ovPayTokens",
"method": "GET",
},
"next":
{
"href": "https://api.integratielaag.nl/abt/abtcustomers/2.0/ovPayTokens?offset=40",
"method": "GET",
},
},
} }
/customers/{customerProfileId}/ovpaytokens: /customers/{customerProfileId}/ovpaytokens:
post: post:
@ -1857,10 +1943,9 @@ paths:
"created": "2024-10-20T17:05:52.000", "created": "2024-10-20T17:05:52.000",
}, },
], ],
"href": "https://api.integratielaag.nl/abt/abtcustomers/2.0/billingInformations?offset=20&limit=20"
} }
getBillingInformationSingleSpecificCustomer: getBillingInformationSingleSpecificCustomer:
summary: Get single billing information entity for a specific customers summary: Get singel billing information entity for a specific customers
description: >- description: >-
Found one billing information matching the search parameters Found one billing information matching the search parameters
value: value:
@ -2050,7 +2135,59 @@ paths:
"updateTimestamp": "2024-03-22T08:55:00", "updateTimestamp": "2024-03-22T08:55:00",
}, },
], ],
"href": "https://api.integratielaag.nl/abt/abtcustomers/2.0/directdebitmandates?offset=20&limit=20" "_links":
{
"self":
{
"href": "https://api.integratielaag.nl/abt/abtcustomers/2.0/directdebitmandates",
"method": "GET",
},
"next":
{
"href": "https://api.integratielaag.nl/abt/abtcustomers/2.0/directdebitmandates?offset=20",
"method": "GET",
},
},
}
getDirectDebitMandateWithNextPageandPreviousPage:
value:
{
"directDebitMandates":
[
{
"directDebitMandateId": 71,
"customerProfileId": 12,
"billingInformationId": 51,
"directDebitMandateType":
{
"directDebitMandateTypeId": 1,
"name": "import",
"description": "import",
},
"created": "2024-03-22T08:55:00",
"mandateReference": "CORE01",
"mandateState": "SIGNED",
"updateTimestamp": "2024-03-22T08:55:00",
},
],
"_links":
{
"self":
{
"href": "https://api.integratielaag.nl/abt/abtcustomers/2.0/directdebitmandates?offset=20",
"method": "GET",
},
"prev":
{
"href": "https://api.integratielaag.nl/abt/abtcustomers/2.0/directdebitmandates",
"method": "GET",
},
"next":
{
"href": "https://api.integratielaag.nl/abt/abtcustomers/2.0/directdebitmandates?offset=40",
"method": "GET",
},
},
} }
/billingInformation/{billingInformationId}/directdebitmandates: /billingInformation/{billingInformationId}/directdebitmandates:
post: post:
@ -2706,7 +2843,7 @@ components:
emailAddress: emailAddress:
type: string type: string
isEmailVerified: isEmailVerified:
type: boolean type: string
postOvPayTokenResponse: postOvPayTokenResponse:
type: object type: object
properties: properties:
@ -2720,14 +2857,6 @@ components:
type: array type: array
items: items:
$ref: "#/components/schemas/getDeviceEntity" $ref: "#/components/schemas/getDeviceEntity"
patchDeviceResponse:
type: object
properties:
deviceId:
type: string
format: uuid
example: "b2c8a8c6-3d1c-4b6b-8f8d-3a6a1b6a1b6a"
getDeviceEntity: getDeviceEntity:
type: object type: object
properties: properties:

View File

@ -111,6 +111,14 @@ paths:
explode: false explode: false
required: false required: false
description: Filter on most recent order status. 1 = concept, 2 = awaitingPayment, 3 = pendingPayment, 4 = paid, 5 = delivered, 6 = cancelled. description: Filter on most recent order status. 1 = concept, 2 = awaitingPayment, 3 = pendingPayment, 4 = paid, 5 = delivered, 6 = cancelled.
- in: query
name: issuedVoucherId
schema:
type: string
format: uuid
example: "b0a9f3c9-9b92-4f8c-b78d-6129be7218a6"
required: false
description: Filter on applied issuedVoucherId for the order.
responses: responses:
"200": "200":
description: OK description: OK

View File

@ -479,7 +479,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/PostOrder" $ref: "#/components/schemas/unavailable"
examples: examples:
Create order with OVpas and PAD: Create order with OVpas and PAD:
summary: Create order with OVpas and PAD summary: Create order with OVpas and PAD
@ -494,11 +494,7 @@ paths:
{ {
"externalOrderLineId": null, "externalOrderLineId": null,
"productId": 1, "productId": 1,
"vouchers":[ "vouchers":[],
{
"voucherCode": "Voucher1234"
}
],
"quantity": 1, "quantity": 1,
"validFrom": "2024-03-22T09:00:00", "validFrom": "2024-03-22T09:00:00",
"validUntil": null, "validUntil": null,
@ -1868,7 +1864,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/PostOrderLine" $ref: "#/components/schemas/unavailable"
examples: examples:
Add order line to concept order: Add order line to concept order:
summary: Add order line to concept order product with children summary: Add order line to concept order product with children
@ -1877,11 +1873,7 @@ paths:
{ {
"externalOrderLineId": null, "externalOrderLineId": null,
"productId": 2, "productId": 2,
"vouchers":[ "vouchers":[],
{
"voucherCode": "HTM-787466"
}
],
"quantity": 1, "quantity": 1,
"validFrom": "2024-03-22T09:00:00", "validFrom": "2024-03-22T09:00:00",
"validUntil": null, "validUntil": null,
@ -1905,6 +1897,7 @@ paths:
value: value:
{ {
"productId": 2, "productId": 2,
"vouchers":[],
"quantity": 1, "quantity": 1,
"createdOn": "2024-03-22T09:00:00", "createdOn": "2024-03-22T09:00:00",
} }
@ -1969,64 +1962,7 @@ paths:
"taxAmount": null, "taxAmount": null,
"amountExclTax": null, "amountExclTax": null,
"amountInclTax": 121, "amountInclTax": 121,
"vouchers":[ "vouchers":[],
{
"issuedVoucherId": "8e405272-470f-4ed6-8303-791ab40e72b5",
"voucherCode": "HTM-787466",
"fromInclusive": "2025-10-04T00:00:00.000",
"untilInclusive": "2026-11-04T00:00:00.000",
"voucherStatus":
{ "voucherStatusId": 1, "name": "New" },
"product":
{
"productId": 263,
"productName": "HTM-80001",
"productDescription": "10 euro korting op HTM maandkorting 20%",
"productCategory":
{
"productCategoryId": 9,
"isTravelProduct": false,
"name": "Voucher",
},
"amountInclTax": -1000,
"requiredProducts":
[
{
"productId": 1,
"productName": "HTM-30001",
"productDescription": "HTM Maandkorting 20%",
"_links":
{
"get_details":
{
"href": "https://api.integratielaag.nl/abt/touchpoint/1.0/products/1",
"method": "GET",
},
},
},
],
"_links":
{
"get_details":
{
"href": "https://api.integratielaag.nl/abt/touchpoint/1.0/products/263",
"method": "GET",
},
},
},
"mandatoryCustomerDataItems":
[
{
"mandatoryCustomerDataItemId": 8,
"customerDataItem": "padBirthDate"
},
{
"mandatoryCustomerDataItemId": 4,
"customerDataItem": "emailAddress"
},
],
}
],
"quantity": 1, "quantity": 1,
"orderLineTerms": "orderLineTerms":
[ [
@ -2392,7 +2328,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/PatchOrderLine" $ref: "#/components/schemas/unavailable"
examples: examples:
Update order line: Update order line:
summary: Update order line summary: Update order line
@ -2400,6 +2336,7 @@ paths:
value: value:
{ {
"productId": 21, "productId": 21,
"vouchers":[],
"quantity": 1, "quantity": 1,
"validFrom": "2024-03-25T09:00:00", "validFrom": "2024-03-25T09:00:00",
"validUntil": null, "validUntil": null,
@ -2871,7 +2808,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/PostVoucher" $ref: "#/components/schemas/unavailable"
examples: examples:
Add voucher to orderLine: Add voucher to orderLine:
summary: Add voucher to orderLine to receive voucher benefits for a specific product summary: Add voucher to orderLine to receive voucher benefits for a specific product
@ -3178,7 +3115,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/PostVoucher" $ref: "#/components/schemas/unavailable"
examples: examples:
Add voucher to concept order: Add voucher to concept order:
summary: Add voucher to orderLine to receive voucher benefits for a whole order summary: Add voucher to orderLine to receive voucher benefits for a whole order
@ -6876,98 +6813,3 @@ components:
ageToInclusive: ageToInclusive:
type: integer type: integer
example: 11 example: 11
PostOrder:
type: object
properties:
externalOrderId:
type: string
description: |-
The id used in an external sales system
example: "MyOrder123"
languageId:
type: integer
description: |-
The id of the language for this order, all communications about the order will be in the given language
example: 1
externalDeviceId:
type: string
description: |-
The mobile device in use when placing the order
example: "ae3d9f69-945b-4290-a286-8c2dd5d7db8e"
orderLines:
type: array
description: |-
The orderLines with the chosen product and if known/required a voucher and customerToken
items:
$ref: "#/components/schemas/PostOrderLine"
PostOrderLine:
type: object
required:
- productId
- quantity
- createdOn
properties:
productId:
type: integer
description: |-
The id of the chosen product
example: 1
quantity:
type: integer
description: |-
Amount of the products to be delivered, in case of saldo quantity is used to define the amount of saldo to be charged in eurocents. If multiple GBO products are added, the same amount of customerTokens will be required.
example: 1
createdOn:
type: string
format: date-time
description: |-
Current datetime, moment client added orderLine to the order
example: "2024-03-22T09:00:00"
externalOrderLineId:
type: string
description: |-
The orderLineId as known in an external sales system, used for reconcilliation
example: "MyOrderLine123"
validFrom:
type: string
format: date-time
description: |-
The moment the chosen product should be activated
example: "2024-03-30T09:00:00"
validUntil:
type: string
format: date-time
description: |-
Only required when a product does not have a fixed duration, otherwise it is calculated by the back end system
example: null
vouchers:
type: array
description: |-
List of vouchers applicable for this orderLine
items:
$ref: "#/components/schemas/PostVoucher"
PatchOrderLine:
type: object
properties:
productId:
type: integer
example: 1
quantity:
type: integer
example: 1
validFrom:
type: string
format: date-time
example: "2024-03-30T09:00:00"
validUntil:
type: string
format: date-time
example: null
PostVoucher:
type: object
required:
- voucherCode
properties:
voucherCode:
type: string
example: "Voucher1234"

View File

@ -313,7 +313,6 @@ paths:
{ {
"productId": 24, "productId": 24,
"parentProductId": null, "parentProductId": null,
"needsVoucher": false,
"layerInfo": null, "layerInfo": null,
"fikoArticleNumber": "1234", "fikoArticleNumber": "1234",
"gboPackageTemplateId": "30901", "gboPackageTemplateId": "30901",
@ -430,7 +429,6 @@ paths:
{ {
"productId": 126, "productId": 126,
"parentProductId": null, "parentProductId": null,
"needsVoucher": false,
"layerInfo": { "layerInfo": {
"layerInfoId": 1, "layerInfoId": 1,
"choiceKey": "isRenewable", "choiceKey": "isRenewable",
@ -539,7 +537,6 @@ paths:
{ {
"productId": 119, "productId": 119,
"parentProductId": 126, "parentProductId": 126,
"needsVoucher": false,
"layerInfo": null, "layerInfo": null,
"fikoArticleNumber": "1234", "fikoArticleNumber": "1234",
"gboPackageTemplateId": "30001", "gboPackageTemplateId": "30001",
@ -655,7 +652,6 @@ paths:
{ {
"productId": 120, "productId": 120,
"parentProductId": 126, "parentProductId": 126,
"needsVoucher": false,
"layerInfo": null, "layerInfo": null,
"fikoArticleNumber": "1234", "fikoArticleNumber": "1234",
"gboPackageTemplateId": "30001", "gboPackageTemplateId": "30001",
@ -783,7 +779,6 @@ paths:
{ {
"productId": 49, "productId": 49,
"parentProductId": null, "parentProductId": null,
"needsVoucher": false,
"layerInfo": { "layerInfo": {
"layerInfoId": 2, "layerInfoId": 2,
"choiceKey": "regio", "choiceKey": "regio",
@ -931,7 +926,6 @@ paths:
{ {
"productId": 109, "productId": 109,
"parentProductId": 49, "parentProductId": 49,
"needsVoucher": false,
"layerInfo": { "layerInfo": {
"layerInfoId": 3, "layerInfoId": 3,
"choiceKey": "allowedGboAgeProfiles", "choiceKey": "allowedGboAgeProfiles",
@ -1082,7 +1076,6 @@ paths:
{ {
"productId": 114, "productId": 114,
"parentProductId": 109, "parentProductId": 109,
"needsVoucher": false,
"layerInfo": { "layerInfo": {
"layerInfoId": 1, "layerInfoId": 1,
"choiceKey": "isRenewable", "choiceKey": "isRenewable",
@ -1233,7 +1226,6 @@ paths:
{ {
"productId": 115, "productId": 115,
"parentProductId": 109, "parentProductId": 109,
"needsVoucher": false,
"layerInfo": { "layerInfo": {
"layerInfoId": 1, "layerInfoId": 1,
"choiceKey": "isRenewable", "choiceKey": "isRenewable",
@ -1385,7 +1377,6 @@ paths:
{ {
"productId": 116, "productId": 116,
"parentProductId": 115, "parentProductId": 115,
"needsVoucher": false,
"layerInfo": null, "layerInfo": null,
"fikoArticleNumber": "1234", "fikoArticleNumber": "1234",
"gboPackageTemplateId": "33615", "gboPackageTemplateId": "33615",
@ -1535,7 +1526,6 @@ paths:
{ {
"productId": 117, "productId": 117,
"parentProductId": 115, "parentProductId": 115,
"needsVoucher": false,
"layerInfo": null, "layerInfo": null,
"fikoArticleNumber": "1234", "fikoArticleNumber": "1234",
"gboPackageTemplateId": "33615", "gboPackageTemplateId": "33615",
@ -1689,7 +1679,6 @@ paths:
{ {
"productId": 112, "productId": 112,
"parentProductId": 49, "parentProductId": 49,
"needsVoucher": false,
"layerInfo": { "layerInfo": {
"layerInfoId": 3, "layerInfoId": 3,
"choiceKey": "allowedGboAgeProfiles", "choiceKey": "allowedGboAgeProfiles",
@ -2161,10 +2150,6 @@ components:
parentProductId: parentProductId:
type: integer type: integer
example: 1 example: 1
needsVoucher:
type: boolean
description: Indicates if the product needs a voucher (with this productId as requiredProductId) to be bought - if false, this is optional, but still allowed
example: false
layerInfo: layerInfo:
$ref: '#/components/schemas/LayerInfoResponse' $ref: '#/components/schemas/LayerInfoResponse'
fikoArticleNumber: fikoArticleNumber:

View File

@ -313,7 +313,6 @@ paths:
{ {
"productId": 24, "productId": 24,
"parentProductId": null, "parentProductId": null,
"needsVoucher": false,
"layerInfo": null, "layerInfo": null,
"fikoArticleNumber": "1234", "fikoArticleNumber": "1234",
"gboPackageTemplateId": "30901", "gboPackageTemplateId": "30901",
@ -430,7 +429,6 @@ paths:
{ {
"productId": 126, "productId": 126,
"parentProductId": null, "parentProductId": null,
"needsVoucher": false,
"layerInfo": { "layerInfo": {
"layerInfoId": 1, "layerInfoId": 1,
"choiceKey": "isRenewable", "choiceKey": "isRenewable",
@ -539,7 +537,6 @@ paths:
{ {
"productId": 119, "productId": 119,
"parentProductId": 126, "parentProductId": 126,
"needsVoucher": false,
"layerInfo": null, "layerInfo": null,
"fikoArticleNumber": "1234", "fikoArticleNumber": "1234",
"gboPackageTemplateId": "30001", "gboPackageTemplateId": "30001",
@ -655,7 +652,6 @@ paths:
{ {
"productId": 120, "productId": 120,
"parentProductId": 126, "parentProductId": 126,
"needsVoucher": false,
"layerInfo": null, "layerInfo": null,
"fikoArticleNumber": "1234", "fikoArticleNumber": "1234",
"gboPackageTemplateId": "30001", "gboPackageTemplateId": "30001",
@ -783,7 +779,6 @@ paths:
{ {
"productId": 49, "productId": 49,
"parentProductId": null, "parentProductId": null,
"needsVoucher": false,
"layerInfo": { "layerInfo": {
"layerInfoId": 2, "layerInfoId": 2,
"choiceKey": "regio", "choiceKey": "regio",
@ -931,7 +926,6 @@ paths:
{ {
"productId": 109, "productId": 109,
"parentProductId": 49, "parentProductId": 49,
"needsVoucher": false,
"layerInfo": { "layerInfo": {
"layerInfoId": 3, "layerInfoId": 3,
"choiceKey": "allowedGboAgeProfiles", "choiceKey": "allowedGboAgeProfiles",
@ -1082,7 +1076,6 @@ paths:
{ {
"productId": 114, "productId": 114,
"parentProductId": 109, "parentProductId": 109,
"needsVoucher": false,
"layerInfo": { "layerInfo": {
"layerInfoId": 1, "layerInfoId": 1,
"choiceKey": "isRenewable", "choiceKey": "isRenewable",
@ -1233,7 +1226,6 @@ paths:
{ {
"productId": 115, "productId": 115,
"parentProductId": 109, "parentProductId": 109,
"needsVoucher": false,
"layerInfo": { "layerInfo": {
"layerInfoId": 1, "layerInfoId": 1,
"choiceKey": "isRenewable", "choiceKey": "isRenewable",
@ -1385,7 +1377,6 @@ paths:
{ {
"productId": 116, "productId": 116,
"parentProductId": 115, "parentProductId": 115,
"needsVoucher": false,
"layerInfo": null, "layerInfo": null,
"fikoArticleNumber": "1234", "fikoArticleNumber": "1234",
"gboPackageTemplateId": "33615", "gboPackageTemplateId": "33615",
@ -1535,7 +1526,6 @@ paths:
{ {
"productId": 117, "productId": 117,
"parentProductId": 115, "parentProductId": 115,
"needsVoucher": false,
"layerInfo": null, "layerInfo": null,
"fikoArticleNumber": "1234", "fikoArticleNumber": "1234",
"gboPackageTemplateId": "33615", "gboPackageTemplateId": "33615",
@ -1689,7 +1679,6 @@ paths:
{ {
"productId": 112, "productId": 112,
"parentProductId": 49, "parentProductId": 49,
"needsVoucher": false,
"layerInfo": { "layerInfo": {
"layerInfoId": 3, "layerInfoId": 3,
"choiceKey": "allowedGboAgeProfiles", "choiceKey": "allowedGboAgeProfiles",
@ -2161,10 +2150,6 @@ components:
parentProductId: parentProductId:
type: integer type: integer
example: 1 example: 1
needsVoucher:
type: boolean
description: Indicates if the product needs a voucher (with this productId as requiredProductId) to be bought - if false, this is optional, but still allowed
example: false
layerInfo: layerInfo:
$ref: '#/components/schemas/LayerInfoResponse' $ref: '#/components/schemas/LayerInfoResponse'
fikoArticleNumber: fikoArticleNumber:

View File

@ -1,17 +1,17 @@
openapi: 3.0.1 openapi: 3.0.1
info: info:
title: Service Engine APIs for HTM voucher for sales Touchpoint title: Service Engine APIs for HTM voucher for sales Touchpint
description: Service Engine APIs for HTM vouchers. These are NOT the CRUD APIs to the data hub. These ARE the APIs for sales touchpoints. description: Service Engine APIs for HTM vouchers. These are NOT the CRUD APIs to the data hub. These ARE the api's for sales touchpoints.
version: "1.0" version: "1.0"
servers: servers:
- url: https://services.acc.api.htm.nl/abt/abtvouchersTouchpoint/1.0 - url: https://services.acc.api.htm.nl/abt/abtvouchersTouchpoint/1.0
paths: paths:
/issuedvouchers/{voucherCode}: /issuedvouchers/{voucherCode}:
get: get:
summary: Get details of a voucher, that was issued for a specific touchpoint summary: Get details of a voucher, that was issued for a specific touch point
description: description:
Get details of an issued voucher for a specific touchpoint. This means that only products that the calling touchpoint is allowed to see or sell Get details of an issued voucher for a specific touch point. This means that only products that the calling touch point is allowed to see or sell
(i.e. has active sellingPeriods for touchPointId of the calling touchpoint) are returned. (i.e. has active sellingPeriods for touchPointId of the calling touch point) are returned.
parameters: parameters:
- name: voucherCode - name: voucherCode
in: path in: path
@ -28,132 +28,98 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/salesTouchpointIssuedVoucherResponse" $ref: "#/components/schemas/unavailable"
examples: examples:
Voucher that grants a voucher-only product for free: Voucher for a product with required attributes:
summary: Voucher that grants a voucher-only product for free summary: Voucher for a single product with required attributes
description: |-
Voucher that grants a product (that can only be acquired via a voucher) for free.
In this case, the voucher has an amountInclTax of 0. If the requiredProduct has a
non-zero sellingPrice (amountInclTax), the difference between sellingPrice and
voucher amountInclTax represents the granted discount. If the requiredProduct has
a sellingPrice of 0, no "discount" is shown; just the voucher's amountInclTax.
value: value:
{ {
"issuedVoucherId": "d4e5f6a7-b8c9-0d1e-2f3a-4b5c6d7e8f90", "issuedVoucherId": "d4e5f6a7-b8c9-0d1e-2f3a-4b5c6d7e8f90",
"voucherCode": "HTM-A7J-128-PYT", "voucherCode": "VOUCHER123",
"fromInclusive": "2024-10-04T00:00:00.000", "fromInclusive": "2024-10-04T00:00:00.000",
"untilInclusive": "2024-11-04T00:00:00.000", "untilInclusive": "2024-11-04T00:00:00.000",
"voucherStatus": { "voucherStatusId": 2, "name": "issued" }, "voucherStatus": { "voucherStatusId": 1, "name": "New" },
"product": "product":
{ {
"productId": 263, "productId": 263,
"productName": "Voucher Ooievaarspas-product AOW", "productName": "HTM-80001",
"productDescription": "Voucher voor AOW-ers in Den Haag met een Ooievaarspas, die ingewisseld kan worden voor het product \"Ooievaarspas voor AOW-ers in Den Haag\"", "productDescription": "10 euro korting op Regiovrij maand.",
"amountInclTax": 0, "productCategory":
{
"productCategoryId": 9,
"isTravelProduct": false,
"name": "Voucher",
},
"amountInclTax": -1000,
"requiredProducts": "requiredProducts":
[ [
{ {
"productId": 982, "productId": 126,
"productName": "Ooievaarspas voor AOW-ers in Den Haag", "productName": "HTM-30001",
"productDescription": "Vrij reizen bij HTM voor Haagse AOW-gerechtigden met een Ooievaarspas.", "productDescription": "Regiovrij maand.",
"productCategory":
{
"productCategoryId": 2,
"isTravelProduct": true,
"name": "Afgekocht reisrecht",
},
"amountInclTax": 0,
"_links": "_links":
{ {
"get_details": "get_details":
{ {
"href": "https://api.integratielaag.nl/abt/touchpoint/1.0/products/982", "href": "https://api.integratielaag.nl/abt/touchpoint/1.0/products/126",
"method": "GET", "method": "GET",
}, },
}, },
}, },
] ],
"_links":
{
"get_details":
{
"href": "https://api.integratielaag.nl/abt/touchpoint/1.0/products/263",
"method": "GET",
},
},
}, },
"mandatoryCustomerDataItems": "mandatoryCustomerDataItems":
[ [
{ {
"mandatoryCustomerDataItemId": 8, "mandatoryCustomerDataItemId": 8,
"customerDataItem": "padBirthDate" "customerDataItem": "padBirthDate",
},
{
"mandatoryCustomerDataItemId": 4,
"customerDataItem": "emailAddress",
},
],
} }
] Voucher for a whole order:
} summary: Voucher for a whole order
Voucher that grants a hybrid product for a reduced price:
summary: Voucher that grants a hybrid product for a reduced price
description: |-
Voucher that grants a hybrid product (that can be acquired via voucher or via ordinary order flow)
for a reduced price. In this case, the voucher has an amountInclTax > 0, which dictates the (modified)
total amount that needs to be paid for said product. If the requiredProduct has a
non-zero sellingPrice (amountInclTax), the difference between sellingPrice and
voucher amountInclTax represents the granted discount. If the requiredProduct has
a sellingPrice of 0, no "discount" is shown; just the voucher's amountInclTax./
Specifically, in this example, the product "HTM 20% Korting" can be purchased for 1 euro instead of 5 euro.
value: value:
{ {
"issuedVoucherId": "d4e5f6a7-b8c9-0d1e-2f3a-4b5c6d7e8f90", "issuedVoucherId": "d4e5f6a7-b8c9-0d1e-2f3a-4b5c6d7e8f90",
"voucherCode": "HTM-A7J-128-PYT", "voucherCode": "VOUCHER123",
"fromInclusive": "2024-10-04T00:00:00.000", "fromInclusive": "2024-10-04T00:00:00.000",
"untilInclusive": "2024-11-04T00:00:00.000", "untilInclusive": "2024-11-04T00:00:00.000",
"voucherStatus": { "voucherStatusId": 2, "name": "issued" }, "voucherStatus": { "voucherStatusId": 1, "name": "New" },
"product": "product":
{ {
"productId": 264, "productId": 263,
"productName": "Kortingsvoucher HTM 20% Korting ", "productName": "HTM-80002",
"productDescription": "Voucher waarmee het product \"HTM 20% Korting\" voor een lagere prijs kan worden aangeschaft.", "productDescription": "10 euro korting op je gehele winkelmand.",
"amountInclTax": 100,
"requiredProducts":
[
{
"productId": 984,
"productName": "HTM 20% Korting",
"productDescription": "Reis met 20% korting op je betaalpas bij HTM.",
"productCategory": "productCategory":
{ {
"productCategoryId": 1, "productCategoryId": 9,
"isTravelProduct": true, "isTravelProduct": false,
"name": "Kortingsabonnement", "name": "Voucher",
}, },
"amountInclTax": 500, "amountInclTax": -1000,
"requiredProducts": [],
"_links": "_links":
{ {
"get_details": "get_details":
{ {
"href": "https://api.integratielaag.nl/abt/touchpoint/1.0/products/984", "href": "https://api.integratielaag.nl/abt/touchpoint/1.0/products/263",
"method": "GET", "method": "GET",
}, },
}, },
}, },
] "mandatoryCustomerDataItems": [],
},
"mandatoryCustomerDataItems": []
}
Voucher that grants a discount for a whole order:
summary: Voucher that grants a discount for a whole order
description: |-
Voucher that grants a discount for a whole order. In this case, no requiredProduct is specified, and
the voucher has an amountInclTax < 0. The (negative) amountInclTax dictates the value of the voucher,
that is subtracted from the total order value as a discount (with a minimum order total of 0).
value:
{
"issuedVoucherId": "d4e5f6a7-b8c9-0d1e-2f3a-4b5c6d7e8f90",
"voucherCode": "HTM-A7J-128-PYT",
"fromInclusive": "2024-10-04T00:00:00.000",
"untilInclusive": "2024-11-04T00:00:00.000",
"voucherStatus": { "voucherStatusId": 2, "name": "issued" },
"product":
{
"productId": 265,
"productName": "Voucher 10 euro korting",
"productDescription": "Voucher die 10 euro korting geeft op je gehele winkelmand.",
"amountInclTax": -1000,
"requiredProducts": []
},
"mandatoryCustomerDataItems": []
} }
"403": "403":
description: Forbidden description: Forbidden
@ -171,20 +137,20 @@ paths:
"detail": "You do not have permission to access this resource.", "detail": "You do not have permission to access this resource.",
"instance": "/issuedvouchers", "instance": "/issuedvouchers",
} }
"400": "404":
description: Bad request description: Not found
content: content:
application/problem+json: application/problem+json:
schema: schema:
$ref: "#/components/schemas/rfc9457" $ref: "#/components/schemas/rfc9457"
examples: examples:
Invalid voucher code: Voucher not found:
summary: Invalid voucher code summary: Voucher not found
value: value:
{ {
"type": "https://example.com/probs/bad-request", "type": "https://example.com/probs/not-found",
"title": "Invalid voucher code", "title": "Voucher not found",
"detail": "No valid voucher found for code VOUCHER123.", "detail": "The voucher with code VOUCHER123 does not exist.",
"instance": "/issuedvouchers", "instance": "/issuedvouchers",
} }
"500": "500":
@ -210,166 +176,6 @@ components:
scheme: bearer scheme: bearer
bearerFormat: JWT bearerFormat: JWT
schemas: schemas:
salesTouchpointIssuedVoucherResponse:
type: object
required:
- issuedVoucherId
- voucherCode
- fromInclusive
- untilInclusive
- voucherStatus
- product
- mandatoryCustomerDataItems
properties:
issuedVoucherId:
type: string
description: The unique (technical) identifier of the issued voucher instance.
example: "d4e5f6a7-b8c9-0d1e-2f3a-4b5c6d7e8f90"
voucherCode:
type: string
description: The voucher code that is shared with the customer and uniquely identifies this voucher.
example: "HTM-A7J-128-PYT"
fromInclusive:
type: string
format: date-time-offset
description: |-
The date and time the voucher becomes valid for redemption. This has nothing to do with
the allowed start date of a requiredProduct (for this, the existing order flow logic is applied).
example: "2024-10-04T00:00:00.000+02:00"
untilInclusive:
type: string
format: date-time-offset
description: |-
The date and time the voucher becomes invalid for redemption. This has nothing to do with
the maximum allowed start date of a requiredProduct (for this, the existing order flow logic is applied).
example: "2024-11-04T00:00:00.000+01:00"
voucherStatus:
type: object
required:
- voucherStatusId
- name
description: |-
The current status of the voucher. Can be any of the following:
- 1 = new
- 2 = issued
- 3 = redeemed
- 4 = revoked
- 5 = expired
- 6 = pendingRedemption
properties:
voucherStatusId:
type: integer
example: 2
name:
type: string
example: "issued"
product:
type: object
description: The "product" referencing the voucher definition that this voucher instance is based on
required:
- productId
- productName
- productDescription
- amountInclTax
properties:
productId:
type: integer
example: 263
productName:
type: string
example: Voucher Ooievaarspas-product AOW
productDescription:
type: string
example: |-
Voucher voor AOW-ers in Den Haag met een Ooievaarspas, die ingewisseld kan worden
voor het product "Ooievaarspas voor AOW-ers in Den Haag".
amountInclTax:
type: integer
description: |-
When combined with a requiredProduct, the (positive or zero) amountInclTax dictates
the (modified) total amount that needs to be paid for said product. When the requiredProduct
has a sellingPrice > 0, the difference between sellingPrice and voucher amountInclTax
represents the granted discount.
When not combined with a required product, the (negative) amountInclTax dictates
the value of the voucher, that is subtracted from the total order value as a discount
(with a minimum order total of 0).
example: 0
requiredProducts:
type: array
description: |-
Currently, only one requiredProduct is supported and thus, this array can contain at
most one element.
items:
type: object
required:
- productId
properties:
productId:
type: integer
example: 892
productName:
type: string
example: "Ooievaarspas voor AOW-ers in Den Haag"
productDescription:
type: string
example: "Vrij reizen bij HTM voor Haagse AOW-gerechtigden met een Ooievaarspas."
productCategory:
type: object
required:
- productCategoryId
- isTravelProduct
- name
properties:
productCategoryId:
type: integer
example: 2
isTravelProduct:
type: boolean
example: true
name:
type: string
example: "Afgekocht reisrecht"
amountInclTax:
type: integer
description: |-
Selling price of the product in cents (including tax, if applicable) that is
currently active for the calling touchpoint. When 0, the product is free of charge.
example: 0
imageReference:
type: string
description: Can be a URL or a base64 encoded image
example: https://www.htm.nl/nog-onbekende-productafbeelding
_links:
type: object
properties:
get_details:
type: object
description: Link to get more details for the product that this voucher instance applies to
properties:
href:
type: string
example: https://api.integratielaag.nl/abt/touchpoint/1.0/products/982
method:
type: string
example: GET
mandatoryCustomerDataItems:
type: array
description: |-
List of mandatory customer data items that are required to redeem this voucher.\
The values provided for these data items may be checked against allowed values as provided by the voucher issuer.
items:
type: object
required:
- mandatoryCustomerDataItemId
- customerDataItem
properties:
mandatoryCustomerDataItemId:
type: integer
example: 8
customerDataItem:
type: string
example: padBirthdate
unavailable: unavailable:
type: object type: object
rfc9457: rfc9457:

File diff suppressed because it is too large Load Diff

View File

@ -1553,9 +1553,6 @@ components:
- productCategoryId: 8 - productCategoryId: 8
name: Saldo name: Saldo
isTravelProduct: true isTravelProduct: true
- productCategoryId: 9
name: Voucher
isTravelProduct: false
type: array type: array
items: items:
$ref: '#/components/schemas/productCategoryGetEntity' $ref: '#/components/schemas/productCategoryGetEntity'
@ -1814,7 +1811,7 @@ components:
- mandatoryCustomerDataItemId: 5 - mandatoryCustomerDataItemId: 5
customerDataItem: address customerDataItem: address
- mandatoryCustomerDataItemId: 6 - mandatoryCustomerDataItemId: 6
customerDataItem: voucher customerDataItem: phone
- mandatoryCustomerDataItemId: 7 - mandatoryCustomerDataItemId: 7
customerDataItem: ovPayToken customerDataItem: ovPayToken
- mandatoryCustomerDataItemId: 8 - mandatoryCustomerDataItemId: 8

View File

@ -46,13 +46,6 @@ paths:
when false, return only non-archived products (isArchived = false). when false, return only non-archived products (isArchived = false).
schema: schema:
type: boolean type: boolean
- name: onlyVouchers
in: query
required: false
description: |-
When omitted, defaults to false (i.e. return all normal products that are NOT voucher definitions); when true, return only voucher definitions
schema:
type: boolean
responses: responses:
'200': '200':
description: OK description: OK
@ -394,7 +387,6 @@ paths:
"fikoArticleNumber": "1234", "fikoArticleNumber": "1234",
"isValid": true, "isValid": true,
"isArchived": false, "isArchived": false,
"needsVoucher": false,
"gboPackageTemplateId": "30901", "gboPackageTemplateId": "30901",
"productName": "HTM pilot 90% korting", "productName": "HTM pilot 90% korting",
"productDescription": "Reis met 90% korting gedurende de eerste F&F pilot!", "productDescription": "Reis met 90% korting gedurende de eerste F&F pilot!",
@ -482,7 +474,6 @@ paths:
"fikoArticleNumber": "1234", "fikoArticleNumber": "1234",
"isValid": true, "isValid": true,
"isArchived": false, "isArchived": false,
"needsVoucher": false,
"gboPackageTemplateId": "30901", "gboPackageTemplateId": "30901",
"productName": "HTM pilot 90% korting", "productName": "HTM pilot 90% korting",
"productDescription": "Reis met 90% korting gedurende de eerste F&F pilot!", "productDescription": "Reis met 90% korting gedurende de eerste F&F pilot!",
@ -569,7 +560,6 @@ paths:
"fikoArticleNumber": "1234", "fikoArticleNumber": "1234",
"isValid": true, "isValid": true,
"isArchived": false, "isArchived": false,
"needsVoucher": false,
"gboPackageTemplateId": "30901", "gboPackageTemplateId": "30901",
"productName": "HTM pilot 90% korting", "productName": "HTM pilot 90% korting",
"productDescription": "Reis met 90% korting gedurende de eerste F&F pilot!", "productDescription": "Reis met 90% korting gedurende de eerste F&F pilot!",
@ -690,7 +680,6 @@ paths:
"fikoArticleNumber": "1234", "fikoArticleNumber": "1234",
"isValid": true, "isValid": true,
"isArchived": false, "isArchived": false,
"needsVoucher": false,
"gboPackageTemplateId": "33610", "gboPackageTemplateId": "33610",
"productName": "HTM Regio Vrij DH73", "productName": "HTM Regio Vrij DH73",
"productDescription": "Voor een vast bedrag onbeperkt reizen met EBS, HTM en RET in het gekozen gebied in de regio Rotterdam Den Haag.", "productDescription": "Voor een vast bedrag onbeperkt reizen met EBS, HTM en RET in het gekozen gebied in de regio Rotterdam Den Haag.",
@ -877,7 +866,6 @@ paths:
"fikoArticleNumber": "1234", "fikoArticleNumber": "1234",
"isValid": true, "isValid": true,
"isArchived": false, "isArchived": false,
"needsVoucher": false,
"gboPackageTemplateId": "30901", "gboPackageTemplateId": "30901",
"tapConnectProductCode": null, "tapConnectProductCode": null,
"productName": "HTM pilot 90% korting", "productName": "HTM pilot 90% korting",
@ -1040,7 +1028,6 @@ paths:
"fikoArticleNumber": "1234", "fikoArticleNumber": "1234",
"isValid": true, "isValid": true,
"isArchived": false, "isArchived": false,
"needsVoucher": false,
"gboPackageTemplateId": "30901", "gboPackageTemplateId": "30901",
"tapConnectProductCode": null, "tapConnectProductCode": null,
"productName": "HTM pilot 90% korting", "productName": "HTM pilot 90% korting",
@ -1283,7 +1270,6 @@ paths:
"fikoArticleNumber": "1234", "fikoArticleNumber": "1234",
"isValid": true, "isValid": true,
"isArchived": false, "isArchived": false,
"needsVoucher": false,
"gboPackageTemplateId": "33610", "gboPackageTemplateId": "33610",
"tapConnectProductCode": null, "tapConnectProductCode": null,
"productName": "HTM Regio Vrij DH73", "productName": "HTM Regio Vrij DH73",
@ -1489,7 +1475,6 @@ paths:
"fikoArticleNumber": "1234", "fikoArticleNumber": "1234",
"isValid": true, "isValid": true,
"isArchived": false, "isArchived": false,
"needsVoucher": false,
"gboPackageTemplateId": null, "gboPackageTemplateId": null,
"tapConnectProductCode": null, "tapConnectProductCode": null,
"productName": "IBAN wijzigen functioneel product", "productName": "IBAN wijzigen functioneel product",
@ -1687,7 +1672,6 @@ paths:
"fikoArticleNumber": "1234", "fikoArticleNumber": "1234",
"isValid": true, "isValid": true,
"isArchived": false, "isArchived": false,
"needsVoucher": false,
"gboPackageTemplateId": null, "gboPackageTemplateId": null,
"tapConnectProductCode": null, "tapConnectProductCode": null,
"productName": "OV-pas saldo (1 eurocent)", "productName": "OV-pas saldo (1 eurocent)",
@ -2791,10 +2775,6 @@ components:
type: boolean type: boolean
description: Indicates if the product is archived - if true, the Service Engine will prevent touchpoints from seeing this product description: Indicates if the product is archived - if true, the Service Engine will prevent touchpoints from seeing this product
example: false example: false
needsVoucher:
type: boolean
description: Indicates if the product needs a voucher (with this productId as requiredProductId) to be bought - if false, this is optional, but still allowed
example: false
gboPackageTemplateId: gboPackageTemplateId:
type: string type: string
example: '30901' example: '30901'
@ -3152,10 +3132,6 @@ components:
type: boolean type: boolean
description: Indicates if the product is archived - if true, the Service Engine will prevent touchpoints from seeing this product description: Indicates if the product is archived - if true, the Service Engine will prevent touchpoints from seeing this product
example: false example: false
needsVoucher:
type: boolean
description: Indicates if the product needs a voucher (with this productId as requiredProductId) to be bought - if false, this is optional, but still allowed
example: false
gboPackageTemplateId: gboPackageTemplateId:
type: string type: string
description: >- description: >-
@ -3527,10 +3503,6 @@ components:
type: boolean type: boolean
description: Indicates if the product is archived - if true, the Service Engine will prevent touchpoints from seeing this product description: Indicates if the product is archived - if true, the Service Engine will prevent touchpoints from seeing this product
example: false example: false
needsVoucher:
type: boolean
description: Indicates if the product needs a voucher (with this productId as requiredProductId) to be bought - if false, this is optional, but still allowed
example: false
gboPackageTemplateId: gboPackageTemplateId:
type: string type: string
description: >- description: >-

View File

@ -1102,12 +1102,6 @@ paths:
format: uuid format: uuid
example: 058a1af7-897f-45d5-b691-9cc9161e387f example: 058a1af7-897f-45d5-b691-9cc9161e387f
description: The id of the purchased product. description: The id of the purchased product.
- in: query
name: productId
schema:
type: integer
example: 1
description: The id of the product and the issuedVouchers related to it
- in: query - in: query
name: voucherCode name: voucherCode
schema: schema:
@ -1123,14 +1117,6 @@ paths:
explode: false explode: false
required: false required: false
description: The moest recent status id of the voucher. description: The moest recent status id of the voucher.
- in: query
name: orderBy
schema:
type: string
enum: [PurchasedProductCreatedOn, PurchasedProductLastUpdatedOn]
explode: false
required: false
description: The ordering of the issuedVouchers in the list
responses: responses:
"200": "200":
description: OK description: OK

View File

@ -0,0 +1,121 @@
# Notification preferences transformation service - Python example
Reference implementation for the Service Engine `GET /customers/notificationpreferences` endpoint. It composes raw CRUD API data into the frontend-friendly response model from `SE-notifications.yaml`.
## Supplied contracts used
- `notifications-crud.yaml`
- `GET /notificationsubscriptions?customerProfileId={id}&expand=eventTypeChannel`
- `GET /notificationcategories?notificationCategoryId={id}&expand=eventTypeChannel`
- `customers-crud-v2.yaml`
- `GET /ovpaytokens?ovPayTokenId={id}`
- `GET /devices?customerProfileId={id}`
- `GET /customers?customerProfileId={id}` for person/email resource data
- `SE-notifications.yaml`
- `GET /customers/notificationpreferences`
## Runtime options
### Local FastAPI with real CRUD APIs
```powershell
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
$env:NOTIFICATIONS_CRUD_BASE_URL="https://your-notifications-crud-api.example.nl"
$env:CUSTOMERS_CRUD_BASE_URL="https://your-customers-crud-api.example.nl"
.\.venv\Scripts\python.exe -m uvicorn app.entrypoints.fastapi_app:app --reload
```
Call:
```powershell
Invoke-RestMethod `
-Uri "http://127.0.0.1:8000/customers/notificationpreferences?deviceId=device-1" `
-Headers @{"X-HTM-CUSTOMER-PROFILE-ID-HEADER"="42"}
```
### Local FastAPI with mock data
Use mock mode when you want to demo or test the transformation without live CRUD APIs.
```powershell
$env:USE_MOCK_DATA="true"
$env:MOCK_SCENARIO="mixed"
.\.venv\Scripts\python.exe -m uvicorn app.entrypoints.fastapi_app:app --reload
```
Open:
```text
http://127.0.0.1:8000/docs
```
Health check:
```text
http://127.0.0.1:8000/health
```
Available mock scenarios:
| Scenario | Purpose |
|---|---|
| `mixed` | Default-on push, default-off email, plus customer deviations |
| `defaults_only` | No customer preferences, so only defaults are applied |
| `global_override` | Preference with `resourceIdentifier = null` applies to all resources |
| `inactive_subscription` | Subscription is returned but marked inactive |
### Azure Function
Use `app/entrypoints/azure_function.py` as the HTTP-trigger body. Keep the `app/services` and `app/domain` modules unchanged.
## Running automated tests
```powershell
.\.venv\Scripts\python.exe -m pytest
```
The tests cover:
- default-on channel without preference -> active
- default-on channel with preference -> inactive deviation
- default-off channel without preference -> inactive
- default-off channel with preference -> active deviation
- global preference where `resourceIdentifier = null`
- device filtering via `deviceId`
- inactive subscription still returned as inactive
## Design choice
The implementation deliberately separates:
- HTTP entrypoints: request/response only
- CRUD client: raw REST calls only
- Mock CRUD client: deterministic local/demo data
- Service layer: composition and transformation logic
- Domain models: frontend/SE response shape
This makes the same transformation reusable in Azure Functions, Container Apps, and tests.
## Business rule interpretation
For each customer subscription:
1. Load customer `NotificationSubscriptions` with `NotificationPreferences` expanded to `EventTypeChannel`.
2. Load the default category definition with all `EventTypes` and `EventTypeChannels`.
3. Load customer resources for each channel resource type:
- `resourceNameId = 8` -> `GET /devices?customerProfileId={id}`
- `resourceNameId = 4` -> `GET /customers?customerProfileId={id}` and use the embedded `person` email data
4. For every default `EventTypeChannel`, write out resources explicitly.
5. If the channel is default-on and no preference exists, resource is active.
6. If the channel is default-off and no preference exists, resource is inactive.
7. A `NotificationPreference` row is interpreted as a deviation:
- default-on + preference -> inactive unless explicit `isActive` says otherwise
- default-off + preference -> active unless explicit `isActive` says otherwise
8. A null `resourceIdentifier` applies to all resources for that EventTypeChannel.
## Production hardening points
- Add authentication propagation from the SE API to both CRUD APIs.
- Decide whether category defaults should be cached across requests.
- Decide whether missing category defaults should become `502 Bad Gateway`, partial data, or an empty category.
- Confirm whether the real `NotificationPreference` has explicit `isActive`; this example supports it, but also works when preference existence alone means deviation.

View File

@ -0,0 +1,89 @@
from __future__ import annotations
from typing import Any, Optional
import httpx
class CrudApiClient:
"""Thin async client around the ABT CRUD APIs.
Keep business/transformation logic out of this class. The client only knows how
to call the source endpoints and unwrap the collection names from the CRUD
contracts.
"""
def __init__(
self,
notifications_base_url: str,
customers_base_url: str,
timeout_seconds: float = 10.0,
):
self.notifications_base_url = notifications_base_url.rstrip("/")
self.customers_base_url = customers_base_url.rstrip("/")
self.timeout_seconds = timeout_seconds
async def _get(
self,
base_url: str,
path: str,
params: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
async with httpx.AsyncClient(base_url=base_url, timeout=self.timeout_seconds) as client:
response = await client.get(path, params=params)
response.raise_for_status()
return response.json()
# notifications-crud.yaml
async def get_notification_subscriptions(self, customer_profile_id: int) -> list[dict[str, Any]]:
payload = await self._get(
self.notifications_base_url,
"/notificationsubscriptions",
params={"customerProfileId": customer_profile_id, "expand": "eventTypeChannel"},
)
return payload.get("notificationSubscriptions", [])
async def get_notification_category_defaults(self, notification_category_id: int) -> Optional[dict[str, Any]]:
payload = await self._get(
self.notifications_base_url,
"/notificationcategories",
params={"notificationCategoryId": notification_category_id, "expand": "eventTypeChannel"},
)
categories = payload.get("notificationCategories", [])
return categories[0] if categories else None
# customers-crud-v2.yaml
async def get_ovpay_token(self, ovpay_token_id: Optional[int]) -> Optional[dict[str, Any]]:
if ovpay_token_id is None:
return None
payload = await self._get(
self.customers_base_url,
"/ovpaytokens",
params={"ovPayTokenId": ovpay_token_id},
)
tokens = payload.get("ovPayTokens", [])
return tokens[0] if tokens else None
async def get_devices(self, customer_profile_id: int) -> list[dict[str, Any]]:
payload = await self._get(
self.customers_base_url,
"/devices",
params={"customerProfileId": customer_profile_id},
)
return payload.get("devices", [])
async def get_persons(self, customer_profile_id: int) -> list[dict[str, Any]]:
"""Return customer/person resources used for email-like channels.
customers-crud-v2.yaml exposes person details through GET /customers,
filtered by customerProfileId. For the transformation service we normalize
the matching customer profile into one resource with the customer's email.
"""
payload = await self._get(
self.customers_base_url,
"/customers",
params={"customerProfileId": customer_profile_id},
)
return payload.get("customers", [])
# Backwards-compatible alias used by the service type hints.
NotificationsCrudClient = CrudApiClient

View File

@ -0,0 +1,162 @@
from __future__ import annotations
from copy import deepcopy
from typing import Any, Optional
class MockCrudClient:
"""Deterministic in-memory CRUD client for local demos and tests.
It implements the same methods as CrudApiClient, so the service layer does
not know whether data comes from real CRUD APIs or from mock fixtures.
"""
def __init__(self, scenario: str = "mixed"):
self.scenario = scenario
async def get_notification_subscriptions(self, customer_profile_id: int) -> list[dict[str, Any]]:
subscriptions = deepcopy(MOCK_SUBSCRIPTIONS.get(self.scenario, MOCK_SUBSCRIPTIONS["mixed"]))
for subscription in subscriptions:
subscription["customerProfileId"] = customer_profile_id
return subscriptions
async def get_notification_category_defaults(self, notification_category_id: int) -> Optional[dict[str, Any]]:
return deepcopy(MOCK_CATEGORIES.get(notification_category_id))
async def get_ovpay_token(self, ovpay_token_id: Optional[int]) -> Optional[dict[str, Any]]:
if ovpay_token_id is None:
return None
return deepcopy(MOCK_OVPAY_TOKENS.get(ovpay_token_id, {"ovPayTokenId": ovpay_token_id, "alias": None}))
async def get_devices(self, customer_profile_id: int) -> list[dict[str, Any]]:
return deepcopy(MOCK_DEVICES)
async def get_persons(self, customer_profile_id: int) -> list[dict[str, Any]]:
return deepcopy(MOCK_CUSTOMERS)
MOCK_CATEGORIES: dict[int, dict[str, Any]] = {
1: {
"notificationCategoryId": 1,
"name": "Reizen",
"groupName": "Mijn passen",
"eventTypes": [
{
"eventTypeId": 10,
"name": "OVPAY_CHECK_IN",
"subName": "Check-in",
"prettyName": "Check-in bevestiging",
"eventTypeChannels": [
{
"eventTypeChannelId": "etc-push-default-on",
"channel": {
"channelId": 1,
"name": "Push",
"resourceName": {"resourceNameId": 8, "name": "devices"},
},
"isDefault": True,
"isMandatory": False,
},
{
"eventTypeChannelId": "etc-email-default-off",
"channel": {
"channelId": 2,
"name": "E-mail",
"resourceName": {"resourceNameId": 4, "name": "customers"},
},
"isDefault": False,
"isMandatory": False,
},
],
},
{
"eventTypeId": 20,
"name": "SERVICE_MESSAGE",
"subName": "Service",
"prettyName": "Servicebericht",
"eventTypeChannels": [
{
"eventTypeChannelId": "etc-email-mandatory",
"channel": {
"channelId": 2,
"name": "E-mail",
"resourceName": {"resourceNameId": 4, "name": "customers"},
},
"isDefault": True,
"isMandatory": True,
}
],
},
],
}
}
MOCK_SUBSCRIPTIONS: dict[str, list[dict[str, Any]]] = {
# Mixed scenario demonstrates both default behavior and customer deviations.
"mixed": [
{
"notificationSubscriptionId": "sub-mixed-1",
"notificationCategoryId": 1,
"notificationCategory": {"notificationCategoryId": 1, "name": "Reizen", "groupName": "Mijn passen"},
"customerProfileId": 42,
"ovPayTokenId": 112,
"subscriptionActivities": [{"timestamp": "2026-04-01T10:00:00Z", "isActive": True}],
"notificationPreferences": [
# Default push=true: this row opts out only device-2.
{"notificationPreferenceId": "pref-push-device-2-off", "eventTypeChannelId": "etc-push-default-on", "resourceIdentifier": "device-2"},
# Default email=false: this row opts in the customer's email resource.
{"notificationPreferenceId": "pref-email-on", "eventTypeChannelId": "etc-email-default-off", "resourceIdentifier": "42"},
],
}
],
# No preferences: output should be entirely based on category defaults.
"defaults_only": [
{
"notificationSubscriptionId": "sub-defaults-1",
"notificationCategoryId": 1,
"notificationCategory": {"notificationCategoryId": 1, "name": "Reizen", "groupName": "Mijn passen"},
"customerProfileId": 42,
"ovPayTokenId": 112,
"subscriptionActivities": [{"timestamp": "2026-04-01T10:00:00Z", "isActive": True}],
"notificationPreferences": [],
}
],
# Global preference: null resourceIdentifier applies the deviation to all resources.
"global_override": [
{
"notificationSubscriptionId": "sub-global-1",
"notificationCategoryId": 1,
"notificationCategory": {"notificationCategoryId": 1, "name": "Reizen", "groupName": "Mijn passen"},
"customerProfileId": 42,
"ovPayTokenId": 112,
"subscriptionActivities": [{"timestamp": "2026-04-01T10:00:00Z", "isActive": True}],
"notificationPreferences": [
{"notificationPreferenceId": "pref-all-push-off", "eventTypeChannelId": "etc-push-default-on", "resourceIdentifier": None}
],
}
],
# Subscription inactive while preferences are still written out for the frontend.
"inactive_subscription": [
{
"notificationSubscriptionId": "sub-inactive-1",
"notificationCategoryId": 1,
"notificationCategory": {"notificationCategoryId": 1, "name": "Reizen", "groupName": "Mijn passen"},
"customerProfileId": 42,
"ovPayTokenId": 112,
"subscriptionActivities": [{"timestamp": "2026-04-01T10:00:00Z", "isActive": False}],
"notificationPreferences": [],
}
],
}
MOCK_OVPAY_TOKENS = {
112: {"ovPayTokenId": 112, "alias": "Mijn betaalpas"},
}
MOCK_DEVICES = [
{"deviceId": "device-1", "alias": "Mijn iPhone"},
{"deviceId": "device-2", "alias": "Werktelefoon"},
]
MOCK_CUSTOMERS = [
{"customerProfileId": 42, "person": {"emailAddress": "klant@example.nl"}},
]

View File

@ -0,0 +1,60 @@
from __future__ import annotations
from typing import Any, Optional
from pydantic import BaseModel, Field
class Resource(BaseModel):
resourceName: str
resourceIdentifier: str
alias: Optional[str] = None
isActive: bool
class EventTypeChannelPreference(BaseModel):
eventTypeChannelId: str
channelId: int
name: str
isMandatory: bool = False
resources: list[Resource] = Field(default_factory=list)
class EventTypePreference(BaseModel):
eventTypeId: int
name: str
subName: Optional[str] = None
prettyName: str
eventTypeChannels: list[EventTypeChannelPreference] = Field(default_factory=list)
class OvPayToken(BaseModel):
ovPayTokenId: int
alias: Optional[str] = None
class NotificationSubscriptionPreference(BaseModel):
notificationSubscriptionId: str
customerProfileId: int
ovPayToken: Optional[OvPayToken] = None
isActive: bool
eventTypes: list[EventTypePreference] = Field(default_factory=list)
class NotificationCategoryPreference(BaseModel):
notificationCategoryId: int
name: str
groupName: Optional[str] = None
notificationSubscriptions: list[NotificationSubscriptionPreference] = Field(default_factory=list)
class CustomerNotificationPreferencesResponse(BaseModel):
notificationCategories: list[NotificationCategoryPreference] = Field(default_factory=list)
def latest_activity_is_active(subscription: dict[str, Any]) -> bool:
"""CRUD returns subscriptionActivities; the most recent/current one determines active state."""
activities = subscription.get("subscriptionActivities") or []
if not activities:
return False
# CRUD activityLimit defaults to 1; when more are supplied, sort defensively on timestamp.
latest = sorted(activities, key=lambda a: a.get("timestamp", ""), reverse=True)[0]
return bool(latest.get("isActive"))

View File

@ -0,0 +1,17 @@
from __future__ import annotations
import azure.functions as func
from app.service_factory import create_notification_preferences_service
async def main(req: func.HttpRequest) -> func.HttpResponse:
customer_profile_id = req.headers.get("X-HTM-CUSTOMER-PROFILE-ID-HEADER")
if not customer_profile_id:
return func.HttpResponse("Missing X-HTM-CUSTOMER-PROFILE-ID-HEADER", status_code=400)
service = create_notification_preferences_service()
result = await service.get_customer_notification_preferences(
customer_profile_id=int(customer_profile_id),
device_id=req.params.get("deviceId"),
)
return func.HttpResponse(result.model_dump_json(exclude_none=True), mimetype="application/json", status_code=200)

View File

@ -0,0 +1,25 @@
from __future__ import annotations
from fastapi import FastAPI, Header, Query
from app.domain.models import CustomerNotificationPreferencesResponse
from app.service_factory import create_notification_preferences_service
from app.settings import settings
app = FastAPI(title="SE Notifications example")
@app.get("/health")
async def health():
return {"status": "ok", "useMockData": settings.use_mock_data, "mockScenario": settings.mock_scenario}
@app.get("/customers/notificationpreferences", response_model=CustomerNotificationPreferencesResponse)
async def get_customer_notification_preferences(
x_htm_customer_profile_id_header: int = Header(..., alias="X-HTM-CUSTOMER-PROFILE-ID-HEADER"),
device_id: str | None = Query(default=None, alias="deviceId"),
):
service = create_notification_preferences_service()
return await service.get_customer_notification_preferences(
customer_profile_id=x_htm_customer_profile_id_header,
device_id=device_id,
)

View File

@ -0,0 +1,18 @@
from __future__ import annotations
from app.clients.crud_client import CrudApiClient
from app.clients.mock_crud_client import MockCrudClient
from app.services.notification_preferences_service import NotificationPreferencesService
from app.settings import settings
def create_notification_preferences_service() -> NotificationPreferencesService:
if settings.use_mock_data:
return NotificationPreferencesService(MockCrudClient(settings.mock_scenario))
return NotificationPreferencesService(
CrudApiClient(
notifications_base_url=settings.notifications_crud_base_url,
customers_base_url=settings.customers_crud_base_url,
)
)

View File

@ -0,0 +1,228 @@
from __future__ import annotations
from collections import defaultdict
from typing import Any, Optional
from app.clients.crud_client import NotificationsCrudClient
from app.domain.models import (
CustomerNotificationPreferencesResponse,
EventTypeChannelPreference,
EventTypePreference,
NotificationCategoryPreference,
NotificationSubscriptionPreference,
OvPayToken,
Resource,
latest_activity_is_active,
)
RESOURCE_DEVICES_ID = 8
RESOURCE_PERSONS_ID = 4
class NotificationPreferencesService:
def __init__(self, crud_client: NotificationsCrudClient):
self.crud_client = crud_client
async def get_customer_notification_preferences(
self,
customer_profile_id: int,
device_id: Optional[str] = None,
) -> CustomerNotificationPreferencesResponse:
subscriptions = await self.crud_client.get_notification_subscriptions(customer_profile_id)
categories_by_id: dict[int, NotificationCategoryPreference] = {}
# Small in-request caches avoid repeated calls for the same category/resources.
default_category_cache: dict[int, dict[str, Any]] = {}
resource_cache: dict[int, list[dict[str, Any]]] = {}
for subscription in subscriptions:
category_id = _extract_category_id(subscription)
default_category = default_category_cache.get(category_id)
if default_category is None:
default_category = await self.crud_client.get_notification_category_defaults(category_id)
if default_category is None:
# Defensive choice: skip orphaned subscriptions rather than returning incomplete UI data.
continue
default_category_cache[category_id] = default_category
category_response = categories_by_id.setdefault(
category_id,
NotificationCategoryPreference(
notificationCategoryId=category_id,
name=default_category.get("name") or subscription.get("notificationCategory", {}).get("name"),
groupName=default_category.get("groupName") or subscription.get("notificationCategory", {}).get("groupName"),
),
)
token_payload = await self.crud_client.get_ovpay_token(subscription.get("ovPayTokenId"))
token = _to_ovpay_token(token_payload, subscription.get("ovPayTokenId"))
subscription_response = NotificationSubscriptionPreference(
notificationSubscriptionId=subscription["notificationSubscriptionId"],
customerProfileId=subscription["customerProfileId"],
ovPayToken=token,
isActive=latest_activity_is_active(subscription),
)
preferences_by_event_type_channel = _index_preferences(subscription)
for event_type in default_category.get("eventTypes", []):
event_type_response = EventTypePreference(
eventTypeId=event_type["eventTypeId"],
name=event_type["name"],
subName=event_type.get("subName"),
prettyName=event_type.get("prettyName") or event_type["name"],
)
for event_type_channel in event_type.get("eventTypeChannels", []):
channel = event_type_channel.get("channel", {})
resource_name = channel.get("resourceName", {})
resource_name_id = resource_name.get("resourceNameId")
resource_name_text = resource_name.get("name") or channel.get("name")
if resource_name_id not in resource_cache:
resource_cache[resource_name_id] = await self._load_resources(resource_name_id, customer_profile_id)
source_resources = resource_cache[resource_name_id]
if device_id and resource_name_id == RESOURCE_DEVICES_ID:
source_resources = [r for r in source_resources if str(_resource_identifier(r)) == str(device_id)]
if not source_resources:
# SE swagger states a deviceId filter returns only ETCs with resources for that device.
continue
matching_preferences = preferences_by_event_type_channel.get(
str(event_type_channel["eventTypeChannelId"]), []
)
resources = self._normalize_resources(
resource_name=resource_name_text,
event_type_channel=event_type_channel,
source_resources=source_resources,
preferences=matching_preferences,
)
event_type_response.eventTypeChannels.append(
EventTypeChannelPreference(
eventTypeChannelId=event_type_channel["eventTypeChannelId"],
channelId=channel["channelId"],
name=channel["name"],
isMandatory=bool(event_type_channel.get("isMandatory")),
resources=resources,
)
)
if event_type_response.eventTypeChannels:
subscription_response.eventTypes.append(event_type_response)
category_response.notificationSubscriptions.append(subscription_response)
return CustomerNotificationPreferencesResponse(notificationCategories=list(categories_by_id.values()))
async def _load_resources(self, resource_name_id: int, customer_profile_id: int) -> list[dict[str, Any]]:
if resource_name_id == RESOURCE_DEVICES_ID:
return await self.crud_client.get_devices(customer_profile_id)
if resource_name_id == RESOURCE_PERSONS_ID:
return await self.crud_client.get_persons(customer_profile_id)
return []
def _normalize_resources(
self,
resource_name: str,
event_type_channel: dict[str, Any],
source_resources: list[dict[str, Any]],
preferences: list[dict[str, Any]],
) -> list[Resource]:
default_active = bool(event_type_channel.get("isDefault"))
if not preferences:
return [
Resource(
resourceName=resource_name,
resourceIdentifier=str(_resource_identifier(resource)),
alias=_resource_alias(resource),
isActive=default_active,
)
for resource in source_resources
]
# Preference rows are exceptions/deviations. A null resourceIdentifier means the exception applies to all resources.
preference_by_resource: dict[str, dict[str, Any]] = {}
global_preference: Optional[dict[str, Any]] = None
for preference in preferences:
pref_resource_id = preference.get("resourceIdentifier")
if pref_resource_id is None:
global_preference = preference
else:
preference_by_resource[str(pref_resource_id)] = preference
normalized: list[Resource] = []
for resource in source_resources:
resource_id = str(_resource_identifier(resource))
preference = preference_by_resource.get(resource_id) or global_preference
if preference is None:
is_active = default_active
else:
# The CRUD example only shows the existence of a preference row, no explicit isActive.
# Therefore: for default-on channels a row means opted out; for default-off a row means opted in.
is_active = bool(preference.get("isActive", not default_active))
normalized.append(
Resource(
resourceName=resource_name,
resourceIdentifier=resource_id,
alias=_resource_alias(resource),
isActive=is_active,
)
)
return normalized
def _extract_category_id(subscription: dict[str, Any]) -> int:
if subscription.get("notificationCategoryId") is not None:
return int(subscription["notificationCategoryId"])
return int(subscription["notificationCategory"]["notificationCategoryId"])
def _index_preferences(subscription: dict[str, Any]) -> dict[str, list[dict[str, Any]]]:
result: dict[str, list[dict[str, Any]]] = defaultdict(list)
for preference in subscription.get("notificationPreferences", []) or []:
event_type_channel_id = preference.get("eventTypeChannelId")
if isinstance(event_type_channel_id, dict):
event_type_channel_id = event_type_channel_id.get("eventTypeChannelId")
if event_type_channel_id is None and isinstance(preference.get("eventTypeChannel"), dict):
event_type_channel_id = preference["eventTypeChannel"].get("eventTypeChannelId")
if event_type_channel_id is not None:
result[str(event_type_channel_id)].append(preference)
return result
def _to_ovpay_token(payload: Optional[dict[str, Any]], fallback_id: Optional[int]) -> Optional[OvPayToken]:
if fallback_id is None:
return None
# Support both {ovPayToken: {...}} and direct {...} shapes.
token = (payload or {}).get("ovPayToken", payload or {})
return OvPayToken(ovPayTokenId=token.get("ovPayTokenId", fallback_id), alias=token.get("alias"))
def _resource_identifier(resource: dict[str, Any]) -> Any:
# Device resources use deviceId. Customer/person email resources are normalized
# to customerProfileId because customers-crud-v2 exposes person under /customers.
return (
resource.get("resourceIdentifier")
or resource.get("deviceId")
or resource.get("personId")
or resource.get("customerProfileId")
or resource.get("id")
)
def _resource_alias(resource: dict[str, Any]) -> Optional[str]:
person = resource.get("person") or {}
return (
resource.get("alias")
or resource.get("emailAddress")
or resource.get("emailAddresses")
or resource.get("email")
or person.get("emailAddress")
or person.get("emailAddresses")
or resource.get("name")
)

View File

@ -0,0 +1,13 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
notifications_crud_base_url: str = "http://localhost:8001"
customers_crud_base_url: str = "http://localhost:8002"
# local development aid: set USE_MOCK_DATA=true to run without CRUD APIs
use_mock_data: bool = False
mock_scenario: str = "mixed"
settings = Settings()

View File

@ -0,0 +1,8 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
httpx==0.28.1
pydantic>=2.12.0
pydantic-settings>=2.7.1
azure-functions==1.21.3
pytest==8.3.4
pytest-asyncio==0.25.2

View File

@ -0,0 +1,69 @@
import pytest
from app.clients.mock_crud_client import MockCrudClient
from app.services.notification_preferences_service import NotificationPreferencesService
def _channels(result):
subscription = result.notificationCategories[0].notificationSubscriptions[0]
return {
channel.eventTypeChannelId: channel
for event_type in subscription.eventTypes
for channel in event_type.eventTypeChannels
}
@pytest.mark.asyncio
async def test_mixed_scenario_applies_default_on_and_default_off_deviations():
service = NotificationPreferencesService(MockCrudClient("mixed"))
result = await service.get_customer_notification_preferences(42)
channels = _channels(result)
push_resources = channels["etc-push-default-on"].resources
assert [(r.resourceIdentifier, r.isActive) for r in push_resources] == [
("device-1", True),
("device-2", False),
]
email_resources = channels["etc-email-default-off"].resources
assert [(r.resourceIdentifier, r.isActive) for r in email_resources] == [("42", True)]
@pytest.mark.asyncio
async def test_defaults_only_scenario_writes_all_resources_from_defaults():
service = NotificationPreferencesService(MockCrudClient("defaults_only"))
result = await service.get_customer_notification_preferences(42)
channels = _channels(result)
assert [r.isActive for r in channels["etc-push-default-on"].resources] == [True, True]
assert [r.isActive for r in channels["etc-email-default-off"].resources] == [False]
assert channels["etc-email-mandatory"].isMandatory is True
assert [r.isActive for r in channels["etc-email-mandatory"].resources] == [True]
@pytest.mark.asyncio
async def test_global_override_applies_to_all_resources_when_resource_identifier_is_null():
service = NotificationPreferencesService(MockCrudClient("global_override"))
result = await service.get_customer_notification_preferences(42)
channels = _channels(result)
assert [r.isActive for r in channels["etc-push-default-on"].resources] == [False, False]
@pytest.mark.asyncio
async def test_device_filter_returns_only_requested_device_for_device_channels():
service = NotificationPreferencesService(MockCrudClient("mixed"))
result = await service.get_customer_notification_preferences(42, device_id="device-1")
channels = _channels(result)
assert [(r.resourceIdentifier, r.isActive) for r in channels["etc-push-default-on"].resources] == [("device-1", True)]
@pytest.mark.asyncio
async def test_inactive_subscription_is_visible_but_marked_inactive():
service = NotificationPreferencesService(MockCrudClient("inactive_subscription"))
result = await service.get_customer_notification_preferences(42)
subscription = result.notificationCategories[0].notificationSubscriptions[0]
assert subscription.isActive is False
assert subscription.eventTypes

View File

@ -0,0 +1,83 @@
import pytest
from app.services.notification_preferences_service import NotificationPreferencesService
class FakeCrudClient:
async def get_notification_subscriptions(self, customer_profile_id: int):
return [
{
"notificationSubscriptionId": "sub-1",
"notificationCategory": {"notificationCategoryId": 1, "name": "Reizen", "groupName": "Mijn passen"},
"customerProfileId": customer_profile_id,
"ovPayTokenId": 112,
"subscriptionActivities": [{"timestamp": "2026-01-01T00:00:00Z", "isActive": True}],
"notificationPreferences": [
# Default push=true, this row switches device-2 off only.
{"notificationPreferenceId": "pref-1", "eventTypeChannelId": "etc-push", "resourceIdentifier": "device-2"},
# Default email=false, this row switches customer email on.
{"notificationPreferenceId": "pref-2", "eventTypeChannelId": "etc-email", "resourceIdentifier": "42"},
],
}
]
async def get_notification_category_defaults(self, notification_category_id: int):
return {
"notificationCategoryId": 1,
"name": "Reizen",
"groupName": "Mijn passen",
"eventTypes": [
{
"eventTypeId": 2,
"name": "GBO",
"subName": "CI",
"prettyName": "Normal Check-in",
"eventTypeChannels": [
{
"eventTypeChannelId": "etc-push",
"channel": {"channelId": 1, "name": "push", "resourceName": {"resourceNameId": 8, "name": "devices"}},
"isDefault": True,
"isMandatory": False,
},
{
"eventTypeChannelId": "etc-email",
"channel": {"channelId": 2, "name": "email", "resourceName": {"resourceNameId": 4, "name": "customers"}},
"isDefault": False,
"isMandatory": False,
},
],
}
],
}
async def get_ovpay_token(self, ovpay_token_id):
return {"ovPayToken": {"ovPayTokenId": ovpay_token_id, "alias": "Mijn ING bankpas"}}
async def get_devices(self, customer_profile_id: int):
return [
{"deviceId": "device-1", "alias": "Mijn iPhone"},
{"deviceId": "device-2", "alias": "Mijn Pixel"},
]
async def get_persons(self, customer_profile_id: int):
return [{"customerProfileId": 42, "emailAddress": "customer@example.nl"}]
@pytest.mark.asyncio
async def test_normalizes_defaults_and_customer_exceptions():
result = await NotificationPreferencesService(FakeCrudClient()).get_customer_notification_preferences(42)
event_type = result.notificationCategories[0].notificationSubscriptions[0].eventTypes[0]
push_resources = event_type.eventTypeChannels[0].resources
assert [r.isActive for r in push_resources] == [True, False]
email_resources = event_type.eventTypeChannels[1].resources
assert email_resources[0].isActive is True
@pytest.mark.asyncio
async def test_device_filter_only_returns_that_device_resource():
result = await NotificationPreferencesService(FakeCrudClient()).get_customer_notification_preferences(42, device_id="device-1")
event_type = result.notificationCategories[0].notificationSubscriptions[0].eventTypes[0]
assert len(event_type.eventTypeChannels[0].resources) == 1
assert event_type.eventTypeChannels[0].resources[0].resourceIdentifier == "device-1"

View File