Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
021d78d
docs: Add PRD for Cash on Delivery (COD) payment method (#641)
mnindrazaka Sep 26, 2026
04cd2ab
feat(api): add COD vocabulary to the contract and domain (#642)
mnindrazaka Sep 26, 2026
52d9d16
feat(ui): add CameraCapture base component for COD verification photo…
mnindrazaka Sep 26, 2026
8f85275
feat(api): add COD schema and photo store (phase 2) (#646)
mnindrazaka Sep 26, 2026
7303044
feat(api): add cod_verification KDS kind and COD pickup line (#645)
mnindrazaka Sep 26, 2026
ae4bf58
feat(api): add COD pay-at-pickup line to guest ready WhatsApp message…
mnindrazaka Sep 26, 2026
366fda2
feat(ui): add order-app frontend slice for COD (phase 8) (#648)
mnindrazaka Sep 26, 2026
b379998
feat(ui): add POS verification slice for COD payments (#647)
mnindrazaka Sep 26, 2026
807c2df
feat(api): add COD checkout branch (phase 4) (#650)
mnindrazaka Sep 26, 2026
af8d8d4
feat(api): add COD verify/approve/reject use case (phase 5) (#649)
mnindrazaka Sep 26, 2026
e6e7bb3
feat(api): add COD lifecycle guards and photo cleanup (phase 6) (#654)
mnindrazaka Sep 26, 2026
5895b4e
feat(ui): add guest status screens for COD verification (phase 11) (#…
mnindrazaka Sep 26, 2026
a343099
feat(ui): add POS badges, Verify sheet and menu guards for COD (phase…
mnindrazaka Sep 26, 2026
0d641a8
feat(ui): details sheet offers COD (phase 10) (#652)
mnindrazaka Sep 26, 2026
01fd555
test(e2e): COD phase 14 — enable, verify, document (#655)
mnindrazaka Sep 26, 2026
91c52dd
Add COD payment and verification expiry secrets
mnindrazaka Sep 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/deploy-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ jobs:
ORDER_PAYMENT_WALLET_ID=${{ secrets.ORDER_PAYMENT_WALLET_ID }}
ORDER_WEB_BASE_URL=${{ secrets.ORDER_WEB_BASE_URL }}
ORDER_PAYMENT_CANCEL_ENABLED=${{ secrets.ORDER_PAYMENT_CANCEL_ENABLED }}
ORDER_COD_PAYMENT_ENABLED=${{ secrets.ORDER_COD_PAYMENT_ENABLED }}
COD_VERIFICATION_EXPIRY_SECONDS=${{ secrets.COD_VERIFICATION_EXPIRY_SECONDS }}
EXPO_PUSH_ACCESS_TOKEN=${{ secrets.EXPO_PUSH_ACCESS_TOKEN }}
KDS_PUSH_SOUND=default
KDS_DISPATCH_INTERVAL_SECONDS=15
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/e2e-main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ jobs:
FONNTE_BASE_URL: http://127.0.0.1:${{ env.FONNTESTUB_PORT }}
WHATSAPP_NUMBER_VALIDATION_ENABLED: 'true'
ORDER_WEB_BASE_URL: http://localhost:3000
ORDER_COD_PAYMENT_ENABLED: 'true'
run: |
nohup ./dist/apps/api > "$RUNNER_TEMP/api.log" 2>&1 &
# Any HTTP response means the server is listening (GET on the
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,20 @@ order-app guest gets to pay cash at the till before the order is cancelled autom
at all; `NEXT_PUBLIC_ORDER_CASHIER_LOCATION` (default `Lantai 1`) is the till location shown to the
guest in both the checkout sheet and the cash instruction screen.

`apps/api/.env`'s `ORDER_COD_PAYMENT_ENABLED` (default `false`) is the server-side gate for the
Cash on Delivery trial — the authoritative one, since it is what stops an old tab, a curl, or a
cached bundle from creating a COD order (and the verification photo that comes with it) once the
trial is off (`docs/prd-order-cod-payment.md`, D12). `COD_VERIFICATION_EXPIRY_SECONDS` (default
`900`) is how long an unverified COD order waits for a barista before the sweeper cancels it and
frees the guest's cart. `apps/order-web/.env.local`'s `NEXT_PUBLIC_ORDER_COD_PAYMENT_ENABLED` is
the method's own client-side kill switch, off by default and only ever an affordance — the API flag
above is what actually matters. Migration `000044_add_payment_verification` must be applied
(`MIGRATIONS_DIR=data/mysql/migrations make migrate-up` from `apps/api`) before the API binary that
expects the `payment_verification_photos` table starts. In production, turning the trial on is
`ORDER_COD_PAYMENT_ENABLED=true` on the API and `NEXT_PUBLIC_ORDER_COD_PAYMENT_ENABLED=true` on
`order-web`, alongside `NEXT_PUBLIC_ORDER_CASH_PAYMENT_ENABLED=false` — COD is a superset of cash
from the guest's point of view, so the two trial flags are meant to be flipped together.

`apps/api/.env`'s `FONNTE_TOKEN`, `FONNTE_BASE_URL` and `ORDER_WEB_BASE_URL` configure the
WhatsApp order-ready notification sent when a barista marks an order-app order ready
(`docs/prd-order-whatsapp-notifications.md`) — leaving `FONNTE_TOKEN` or `ORDER_WEB_BASE_URL`
Expand Down
7 changes: 7 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ ORDER_PAYMENT_WALLET_ID= # the QRIS wallet's id; validated by checkout fro
ORDER_WEB_BASE_URL= # apps/order-web's public origin, no trailing slash (docs/prd-order-whatsapp-notifications.md D11); empty disables the WhatsApp gateway
ORDER_PAYMENT_CANCEL_ENABLED= # kill switch for guest payment cancellation (docs/prd-order-payment-cancellation.md D11); empty/false until phase 10 flips it on

# Cash on Delivery (COD) trial (docs/prd-order-cod-payment.md). ORDER_COD_PAYMENT_ENABLED gates
# checkout only (D12) — verify/approve/reject stay available so in-flight orders can drain after
# the trial is turned off. COD_VERIFICATION_EXPIRY_SECONDS is how long an unverified order waits
# for a barista before the sweeper expires it (D8, D19); an approved order never expires.
ORDER_COD_PAYMENT_ENABLED=false
COD_VERIFICATION_EXPIRY_SECONDS=900

# Expo push notifications for the KDS app (docs/prd-kds-order-notifications.md D7).
# The API is the only thing that ever calls Expo — no frontend holds this token.
EXPO_PUSH_ACCESS_TOKEN= # Expo access token, sent as "Authorization: Bearer ..." to exp.host
Expand Down
100 changes: 100 additions & 0 deletions apps/api/data/mock/payment_verification_repository.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 45 additions & 0 deletions apps/api/data/mysql/kds_notification_repo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,51 @@ func TestEnqueueForTransaction_CashPendingBypassesTheStaleCheck(t *testing.T) {
require.NoError(t, mock.ExpectationsWereMet())
}

// FR-9/D15: cod_verification asks a barista to look at a photo, not make a drink, so a board-game-
// only COD order still needs a decision.
func TestEnqueueForTransaction_CodVerificationBypassesTheStationRule(t *testing.T) {
repo, mock := newMockKdsNotificationRepository(t)

transaction := domain.Transaction{
Id: 1,
TransactionItems: []domain.TransactionItem{
{
Amount: 1,
ProductName: "Board Game Ticket",
Variant: domain.Variant{Product: domain.Product{Category: domain.Category{Station: "NONE"}}},
},
},
}

mock.ExpectBegin()
mock.ExpectExec("INSERT INTO `kds_notifications` \\(`transaction_id`,`kind`,`status`,`attempt_count`,`detail`,`created_at`,`sent_at`\\) VALUES \\(\\?,\\?,\\?,\\?,\\?,\\?,\\?\\) ON DUPLICATE KEY UPDATE `id`=id").
WithArgs(int64(1), "cod_verification", "pending", 0, nil, sqlmock.AnyArg(), nil).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()

err := repo.EnqueueForTransaction(context.Background(), transaction, domain.KdsNotificationKindCodVerification)

require.Nil(t, err)
require.NoError(t, mock.ExpectationsWereMet())
}

// FR-9/D15: a cod_verification row is written the instant the transaction is created, so the
// business-day staleness check can never fire for it either.
func TestEnqueueForTransaction_CodVerificationBypassesTheStaleCheck(t *testing.T) {
repo, mock := newMockKdsNotificationRepository(t)

mock.ExpectBegin()
mock.ExpectExec("INSERT INTO `kds_notifications` \\(`transaction_id`,`kind`,`status`,`attempt_count`,`detail`,`created_at`,`sent_at`\\) VALUES \\(\\?,\\?,\\?,\\?,\\?,\\?,\\?\\) ON DUPLICATE KEY UPDATE `id`=id").
WithArgs(int64(1), "cod_verification", "pending", 0, nil, sqlmock.AnyArg(), nil).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()

err := repo.EnqueueForTransaction(context.Background(), kdsBarTransaction(1, time.Now().AddDate(0, 0, -1)), domain.KdsNotificationKindCodVerification)

require.Nil(t, err)
require.NoError(t, mock.ExpectationsWereMet())
}

// D22: a transaction paid on a later business day is enqueued as 'skipped' rather than left
// unwritten, so the outbox still answers what happened to this order's notification.
func TestEnqueueForTransaction_WritesSkippedStatusForAStaleTransaction(t *testing.T) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
DROP TABLE IF EXISTS `payment_verification_photos`;

ALTER TABLE `payments`
DROP COLUMN `verified_at`,
DROP COLUMN `verification_status`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
ALTER TABLE `payments`
ADD COLUMN `verification_status` VARCHAR(16) NULL AFTER `status`,
ADD COLUMN `verified_at` DATETIME NULL AFTER `verification_status`;

CREATE TABLE `payment_verification_photos` (
`payment_id` BIGINT NOT NULL,
`content_type` VARCHAR(32) NOT NULL,
`byte_size` INT NOT NULL,
`data` MEDIUMBLOB NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`payment_id`),
CONSTRAINT `fk_payment_verification_photos_payment`
FOREIGN KEY (`payment_id`) REFERENCES `payments` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
2 changes: 2 additions & 0 deletions apps/api/data/mysql/payment_entity.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ type Payment struct {
PaidAt *time.Time
CancelledAt *time.Time
CancelReason *string
VerificationStatus *string
VerifiedAt *time.Time
StatusCheckedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
Expand Down
6 changes: 5 additions & 1 deletion apps/api/data/mysql/payment_repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,10 @@ func (repo Repository) GetPaymentsBySessionIdTotal(ctx context.Context, sessionI

func (repo Repository) GetExpirablePayments(ctx context.Context, now time.Time, limit int) ([]domain.Payment, *domain.Error) {
db := GetDbFromCtx(ctx, repo.db)
// FR-5: an approved COD payment is never expirable — the bar has started making it.
query := db.Table("payments").
Where("status = ? AND deleted_at IS NULL AND expired_at < ?", string(domain.PaymentStatePending), now).
Where("status = ? AND deleted_at IS NULL AND expired_at < ? AND (verification_status IS NULL OR verification_status <> ?)",
string(domain.PaymentStatePending), now, string(domain.PaymentVerificationStatusApproved)).
Order("id ASC")

if limit > 0 {
Expand Down Expand Up @@ -148,6 +150,8 @@ func (repo Repository) UpdatePaymentById(ctx context.Context, payment domain.Pay
"status": payload.Status,
"qr_content": payload.QrContent,
"paid_at": payload.PaidAt,
"verification_status": payload.VerificationStatus,
"verified_at": payload.VerifiedAt,
"status_checked_at": payload.StatusCheckedAt,
}); result.Error != nil {
return domain.Payment{}, ToErrorCtx(ctx, result.Error, "UpdatePaymentById")
Expand Down
35 changes: 31 additions & 4 deletions apps/api/data/mysql/payment_repo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,8 @@ func TestPaymentRepository_GetExpirablePayments_FiltersToPendingPastExpiry(t *te
repo, mock := newMockPaymentRepository(t)
now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)

mock.ExpectQuery("SELECT \\* FROM `payments` WHERE status = \\? AND deleted_at IS NULL AND expired_at < \\? ORDER BY id ASC LIMIT \\?").
WithArgs("pending", now, 50).
mock.ExpectQuery("SELECT \\* FROM `payments` WHERE status = \\? AND deleted_at IS NULL AND expired_at < \\? AND \\(verification_status IS NULL OR verification_status <> \\?\\) ORDER BY id ASC LIMIT \\?").
WithArgs("pending", now, "approved", 50).
WillReturnRows(sqlmock.NewRows([]string{"id"}))

payments, err := repo.GetExpirablePayments(context.Background(), now, 50)
Expand All @@ -135,12 +135,39 @@ func TestPaymentRepository_GetExpirablePayments_NoLimitFetchesEverything(t *test
repo, mock := newMockPaymentRepository(t)
now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)

mock.ExpectQuery("SELECT \\* FROM `payments` WHERE status = \\? AND deleted_at IS NULL AND expired_at < \\? ORDER BY id ASC").
WithArgs("pending", now).
mock.ExpectQuery("SELECT \\* FROM `payments` WHERE status = \\? AND deleted_at IS NULL AND expired_at < \\? AND \\(verification_status IS NULL OR verification_status <> \\?\\) ORDER BY id ASC").
WithArgs("pending", now, "approved").
WillReturnRows(sqlmock.NewRows([]string{"id"}))

_, err := repo.GetExpirablePayments(context.Background(), now, 0)

require.Nil(t, err)
require.NoError(t, mock.ExpectationsWereMet())
}

// FR-4/D2: approving a COD payment writes verification_status and verified_at through the same
// UpdatePaymentById every other payment transition uses.
func TestPaymentRepository_UpdatePaymentById_PersistsVerificationStatusAndVerifiedAt(t *testing.T) {
repo, mock := newMockPaymentRepository(t)
verifiedAt := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
verificationStatus := domain.PaymentVerificationStatusApproved

mock.ExpectBegin()
mock.ExpectExec("UPDATE `payments` SET .*`verification_status`=\\?.*`verified_at`=\\?.* WHERE id = \\?").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
mock.ExpectQuery("SELECT \\* FROM `payments` WHERE id = \\? AND deleted_at IS NULL ORDER BY `payments`.`id` LIMIT \\?").
WithArgs(int64(1), 1).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(1)))

_, err := repo.UpdatePaymentById(context.Background(), domain.Payment{
Id: 1,
Method: domain.PaymentMethodCod,
Status: domain.PaymentStatePending,
VerificationStatus: &verificationStatus,
VerifiedAt: &verifiedAt,
}, 1)

require.Nil(t, err)
require.NoError(t, mock.ExpectationsWereMet())
}
16 changes: 16 additions & 0 deletions apps/api/data/mysql/payment_transformer.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ func ToPaymentDB(d domain.Payment) Payment {
cancelReason = &reason
}

var verificationStatus *string
if d.VerificationStatus != nil {
status := string(*d.VerificationStatus)
verificationStatus = &status
}

return Payment{
Id: d.Id,
CartId: d.CartId,
Expand All @@ -32,6 +38,8 @@ func ToPaymentDB(d domain.Payment) Payment {
PaidAt: d.PaidAt,
CancelledAt: d.CancelledAt,
CancelReason: cancelReason,
VerificationStatus: verificationStatus,
VerifiedAt: d.VerifiedAt,
StatusCheckedAt: d.StatusCheckedAt,
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
Expand Down Expand Up @@ -63,6 +71,12 @@ func ToPaymentDomain(db Payment) domain.Payment {
cancelReason = &reason
}

var verificationStatus *domain.PaymentVerificationStatus
if db.VerificationStatus != nil {
status := domain.PaymentVerificationStatus(*db.VerificationStatus)
verificationStatus = &status
}

return domain.Payment{
Id: db.Id,
CartId: db.CartId,
Expand All @@ -80,6 +94,8 @@ func ToPaymentDomain(db Payment) domain.Payment {
PaidAt: db.PaidAt,
CancelledAt: db.CancelledAt,
CancelReason: cancelReason,
VerificationStatus: verificationStatus,
VerifiedAt: db.VerifiedAt,
StatusCheckedAt: db.StatusCheckedAt,
CreatedAt: db.CreatedAt,
UpdatedAt: db.UpdatedAt,
Expand Down
12 changes: 12 additions & 0 deletions apps/api/data/mysql/payment_transformer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ func TestPaymentTransformerRoundTrip(t *testing.T) {
cancelledAt := time.Date(2026, 9, 10, 12, 4, 0, 0, time.UTC)
cancelReason := domain.PaymentCancelReasonGuest
statusCheckedAt := time.Date(2026, 9, 10, 12, 2, 55, 0, time.UTC)
verifiedAt := time.Date(2026, 9, 10, 12, 1, 30, 0, time.UTC)
verificationStatus := domain.PaymentVerificationStatusApproved

payment := domain.Payment{
Id: 7,
Expand All @@ -33,6 +35,8 @@ func TestPaymentTransformerRoundTrip(t *testing.T) {
PaidAt: &paidAt,
CancelledAt: &cancelledAt,
CancelReason: &cancelReason,
VerificationStatus: &verificationStatus,
VerifiedAt: &verifiedAt,
StatusCheckedAt: &statusCheckedAt,
CreatedAt: time.Date(2026, 9, 10, 12, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2026, 9, 10, 12, 3, 0, 0, time.UTC),
Expand All @@ -54,3 +58,11 @@ func TestPaymentTransformerMapsNilCancelReasonToNull(t *testing.T) {
assert.Nil(t, db.CancelReason)
assert.Nil(t, mysql.ToPaymentDomain(db).CancelReason)
}

// VerificationStatus is nil for QRIS and cash (D2); only a COD payment ever sets it.
func TestPaymentTransformerMapsNilVerificationStatusToNull(t *testing.T) {
db := mysql.ToPaymentDB(domain.Payment{VerificationStatus: nil})

assert.Nil(t, db.VerificationStatus)
assert.Nil(t, mysql.ToPaymentDomain(db).VerificationStatus)
}
11 changes: 11 additions & 0 deletions apps/api/data/mysql/payment_verification_entity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package mysql

import "time"

type PaymentVerificationPhoto struct {
PaymentId int64 `gorm:"primaryKey"`
ContentType string
ByteSize int
Data []byte
CreatedAt time.Time
}
Loading
Loading